mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-09-01 19:48:52 +02:00
llm prompt template management ux
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@
|
||||
<v-card-actions v-if="!waitingForTemplateSelection">
|
||||
<v-btn @click.stop="determineBestTemplate" prepend-icon="mdi-web-box">Determine via
|
||||
HuggingFace</v-btn>
|
||||
<v-btn @click.stop="openLLMTemplates" prepend-icon="mdi-file-cog-outline">Manage
|
||||
Templates</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
<v-checkbox v-model="client.lock_template" hint="If checked, the prompt template will not longer automatically update." density="compact" color="primary">
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
507
talemate_frontend/src/components/prompts/LLMTemplatesTab.vue
Normal file
507
talemate_frontend/src/components/prompts/LLMTemplatesTab.vue
Normal file
@@ -0,0 +1,507 @@
|
||||
<template>
|
||||
<div class="llm-templates-tab">
|
||||
<!-- Header -->
|
||||
<div class="header d-flex align-center pa-3">
|
||||
<div>
|
||||
<span class="text-subtitle-1 font-weight-medium">
|
||||
<v-icon start size="small">mdi-code-braces</v-icon>
|
||||
LLM Prompt Templates
|
||||
</span>
|
||||
<span class="text-caption text-grey ml-2">
|
||||
Base chat formatting templates for local LLM inference
|
||||
</span>
|
||||
</div>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="openNewDialog"
|
||||
>
|
||||
New Template
|
||||
<v-tooltip activator="parent" location="top">Create a new user template in std/user/</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- Main Content: List + Editor -->
|
||||
<v-row no-gutters class="content-split">
|
||||
<!-- List Panel -->
|
||||
<v-col cols="auto" class="list-panel pa-2">
|
||||
<div v-if="loading" class="d-flex justify-center align-center pa-8">
|
||||
<v-progress-circular indeterminate color="primary" size="32"></v-progress-circular>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- User Templates -->
|
||||
<div class="text-subtitle-2 text-grey mb-1">
|
||||
<v-icon size="small" class="mr-1">mdi-account-edit</v-icon>
|
||||
User Templates
|
||||
<span class="text-caption">(editable)</span>
|
||||
</div>
|
||||
<v-list density="compact" class="mb-3" v-if="userTemplates.length > 0">
|
||||
<v-list-item
|
||||
v-for="tmpl in userTemplates"
|
||||
:key="'user/' + tmpl.name"
|
||||
:active="selectedKey === 'user/' + tmpl.name"
|
||||
@click="selectTemplate('user', tmpl)"
|
||||
class="template-item"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" color="primary">mdi-file-document-edit-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2">user/{{ tmpl.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="text-caption text-grey pa-2 mb-3">
|
||||
No user templates yet. Click "New Template" or copy a built-in template.
|
||||
</div>
|
||||
|
||||
<v-divider class="mb-2"></v-divider>
|
||||
|
||||
<!-- Built-in Templates -->
|
||||
<div class="text-subtitle-2 text-grey mb-1">
|
||||
<v-icon size="small" class="mr-1">mdi-lock-outline</v-icon>
|
||||
Built-in Templates
|
||||
<span class="text-caption">(read-only)</span>
|
||||
</div>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="tmpl in builtinTemplates"
|
||||
:key="'builtin/' + tmpl.name"
|
||||
:active="selectedKey === 'builtin/' + tmpl.name"
|
||||
@click="selectTemplate('builtin', tmpl)"
|
||||
class="template-item"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" color="grey">mdi-file-document-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2">{{ tmpl.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-col>
|
||||
|
||||
<!-- Editor Panel -->
|
||||
<v-col class="editor-panel pa-2">
|
||||
<div class="text-subtitle-2 text-grey mb-2">
|
||||
<v-icon size="small" class="mr-1">mdi-code-braces</v-icon>
|
||||
{{ selectedSource === 'builtin' ? 'Preview' : 'Editor' }}
|
||||
</div>
|
||||
|
||||
<v-card v-if="selectedTemplate" flat class="editor-container">
|
||||
<v-card-subtitle class="pa-2 d-flex align-center">
|
||||
<v-chip size="small" label :color="selectedSource === 'user' ? 'primary' : 'grey'" variant="tonal">
|
||||
{{ selectedSource === 'user' ? 'user/' : '' }}{{ selectedTemplate.name }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="selectedSource === 'builtin'"
|
||||
size="small"
|
||||
label
|
||||
color="grey"
|
||||
variant="outlined"
|
||||
class="ml-2"
|
||||
>
|
||||
read-only
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="isDirty"
|
||||
size="small"
|
||||
label
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
class="ml-2"
|
||||
>
|
||||
unsaved
|
||||
</v-chip>
|
||||
<v-spacer></v-spacer>
|
||||
<div class="actions d-flex ga-2">
|
||||
<v-btn
|
||||
v-if="selectedSource === 'builtin'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-content-copy"
|
||||
@click="copyToUser"
|
||||
>
|
||||
Copy to User Templates
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="selectedSource === 'user'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-content-save"
|
||||
:disabled="!isDirty"
|
||||
:loading="saving"
|
||||
@click="saveTemplate"
|
||||
>
|
||||
Save
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="selectedSource === 'user'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
color="error"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-subtitle>
|
||||
<v-card-text class="pa-0">
|
||||
<Codemirror
|
||||
v-model="editorContent"
|
||||
:extensions="extensions"
|
||||
:disabled="selectedSource === 'builtin'"
|
||||
class="code-editor"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card v-else flat color="transparent" class="d-flex align-center justify-center" style="min-height: 300px;">
|
||||
<v-card-text class="text-center text-grey">
|
||||
<v-icon size="64" color="grey-darken-1">mdi-file-document-edit-outline</v-icon>
|
||||
<div class="mt-2">Select a template to view or edit</div>
|
||||
<div class="text-caption">User templates are fully editable. Built-in templates are read-only but can be copied.</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- New Template Dialog -->
|
||||
<v-dialog v-model="showNewDialog" max-width="450">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-file-plus</v-icon>
|
||||
Create New LLM Template
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form ref="newForm" v-model="newFormValid" @submit.prevent="createTemplate">
|
||||
<v-text-field
|
||||
v-model="newTemplateName"
|
||||
label="Template name"
|
||||
:rules="[
|
||||
v => !!v || 'Name is required',
|
||||
v => validateFileName(v)
|
||||
]"
|
||||
required
|
||||
autofocus
|
||||
hint="e.g. MyModel. Extension .jinja2 will be added automatically."
|
||||
persistent-hint
|
||||
></v-text-field>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="showNewDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" variant="tonal" :disabled="!newFormValid" @click="createTemplate">Create</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<ConfirmActionPrompt
|
||||
ref="deletePrompt"
|
||||
actionLabel="Delete Template"
|
||||
:description="`Permanently delete user/${selectedTemplate?.name || ''}?`"
|
||||
icon="mdi-delete"
|
||||
color="error"
|
||||
@confirm="deleteTemplate"
|
||||
/>
|
||||
|
||||
<!-- Toast notification -->
|
||||
<v-snackbar
|
||||
v-model="showToast"
|
||||
:color="toastColor"
|
||||
:timeout="5000"
|
||||
location="top"
|
||||
>
|
||||
{{ toastMessage }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Codemirror } from 'vue-codemirror';
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import ConfirmActionPrompt from '../ConfirmActionPrompt.vue';
|
||||
|
||||
export default {
|
||||
name: 'LLMTemplatesTab',
|
||||
components: {
|
||||
Codemirror,
|
||||
ConfirmActionPrompt,
|
||||
},
|
||||
inject: [
|
||||
'getWebsocket',
|
||||
'registerMessageHandler',
|
||||
'unregisterMessageHandler',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
builtinTemplates: [],
|
||||
userTemplates: [],
|
||||
loading: false,
|
||||
saving: false,
|
||||
|
||||
// Selection state
|
||||
selectedSource: null, // 'builtin' or 'user'
|
||||
selectedTemplate: null, // {name, content}
|
||||
editorContent: '',
|
||||
originalContent: '',
|
||||
|
||||
// New template dialog
|
||||
showNewDialog: false,
|
||||
newTemplateName: '',
|
||||
newFormValid: false,
|
||||
|
||||
// Toast
|
||||
showToast: false,
|
||||
toastMessage: '',
|
||||
toastColor: 'success',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectedKey() {
|
||||
if (!this.selectedTemplate || !this.selectedSource) return null;
|
||||
return `${this.selectedSource}/${this.selectedTemplate.name}`;
|
||||
},
|
||||
isDirty() {
|
||||
return this.selectedSource === 'user' && this.editorContent !== this.originalContent;
|
||||
},
|
||||
extensions() {
|
||||
return [
|
||||
markdown({
|
||||
base: markdownLanguage,
|
||||
codeLanguages: languages,
|
||||
}),
|
||||
oneDark,
|
||||
EditorView.lineWrapping,
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
requestTemplates() {
|
||||
this.loading = true;
|
||||
this.getWebsocket().send(JSON.stringify({
|
||||
type: 'config',
|
||||
action: 'list_llm_templates',
|
||||
data: {},
|
||||
}));
|
||||
},
|
||||
|
||||
selectTemplate(source, tmpl) {
|
||||
this.selectedSource = source;
|
||||
this.selectedTemplate = tmpl;
|
||||
this.editorContent = tmpl.content;
|
||||
this.originalContent = tmpl.content;
|
||||
},
|
||||
|
||||
copyToUser() {
|
||||
if (!this.selectedTemplate) return;
|
||||
const name = this.selectedTemplate.name;
|
||||
const content = this.selectedTemplate.content;
|
||||
|
||||
// Check if user template with same name already exists
|
||||
if (this.userTemplates.some(t => t.name === name)) {
|
||||
this.showNotification(`User template "${name}" already exists. Delete it first or choose a different name.`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this._pendingSelectUserTemplate = name;
|
||||
this.saving = true;
|
||||
this.getWebsocket().send(JSON.stringify({
|
||||
type: 'config',
|
||||
action: 'save_llm_template',
|
||||
data: { name, content },
|
||||
}));
|
||||
},
|
||||
|
||||
saveTemplate() {
|
||||
if (!this.selectedTemplate || !this.isDirty) return;
|
||||
this.saving = true;
|
||||
this.getWebsocket().send(JSON.stringify({
|
||||
type: 'config',
|
||||
action: 'save_llm_template',
|
||||
data: {
|
||||
name: this.selectedTemplate.name,
|
||||
content: this.editorContent,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
confirmDelete() {
|
||||
this.$refs.deletePrompt.initiateAction({});
|
||||
},
|
||||
|
||||
deleteTemplate() {
|
||||
if (!this.selectedTemplate) return;
|
||||
this.getWebsocket().send(JSON.stringify({
|
||||
type: 'config',
|
||||
action: 'delete_llm_template',
|
||||
data: { name: this.selectedTemplate.name },
|
||||
}));
|
||||
},
|
||||
|
||||
openNewDialog() {
|
||||
this.newTemplateName = '';
|
||||
this.showNewDialog = true;
|
||||
},
|
||||
|
||||
createTemplate() {
|
||||
if (!this.newFormValid || !this.newTemplateName) return;
|
||||
|
||||
let name = this.newTemplateName;
|
||||
if (!name.endsWith('.jinja2')) {
|
||||
name += '.jinja2';
|
||||
}
|
||||
|
||||
this._pendingSelectUserTemplate = name;
|
||||
this.getWebsocket().send(JSON.stringify({
|
||||
type: 'config',
|
||||
action: 'save_llm_template',
|
||||
data: {
|
||||
name,
|
||||
content: '{#- GGUF/llama.cpp chat templates also work here (messages, bos_token, eos_token, add_generation_prompt, etc.) -#}\n{{ system_message }}\n\n{{ user_message }}\n\n{{ coercion_message }}\n',
|
||||
},
|
||||
}));
|
||||
|
||||
this.showNewDialog = false;
|
||||
},
|
||||
|
||||
validateFileName(value) {
|
||||
if (value == null) return true;
|
||||
if (value.includes('/') || value.includes('\\')) {
|
||||
return 'Name cannot contain directories';
|
||||
}
|
||||
if (/[<>:"|?*]/.test(value)) {
|
||||
return 'Name contains invalid characters';
|
||||
}
|
||||
if (value.endsWith('.jinja2')) {
|
||||
return 'Extension will be added automatically';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
showNotification(message, color = 'success') {
|
||||
this.toastMessage = message;
|
||||
this.toastColor = color;
|
||||
this.showToast = true;
|
||||
},
|
||||
|
||||
handleMessage(data) {
|
||||
if (data.type !== 'config') return;
|
||||
|
||||
switch (data.action) {
|
||||
case 'llm_templates_list':
|
||||
this.loading = false;
|
||||
this.builtinTemplates = data.data.builtin || [];
|
||||
this.userTemplates = data.data.user || [];
|
||||
// Auto-select a user template if one was just created/copied
|
||||
if (this._pendingSelectUserTemplate) {
|
||||
const tmpl = this.userTemplates.find(t => t.name === this._pendingSelectUserTemplate);
|
||||
if (tmpl) {
|
||||
this.selectTemplate('user', tmpl);
|
||||
}
|
||||
this._pendingSelectUserTemplate = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'save_llm_template_complete':
|
||||
this.saving = false;
|
||||
if (data.data.success) {
|
||||
this.showNotification('Template saved successfully');
|
||||
// Update the local content as saved
|
||||
if (this.selectedSource === 'user' && this.selectedTemplate) {
|
||||
this.originalContent = this.editorContent;
|
||||
this.selectedTemplate.content = this.editorContent;
|
||||
}
|
||||
// Refresh the full list
|
||||
this.requestTemplates();
|
||||
} else {
|
||||
this.showNotification(`Failed to save: ${data.data.error}`, 'error');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete_llm_template_complete':
|
||||
if (data.data.success) {
|
||||
this.showNotification('Template deleted');
|
||||
this.selectedTemplate = null;
|
||||
this.selectedSource = null;
|
||||
this.editorContent = '';
|
||||
this.originalContent = '';
|
||||
this.requestTemplates();
|
||||
} else {
|
||||
this.showNotification('Failed to delete template', 'error');
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.registerMessageHandler(this.handleMessage);
|
||||
this.requestTemplates();
|
||||
},
|
||||
unmounted() {
|
||||
this.unregisterMessageHandler(this.handleMessage);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.llm-templates-tab {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content-split {
|
||||
height: calc(100vh - 385px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow-y: auto;
|
||||
max-width: 350px;
|
||||
min-width: 250px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
height: calc(100vh - 400px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-container .v-card-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.code-editor :deep(.cm-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.code-editor :deep(.cm-scroller) {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -26,6 +26,10 @@
|
||||
Template Files
|
||||
<v-icon v-if="outdatedCount > 0" size="x-small" color="warning" class="ml-1">mdi-alert</v-icon>
|
||||
</v-tab>
|
||||
<v-tab value="llm-templates">
|
||||
<v-icon start>mdi-code-braces</v-icon>
|
||||
LLM Prompt Templates
|
||||
</v-tab>
|
||||
<v-tab :disabled="!sceneLoaded" value="context-review">
|
||||
<v-icon start>mdi-view-split-horizontal</v-icon>
|
||||
Scene Context
|
||||
@@ -158,6 +162,11 @@
|
||||
</template>
|
||||
</v-window-item>
|
||||
|
||||
<!-- LLM Prompt Templates Tab -->
|
||||
<v-window-item value="llm-templates">
|
||||
<LLMTemplatesTab />
|
||||
</v-window-item>
|
||||
|
||||
<!-- Context Review Tab -->
|
||||
<v-window-item value="context-review">
|
||||
<SceneContextReviewInline
|
||||
@@ -224,6 +233,7 @@
|
||||
<script>
|
||||
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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user