- Tool-using playbooks: read-only tool registry (search_memory, search_history, search_documents, list_models, get_time), per-playbook allowlist, agentic loop, and a "running tool" status indicator. - Document ingest / RAG: documents table + chunker + embed/cosine retrieval reusing the existing stack; Documents page (upload/paste, viewer, delete); top-k chunks injected into the system prompt. - Vision chat: attach images, base64 into /api/chat. - Voice I/O: Web Speech dictation + read-aloud (browser-native, no backend). - Chat controls: stop, regenerate, edit-and-resend; num_ctx knob in Settings. - Per-playbook model override. - History polish: per-conversation + bulk ShareGPT export. - Faster ncp start: UI up first, Ollama warms in the background, with a per-phase timing readout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
102 lines
3.0 KiB
Python
102 lines
3.0 KiB
Python
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
import yaml
|
|
from pydantic import BaseModel
|
|
|
|
|
|
class _BlockDumper(yaml.Dumper):
|
|
pass
|
|
|
|
_BlockDumper.add_representer(
|
|
str,
|
|
lambda dumper, data: dumper.represent_scalar(
|
|
"tag:yaml.org,2002:str", data, style="|" if "\n" in data else None
|
|
),
|
|
)
|
|
|
|
|
|
class PlaybookItem(BaseModel):
|
|
id: str
|
|
title: str
|
|
goal: str
|
|
instructions: str
|
|
tags: List[str] = []
|
|
tools: List[str] = []
|
|
model: str = ""
|
|
order: int = 0
|
|
|
|
|
|
class PlaybookFileStore:
|
|
def __init__(self, directory: Path):
|
|
self.directory = directory
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _path(self, id: str) -> Path:
|
|
return self.directory / f"{id}.yaml"
|
|
|
|
def _read(self, path: Path) -> Optional[PlaybookItem]:
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
data = yaml.safe_load(f)
|
|
return PlaybookItem(
|
|
id=data["id"],
|
|
title=data["title"],
|
|
goal=data.get("goal", ""),
|
|
instructions=data.get("instructions", ""),
|
|
tags=data.get("tags", []),
|
|
tools=data.get("tools", []),
|
|
model=data.get("model", ""),
|
|
order=data.get("order", 0),
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
@staticmethod
|
|
def _clean(s: str) -> str:
|
|
return "\n".join(line.rstrip() for line in s.split("\n")).strip()
|
|
|
|
def _write(self, item: PlaybookItem):
|
|
data = {
|
|
"id": item.id,
|
|
"title": item.title,
|
|
"goal": item.goal,
|
|
"tags": item.tags,
|
|
"tools": item.tools,
|
|
"model": item.model,
|
|
"order": item.order,
|
|
"instructions": self._clean(item.instructions),
|
|
}
|
|
with open(self._path(item.id), "w", encoding="utf-8") as f:
|
|
yaml.dump(data, f, Dumper=_BlockDumper, allow_unicode=True,
|
|
default_flow_style=False, sort_keys=False, width=4096)
|
|
|
|
def all_playbooks(self) -> List[PlaybookItem]:
|
|
items = [self._read(p) for p in self.directory.glob("*.yaml")]
|
|
return sorted((i for i in items if i), key=lambda x: x.order)
|
|
|
|
def get_playbook(self, id: str) -> Optional[PlaybookItem]:
|
|
return self._read(self._path(id))
|
|
|
|
def add_playbook(self, item: PlaybookItem):
|
|
self._write(item)
|
|
|
|
def delete_playbook(self, id: str):
|
|
p = self._path(id)
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
def reorder_playbooks(self, ordered_ids: List[str]):
|
|
all_ids = {p.stem for p in self.directory.glob("*.yaml")}
|
|
valid = [pid for pid in ordered_ids if pid in all_ids]
|
|
missing = sorted(all_ids - set(valid))
|
|
for index, pid in enumerate(valid + missing):
|
|
item = self.get_playbook(pid)
|
|
if item:
|
|
item.order = index
|
|
self._write(item)
|
|
|
|
|
|
from ..nexus_config import PLAYBOOK_DIR
|
|
playbook_store = PlaybookFileStore(PLAYBOOK_DIR)
|