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 或以
-
-
+
+
-
+
@@ -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 {
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
+ },
+ )
diff --git a/web/src/playground/markdown.ts b/web/src/playground/markdown.ts
index 6653e8d..55fde38 100644
--- a/web/src/playground/markdown.ts
+++ b/web/src/playground/markdown.ts
@@ -1,3 +1,4 @@
+import { markRaw } from 'vue'
import MarkdownIt, { type Env, type Token } from 'markdown-it'
import katexPlugin from '@vscode/markdown-it-katex'
import './katex.scss'
@@ -96,11 +97,13 @@ function parseBlocks(text: string, base: number, firstKey: number, env: Env): Bl
if (depth !== 0) continue
const group = tokens.slice(start, index + 1)
const line = group[0]?.map?.[0] ?? 0
- blocks.push({
- key: firstKey + blocks.length,
- offset: base + (offsets[line] ?? 0),
- nodes: buildNodes(group, env),
- })
+ blocks.push(
+ markRaw({
+ key: firstKey + blocks.length,
+ offset: base + (offsets[line] ?? 0),
+ nodes: markRaw(buildNodes(group, env)),
+ }),
+ )
start = index + 1
}
return blocks
diff --git a/web/src/playground/media/LiveSystemInstructions.vue b/web/src/playground/media/LiveSystemInstructions.vue
index d00cd09..a93b7a1 100644
--- a/web/src/playground/media/LiveSystemInstructions.vue
+++ b/web/src/playground/media/LiveSystemInstructions.vue
@@ -1,18 +1,11 @@
diff --git a/web/src/playground/session.ts b/web/src/playground/session.ts
index 385a2f1..b857903 100644
--- a/web/src/playground/session.ts
+++ b/web/src/playground/session.ts
@@ -14,7 +14,7 @@ import { api, ApiError } from '@/api'
import { useConsole } from '@/console'
import { keepPage } from '@/desktop'
import { useI18n } from '@/i18n'
-import type { Model, SavedPrompt } from '@/types'
+import type { Model, SavedPrompt, SystemInstructionPreset } from '@/types'
import { defaultChatModel, hasCode, toolCodes, toolCompatible, type Tool } from './catalog'
import { readImage } from './media/api'
import { generate, protocolsFor } from './protocols'
@@ -265,10 +265,15 @@ function createSession(temporary = false, links: SessionLinks = {}) {
return text.length > 60 ? `${text.slice(0, 60)}…` : text
}
+ // hasContent 表示对话已有轮次或系统指令,可写入对话库
+ function hasContent(): boolean {
+ return session.turns.length > 0 || session.settings.systemInstruction.trim() !== ''
+ }
+
// save 把调用时的对话连同对比模式的右侧窗格写入对话库,右侧窗格保存所在的页面对话,其他临时对话不保存;保存状态只回写到仍在显示的同一对话
async function save(): Promise {
if (links.save) return links.save()
- if (session.temporary || session.turns.length === 0) return
+ if (session.temporary || (!hasContent() && session.id === null)) return
const compare = links.compare?.()
session.id ??= newTurnID()
const id = session.id
@@ -288,6 +293,7 @@ function createSession(temporary = false, links: SessionLinks = {}) {
// scheduleSave 在对话变化后合并保存,生成中到时的保存由生成结束时的保存代替
function scheduleSave(): void {
+ if (session.saveState === 'saved') session.saveState = 'idle'
window.clearTimeout(saveTimer.value)
saveTimer.value = window.setTimeout(() => {
saveTimer.value = 0
@@ -582,7 +588,7 @@ function createSession(temporary = false, links: SessionLinks = {}) {
() => [session.settings, session.temporary] as const,
([settings], [previous]) => {
if (settings !== previous && settings === opened) return
- if (session.id !== null && !session.running) scheduleSave()
+ if ((session.id !== null || session.turns.length > 0) && !session.running) scheduleSave()
},
{ deep: true },
)
@@ -836,3 +842,147 @@ export function useCompare() {
toggleRaw,
}
}
+
+// presetsKey 是系统指令预设的本机存储键
+const presetsKey = 'playground_system_instructions'
+
+// loadLocalPresets 读取本机保存的系统指令预设
+function loadLocalPresets(): SystemInstructionPreset[] {
+ try {
+ return JSON.parse(localStorage.getItem(presetsKey) ?? '[]') as SystemInstructionPreset[]
+ } catch {
+ return []
+ }
+}
+
+// saveLocalPresets 把系统指令预设写入本机存储
+function saveLocalPresets(items: SystemInstructionPreset[]): void {
+ try {
+ localStorage.setItem(presetsKey, JSON.stringify(items))
+ } catch {
+ // 本机存储不可用时忽略
+ }
+}
+
+// useInstructionPresets 管理本机与服务端同步的系统指令预设
+export function useInstructionPresets(
+ open: () => boolean,
+ getText: () => string,
+ setText: (value: string) => void,
+) {
+ const presets = ref(loadLocalPresets())
+ 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))
+
+ // syncSelection 按当前系统指令匹配预设选中态
+ function syncSelection(): void {
+ const match = presets.value.find((preset) => preset.text === getText())
+ selected.value = match?.id ?? ''
+ title.value = match?.title ?? ''
+ }
+
+ // persist 把预设写入本机存储与服务端对话库
+ async function persist(): Promise {
+ saveLocalPresets(presets.value)
+ try {
+ presets.value = await api.saveSystemInstructions(presets.value)
+ saveLocalPresets(presets.value)
+ } catch {
+ // 服务端不可达时保留本机缓存
+ }
+ saved.value = true
+ }
+
+ // commit 把当前标题与系统指令写入预设列表并保存
+ function commit(): void {
+ const text = getText()
+ 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
+ void persist()
+ }
+
+ // flush 立即提交待保存的系统指令预设
+ function flush(): void {
+ if (saveTimer === 0) return
+ window.clearTimeout(saveTimer)
+ saveTimer = 0
+ commit()
+ }
+
+ // remember 在编辑后合并保存当前指令到预设
+ function remember(): void {
+ saved.value = false
+ window.clearTimeout(saveTimer)
+ saveTimer = window.setTimeout(() => {
+ saveTimer = 0
+ commit()
+ }, 500)
+ }
+
+ // choose 切换到另一条预设或新建空白指令
+ function choose(id: string | number | undefined): void {
+ flush()
+ selected.value = String(id ?? '')
+ const preset = currentPreset.value
+ title.value = preset?.title ?? ''
+ setText(preset?.text ?? '')
+ }
+
+ // remove 删除当前预设并清空指令
+ function remove(): void {
+ window.clearTimeout(saveTimer)
+ saveTimer = 0
+ presets.value = presets.value.filter((preset) => preset.id !== selected.value)
+ void persist()
+ choose('')
+ }
+
+ // refresh 从服务端读取预设并与本机预设合并
+ async function refresh(): Promise {
+ const local = loadLocalPresets()
+ presets.value = local
+ syncSelection()
+ try {
+ const remote = await api.systemInstructions()
+ const merged = [...remote]
+ for (const item of local) {
+ if (!item.id && !item.text.trim() && !item.title.trim()) continue
+ if (
+ !merged.some(
+ (preset) =>
+ preset.id === item.id || (preset.title === item.title && preset.text === item.text),
+ )
+ )
+ merged.push(item)
+ }
+ presets.value = merged
+ saveLocalPresets(merged)
+ if (merged.length !== remote.length) await api.saveSystemInstructions(merged)
+ syncSelection()
+ } catch {
+ // 服务端不可达时使用本机缓存
+ }
+ }
+
+ watch(open, (value) => {
+ if (value) void refresh()
+ else flush()
+ })
+
+ return { presets, selected, title, saved, currentPreset, remember, choose, remove }
+}
diff --git a/web/src/style.css b/web/src/style.css
index 4d66ed7..e95cb87 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -337,6 +337,7 @@ button[aria-busy='true']::before {
}
button[aria-busy='true']:has(> svg)::before {
+ display: none;
content: none;
}
diff --git a/web/src/types.ts b/web/src/types.ts
index 2467f9d..5f04a5a 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -470,3 +470,10 @@ export interface PromptInput {
export interface SavedPrompt extends PromptSummary {
data: T
}
+
+// SystemInstructionPreset 是保存在服务端对话库中的系统指令预设
+export interface SystemInstructionPreset {
+ id: string
+ title: string
+ text: string
+}