diff --git a/dashboard/src/lib/prompt-api.ts b/dashboard/src/lib/prompt-api.ts index 96e967d8..f93c0b32 100644 --- a/dashboard/src/lib/prompt-api.ts +++ b/dashboard/src/lib/prompt-api.ts @@ -11,6 +11,7 @@ export interface PromptFileInfo { display_name: string advanced: boolean description: string + customized: boolean } export interface PromptCatalog { @@ -24,6 +25,7 @@ export interface PromptFileContent { language: string filename: string content: string + customized: boolean } export async function getPromptCatalog(): Promise> { @@ -39,6 +41,16 @@ export async function getPromptFile( return parseResponse(response) } +export async function getDefaultPromptFile( + language: string, + filename: string +): Promise> { + const response = await fetchWithAuth( + `${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}/default` + ) + return parseResponse(response) +} + export async function updatePromptFile( language: string, filename: string, @@ -50,3 +62,13 @@ export async function updatePromptFile( }) return parseResponse(response) } + +export async function resetPromptFile( + language: string, + filename: string +): Promise> { + const response = await fetchWithAuth(`${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`, { + method: 'DELETE', + }) + return parseResponse(response) +} diff --git a/dashboard/src/routes/config/prompts.tsx b/dashboard/src/routes/config/prompts.tsx index b7382950..1f63e77b 100644 --- a/dashboard/src/routes/config/prompts.tsx +++ b/dashboard/src/routes/config/prompts.tsx @@ -1,18 +1,27 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { FileText, Loader2, RefreshCw, Save, Search, SlidersHorizontal } from 'lucide-react' +import { Eye, FileText, Loader2, RefreshCw, RotateCcw, Save, Search, SlidersHorizontal } from 'lucide-react' import { CodeEditor } from '@/components/CodeEditor' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { useToast } from '@/hooks/use-toast' import { + getDefaultPromptFile, getPromptCatalog, getPromptFile, + resetPromptFile, updatePromptFile, type PromptCatalog, type PromptFileInfo, @@ -35,6 +44,10 @@ export function PromptManagementPage() { const [loadingCatalog, setLoadingCatalog] = useState(true) const [loadingFile, setLoadingFile] = useState(false) const [saving, setSaving] = useState(false) + const [resetting, setResetting] = useState(false) + const [loadingDefaultPrompt, setLoadingDefaultPrompt] = useState(false) + const [defaultPromptOpen, setDefaultPromptOpen] = useState(false) + const [defaultPromptContent, setDefaultPromptContent] = useState('') const [query, setQuery] = useState('') const [showAdvancedPrompts, setShowAdvancedPrompts] = useState(false) @@ -63,6 +76,7 @@ export function PromptManagementPage() { }, [visiblePromptFiles, query]) const selectedFile = promptFiles.find((file) => file.name === filename) + const isCustomized = selectedFile?.customized ?? false useEffect(() => { if (!filename || showAdvancedPrompts) return const currentFile = promptFiles.find((file) => file.name === filename) @@ -181,6 +195,58 @@ export function PromptManagementPage() { } } + const handleShowDefault = async () => { + if (!language || !filename) return + + try { + setLoadingDefaultPrompt(true) + setDefaultPromptOpen(true) + const result = await getDefaultPromptFile(language, filename) + if (!result.success) { + toast({ title: '读取默认 Prompt 失败', description: result.error, variant: 'destructive' }) + setDefaultPromptOpen(false) + return + } + + setDefaultPromptContent(result.data.content) + } catch (error) { + toast({ + title: '读取默认 Prompt 失败', + description: (error as Error).message, + variant: 'destructive', + }) + setDefaultPromptOpen(false) + } finally { + setLoadingDefaultPrompt(false) + } + } + + const handleReset = async () => { + if (!language || !filename || !isCustomized) return + + try { + setResetting(true) + const result = await resetPromptFile(language, filename) + if (!result.success) { + toast({ title: '恢复默认 Prompt 失败', description: result.error, variant: 'destructive' }) + return + } + + setContent(result.data.content) + setSavedContent(result.data.content) + toast({ title: '已恢复默认 Prompt', description: `${language}/${filename}` }) + void loadCatalog() + } catch (error) { + toast({ + title: '恢复默认 Prompt 失败', + description: (error as Error).message, + variant: 'destructive', + }) + } finally { + setResetting(false) + } + } + return (
@@ -211,6 +277,24 @@ export function PromptManagementPage() { {showAdvancedPrompts ? '隐藏高级' : '显示高级'} + +
{file.advanced && 高级} + {file.customized && 自定义}
{file.name} · {formatFileSize(file.size)}
{file.description && ( @@ -281,6 +366,7 @@ export function PromptManagementPage() { {selectedFile?.display_name || filename || '未选择文件'} {selectedFile?.advanced && 高级} + {isCustomized && 自定义}

{language} @@ -311,6 +397,31 @@ export function PromptManagementPage() { + +

+ + + 默认 Prompt + + {language}/{filename} 的内置模板,只读显示,不会修改或删除自定义内容。 + + + {loadingDefaultPrompt ? ( +
+ + 读取中 +
+ ) : ( + + )} +
+
) } diff --git a/pytests/prompt_test/test_prompt_manager.py b/pytests/prompt_test/test_prompt_manager.py index 58505da3..e00a2fcc 100644 --- a/pytests/prompt_test/test_prompt_manager.py +++ b/pytests/prompt_test/test_prompt_manager.py @@ -13,6 +13,7 @@ PROJECT_ROOT: Path = Path(__file__).parent.parent.parent.absolute().resolve() sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT / "src" / "config")) +from src.common.i18n.loaders import DEFAULT_LOCALE # noqa from src.prompt.prompt_manager import ( # noqa SUFFIX_PROMPT, Prompt, @@ -689,7 +690,7 @@ def test_prompt_manager_save_prompts_io_error_on_write(tmp_path, monkeypatch): original_write_text = Path.write_text def fake_write_text(self, *args, **kwargs): - if self == custom_dir / f"save_error{SUFFIX_PROMPT}": + if self == custom_dir / DEFAULT_LOCALE / f"save_error{SUFFIX_PROMPT}": raise OSError("disk write error") return original_write_text(self, *args, **kwargs) @@ -863,7 +864,7 @@ def test_prompt_manager_save_prompts_use_custom_dir(tmp_path, monkeypatch): manager.save_prompts() # Assert: 文件应保存在 custom_dir 中 - saved_file = custom_dir / f"save_me{SUFFIX_PROMPT}" + saved_file = custom_dir / DEFAULT_LOCALE / f"save_me{SUFFIX_PROMPT}" assert saved_file.exists() assert saved_file.read_text(encoding="utf-8") == "Template {x}" diff --git a/src/prompt/prompt_manager.py b/src/prompt/prompt_manager.py index 84e2f3a6..dbc0f98a 100644 --- a/src/prompt/prompt_manager.py +++ b/src/prompt/prompt_manager.py @@ -5,8 +5,10 @@ from typing import Any, Optional import inspect +from src.common.i18n import get_locale +from src.common.i18n.loaders import DEFAULT_LOCALE, normalize_locale from src.common.logger import get_logger -from src.common.prompt_i18n import list_prompt_templates, load_prompt +from src.common.prompt_i18n import list_prompt_templates logger = get_logger("Prompt") @@ -23,6 +25,44 @@ CUSTOM_PROMPTS_DIR.mkdir(parents=True, exist_ok=True) SUFFIX_PROMPT = ".prompt" +def _normalize_prompt_locale(locale: str | None = None) -> str: + return normalize_locale(locale or get_locale()) + + +def _get_prompt_locale_from_path(prompt_path: Path) -> str | None: + try: + relative_path = prompt_path.resolve().relative_to(PROMPTS_DIR.resolve()) + except ValueError: + return None + + return relative_path.parts[0] if len(relative_path.parts) > 1 else None + + +def _custom_prompt_path(prompt_name: str, locale: str | None = None) -> Path: + return CUSTOM_PROMPTS_DIR / _normalize_prompt_locale(locale) / f"{prompt_name}{SUFFIX_PROMPT}" + + +def _legacy_custom_prompt_path(prompt_name: str) -> Path: + return CUSTOM_PROMPTS_DIR / f"{prompt_name}{SUFFIX_PROMPT}" + + +def _iter_custom_prompt_candidates(prompt_name: str, locale: str | None = None) -> list[Path]: + candidates: list[Path] = [] + if locale: + candidates.append(_custom_prompt_path(prompt_name, locale)) + candidates.append(_legacy_custom_prompt_path(prompt_name)) + return candidates + + +def _iter_active_custom_prompt_dirs() -> list[Path]: + prompt_dirs = [ + CUSTOM_PROMPTS_DIR / DEFAULT_LOCALE, + CUSTOM_PROMPTS_DIR / _normalize_prompt_locale(), + CUSTOM_PROMPTS_DIR, + ] + return list(dict.fromkeys(prompt_dirs)) + + class Prompt: def __init__(self, prompt_name: str, template: str) -> None: self.prompt_name = prompt_name @@ -74,8 +114,10 @@ class PromptManager: """模板解析器""" self._prompt_to_save: set[str] = set() """需要保存的 Prompt 名称集合""" + self._prompt_save_locales: dict[str, str] = {} + """Prompt 保存时使用的语言目录""" - def add_prompt(self, prompt: Prompt, need_save: bool = False) -> None: + def add_prompt(self, prompt: Prompt, need_save: bool = False, prompt_locale: str | None = None) -> None: """ 添加一个新的 Prompt 实例 @@ -91,6 +133,7 @@ class PromptManager: self.prompts[prompt.prompt_name] = prompt if need_save: self._prompt_to_save.add(prompt.prompt_name) + self._prompt_save_locales[prompt.prompt_name] = _normalize_prompt_locale(prompt_locale) def remove_prompt(self, prompt_name: str) -> None: """ @@ -105,8 +148,9 @@ class PromptManager: del self.prompts[prompt_name] if prompt_name in self._prompt_to_save: self._prompt_to_save.remove(prompt_name) + self._prompt_save_locales.pop(prompt_name, None) - def replace_prompt(self, prompt: Prompt, need_save: bool = False) -> None: + def replace_prompt(self, prompt: Prompt, need_save: bool = False, prompt_locale: str | None = None) -> None: """ 替换一个已存在的 Prompt 实例 Args: @@ -120,8 +164,10 @@ class PromptManager: self.prompts[prompt.prompt_name] = prompt if need_save: self._prompt_to_save.add(prompt.prompt_name) + self._prompt_save_locales[prompt.prompt_name] = _normalize_prompt_locale(prompt_locale) elif prompt.prompt_name in self._prompt_to_save: self._prompt_to_save.remove(prompt.prompt_name) + self._prompt_save_locales.pop(prompt.prompt_name, None) def add_context_construct_function(self, name: str, func: Callable[[str], str | Coroutine[Any, Any, str]]) -> None: """ @@ -245,27 +291,33 @@ class PromptManager: Raises: Exception: 如果在保存过程中出现任何文件操作错误则引发该异常 """ - # 先清空自定义目录下的所有 Prompt 文件 - for prompt_file in CUSTOM_PROMPTS_DIR.glob(f"*{SUFFIX_PROMPT}"): - try: - prompt_file.unlink() - except Exception as exc: - logger.error(f"删除自定义 Prompt 文件 '{prompt_file}' 时出错,错误信息: {exc}") - raise + # 只清理当前加载语言层的 Prompt 文件,避免误删其它语言的用户自定义模板。 + for prompt_dir in _iter_active_custom_prompt_dirs(): + if not prompt_dir.exists(): + continue + for prompt_file in prompt_dir.glob(f"*{SUFFIX_PROMPT}"): + try: + prompt_file.unlink() + except Exception as exc: + logger.error(f"删除自定义 Prompt 文件 '{prompt_file}' 时出错,错误信息: {exc}") + raise for prompt_name in self._prompt_to_save: prompt = self.prompts[prompt_name] - file_path = CUSTOM_PROMPTS_DIR / f"{prompt_name}{SUFFIX_PROMPT}" + prompt_locale = self._prompt_save_locales.get(prompt_name, _normalize_prompt_locale()) + file_path = _custom_prompt_path(prompt_name, prompt_locale) try: + file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(prompt.template, encoding="utf-8") except Exception as exc: logger.error(f"保存 Prompt '{prompt_name}' 时出错,文件路径: '{file_path}',错误信息: {exc}") raise - def _load_prompt_template(self, prompt_name: str) -> tuple[str, bool]: - custom_prompt_path = CUSTOM_PROMPTS_DIR / f"{prompt_name}{SUFFIX_PROMPT}" - if custom_prompt_path.exists(): - return custom_prompt_path.read_text(encoding="utf-8"), True - return load_prompt(prompt_name, prompts_root=PROMPTS_DIR), False + def _load_prompt_template(self, prompt_name: str, source_path: Path) -> tuple[str, bool, str | None]: + prompt_locale = _get_prompt_locale_from_path(source_path) + for custom_prompt_path in _iter_custom_prompt_candidates(prompt_name, prompt_locale): + if custom_prompt_path.exists(): + return custom_prompt_path.read_text(encoding="utf-8"), True, prompt_locale + return source_path.read_text(encoding="utf-8"), False, prompt_locale def load_prompts(self) -> None: """ @@ -276,20 +328,34 @@ class PromptManager: prompt_templates = list_prompt_templates(prompts_root=PROMPTS_DIR) for prompt_name, prompt_template in prompt_templates.items(): try: - template, need_save = self._load_prompt_template(prompt_name) - self.add_prompt(Prompt(prompt_name=prompt_name, template=template), need_save=need_save) + template, need_save, prompt_locale = self._load_prompt_template(prompt_name, prompt_template.path) + self.add_prompt( + Prompt(prompt_name=prompt_name, template=template), + need_save=need_save, + prompt_locale=prompt_locale, + ) except Exception as exc: logger.error(f"加载 Prompt 文件 '{prompt_template.path}' 时出错,错误信息: {exc}") raise - for prompt_file in CUSTOM_PROMPTS_DIR.glob(f"*{SUFFIX_PROMPT}"): - if prompt_file.stem in prompt_templates: - continue # 已经加载过了,跳过 - try: - template = prompt_file.read_text(encoding="utf-8") - self.add_prompt(Prompt(prompt_name=prompt_file.stem, template=template), need_save=True) - except Exception as exc: - logger.error(f"加载自定义 Prompt 文件 '{prompt_file}' 时出错,错误信息: {exc}") - raise + loaded_custom_prompts = set(prompt_templates) + for prompt_dir in _iter_active_custom_prompt_dirs(): + if not prompt_dir.exists(): + continue + prompt_locale = prompt_dir.name if prompt_dir.parent == CUSTOM_PROMPTS_DIR else None + for prompt_file in prompt_dir.glob(f"*{SUFFIX_PROMPT}"): + if prompt_file.stem in loaded_custom_prompts: + continue # 已经加载过了,跳过 + try: + template = prompt_file.read_text(encoding="utf-8") + self.add_prompt( + Prompt(prompt_name=prompt_file.stem, template=template), + need_save=True, + prompt_locale=prompt_locale, + ) + loaded_custom_prompts.add(prompt_file.stem) + except Exception as exc: + logger.error(f"加载自定义 Prompt 文件 '{prompt_file}' 时出错,错误信息: {exc}") + raise async def _get_function_result( self, diff --git a/src/webui/routers/config.py b/src/webui/routers/config.py index f05e9e74..5f5feff0 100644 --- a/src/webui/routers/config.py +++ b/src/webui/routers/config.py @@ -55,6 +55,7 @@ PromptContentBody = Annotated[str, Body(embed=True)] router = APIRouter(prefix="/config", tags=["config"], dependencies=[Depends(require_auth)]) PROMPTS_DIR = PROJECT_ROOT / "prompts" +CUSTOM_PROMPTS_DIR = PROJECT_ROOT / "data" / "custom_prompts" MAISAKA_PROMPT_PREVIEW_DIR = (PROJECT_ROOT / "logs" / "maisaka_prompt").resolve() @@ -67,6 +68,7 @@ class PromptFileInfo(BaseModel): display_name: str = Field(default="", description="Prompt 展示名称") advanced: bool = Field(default=False, description="是否为高级 Prompt") description: str = Field(default="", description="Prompt 描述") + customized: bool = Field(default=False, description="是否存在用户自定义覆盖") class PromptCatalogResponse(BaseModel): @@ -84,6 +86,7 @@ class PromptFileResponse(BaseModel): language: str filename: str content: str + customized: bool = False def _safe_prompt_path(language: str, filename: str) -> Path: @@ -106,6 +109,26 @@ def _safe_prompt_path(language: str, filename: str) -> Path: return prompt_path +def _safe_custom_prompt_path(language: str, filename: str) -> Path: + """校验并解析 data/custom_prompts 下的用户覆盖文件路径。""" + + normalized_language = language.strip() + normalized_filename = filename.strip() + + if not normalized_language or any(part in normalized_language for part in ("..", "/", "\\")): + raise HTTPException(status_code=400, detail="无效的 Prompt 语言目录") + if not normalized_filename.endswith(".prompt") or any(part in normalized_filename for part in ("..", "/", "\\")): + raise HTTPException(status_code=400, detail="无效的 Prompt 文件名") + + prompt_path = (CUSTOM_PROMPTS_DIR / normalized_language / normalized_filename).resolve() + custom_prompts_root = CUSTOM_PROMPTS_DIR.resolve() + try: + prompt_path.relative_to(custom_prompts_root) + except ValueError as exc: + raise HTTPException(status_code=400, detail="Prompt 路径越界") from exc + return prompt_path + + def _safe_maisaka_prompt_preview_path(relative_path: str) -> Path: """校验并解析 MaiSaka Prompt HTML 预览路径。""" @@ -219,7 +242,9 @@ async def list_prompt_files(): prompt_template_infos = list_prompt_templates(locale=language, prompts_root=PROMPTS_DIR) prompt_files: List[PromptFileInfo] = [] for prompt_file in sorted(language_dir.glob("*.prompt"), key=lambda item: item.name): - stat = prompt_file.stat() + custom_prompt_file = _safe_custom_prompt_path(language, prompt_file.name) + effective_prompt_file = custom_prompt_file if custom_prompt_file.exists() else prompt_file + stat = effective_prompt_file.stat() template_info = prompt_template_infos.get(prompt_file.stem) metadata = template_info.metadata if template_info and template_info.path == prompt_file else None prompt_files.append( @@ -230,6 +255,7 @@ async def list_prompt_files(): display_name=metadata.display_name if metadata else "", advanced=metadata.advanced if metadata else False, description=metadata.description if metadata else "", + customized=custom_prompt_file.exists(), ) ) @@ -248,16 +274,39 @@ async def list_prompt_files(): async def get_prompt_file(language: str, filename: str): """读取指定语言下的 Prompt 文件内容。""" + prompt_path = _safe_prompt_path(language, filename) + custom_prompt_path = _safe_custom_prompt_path(language, filename) + if not prompt_path.exists() or not prompt_path.is_file(): + raise HTTPException(status_code=404, detail="Prompt 文件不存在") + + try: + effective_prompt_path = custom_prompt_path if custom_prompt_path.exists() else prompt_path + content = effective_prompt_path.read_text(encoding="utf-8") + return PromptFileResponse( + language=language, + filename=filename, + content=content, + customized=custom_prompt_path.exists(), + ) + except Exception as e: + logger.error(f"读取 Prompt 文件失败: {prompt_path} {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"读取 Prompt 文件失败: {str(e)}") from e + + +@router.get("/prompts/{language}/{filename}/default", response_model=PromptFileResponse) +async def get_default_prompt_file(language: str, filename: str): + """只读获取内置 Prompt 模板内容,不读取或修改用户自定义覆盖。""" + prompt_path = _safe_prompt_path(language, filename) if not prompt_path.exists() or not prompt_path.is_file(): raise HTTPException(status_code=404, detail="Prompt 文件不存在") try: content = prompt_path.read_text(encoding="utf-8") - return PromptFileResponse(language=language, filename=filename, content=content) + return PromptFileResponse(language=language, filename=filename, content=content, customized=False) except Exception as e: - logger.error(f"读取 Prompt 文件失败: {prompt_path} {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"读取 Prompt 文件失败: {str(e)}") from e + logger.error(f"读取默认 Prompt 文件失败: {prompt_path} {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"读取默认 Prompt 文件失败: {str(e)}") from e @router.put("/prompts/{language}/{filename}", response_model=PromptFileResponse) @@ -265,19 +314,40 @@ async def update_prompt_file(language: str, filename: str, content: PromptConten """更新指定语言下的 Prompt 文件内容。""" prompt_path = _safe_prompt_path(language, filename) + custom_prompt_path = _safe_custom_prompt_path(language, filename) if not prompt_path.parent.exists() or not prompt_path.parent.is_dir(): raise HTTPException(status_code=404, detail="Prompt 语言目录不存在") if not prompt_path.exists() or not prompt_path.is_file(): raise HTTPException(status_code=404, detail="Prompt 文件不存在") try: - prompt_path.write_text(content, encoding="utf-8", newline="\n") - return PromptFileResponse(language=language, filename=filename, content=content) + custom_prompt_path.parent.mkdir(parents=True, exist_ok=True) + custom_prompt_path.write_text(content, encoding="utf-8", newline="\n") + return PromptFileResponse(language=language, filename=filename, content=content, customized=True) except Exception as e: logger.error(f"保存 Prompt 文件失败: {prompt_path} {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"保存 Prompt 文件失败: {str(e)}") from e +@router.delete("/prompts/{language}/{filename}", response_model=PromptFileResponse) +async def reset_prompt_file(language: str, filename: str): + """删除用户自定义覆盖,恢复使用内置 Prompt 模板。""" + + prompt_path = _safe_prompt_path(language, filename) + custom_prompt_path = _safe_custom_prompt_path(language, filename) + if not prompt_path.exists() or not prompt_path.is_file(): + raise HTTPException(status_code=404, detail="Prompt 文件不存在") + + try: + if custom_prompt_path.exists(): + custom_prompt_path.unlink() + content = prompt_path.read_text(encoding="utf-8") + return PromptFileResponse(language=language, filename=filename, content=content, customized=False) + except Exception as e: + logger.error(f"恢复 Prompt 默认模板失败: {prompt_path} {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"恢复 Prompt 默认模板失败: {str(e)}") from e + + @router.get("/maisaka-prompt-preview", response_class=FileResponse) async def get_maisaka_prompt_preview(path: str = Query(..., description="logs/maisaka_prompt 下的相对 HTML 路径")): """打开 MaiSaka 监控中生成的 Prompt HTML 预览。"""