feat:支持用户自定义prompt
This commit is contained in:
@@ -11,6 +11,7 @@ export interface PromptFileInfo {
|
|||||||
display_name: string
|
display_name: string
|
||||||
advanced: boolean
|
advanced: boolean
|
||||||
description: string
|
description: string
|
||||||
|
customized: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptCatalog {
|
export interface PromptCatalog {
|
||||||
@@ -24,6 +25,7 @@ export interface PromptFileContent {
|
|||||||
language: string
|
language: string
|
||||||
filename: string
|
filename: string
|
||||||
content: string
|
content: string
|
||||||
|
customized: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPromptCatalog(): Promise<ApiResponse<PromptCatalog>> {
|
export async function getPromptCatalog(): Promise<ApiResponse<PromptCatalog>> {
|
||||||
@@ -39,6 +41,16 @@ export async function getPromptFile(
|
|||||||
return parseResponse<PromptFileContent>(response)
|
return parseResponse<PromptFileContent>(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDefaultPromptFile(
|
||||||
|
language: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<ApiResponse<PromptFileContent>> {
|
||||||
|
const response = await fetchWithAuth(
|
||||||
|
`${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}/default`
|
||||||
|
)
|
||||||
|
return parseResponse<PromptFileContent>(response)
|
||||||
|
}
|
||||||
|
|
||||||
export async function updatePromptFile(
|
export async function updatePromptFile(
|
||||||
language: string,
|
language: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
@@ -50,3 +62,13 @@ export async function updatePromptFile(
|
|||||||
})
|
})
|
||||||
return parseResponse<PromptFileContent>(response)
|
return parseResponse<PromptFileContent>(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resetPromptFile(
|
||||||
|
language: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<ApiResponse<PromptFileContent>> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
return parseResponse<PromptFileContent>(response)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
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 { CodeEditor } from '@/components/CodeEditor'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
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 { Input } from '@/components/ui/input'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import {
|
import {
|
||||||
|
getDefaultPromptFile,
|
||||||
getPromptCatalog,
|
getPromptCatalog,
|
||||||
getPromptFile,
|
getPromptFile,
|
||||||
|
resetPromptFile,
|
||||||
updatePromptFile,
|
updatePromptFile,
|
||||||
type PromptCatalog,
|
type PromptCatalog,
|
||||||
type PromptFileInfo,
|
type PromptFileInfo,
|
||||||
@@ -35,6 +44,10 @@ export function PromptManagementPage() {
|
|||||||
const [loadingCatalog, setLoadingCatalog] = useState(true)
|
const [loadingCatalog, setLoadingCatalog] = useState(true)
|
||||||
const [loadingFile, setLoadingFile] = useState(false)
|
const [loadingFile, setLoadingFile] = useState(false)
|
||||||
const [saving, setSaving] = 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 [query, setQuery] = useState('')
|
||||||
const [showAdvancedPrompts, setShowAdvancedPrompts] = useState(false)
|
const [showAdvancedPrompts, setShowAdvancedPrompts] = useState(false)
|
||||||
|
|
||||||
@@ -63,6 +76,7 @@ export function PromptManagementPage() {
|
|||||||
}, [visiblePromptFiles, query])
|
}, [visiblePromptFiles, query])
|
||||||
|
|
||||||
const selectedFile = promptFiles.find((file) => file.name === filename)
|
const selectedFile = promptFiles.find((file) => file.name === filename)
|
||||||
|
const isCustomized = selectedFile?.customized ?? false
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filename || showAdvancedPrompts) return
|
if (!filename || showAdvancedPrompts) return
|
||||||
const currentFile = promptFiles.find((file) => file.name === filename)
|
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 (
|
return (
|
||||||
<div className="flex h-[calc(100vh-140px)] flex-col gap-4 p-4 sm:p-6">
|
<div className="flex h-[calc(100vh-140px)] flex-col gap-4 p-4 sm:p-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -211,6 +277,24 @@ export function PromptManagementPage() {
|
|||||||
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||||||
{showAdvancedPrompts ? '隐藏高级' : '显示高级'}
|
{showAdvancedPrompts ? '隐藏高级' : '显示高级'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!isCustomized || resetting || loadingFile || !filename}
|
||||||
|
>
|
||||||
|
<RotateCcw className={cn('mr-2 h-4 w-4', resetting && 'animate-spin')} />
|
||||||
|
恢复默认
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleShowDefault}
|
||||||
|
disabled={loadingDefaultPrompt || loadingFile || !filename}
|
||||||
|
>
|
||||||
|
<Eye className={cn('mr-2 h-4 w-4', loadingDefaultPrompt && 'animate-pulse')} />
|
||||||
|
查看默认
|
||||||
|
</Button>
|
||||||
<Button size="sm" onClick={handleSave} disabled={!hasUnsavedChanges || saving || loadingFile || !filename}>
|
<Button size="sm" onClick={handleSave} disabled={!hasUnsavedChanges || saving || loadingFile || !filename}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{saving ? '保存中' : hasUnsavedChanges ? '保存' : '已保存'}
|
{saving ? '保存中' : hasUnsavedChanges ? '保存' : '已保存'}
|
||||||
@@ -261,6 +345,7 @@ export function PromptManagementPage() {
|
|||||||
{file.display_name || file.name}
|
{file.display_name || file.name}
|
||||||
</div>
|
</div>
|
||||||
{file.advanced && <Badge variant="outline" className="shrink-0 text-[10px]">高级</Badge>}
|
{file.advanced && <Badge variant="outline" className="shrink-0 text-[10px]">高级</Badge>}
|
||||||
|
{file.customized && <Badge variant="secondary" className="shrink-0 text-[10px]">自定义</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">{file.name} · {formatFileSize(file.size)}</div>
|
<div className="mt-0.5 truncate text-xs text-muted-foreground">{file.name} · {formatFileSize(file.size)}</div>
|
||||||
{file.description && (
|
{file.description && (
|
||||||
@@ -281,6 +366,7 @@ export function PromptManagementPage() {
|
|||||||
<CardTitle className="flex items-center gap-2 truncate text-sm">
|
<CardTitle className="flex items-center gap-2 truncate text-sm">
|
||||||
<span className="truncate">{selectedFile?.display_name || filename || '未选择文件'}</span>
|
<span className="truncate">{selectedFile?.display_name || filename || '未选择文件'}</span>
|
||||||
{selectedFile?.advanced && <Badge variant="outline" className="shrink-0">高级</Badge>}
|
{selectedFile?.advanced && <Badge variant="outline" className="shrink-0">高级</Badge>}
|
||||||
|
{isCustomized && <Badge variant="secondary" className="shrink-0">自定义</Badge>}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{language}
|
{language}
|
||||||
@@ -311,6 +397,31 @@ export function PromptManagementPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={defaultPromptOpen} onOpenChange={setDefaultPromptOpen}>
|
||||||
|
<DialogContent className="max-w-[min(96vw,1100px)]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>默认 Prompt</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{language}/{filename} 的内置模板,只读显示,不会修改或删除自定义内容。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{loadingDefaultPrompt ? (
|
||||||
|
<div className="flex h-[520px] items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
读取中
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CodeEditor
|
||||||
|
value={defaultPromptContent}
|
||||||
|
readOnly
|
||||||
|
language="text"
|
||||||
|
height="min(62vh, 620px)"
|
||||||
|
minHeight="420px"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
sys.path.insert(0, str(PROJECT_ROOT / "src" / "config"))
|
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
|
from src.prompt.prompt_manager import ( # noqa
|
||||||
SUFFIX_PROMPT,
|
SUFFIX_PROMPT,
|
||||||
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
|
original_write_text = Path.write_text
|
||||||
|
|
||||||
def fake_write_text(self, *args, **kwargs):
|
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")
|
raise OSError("disk write error")
|
||||||
return original_write_text(self, *args, **kwargs)
|
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()
|
manager.save_prompts()
|
||||||
|
|
||||||
# Assert: 文件应保存在 custom_dir 中
|
# 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.exists()
|
||||||
assert saved_file.read_text(encoding="utf-8") == "Template {x}"
|
assert saved_file.read_text(encoding="utf-8") == "Template {x}"
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
import inspect
|
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.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")
|
logger = get_logger("Prompt")
|
||||||
@@ -23,6 +25,44 @@ CUSTOM_PROMPTS_DIR.mkdir(parents=True, exist_ok=True)
|
|||||||
SUFFIX_PROMPT = ".prompt"
|
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:
|
class Prompt:
|
||||||
def __init__(self, prompt_name: str, template: str) -> None:
|
def __init__(self, prompt_name: str, template: str) -> None:
|
||||||
self.prompt_name = prompt_name
|
self.prompt_name = prompt_name
|
||||||
@@ -74,8 +114,10 @@ class PromptManager:
|
|||||||
"""模板解析器"""
|
"""模板解析器"""
|
||||||
self._prompt_to_save: set[str] = set()
|
self._prompt_to_save: set[str] = set()
|
||||||
"""需要保存的 Prompt 名称集合"""
|
"""需要保存的 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 实例
|
添加一个新的 Prompt 实例
|
||||||
|
|
||||||
@@ -91,6 +133,7 @@ class PromptManager:
|
|||||||
self.prompts[prompt.prompt_name] = prompt
|
self.prompts[prompt.prompt_name] = prompt
|
||||||
if need_save:
|
if need_save:
|
||||||
self._prompt_to_save.add(prompt.prompt_name)
|
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:
|
def remove_prompt(self, prompt_name: str) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -105,8 +148,9 @@ class PromptManager:
|
|||||||
del self.prompts[prompt_name]
|
del self.prompts[prompt_name]
|
||||||
if prompt_name in self._prompt_to_save:
|
if prompt_name in self._prompt_to_save:
|
||||||
self._prompt_to_save.remove(prompt_name)
|
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 实例
|
替换一个已存在的 Prompt 实例
|
||||||
Args:
|
Args:
|
||||||
@@ -120,8 +164,10 @@ class PromptManager:
|
|||||||
self.prompts[prompt.prompt_name] = prompt
|
self.prompts[prompt.prompt_name] = prompt
|
||||||
if need_save:
|
if need_save:
|
||||||
self._prompt_to_save.add(prompt.prompt_name)
|
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:
|
elif prompt.prompt_name in self._prompt_to_save:
|
||||||
self._prompt_to_save.remove(prompt.prompt_name)
|
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:
|
def add_context_construct_function(self, name: str, func: Callable[[str], str | Coroutine[Any, Any, str]]) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -245,27 +291,33 @@ class PromptManager:
|
|||||||
Raises:
|
Raises:
|
||||||
Exception: 如果在保存过程中出现任何文件操作错误则引发该异常
|
Exception: 如果在保存过程中出现任何文件操作错误则引发该异常
|
||||||
"""
|
"""
|
||||||
# 先清空自定义目录下的所有 Prompt 文件
|
# 只清理当前加载语言层的 Prompt 文件,避免误删其它语言的用户自定义模板。
|
||||||
for prompt_file in CUSTOM_PROMPTS_DIR.glob(f"*{SUFFIX_PROMPT}"):
|
for prompt_dir in _iter_active_custom_prompt_dirs():
|
||||||
try:
|
if not prompt_dir.exists():
|
||||||
prompt_file.unlink()
|
continue
|
||||||
except Exception as exc:
|
for prompt_file in prompt_dir.glob(f"*{SUFFIX_PROMPT}"):
|
||||||
logger.error(f"删除自定义 Prompt 文件 '{prompt_file}' 时出错,错误信息: {exc}")
|
try:
|
||||||
raise
|
prompt_file.unlink()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"删除自定义 Prompt 文件 '{prompt_file}' 时出错,错误信息: {exc}")
|
||||||
|
raise
|
||||||
for prompt_name in self._prompt_to_save:
|
for prompt_name in self._prompt_to_save:
|
||||||
prompt = self.prompts[prompt_name]
|
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:
|
try:
|
||||||
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
file_path.write_text(prompt.template, encoding="utf-8")
|
file_path.write_text(prompt.template, encoding="utf-8")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"保存 Prompt '{prompt_name}' 时出错,文件路径: '{file_path}',错误信息: {exc}")
|
logger.error(f"保存 Prompt '{prompt_name}' 时出错,文件路径: '{file_path}',错误信息: {exc}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _load_prompt_template(self, prompt_name: str) -> tuple[str, bool]:
|
def _load_prompt_template(self, prompt_name: str, source_path: Path) -> tuple[str, bool, str | None]:
|
||||||
custom_prompt_path = CUSTOM_PROMPTS_DIR / f"{prompt_name}{SUFFIX_PROMPT}"
|
prompt_locale = _get_prompt_locale_from_path(source_path)
|
||||||
if custom_prompt_path.exists():
|
for custom_prompt_path in _iter_custom_prompt_candidates(prompt_name, prompt_locale):
|
||||||
return custom_prompt_path.read_text(encoding="utf-8"), True
|
if custom_prompt_path.exists():
|
||||||
return load_prompt(prompt_name, prompts_root=PROMPTS_DIR), False
|
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:
|
def load_prompts(self) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -276,20 +328,34 @@ class PromptManager:
|
|||||||
prompt_templates = list_prompt_templates(prompts_root=PROMPTS_DIR)
|
prompt_templates = list_prompt_templates(prompts_root=PROMPTS_DIR)
|
||||||
for prompt_name, prompt_template in prompt_templates.items():
|
for prompt_name, prompt_template in prompt_templates.items():
|
||||||
try:
|
try:
|
||||||
template, need_save = self._load_prompt_template(prompt_name)
|
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)
|
self.add_prompt(
|
||||||
|
Prompt(prompt_name=prompt_name, template=template),
|
||||||
|
need_save=need_save,
|
||||||
|
prompt_locale=prompt_locale,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"加载 Prompt 文件 '{prompt_template.path}' 时出错,错误信息: {exc}")
|
logger.error(f"加载 Prompt 文件 '{prompt_template.path}' 时出错,错误信息: {exc}")
|
||||||
raise
|
raise
|
||||||
for prompt_file in CUSTOM_PROMPTS_DIR.glob(f"*{SUFFIX_PROMPT}"):
|
loaded_custom_prompts = set(prompt_templates)
|
||||||
if prompt_file.stem in prompt_templates:
|
for prompt_dir in _iter_active_custom_prompt_dirs():
|
||||||
continue # 已经加载过了,跳过
|
if not prompt_dir.exists():
|
||||||
try:
|
continue
|
||||||
template = prompt_file.read_text(encoding="utf-8")
|
prompt_locale = prompt_dir.name if prompt_dir.parent == CUSTOM_PROMPTS_DIR else None
|
||||||
self.add_prompt(Prompt(prompt_name=prompt_file.stem, template=template), need_save=True)
|
for prompt_file in prompt_dir.glob(f"*{SUFFIX_PROMPT}"):
|
||||||
except Exception as exc:
|
if prompt_file.stem in loaded_custom_prompts:
|
||||||
logger.error(f"加载自定义 Prompt 文件 '{prompt_file}' 时出错,错误信息: {exc}")
|
continue # 已经加载过了,跳过
|
||||||
raise
|
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(
|
async def _get_function_result(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ PromptContentBody = Annotated[str, Body(embed=True)]
|
|||||||
router = APIRouter(prefix="/config", tags=["config"], dependencies=[Depends(require_auth)])
|
router = APIRouter(prefix="/config", tags=["config"], dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
PROMPTS_DIR = PROJECT_ROOT / "prompts"
|
PROMPTS_DIR = PROJECT_ROOT / "prompts"
|
||||||
|
CUSTOM_PROMPTS_DIR = PROJECT_ROOT / "data" / "custom_prompts"
|
||||||
MAISAKA_PROMPT_PREVIEW_DIR = (PROJECT_ROOT / "logs" / "maisaka_prompt").resolve()
|
MAISAKA_PROMPT_PREVIEW_DIR = (PROJECT_ROOT / "logs" / "maisaka_prompt").resolve()
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ class PromptFileInfo(BaseModel):
|
|||||||
display_name: str = Field(default="", description="Prompt 展示名称")
|
display_name: str = Field(default="", description="Prompt 展示名称")
|
||||||
advanced: bool = Field(default=False, description="是否为高级 Prompt")
|
advanced: bool = Field(default=False, description="是否为高级 Prompt")
|
||||||
description: str = Field(default="", description="Prompt 描述")
|
description: str = Field(default="", description="Prompt 描述")
|
||||||
|
customized: bool = Field(default=False, description="是否存在用户自定义覆盖")
|
||||||
|
|
||||||
|
|
||||||
class PromptCatalogResponse(BaseModel):
|
class PromptCatalogResponse(BaseModel):
|
||||||
@@ -84,6 +86,7 @@ class PromptFileResponse(BaseModel):
|
|||||||
language: str
|
language: str
|
||||||
filename: str
|
filename: str
|
||||||
content: str
|
content: str
|
||||||
|
customized: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _safe_prompt_path(language: str, filename: str) -> Path:
|
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
|
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:
|
def _safe_maisaka_prompt_preview_path(relative_path: str) -> Path:
|
||||||
"""校验并解析 MaiSaka Prompt HTML 预览路径。"""
|
"""校验并解析 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_template_infos = list_prompt_templates(locale=language, prompts_root=PROMPTS_DIR)
|
||||||
prompt_files: List[PromptFileInfo] = []
|
prompt_files: List[PromptFileInfo] = []
|
||||||
for prompt_file in sorted(language_dir.glob("*.prompt"), key=lambda item: item.name):
|
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)
|
template_info = prompt_template_infos.get(prompt_file.stem)
|
||||||
metadata = template_info.metadata if template_info and template_info.path == prompt_file else None
|
metadata = template_info.metadata if template_info and template_info.path == prompt_file else None
|
||||||
prompt_files.append(
|
prompt_files.append(
|
||||||
@@ -230,6 +255,7 @@ async def list_prompt_files():
|
|||||||
display_name=metadata.display_name if metadata else "",
|
display_name=metadata.display_name if metadata else "",
|
||||||
advanced=metadata.advanced if metadata else False,
|
advanced=metadata.advanced if metadata else False,
|
||||||
description=metadata.description if metadata else "",
|
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):
|
async def get_prompt_file(language: str, filename: str):
|
||||||
"""读取指定语言下的 Prompt 文件内容。"""
|
"""读取指定语言下的 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)
|
prompt_path = _safe_prompt_path(language, filename)
|
||||||
if not prompt_path.exists() or not prompt_path.is_file():
|
if not prompt_path.exists() or not prompt_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail="Prompt 文件不存在")
|
raise HTTPException(status_code=404, detail="Prompt 文件不存在")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = prompt_path.read_text(encoding="utf-8")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"读取 Prompt 文件失败: {prompt_path} {e}", exc_info=True)
|
logger.error(f"读取默认 Prompt 文件失败: {prompt_path} {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"读取 Prompt 文件失败: {str(e)}") from e
|
raise HTTPException(status_code=500, detail=f"读取默认 Prompt 文件失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
@router.put("/prompts/{language}/{filename}", response_model=PromptFileResponse)
|
@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 文件内容。"""
|
||||||
|
|
||||||
prompt_path = _safe_prompt_path(language, filename)
|
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():
|
if not prompt_path.parent.exists() or not prompt_path.parent.is_dir():
|
||||||
raise HTTPException(status_code=404, detail="Prompt 语言目录不存在")
|
raise HTTPException(status_code=404, detail="Prompt 语言目录不存在")
|
||||||
if not prompt_path.exists() or not prompt_path.is_file():
|
if not prompt_path.exists() or not prompt_path.is_file():
|
||||||
raise HTTPException(status_code=404, detail="Prompt 文件不存在")
|
raise HTTPException(status_code=404, detail="Prompt 文件不存在")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
prompt_path.write_text(content, encoding="utf-8", newline="\n")
|
custom_prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
return PromptFileResponse(language=language, filename=filename, content=content)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"保存 Prompt 文件失败: {prompt_path} {e}", exc_info=True)
|
logger.error(f"保存 Prompt 文件失败: {prompt_path} {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"保存 Prompt 文件失败: {str(e)}") from e
|
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)
|
@router.get("/maisaka-prompt-preview", response_class=FileResponse)
|
||||||
async def get_maisaka_prompt_preview(path: str = Query(..., description="logs/maisaka_prompt 下的相对 HTML 路径")):
|
async def get_maisaka_prompt_preview(path: str = Query(..., description="logs/maisaka_prompt 下的相对 HTML 路径")):
|
||||||
"""打开 MaiSaka 监控中生成的 Prompt HTML 预览。"""
|
"""打开 MaiSaka 监控中生成的 Prompt HTML 预览。"""
|
||||||
|
|||||||
Reference in New Issue
Block a user