diff --git a/.gitignore b/.gitignore index 0d4ada56..cfa9eb43 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ logs/ # uv .venv/ templates/llm-prompt/user/*.jinja2 +templates/llm-prompt/std/user/ templates/world-state/*.yaml tts/voice/piper/*.onnx tts/voice/piper/*.json diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index 769770bb..3b32117e 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -12,6 +12,7 @@ - "Prompt From Template Node: Added a dedupe property (default on) to control prompt deduplication. Useful for templates with structured repeated content like beat listings that should not be deduplicated." - "Gemma 4 Prompt Template: Added a prompt template for Google's Gemma 4 model family, with support for toggling thinking mode via the reasoning tokens setting." - "Advance Time: Moved time tools into a dedicated sub-component with grouped presets (Minutes, Hours, Days, Weeks, Months, Years) and a custom time dialog for arbitrary durations." + - "LLM Prompt Templates Manager: Added a dedicated UI tab for viewing, creating, editing, and deleting LLM prompt templates. Built-in templates are read-only and can be copied to user templates. User templates are stored in std/user/ and gitignored. GGUF/llama.cpp chat templates can be used directly." fixes: - "Message Regeneration: Fixed a bug where regenerating a message that fails (e.g., missing agent or function) would permanently remove the original message from the scene. The original message is now restored to both history and UI on failure, and unhandled exceptions during regeneration are caught." - "Anthropic Client: Fixed generation error caused by missing required max_tokens parameter when response length capping is disabled. Now defaults to the model's API output token limit." @@ -26,6 +27,7 @@ - "Encryption: Fixed API keys in agent action configs (e.g., OpenAI-compatible TTS, visual backends) being stored in plaintext. The encryption walker now handles the nested AgentActionConfig structure where the key is wrapped in a {value: ...} dict." - "Agent Config: Stopped persisting unified_api_key fields to config.yaml. These are static reference pointers (e.g., 'openai.api_key') defined in code and never change, so saving them was unnecessary." - "Contextual Generate: Fixed list-type generation prefilling an empty line after '1.', which caused some LLMs to produce empty lists." + - "Narrate Progress: Fixed narration sometimes generating screenplay-style dialogue entries instead of continuous narrative prose." - "Advance Time: Fixed toolbar time advancement being completely non-functional due to a missing websocket handler. Also fixed invalid ISO 8601 duration strings for the 1 week and 2 weeks options." improvements: - "System Prompt Override Indicators: The system prompt override list now shows a pencil icon next to entries that have an active override, making it easy to see which prompts have been customized." diff --git a/src/talemate/client/model_prompts.py b/src/talemate/client/model_prompts.py index ecfe340a..ca4f26c0 100644 --- a/src/talemate/client/model_prompts.py +++ b/src/talemate/client/model_prompts.py @@ -1,3 +1,4 @@ +import datetime import json import os import shutil @@ -23,6 +24,9 @@ BASE_TEMPLATE_PATH = os.path.join( # holds the default templates STD_TEMPLATE_PATH = os.path.join(BASE_TEMPLATE_PATH, "std") +# user-supplied base templates (sits inside std/ but gitignored) +STD_USER_TEMPLATE_PATH = os.path.join(STD_TEMPLATE_PATH, "user") + # llm prompt templates provided by talemate TALEMATE_TEMPLATE_PATH = os.path.join(BASE_TEMPLATE_PATH, "talemate") @@ -42,6 +46,10 @@ def register_template_identifier(cls): log = structlog.get_logger("talemate.model_prompts") +def _raise_exception(msg): + raise Exception(msg) + + class PromptSpec(pydantic.BaseModel): template: str | None = None reasoning_pattern: str | None = None @@ -77,7 +85,12 @@ class ModelPrompt: @property def std_templates(self) -> list[str]: env = Environment(loader=FileSystemLoader(STD_TEMPLATE_PATH)) - return sorted(env.list_templates()) + all_templates = env.list_templates() + # Built-in: everything not under user/ subdirectory + builtin = [t for t in all_templates if not t.startswith("user/")] + # User-supplied: explicitly from user/ subdir + user = [t for t in all_templates if t.startswith("user/")] + return sorted(builtin) + sorted(user) def __call__( self, @@ -112,9 +125,18 @@ class ModelPrompt: spec.template = template_file + # Build GGUF/llama.cpp compatible messages list + messages = [] + if system_message: + messages.append({"role": "system", "content": system_message}) + messages.append({"role": "user", "content": user_message.strip()}) + if coercion_message: + messages.append({"role": "assistant", "content": coercion_message}) + return ( template.render( { + # Talemate native vars "system_message": system_message, "prompt": prompt.strip(), "user_message": user_message.strip(), @@ -124,6 +146,15 @@ class ModelPrompt: ), "reasoning_tokens": reasoning_tokens, "spec": spec, + # GGUF/llama.cpp compatible vars + "messages": messages, + "bos_token": "", + "eos_token": "", + "add_generation_prompt": True, + "enable_thinking": reasoning_tokens > 0, + "thinking_budget": reasoning_tokens, + "strftime_now": lambda fmt: datetime.datetime.now().strftime(fmt), + "raise_exception": _raise_exception, } ), template_file, @@ -193,19 +224,71 @@ class ModelPrompt: def create_user_override(self, template_name: str, model_name: str): """ - Will copy STD_TEMPLATE_PATH/template_name to USER_TEMPLATE_PATH/model_name.jinja2 + Will copy a std template to USER_TEMPLATE_PATH/model_name.jinja2 + + Supports both built-in templates (e.g. "ChatML.jinja2") and + user-supplied templates (e.g. "user/MyTemplate.jinja2"). """ template_name = template_name.split(".jinja2")[0] cleaned_model_name = self.clean_model_name(model_name) - shutil.copyfile( - os.path.join(STD_TEMPLATE_PATH, template_name + ".jinja2"), - os.path.join(USER_TEMPLATE_PATH, cleaned_model_name + ".jinja2"), - ) + if template_name.startswith("user/"): + safe_name = os.path.basename(template_name[5:]) + source_path = os.path.join( + STD_USER_TEMPLATE_PATH, safe_name + ".jinja2" + ) + else: + source_path = os.path.join(STD_TEMPLATE_PATH, template_name + ".jinja2") - return os.path.join(USER_TEMPLATE_PATH, cleaned_model_name + ".jinja2") + dest_path = os.path.join(USER_TEMPLATE_PATH, cleaned_model_name + ".jinja2") + shutil.copyfile(source_path, dest_path) + + return dest_path + + def _list_templates_in_dir(self, directory: str) -> list[dict]: + """List .jinja2 templates in a directory with their content.""" + if not os.path.isdir(directory): + return [] + results = [] + for fname in sorted(os.listdir(directory)): + if not fname.endswith(".jinja2"): + continue + fpath = os.path.join(directory, fname) + if not os.path.isfile(fpath): + continue + with open(fpath, "r", encoding="utf-8") as f: + results.append({"name": fname, "content": f.read()}) + return results + + def list_std_builtin_templates(self) -> list[dict]: + """List built-in std/ templates with their content.""" + return self._list_templates_in_dir(STD_TEMPLATE_PATH) + + def list_std_user_templates(self) -> list[dict]: + """List user-supplied templates in std/user/ with their content.""" + return self._list_templates_in_dir(STD_USER_TEMPLATE_PATH) + + def save_std_user_template(self, template_name: str, content: str) -> str: + """Save/create a template in std/user/. Returns the file path.""" + os.makedirs(STD_USER_TEMPLATE_PATH, exist_ok=True) + safe_name = os.path.basename(template_name) + if not safe_name.endswith(".jinja2"): + safe_name += ".jinja2" + fpath = os.path.join(STD_USER_TEMPLATE_PATH, safe_name) + with open(fpath, "w", encoding="utf-8") as f: + f.write(content) + return fpath + + def delete_std_user_template(self, template_name: str) -> bool: + """Delete a template from std/user/.""" + safe_name = os.path.basename(template_name) + fpath = os.path.join(STD_USER_TEMPLATE_PATH, safe_name) + if os.path.isfile(fpath): + os.remove(fpath) + return True + return False def query_hf_for_prompt_template_suggestion(self, model_name: str): api = huggingface_hub.HfApi() diff --git a/src/talemate/server/config.py b/src/talemate/server/config.py index cb343ea4..0e595ad1 100644 --- a/src/talemate/server/config.py +++ b/src/talemate/server/config.py @@ -42,6 +42,15 @@ class DetermineLLMTemplatePayload(pydantic.BaseModel): client_name: str | None = None +class SaveLLMTemplatePayload(pydantic.BaseModel): + name: str + content: str + + +class DeleteLLMTemplatePayload(pydantic.BaseModel): + name: str + + class ToggleClientPayload(pydantic.BaseModel): name: str state: bool @@ -142,6 +151,57 @@ class ConfigPlugin(Plugin): } ) + async def handle_list_llm_templates(self, data): + """List all std/ built-in and std/user/ templates with content.""" + self.websocket_handler.queue_put( + { + "type": "config", + "action": "llm_templates_list", + "data": { + "builtin": model_prompt.list_std_builtin_templates(), + "user": model_prompt.list_std_user_templates(), + }, + } + ) + + async def handle_save_llm_template(self, data): + """Save/create a user-supplied LLM prompt template in std/user/.""" + payload = SaveLLMTemplatePayload(**data["data"]) + + try: + model_prompt.save_std_user_template(payload.name, payload.content) + log.info("Saved LLM template", name=payload.name) + self.websocket_handler.queue_put( + { + "type": "config", + "action": "save_llm_template_complete", + "data": {"success": True, "name": payload.name}, + } + ) + except Exception as e: + log.error("Failed to save LLM template", name=payload.name, error=str(e)) + self.websocket_handler.queue_put( + { + "type": "config", + "action": "save_llm_template_complete", + "data": {"success": False, "error": str(e)}, + } + ) + + async def handle_delete_llm_template(self, data): + """Delete a user-supplied LLM prompt template from std/user/.""" + payload = DeleteLLMTemplatePayload(**data["data"]) + + deleted = model_prompt.delete_std_user_template(payload.name) + log.info("Deleted LLM template", name=payload.name, deleted=deleted) + self.websocket_handler.queue_put( + { + "type": "config", + "action": "delete_llm_template_complete", + "data": {"success": deleted, "name": payload.name}, + } + ) + async def handle_set_llm_template(self, data): payload = SetLLMTemplatePayload(**data["data"]) diff --git a/talemate_frontend/src/components/ClientModal.vue b/talemate_frontend/src/components/ClientModal.vue index 6a5c5a3d..945aa850 100644 --- a/talemate_frontend/src/components/ClientModal.vue +++ b/talemate_frontend/src/components/ClientModal.vue @@ -108,6 +108,8 @@ Determine via HuggingFace + Manage + Templates @@ -364,6 +366,7 @@ export default { 'state', 'getWebsocket', 'registerMessageHandler', + 'navigateToLLMTemplates', ], data() { return { @@ -624,6 +627,10 @@ export default { close() { this.$emit('update:dialog', false); }, + openLLMTemplates() { + this.close(); + this.navigateToLLMTemplates(); + }, save() { if (!this.validateName()) { diff --git a/talemate_frontend/src/components/TalemateApp.vue b/talemate_frontend/src/components/TalemateApp.vue index d625c677..c0c0f580 100644 --- a/talemate_frontend/src/components/TalemateApp.vue +++ b/talemate_frontend/src/components/TalemateApp.vue @@ -856,6 +856,12 @@ export default { }, callAgentTool: (actionName, args) => this.callAgentTool(actionName, args), openDirectorConsole: () => this.toggleNavigation('directorConsole', true), + navigateToLLMTemplates: () => { + this.tab = 'prompts'; + this.$nextTick(() => { + this.promptsMainTab = 'llm-templates'; + }); + }, }; }, methods: { diff --git a/talemate_frontend/src/components/prompts/LLMTemplatesTab.vue b/talemate_frontend/src/components/prompts/LLMTemplatesTab.vue new file mode 100644 index 00000000..c746d47e --- /dev/null +++ b/talemate_frontend/src/components/prompts/LLMTemplatesTab.vue @@ -0,0 +1,507 @@ + + + + + + + mdi-code-braces + LLM Prompt Templates + + + Base chat formatting templates for local LLM inference + + + + + New Template + Create a new user template in std/user/ + + + + + + + + + + + + + + + + mdi-account-edit + User Templates + (editable) + + + + + mdi-file-document-edit-outline + + user/{{ tmpl.name }} + + + + No user templates yet. Click "New Template" or copy a built-in template. + + + + + + + mdi-lock-outline + Built-in Templates + (read-only) + + + + + mdi-file-document-outline + + {{ tmpl.name }} + + + + + + + + + mdi-code-braces + {{ selectedSource === 'builtin' ? 'Preview' : 'Editor' }} + + + + + + {{ selectedSource === 'user' ? 'user/' : '' }}{{ selectedTemplate.name }} + + + read-only + + + unsaved + + + + + Copy to User Templates + + + Save + + + Delete + + + + + + + + + + + mdi-file-document-edit-outline + Select a template to view or edit + User templates are fully editable. Built-in templates are read-only but can be copied. + + + + + + + + + + mdi-file-plus + Create New LLM Template + + + + + + + + + Cancel + Create + + + + + + + + + + {{ toastMessage }} + + + + + + + diff --git a/talemate_frontend/src/components/prompts/PromptsView.vue b/talemate_frontend/src/components/prompts/PromptsView.vue index 742a31c2..dc17ec93 100644 --- a/talemate_frontend/src/components/prompts/PromptsView.vue +++ b/talemate_frontend/src/components/prompts/PromptsView.vue @@ -26,6 +26,10 @@ Template Files mdi-alert + + mdi-code-braces + LLM Prompt Templates + mdi-view-split-horizontal Scene Context @@ -158,6 +162,11 @@ + + + + + import ActiveTab from './ActiveTab.vue'; import GroupTab from './GroupTab.vue'; +import LLMTemplatesTab from './LLMTemplatesTab.vue'; import PromptDetailView from './PromptDetailView.vue'; import SceneContextReviewInline from '../SceneContextReviewInline.vue'; @@ -232,6 +242,7 @@ export default { components: { ActiveTab, GroupTab, + LLMTemplatesTab, PromptDetailView, SceneContextReviewInline, },