diff --git a/internal/api/prompts.go b/internal/api/prompts.go index a614c4a..96ef02b 100644 --- a/internal/api/prompts.go +++ b/internal/api/prompts.go @@ -37,7 +37,14 @@ type Prompt struct { Data json.RawMessage `json:"data"` } -// PromptStore 保存 Playground 对话 +// SystemInstructionPreset 是一条保存的系统指令预设 +type SystemInstructionPreset struct { + ID string `json:"id"` + Title string `json:"title"` + Text string `json:"text"` +} + +// PromptStore 保存 Playground 对话与系统指令预设 type PromptStore interface { Prompts(ctx context.Context, pageSize int, pageToken string) ([]PromptSummary, string, error) Prompt(context.Context, string) (Prompt, error) @@ -45,9 +52,13 @@ type PromptStore interface { RenamePrompt(ctx context.Context, id, title, description string) (PromptSummary, error) CopyPrompt(ctx context.Context, source, id, title string) (PromptSummary, error) DeletePrompt(context.Context, string) error + SystemInstructions(context.Context) ([]SystemInstructionPreset, error) + SaveSystemInstructions(context.Context, []SystemInstructionPreset) error } func (s *server) registerPrompts(mux *http.ServeMux) { + mux.HandleFunc("GET /api/prompts/system-instructions", s.handleSystemInstructions) + mux.HandleFunc("PUT /api/prompts/system-instructions", s.handleSaveSystemInstructions) mux.HandleFunc("GET /api/prompts", s.handlePrompts) mux.HandleFunc("GET /api/prompts/{id}", s.handlePrompt) mux.HandleFunc("PUT /api/prompts/{id}", s.handleSavePrompt) @@ -194,6 +205,36 @@ func (s *server) handleDeletePrompt(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// handleSystemInstructions 返回保存在对话库中的系统指令预设列表 +func (s *server) handleSystemInstructions(w http.ResponseWriter, r *http.Request) { + presets, err := s.config.Prompts.SystemInstructions(r.Context()) + if err != nil { + writeAdminError(w, http.StatusInternalServerError, "prompt_store_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, struct { + Presets []SystemInstructionPreset `json:"presets"` + }{presets}) +} + +// handleSaveSystemInstructions 保存系统指令预设列表 +func (s *server) handleSaveSystemInstructions(w http.ResponseWriter, r *http.Request) { + var body struct { + Presets []SystemInstructionPreset `json:"presets"` + } + if err := decodeJSON(r, &body); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if err := s.config.Prompts.SaveSystemInstructions(r.Context(), body.Presets); err != nil { + writeAdminError(w, http.StatusInternalServerError, "prompt_store_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, struct { + Presets []SystemInstructionPreset `json:"presets"` + }{body.Presets}) +} + // writePromptError 把对话库错误写为管理端错误 func writePromptError(w http.ResponseWriter, err error) { if errors.Is(err, ErrPromptNotFound) { diff --git a/internal/promptdb/promptdb.go b/internal/promptdb/promptdb.go index 8cb78af..e32d1ed 100644 --- a/internal/promptdb/promptdb.go +++ b/internal/promptdb/promptdb.go @@ -22,7 +22,9 @@ const schema = ` CREATE TABLE IF NOT EXISTS prompts ( id TEXT PRIMARY KEY, title TEXT NOT NULL, model TEXT NOT NULL, description TEXT NOT NULL, created INTEGER NOT NULL, updated INTEGER NOT NULL, data BLOB NOT NULL); -CREATE INDEX IF NOT EXISTS prompts_updated ON prompts(updated);` +CREATE INDEX IF NOT EXISTS prompts_updated ON prompts(updated); +CREATE TABLE IF NOT EXISTS prompt_settings ( + key TEXT PRIMARY KEY, data BLOB NOT NULL);` // summaryColumns 是对话列表项的列 const summaryColumns = `id, title, model, description, created, updated` @@ -145,6 +147,42 @@ func (s *Store) DeletePrompt(ctx context.Context, id string) error { return sqlitedb.Reclaim(ctx, s.db) } +const systemInstructionsKey = "system_instructions" + +// SystemInstructions 读取保存的系统指令预设列表 +func (s *Store) SystemInstructions(ctx context.Context) ([]api.SystemInstructionPreset, error) { + var raw []byte + err := s.db.QueryRowContext(ctx, `SELECT data FROM prompt_settings WHERE key = ?`, systemInstructionsKey).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return []api.SystemInstructionPreset{}, nil + } + if err != nil { + return nil, err + } + var presets []api.SystemInstructionPreset + if err := json.Unmarshal(raw, &presets); err != nil { + return nil, err + } + if presets == nil { + presets = []api.SystemInstructionPreset{} + } + return presets, nil +} + +// SaveSystemInstructions 写入系统指令预设列表 +func (s *Store) SaveSystemInstructions(ctx context.Context, presets []api.SystemInstructionPreset) error { + if presets == nil { + presets = []api.SystemInstructionPreset{} + } + raw, err := json.Marshal(presets) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `INSERT INTO prompt_settings (key, data) VALUES (?, ?) +ON CONFLICT(key) DO UPDATE SET data = excluded.data`, systemInstructionsKey, raw) + return err +} + // scanSummary 读取一行对话列表项,没有行时返回 api.ErrPromptNotFound func scanSummary(row interface{ Scan(...any) error }) (api.PromptSummary, error) { var item api.PromptSummary diff --git a/web/src/api.ts b/web/src/api.ts index 91a43bc..c60068d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -16,6 +16,7 @@ import type { PromptInput, PromptSummary, SavedPrompt, + SystemInstructionPreset, Cooldown, RequestBody, RequestSummary, @@ -286,6 +287,19 @@ export const api = { body: jsonBody({ model, prompt, answer }), }) ).title, + systemInstructions: async () => + (await requestJSON<{ presets: SystemInstructionPreset[] }>('/api/prompts/system-instructions')) + .presets, + saveSystemInstructions: async (presets: SystemInstructionPreset[]) => + ( + await requestJSON<{ presets: SystemInstructionPreset[] }>( + '/api/prompts/system-instructions', + { + method: 'PUT', + body: jsonBody({ presets }), + }, + ) + ).presets, } // openAdminEvents 建立唯一的管理状态 SSE 连接 diff --git a/web/src/playground/ChatTurn.vue b/web/src/playground/ChatTurn.vue index 5e9b7a0..1659452 100644 --- a/web/src/playground/ChatTurn.vue +++ b/web/src/playground/ChatTurn.vue @@ -106,6 +106,16 @@ const fullTime = computed(() => const roleLabel = computed(() => (props.turn.role === 'user' ? t('pg.user') : t('pg.model'))) // hasThoughtSegment 表示模型轮次单独显示思考段 const hasThoughtSegment = computed(() => props.turn.role === 'model' && thoughtText.value !== '') +// hasStickyCode 表示回复段含有块级代码标题,供操作栏避让粘性标题而无需全树匹配 :has(.ms-code-block) +const hasStickyCode = computed(() => + answerParts.value.some( + (part) => + part.functionCall !== undefined || + part.executableCode !== undefined || + part.codeExecutionResult !== undefined || + (part.text !== undefined && (part.text.includes('```') || part.text.includes('~~~'))), + ), +) // hasAnswerSegment 表示需要显示回复段 const hasAnswerSegment = computed( () => @@ -116,14 +126,17 @@ const hasAnswerSegment = computed( waiting.value || (props.turn.sources?.length ?? 0) > 0, ) -// now 是生成中每 100 ms 更新的时刻,轮次耗时与官网一样随之计数 +// now 是生成中每 200 ms 更新的时刻,轮次耗时随之计数 const now = ref(performance.now()) let ticker = 0 watch( () => props.streaming, (running) => { window.clearInterval(ticker) - if (running) ticker = window.setInterval(() => (now.value = performance.now()), 100) + if (running) { + now.value = performance.now() + ticker = window.setInterval(() => (now.value = performance.now()), 200) + } }, { immediate: true }, ) @@ -265,13 +278,15 @@ let resize: ResizeObserver | undefined onMounted(() => { intersection = new IntersectionObserver( - ([entry]) => { - visible.value = entry?.isIntersecting ?? true + (entries) => { + const last = entries.at(-1) + if (last) visible.value = last.isIntersecting }, { root: root.value?.closest('.chat-scroller') ?? null, rootMargin: '1500px 0px' }, ) - resize = new ResizeObserver(([entry]) => { - if (visible.value && entry) height.value = entry.contentRect.height + resize = new ResizeObserver((entries) => { + const rect = entries.at(-1)?.contentRect + if (visible.value && rect && rect.height > 0) height.value = rect.height }) if (root.value) { intersection.observe(root.value) @@ -368,7 +383,7 @@ onUnmounted(() => { v-if="hasAnswerSegment" tabindex="-1" class="chat-turn-container" - :class="[turn.role, { edit: editing }]" + :class="[turn.role, { edit: editing, 'has-code': hasStickyCode }]" >
@@ -626,6 +641,7 @@ onUnmounted(() => { display: flex; flex-direction: column; width: 100%; + contain: layout style; } .chat-turn + .chat-turn { @@ -651,7 +667,7 @@ onUnmounted(() => { } .chat-turn-container:focus-visible, -.chat-turn-container:has([aria-expanded='true'][aria-haspopup='menu']):not(.edit) { +.chat-turn-container:not(.edit):has(> .actions-container [aria-expanded='true']) { border-color: var(--color-v3-outline); } @@ -660,12 +676,13 @@ onUnmounted(() => { border-color: var(--color-v3-outline); } - .chat-turn-container:hover .hover-or-edit { + .chat-turn-container:hover > .actions-container .hover-or-edit, + .chat-turn-container:hover > .turn-footer .hover-or-edit { opacity: 1; pointer-events: auto; } - .chat-turn-container:hover .turn-footer { + .chat-turn-container:hover > .turn-footer { background-color: var(--color-v3-surface); } } @@ -684,7 +701,7 @@ onUnmounted(() => { height: 0; } -.chat-turn-container:has(.ms-code-block .code-header.sticky) .actions-container { +.chat-turn-container.has-code > .actions-container { top: calc(56px * var(--ms-autoscroll-container-scrolled, 1)); transition: top 0.6s ease-in-out; } @@ -701,11 +718,11 @@ onUnmounted(() => { background-color: var(--color-v3-surface-container-high); } -.chat-turn-container.model:has(.ms-code-block .code-header.sticky) .actions { +.chat-turn-container.model.has-code > .actions-container .actions { right: 8px; } -.chat-turn-container.user:has(.ms-code-block .code-header.sticky) .actions { +.chat-turn-container.user.has-code > .actions-container .actions { right: 20px; } @@ -715,9 +732,14 @@ onUnmounted(() => { transition: opacity 0.3s ease-in-out; } -.chat-turn-container:focus-within .hover-or-edit, -.chat-turn-container:has([aria-expanded='true'][aria-haspopup='menu']) .hover-or-edit, -.chat-turn-container.edit .hover-or-edit { +.chat-turn-container:focus-within > .actions-container .hover-or-edit, +.chat-turn-container:focus-within > .turn-footer .hover-or-edit, +.chat-turn-container:has(> .actions-container [aria-expanded='true']) + > .actions-container + .hover-or-edit, +.chat-turn-container:has(> .actions-container [aria-expanded='true']) > .turn-footer .hover-or-edit, +.chat-turn-container.edit > .actions-container .hover-or-edit, +.chat-turn-container.edit > .turn-footer .hover-or-edit { opacity: 1; pointer-events: auto; } diff --git a/web/src/playground/CodeBlock.vue b/web/src/playground/CodeBlock.vue index 46e9a47..7d90e9f 100644 --- a/web/src/playground/CodeBlock.vue +++ b/web/src/playground/CodeBlock.vue @@ -92,17 +92,77 @@ const title = computed(() => { : language.charAt(0).toUpperCase() + language.slice(1) return props.headerText || name || t('pg.code') }) -// highlighted 是高亮后的代码;语言为 none 时按纯文本,未声明或不认识的语言自动识别 -const highlighted = computed(() => { - if (props.language === 'none') return escapeHTML(trimmed.value) - return hljs.getLanguage(props.language) - ? hljs.highlight(trimmed.value, { language: props.language, ignoreIllegals: true }).value - : hljs.highlightAuto(trimmed.value).value + +// hasAtLeastLines 判断文本是否至少包含 count 行,不分配数组 +function hasAtLeastLines(text: string, count: number): boolean { + let lines = 1 + for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) { + if (++lines >= count) return true + } + return false +} + +// autoHighlightLimit 是未指定语言时执行自动识别高亮的最大字符数 +const autoHighlightLimit = 16_384 +// incrementalChunkSize 是长代码增量高亮的分块字符数 +const incrementalChunkSize = 4_096 +// largeCodeThreshold 是跳过网格折叠动画并在折叠后卸载 DOM 的字符阈值 +const largeCodeThreshold = 4_096 + +const isLargeCode = computed(() => props.code.length >= largeCodeThreshold) +const bodyMounted = ref(true) +let unmountTimer = 0 +let cachedLanguage = '' +let cachedPrefix = '' +let cachedHTML = '' + +// highlightSegment 高亮单段代码 +function highlightSegment(text: string, language: string): string { + if (text === '') return '' + if (language === 'none') return escapeHTML(text) + if (hljs.getLanguage(language)) + return hljs.highlight(text, { language, ignoreIllegals: true }).value + if (text.length > autoHighlightLimit) return escapeHTML(text) + return hljs.highlightAuto(text).value +} + +// highlightedParts 把长代码拆为稳定的已高亮前缀与流式变化的小尾部,避免每帧重写整块 innerHTML +const highlightedParts = computed<{ prefix: string; tail: string }>(() => { + const text = trimmed.value + const language = props.language + if (language === 'none') return { prefix: '', tail: escapeHTML(text) } + if (!hljs.getLanguage(language)) { + const html = + text.length <= autoHighlightLimit ? hljs.highlightAuto(text).value : escapeHTML(text) + return { prefix: '', tail: html } + } + if (text.length < incrementalChunkSize) { + cachedLanguage = language + cachedPrefix = '' + cachedHTML = '' + return { prefix: '', tail: highlightSegment(text, language) } + } + if (language !== cachedLanguage || !text.startsWith(cachedPrefix)) { + cachedLanguage = language + cachedPrefix = '' + cachedHTML = '' + } + const rest = text.slice(cachedPrefix.length) + const boundary = rest.lastIndexOf('\n') + if (boundary >= incrementalChunkSize) { + const chunk = rest.slice(0, boundary + 1) + cachedPrefix += chunk + cachedHTML += highlightSegment(chunk, language) + } + return { + prefix: cachedHTML, + tail: highlightSegment(text.slice(cachedPrefix.length), language), + } }) // previewable 与官网一致:15 行以上的 HTML 或以 - (props.language === 'html' && props.code.split('\n').length >= 15) || + (props.language === 'html' && hasAtLeastLines(props.code, 15)) || props.code.startsWith(' window.addEventListener('message', sendPreview)) -onUnmounted(() => window.removeEventListener('message', sendPreview)) +onUnmounted(() => { + window.removeEventListener('message', sendPreview) + window.clearTimeout(unmountTimer) +}) // escapeHTML 转义文本中的 HTML 特殊字符 function escapeHTML(text: string): string { return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') } -// toggle 展开或折叠代码 +// toggle 展开或折叠代码;长代码块立即切换并卸载内部节点以避免整页重排卡顿 function toggle(): void { - if (props.expandable) expanded.value = !expanded.value + if (!props.expandable) return + window.clearTimeout(unmountTimer) + const next = !expanded.value + if (next) { + bodyMounted.value = true + expanded.value = true + return + } + expanded.value = false + if (isLargeCode.value) { + bodyMounted.value = false + } else { + unmountTimer = window.setTimeout(() => { + if (!expanded.value) bodyMounted.value = false + }, 225) + } } // copy 复制代码并以提示条告知结果 @@ -220,19 +298,19 @@ function download(): void {
-
-
+
+
@@ -338,6 +416,10 @@ function download(): void { visibility 225ms; } +.code-body.no-transition { + transition: none; +} + .panel.expanded .code-body { grid-template-rows: 1fr; visibility: visible; @@ -354,6 +436,7 @@ pre { margin: 0; padding: 0 16px 12px 12px; overflow-x: auto; + contain: content; color: var(--color-v3-text); line-height: 22px; } diff --git a/web/src/playground/MarkdownBlock.ts b/web/src/playground/MarkdownBlock.ts index 524b90e..01d8599 100644 --- a/web/src/playground/MarkdownBlock.ts +++ b/web/src/playground/MarkdownBlock.ts @@ -32,6 +32,14 @@ export default defineComponent({ name: 'MarkdownBlock', props: { nodes: { type: Array as PropType, required: true } }, setup(props) { - return () => props.nodes.map(renderNode) + let prevNodes: MarkdownNode[] | undefined + let prevVNodes: VNode[] = [] + return () => { + if (props.nodes !== prevNodes) { + prevNodes = props.nodes + prevVNodes = props.nodes.map(renderNode) + } + return prevVNodes + } }, }) diff --git a/web/src/playground/PlaygroundPage.vue b/web/src/playground/PlaygroundPage.vue index 4d9d2ba..ed9048c 100644 --- a/web/src/playground/PlaygroundPage.vue +++ b/web/src/playground/PlaygroundPage.vue @@ -61,7 +61,7 @@ watch( async (id) => { if (typeof id === 'string' && id !== '') await playground.open(id, props.prompt) else if (session.id !== null || session.turns.length > 0 || session.loadError || otherModel()) - playground.newChat() + playground.newChat(session.settings.systemInstruction) }, { immediate: true }, ) diff --git a/web/src/playground/PlaygroundToolbar.vue b/web/src/playground/PlaygroundToolbar.vue index c3f0b8f..faedc79 100644 --- a/web/src/playground/PlaygroundToolbar.vue +++ b/web/src/playground/PlaygroundToolbar.vue @@ -33,7 +33,12 @@ const editing = ref(false) const confirmDelete = ref(false) // hasPrompt 表示对话已有内容,标题可编辑 -const hasPrompt = computed(() => session.turns.length > 0 || session.id !== null) +const hasPrompt = computed( + () => + session.turns.length > 0 || + session.id !== null || + session.settings.systemInstruction.trim() !== '', +) // temporaryLabel 与官网一样在临时对话不可用时显示原因 const temporaryLabel = computed(() => isOmniModel(playground.currentModel.value) @@ -168,16 +173,12 @@ async function removePrompt(): Promise { {{ - session.turns.length === 0 || session.saveState === 'saved' - ? t('pg.noChanges') - : t('pg.savePrompt') + !hasPrompt || session.saveState === 'saved' ? t('pg.noChanges') : t('pg.savePrompt') }} import { vTooltip } from '@/playground/ui/tooltip' -import { computed, ref, watch } from 'vue' import { useI18n } from '@/i18n' import UiIcon from '@/playground/ui/UiIcon.vue' import UiSelect from '@/playground/ui/UiSelect.vue' import UiSlidingPanel from '@/playground/ui/UiSlidingPanel.vue' -import { newTurnID, usePlayground } from './session' - -// Preset 是一条保存在本机的系统指令 -interface Preset { - id: string - title: string - text: string -} +import { useInstructionPresets, usePlayground } from './session' const open = defineModel('open', { required: true }) const { t } = useI18n() const { session } = usePlayground() - -// presetsKey 是系统指令预设的本机存储键 -const presetsKey = 'playground_system_instructions' - -// loadPresets 读取本机保存的系统指令 -function loadPresets(): Preset[] { - try { - return JSON.parse(localStorage.getItem(presetsKey) ?? '[]') as Preset[] - } catch { - return [] - } -} - -const presets = ref(loadPresets()) -const selected = ref('') -const title = ref('') -const saved = ref(true) -let saveTimer = 0 - -// currentPreset 是当前编辑的预设 -const currentPreset = computed(() => presets.value.find((preset) => preset.id === selected.value)) - -// persist 把预设写入本机存储 -function persist(): void { - try { - localStorage.setItem(presetsKey, JSON.stringify(presets.value)) - } catch { - // 本机存储不可用时预设只在本次打开期间有效 - } - saved.value = true -} - -// remember 在编辑后合并保存当前指令到预设 -function remember(): void { - saved.value = false - window.clearTimeout(saveTimer) - saveTimer = window.setTimeout(() => { - const text = session.settings.systemInstruction - if (text.trim() === '' && title.value.trim() === '') { - saved.value = true - return - } - let preset = currentPreset.value - if (preset === undefined) { - preset = { id: newTurnID(), title: '', text: '' } - presets.value.push(preset) - selected.value = preset.id - } - preset.title = title.value - preset.text = text - persist() - }, 500) -} - -// choose 切换到另一条预设或新建空白指令 -function choose(id: string | number | undefined): void { - selected.value = String(id ?? '') - const preset = currentPreset.value - title.value = preset?.title ?? '' - session.settings.systemInstruction = preset?.text ?? '' -} - -// remove 删除当前预设并清空指令 -function remove(): void { - presets.value = presets.value.filter((preset) => preset.id !== selected.value) - persist() - choose('') -} - -watch(open, (value) => { - if (!value) return - const match = presets.value.find((preset) => preset.text === session.settings.systemInstruction) - selected.value = match?.id ?? '' - title.value = match?.title ?? '' -}) +const { presets, selected, title, saved, currentPreset, remember, choose, remove } = + useInstructionPresets( + () => open.value, + () => session.settings.systemInstruction, + (value) => { + session.settings.systemInstruction = value + }, + )