diff options
| -rw-r--r-- | llamachat/ui.py | 113 | ||||
| -rwxr-xr-x | test_llamachat.py | 31 |
2 files changed, 143 insertions, 1 deletions
diff --git a/llamachat/ui.py b/llamachat/ui.py index c22b3db..f29b39c 100644 --- a/llamachat/ui.py +++ b/llamachat/ui.py @@ -36,7 +36,7 @@ from PySide6.QtWidgets import ( QVBoxLayout, QWidget, ) -from . import backend, models as models_mod, prompts +from . import backend, models as models_mod, prompts, skills from . import providers as providers_mod from .backend import Attachment, BackendError, SearchConfig from .config import GLOBAL_PROMPT @@ -511,6 +511,8 @@ class ChatWindow(QMainWindow): self.store = store self.prompts = prompts.PromptStore(cfg.prompts_dir) self.prompts.ensure_default() + self.skills = skills.SkillStore(cfg.skills_dir) + self.loaded_skills: list[str] = [] # Which system prompt this conversation uses: a name from the store, # the NONE sentinel, or CUSTOM with text held in prompt_custom. @@ -1056,6 +1058,8 @@ class ChatWindow(QMainWindow): self.assistant_buffer = "" self.reasoning_buffer = "" self.searches = [] + self.loaded_skills = [] + self._refresh_skill_chips() self.exact_tokens = 0 self.bubbles.clear() self.expanded.clear() @@ -1203,6 +1207,10 @@ class ChatWindow(QMainWindow): self.show_status("No model selected.", error=True) return + text = self._load_skills_from_text(text) + if not text and not self.attachments: + return + if self.session_id is None: title = backend.placeholder_title( text or self.attachments[0].path.name @@ -1215,6 +1223,8 @@ class ChatWindow(QMainWindow): prompt_custom=self.prompt_custom, ) + self._persist_skills() + content = backend.build_user_content(text, self.attachments) stored = content if isinstance(content, str) else content[0]["text"] message_id = self.history.add_message(self.session_id, "user", stored) @@ -1266,9 +1276,89 @@ class ChatWindow(QMainWindow): parts = [self.system_prompt_text()] if self.cfg.search_enabled: parts.append(_date_note()) + parts.extend(_skill_parts(self.skills, self.loaded_skills)) text = "\n\n".join(p for p in parts if p) return [{"role": "system", "content": text}] if text else [] + # -- skills ----------------------------------------------------------- + + def _load_skill(self, name: str) -> bool: + """Record a skill as loaded; True when it was not already present. + + Persisted to the session in chat mode so reopening the conversation + restores it. One-shot mode keeps the set in memory only; it is + cleared when the reply finishes. + """ + if not self.cfg.skills_enabled or name in self.loaded_skills: + return False + if self.skills.load(name) is None: + return False + self.loaded_skills.append(name) + self._persist_skills() + self._refresh_skill_chips() + return True + + def _unload_skill(self, name: str) -> None: + if name not in self.loaded_skills: + return + self.loaded_skills.remove(name) + self._persist_skills() + self._refresh_skill_chips() + + def _persist_skills(self) -> None: + if ( + self.cfg.skills_enabled + and self.mode == MODE_CHAT + and self.session_id is not None + ): + self.history.set_skills(self.session_id, self.loaded_skills) + + def _load_skills_from_text(self, text: str) -> str: + """Load skills named in the draft; returns the text to actually send. + + /name tokens load and are stripped. A skill name appearing as a + whole word loads and stays, since it reads naturally either way. + """ + if not self.cfg.skills_enabled: + return text + known = set(self.skills.names()) + text, command_names = skills.parse_commands(text, known) + for name in command_names: + self._load_skill(name) + for name in self.skills.match_mentions(text): + self._load_skill(name) + return text + + def _skills_config(self) -> backend.SkillsConfig | None: + """What the stream worker needs to offer the load_skill tool.""" + if not self.cfg.skills_enabled or not self.skills.names(): + return None + return backend.SkillsConfig( + enabled=True, store=self.skills, max_searches=self.cfg.max_searches + ) + + def _refresh_skill_chips(self) -> None: + """Rebuild the loaded-skill chips row; hidden when nothing is loaded. + + The chips bar is built by `_build_ui`, so this is a no-op until then + (it is first called from `new_session` during construction). + """ + if not hasattr(self, "skills_bar"): + return # still building the window + for chip in self.skill_chips.values(): + chip.deleteLater() + self.skill_chips.clear() + for name in self.loaded_skills: + chip = QPushButton(f"✕ {name}") + chip.setFlat(True) + chip.setToolTip(f"Loaded skill. Click to unload {name}.") + chip.clicked.connect( + lambda _checked=False, n=name: self._unload_skill(n) + ) + self.skills_layout.addWidget(chip) + self.skill_chips[name] = chip + self.skills_bar.setVisible(bool(self.loaded_skills)) + def update_meter(self) -> None: """Refresh the context bar from whatever is currently composed. @@ -2103,6 +2193,27 @@ def _date_note(now: datetime.datetime | None = None) -> str: ) +def _skill_parts(store, loaded: list[str]) -> list[str]: + """The system-prompt chunks for the loaded skills, in load order.""" + parts = [] + for name in loaded: + skill = store.load(name) + if skill is not None: + parts.append(f"[skill: {skill.name}]\n{skill.text}") + return parts + + +def _skills_list(raw: str) -> list[str]: + """Session-stored skills JSON back to a list, tolerating absence/garbage.""" + try: + parsed = json.loads(raw) if raw else [] + except ValueError: + return [] + if not isinstance(parsed, list): + return [] + return [name for name in parsed if isinstance(name, str)] + + def _searches(stored: str) -> list[dict]: """Decode the stored searches column into a list, tolerating anything.""" if not stored: diff --git a/test_llamachat.py b/test_llamachat.py index ebb90ce..85e69a6 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -3160,6 +3160,8 @@ def test_date_note(): class _Win: cfg = _Cfg() system_prompt_text = staticmethod(lambda: "Be terse.") + skills = None + loaded_skills = [] _system_messages = ui.ChatWindow._system_messages msgs = _Win._system_messages(_Win()) @@ -3684,6 +3686,34 @@ def test_skills_column_migration(): print("ok skills column migration") +def test_skill_parts(): + from llamachat import skills + from llamachat.ui import _skill_parts, _skills_list + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_skills(root, {"finance-cli/SKILL.md": SKILLS_SAMPLE["finance-cli/SKILL.md"]}) + store = skills.SkillStore(root) + + parts = _skill_parts(store, ["finance-cli"]) + assert len(parts) == 1, parts + assert "[skill: finance-cli]" in parts[0] + assert "finance auth test" in parts[0] + + # A name that no longer exists contributes nothing, and the parts + # keep load order. + parts2 = _skill_parts(store, ["gone", "finance-cli"]) + assert parts2 == [ + "[skill: finance-cli]\n# finance-cli\nRun `finance auth test` first." + ] + + assert _skills_list('["a", "b"]') == ["a", "b"] + assert _skills_list("") == [] + assert _skills_list("not json") == [] + assert _skills_list("[1, 2]") == [] + print("ok skill parts") + + if __name__ == "__main__": test_presets() test_real_presets() @@ -3756,4 +3786,5 @@ if __name__ == "__main__": test_skill_tool_message() test_skills_db() test_skills_column_migration() + test_skill_parts() print("\nall checks passed") |
