-
{{ t('思考过程', 'Reasoning') }} {{ message.thinking }}
+
{{ t('思考过程', 'Reasoning') }} {{ message.thinking }}
{{ t('正在思考…', 'Thinking…') }}
@@ -89,24 +106,26 @@ async function openCitationCard(citation: Citation) {
-
+
{{ t('回答可能包含错误,请核对 Citation。', 'Answers may contain errors. Verify the citations.') }}
{{ t('停止', 'Stop') }}
{{ t('发送', 'Send') }}
+
diff --git a/frontend/src/features/editor/MarkdownPreferenceSettings.vue b/frontend/src/features/editor/MarkdownPreferenceSettings.vue
new file mode 100644
index 0000000..bdbdff3
--- /dev/null
+++ b/frontend/src/features/editor/MarkdownPreferenceSettings.vue
@@ -0,0 +1,45 @@
+
+
+
+ {{ t('Markdown 语法与编辑预设', 'Markdown syntax and editing presets') }}
+ {{ t('语法和代码设置在下次打开写作编辑器时应用;不批量改写已有笔记。静态预览即时更新。', 'Syntax and code settings apply when the visual editor next opens; existing notes are not rewritten in bulk. Static previews update immediately.') }}
+ {{ t('扩展 Markdown', 'Extended Markdown') }} GitHub {{ t('基础 Markdown', 'Basic Markdown') }}
+
+ {{ t('标题语法', 'Heading syntax') }}ATX (#) Setext (=== / ---)
+ {{ t('无序列表', 'Bullet list') }}- * +
+ {{ t('有序列表', 'Ordered list') }}1. 2. 3. 1. 1. 1.
+ {{ t('代码围栏', 'Code fence') }}``` ~~~
+
+ {{ t('Setext 适用于 H1/H2,H3–H6 仍使用 #。写作模式保存时会规范化整篇正文的标记;源码模式保留手写语法。', 'Setext applies to H1/H2; H3–H6 use #. Visual-mode saves normalize document markers; source mode preserves handwritten syntax.') }}
+
+ {{ t('自动识别裸链接', 'Recognize bare URLs') }}
+ {{ t('数学公式', 'Math') }}
+ {{ t('警告框与提示框', 'Alerts and callouts') }}
+ Mermaid
+ {{ t('代码行号', 'Code line numbers') }}
+ {{ t('代码自动换行', 'Wrap code') }}
+
+
+ {{ t('代码缩进', 'Code indent') }}2 4 8
+ {{ t('新建代码块默认语言', 'Default language for new code blocks') }}
+
+
+ {{ error }}
+ {{ preset.name }} {{ t('应用', 'Apply') }} {{ t('删除', 'Delete') }}
+
+
+
diff --git a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts
index 95c2c59..492fa66 100644
--- a/frontend/src/features/editor/VisualMarkdownEditor.spec.ts
+++ b/frontend/src/features/editor/VisualMarkdownEditor.spec.ts
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+// The application has a doctype; happy-dom otherwise reports quirks mode to KaTeX.
+vi.hoisted(() => { Object.defineProperty(document, 'compatMode', {value:'CSS1Compat',configurable:true}) })
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { editorViewCtx, type Editor } from '@milkdown/kit/core'
@@ -11,6 +13,10 @@ import { useThemeStore } from '@/stores/theme'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { EditorView as CodeMirror } from '@codemirror/view'
import { renderMarkdown } from '@/utils/markdown'
+import { useEditorStore } from '@/stores/editor'
+import { executeEditorCommand } from '@/services/editorCommandService'
+import { headingFoldKey } from './headingFolding'
+import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
type EditorComponent = { getEditor: () => Editor | undefined }
@@ -49,6 +55,211 @@ afterEach(() => {
})
describe('VisualMarkdownEditor formatting toolbars', () => {
+ it('applies syntax and renderer preferences when opening the visual editor', async () => {
+ const preferences = useMarkdownPreferencesStore()
+ preferences.preferences.heading = 'setext'
+ preferences.preferences.bullet = '+'
+ preferences.preferences.fence = '~'
+ preferences.preferences.callouts = false
+ preferences.preferences.math = false
+ preferences.preferences.autoLinks = false
+ const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '# Heading\n\n- first\n- second\n\n> [!NOTE]\n> text\n\nhttps://example.com\n\n```text\ncode\n```' }, attachTo: document.body })
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ const result = editor.action(getMarkdown())
+ expect(result).toContain('Heading\n===')
+ expect(result).toContain('+ first')
+ expect(result).toContain('~~~text')
+ expect(wrapper.find('.markdown-callout').exists()).toBe(false)
+ expect(wrapper.find('.ProseMirror a').exists()).toBe(false)
+ })
+ it('folds heading sections, retains nested state and opens hidden outline targets', async () => {
+ const source = '# A\n\nbody\n\n## B\n\nchild\n\n# C\n\nvisible'
+ const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ await wrapper.get('.heading-fold-toggle[aria-label="折叠 H2 B"]').trigger('click')
+ await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click')
+ expect(wrapper.findAll('.heading-fold-hidden').length).toBeGreaterThan(1)
+ await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 A"]').trigger('click')
+ expect(wrapper.get('.heading-fold-toggle[aria-label="展开 H2 B"]').attributes('aria-expanded')).toBe('false')
+ editor.action(ctx => {
+ const view = ctx.get(editorViewCtx)
+ let position = 0
+ view.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'child') position = pos })
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, position)))
+ expect(headingFoldKey.getState(view.state)?.size).toBe(0)
+ })
+ expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
+ expect(editor.action(getMarkdown()).trim()).toBe(source)
+ await wrapper.get('button[aria-label="折叠所有章节"]').trigger('click')
+ expect(wrapper.findAll('.section-actions button')).toHaveLength(1)
+ expect(wrapper.get('.section-actions button').text()).toBe('全部展开')
+ await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 C"]').trigger('click')
+ expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
+ await wrapper.get('button[aria-label="折叠所有章节"]').trigger('click')
+ await wrapper.get('button[aria-label="展开所有章节"]').trigger('click')
+ expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
+ expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
+ })
+ it('offers expand all when individually collapsed parents hide expanded children', async () => {
+ const source = '# A\n\nbody\n\n## B\n\nchild\n\n# C\n\nbody'
+ const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click')
+ expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
+ await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 C"]').trigger('click')
+ expect(wrapper.get('.section-actions button').text()).toBe('全部展开')
+ expect(wrapper.get('.heading-fold-toggle[aria-label="折叠 H2 B"]').attributes('aria-expanded')).toBe('true')
+ await wrapper.get('.heading-fold-toggle[aria-label="展开 H1 A"]').trigger('click')
+ expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
+ expect(wrapper.get('.heading-fold-toggle[aria-label="折叠 H2 B"]').attributes('aria-expanded')).toBe('true')
+ await wrapper.get('.heading-fold-toggle[aria-label="折叠 H1 A"]').trigger('click')
+ await wrapper.get('.section-actions button').trigger('click')
+ expect(wrapper.find('.heading-fold-hidden').exists()).toBe(false)
+ expect(wrapper.get('.section-actions button').text()).toBe('全部折叠')
+ expect(editor.action(getMarkdown()).trim()).toBe(source)
+ })
+ it('renders and folds callouts without losing portable Markdown on serialization', async () => {
+ const source = '> [!WARNING]- 注意\n>\n> **正文**\n>\n> > [!TIP] 内层\n> > 内容'
+ const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: source }, attachTo: document.body })
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ expect(wrapper.findAll('.markdown-callout')).toHaveLength(2)
+ expect(wrapper.get('.markdown-callout').attributes('data-collapsed')).toBe('true')
+ await wrapper.get('.callout-title').trigger('click')
+ expect(wrapper.get('.markdown-callout').attributes('data-collapsed')).toBe('false')
+ const markdown = editor.action(getMarkdown())
+ expect(markdown.trim()).toBe(source)
+ })
+ it('dispatches native-ready commands through editor transactions and rejects invalid parameters', async () => {
+ useEditorStore().currentFilePath = 'test.md'
+ const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: 'text' }, attachTo: document.body })
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ expect(await executeEditorCommand('editor.heading', 8)).toEqual({ ok: false, reason: 'invalid-params' })
+ expect(await executeEditorCommand('editor.heading', 2)).toEqual({ ok: true })
+ expect(editor.action(getMarkdown())).toContain('## text')
+ expect(await executeEditorCommand('editor.callout', { type: 'tip', body: '**test**' })).toEqual({ ok: true })
+ expect(wrapper.find('.markdown-callout').exists()).toBe(true)
+ useEditorStore().saveStatus = 'conflict'
+ expect(await executeEditorCommand('editor.bold')).toEqual({ ok: false, reason: 'unavailable' })
+ })
+ it('keeps code examples as ordinary quotes and renders newly typed markers', async () => {
+ const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '> `[!NOTE]`\n\n> text' }, attachTo: document.body })
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ expect(wrapper.find('.markdown-callout').exists()).toBe(false)
+ editor.action(ctx => {
+ const view = ctx.get(editorViewCtx)
+ let position = 0
+ view.state.doc.descendants((node, pos) => { if (node.isText && node.text === 'text') position = pos })
+ view.dispatch(view.state.tr.insertText('[!TIP]', position, position + 4))
+ })
+ expect(wrapper.find('.markdown-callout').exists()).toBe(true)
+ expect(editor.action(getMarkdown())).toContain('[!TIP]')
+ })
+ it('renders the supported format matrix and preserves inline code', async () => {
+ const source = ['# H1','## H2','### H3','#### H4','##### H5','###### H6',
+ '正文 **粗体** *斜体* ~~删除~~ `s` 与 ``a`b``', '> 引用', '- 项目\n - 子项', '1. 第一\n2. 第二',
+ '- [x] 完成\n- [ ] 未完成', '[链接](https://example.com)',
+ '| A | B |\n| --- | --- |\n| x | y |', '---', '$x^2$', '$$\nx^2\n$$', '```js\nconst n = 1\n```'].join('\n\n')
+ const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:source},attachTo:document.body})
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ expect(wrapper.get('.ProseMirror code').text()).toBe('s')
+ for (const selector of ['h1','h2','h3','h4','h5','h6','strong','em','del','blockquote','ol','ul','table','hr','a']) expect(wrapper.find(`.ProseMirror ${selector}`).exists(), selector).toBe(true)
+ expect(editor.action(getMarkdown())).toContain('`s`')
+ expect(editor.action(getMarkdown())).toContain('``a`b``')
+ })
+ it('converts a typed closing backtick to inline code', async () => {
+ const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ editor.action(ctx => {
+ const view = ctx.get(editorViewCtx)
+ for (const text of '`s`') {
+ const {from,to} = view.state.selection
+ let handled = false
+ view.someProp('handleTextInput', handler => { if (handler(view,from,to,text, () => view.state.tr.insertText(text,from,to))) { handled = true; return true } })
+ if (!handled) view.dispatch(view.state.tr.insertText(text,from,to))
+ }
+ })
+ await wrapper.vm.$nextTick()
+ expect(wrapper.get('.ProseMirror code').text()).toBe('s')
+ })
+ it('reconciles IME composition text without handleTextInput', async () => {
+ const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
+ await wrapper.get('.ProseMirror').trigger('compositionend', {data:'`s`'})
+ await new Promise(resolve => setTimeout(resolve, 30))
+ expect(wrapper.get('.ProseMirror code').text()).toBe('s')
+ expect(editor.action(getMarkdown()).trim()).toBe('`s`')
+ })
+ it.each(['insertText', 'insertCompositionText', 'insertReplacementText'])('reconciles %s without event.data after the DOM update', async (inputType) => {
+ const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ // Chromium/IME can omit data and commit its DOM change after the input event.
+ await wrapper.get('.ProseMirror').trigger('input', {inputType, data:null})
+ await new Promise(resolve => setTimeout(resolve, 10))
+ editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
+ await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s'))
+ })
+ it('waits for composition cleanup before converting committed text', async () => {
+ const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ const view = editor.action(ctx => ctx.get(editorViewCtx))
+ const composing = vi.spyOn(view, 'composing', 'get').mockReturnValue(true)
+ await wrapper.get('.ProseMirror').trigger('compositionstart')
+ view.dispatch(view.state.tr.insertText('`s`'))
+ await wrapper.get('.ProseMirror').trigger('compositionend', {data:'`s`'})
+ await new Promise(resolve => setTimeout(resolve, 50))
+ expect(wrapper.find('.ProseMirror code').exists()).toBe(false)
+ composing.mockReturnValue(false)
+ await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s'))
+ composing.mockRestore()
+ })
+ it('converts content typed between an existing pair of backticks', async () => {
+ const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ const view = editor.action(ctx => ctx.get(editorViewCtx))
+ view.dispatch(view.state.tr.insertText('``'))
+ await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'`'})
+ await new Promise(resolve => setTimeout(resolve, 60))
+ // Empty pairs are serialized as escaped literal text, but that must not
+ // prevent recognition after the user moves back and fills in the content.
+ expect(editor.action(getMarkdown())).toContain('\\`')
+ view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, 2)).insertText('s'))
+ await wrapper.get('.ProseMirror').trigger('input', {inputType:'insertText', data:'s'})
+ await vi.waitFor(() => expect(wrapper.get('.ProseMirror code').text()).toBe('s'))
+ expect(editor.action(getMarkdown()).trim()).toBe('`s`')
+ expect(view.state.selection.from).toBe(2)
+ view.dispatch(view.state.tr.insertText('tring'))
+ expect(editor.action(getMarkdown()).trim()).toBe('`string`')
+ })
+ it.each(['insertFromPaste', 'historyUndo', 'deleteContentBackward'])('does not reinterpret literals on %s', async (inputType) => {
+ const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('`s`')))
+ await wrapper.get('.ProseMirror').trigger('input', {inputType, data:null})
+ await new Promise(resolve => setTimeout(resolve, 60))
+ expect(wrapper.find('.ProseMirror code').exists()).toBe(false)
+ })
+ it('enables inline code from the toolbar at an empty selection', async () => {
+ const wrapper = mount(VisualMarkdownEditor, {props:{initialContent:''},attachTo:document.body})
+ mounted.push(wrapper)
+ const editor = await waitForEditor(wrapper)
+ await wrapper.get('[aria-label="行内代码"]').trigger('pointerdown')
+ editor.action(ctx => ctx.get(editorViewCtx).dispatch(ctx.get(editorViewCtx).state.tr.insertText('value')))
+ expect(editor.action(getMarkdown()).trim()).toBe('`value`')
+ })
it.each([['jsonc', 'JSON with Comments', '// comment\n{"answer": 42}'], ['ahk', 'AutoHotkey', 'MsgBox "Hello"']])('persists %s from the language menu and renders it with Shiki', async (id, label, source) => {
const wrapper = mount(VisualMarkdownEditor, { props: { initialContent: `\`\`\`text\n${source}\n\`\`\`` }, attachTo: document.body })
mounted.push(wrapper)
diff --git a/frontend/src/features/editor/VisualMarkdownEditor.vue b/frontend/src/features/editor/VisualMarkdownEditor.vue
index 23e61e0..0687e4a 100644
--- a/frontend/src/features/editor/VisualMarkdownEditor.vue
+++ b/frontend/src/features/editor/VisualMarkdownEditor.vue
@@ -1,10 +1,16 @@
-
+
+
{{ t('正在加载编辑器…', 'Loading editor…') }}
@@ -313,15 +479,21 @@ defineExpose({ getEditor: () => crepe?.editor })
-
+
diff --git a/frontend/src/features/editor/calloutPlugin.ts b/frontend/src/features/editor/calloutPlugin.ts
new file mode 100644
index 0000000..b7b80ab
--- /dev/null
+++ b/frontend/src/features/editor/calloutPlugin.ts
@@ -0,0 +1,89 @@
+import { $prose } from '@milkdown/kit/utils'
+import { Plugin } from '@milkdown/kit/prose/state'
+import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
+import { parseCallout } from '@/utils/callouts'
+import '@/styles/callouts.css'
+import { remarkStringifyOptionsCtx, type Editor } from '@milkdown/kit/core'
+
+export const configureCalloutSerialization: Parameters
[0] = ctx => {
+ ctx.update(remarkStringifyOptionsCtx, options => ({
+ ...options,
+ handlers: { ...options.handlers, blockquote(node, _parent, state, info) {
+ const exit = state.enter('blockquote')
+ const tracker = state.createTracker(info)
+ tracker.move('> ')
+ tracker.shift(2)
+ const result = state.indentLines(state.containerFlow(node, tracker.current()), (line, _index, blank) => `>${blank ? '' : ' '}${line}`)
+ exit()
+ // Only remove escaping from a leading callout marker, never body literals.
+ return result.replace(/^(> )\\\[!([\w-]+)\\?\]/, '$1[!$2]')
+ } },
+ }))
+}
+
+// Keep native blockquotes in the document: typing, undo and Markdown serialization
+// remain Milkdown transactions; the view never rewrites a user's callout source.
+export const calloutPlugin = $prose(() => new Plugin({
+ props: {
+ decorations(state) {
+ const decorations: Decoration[] = []
+ state.doc.descendants((node, position) => {
+ if (node.type.name !== 'blockquote' || node.firstChild?.type.name !== 'paragraph' || node.firstChild.firstChild?.marks.length) return
+ const callout = parseCallout(node.firstChild.textBetween(0, node.firstChild.content.size, '\n', '\n'))
+ if (!callout) return
+ const from = position + 2
+ const to = from + callout.markerLength
+ const editing = state.selection.from <= to && state.selection.to >= from
+ decorations.push(Decoration.inline(from, to, { class: editing ? 'callout-marker-editing' : 'callout-marker' }))
+ })
+ return DecorationSet.create(state.doc, decorations)
+ },
+ nodeViews: {
+ blockquote(initialNode) {
+ const dom = document.createElement('blockquote')
+ const header = document.createElement('button')
+ header.type = 'button'
+ header.className = 'callout-title'
+ header.contentEditable = 'false'
+ const contentDOM = document.createElement('div')
+ contentDOM.className = 'callout-body'
+ dom.append(header, contentDOM)
+ let signature = ''
+ let foldable = false
+ const update = (node: typeof initialNode) => {
+ if (node.type.name !== 'blockquote') return false
+ const callout = node.firstChild?.type.name === 'paragraph' && !node.firstChild.firstChild?.marks.length
+ ? parseCallout(node.firstChild.textBetween(0, node.firstChild.content.size, '\n', '\n')) : null
+ dom.className = callout ? 'markdown-callout' : ''
+ header.hidden = !callout
+ foldable = !!callout?.fold
+ header.disabled = !foldable
+ if (callout) {
+ dom.dataset.callout = callout.type
+ const next = `${callout.name}:${callout.fold}`
+ if (signature !== next) dom.dataset.collapsed = String(callout.fold === '-')
+ signature = next
+ header.textContent = `${foldable ? (dom.dataset.collapsed === 'true' ? '▸ ' : '▾ ') : ''}${callout.title}`
+ if (foldable) header.setAttribute('aria-expanded', String(dom.dataset.collapsed !== 'true'))
+ else header.removeAttribute('aria-expanded')
+ } else {
+ delete dom.dataset.callout
+ delete dom.dataset.collapsed
+ signature = ''
+ }
+ return true
+ }
+ let current = initialNode
+ header.onclick = () => {
+ if (!foldable) return
+ dom.dataset.collapsed = String(dom.dataset.collapsed !== 'true')
+ update(current)
+ }
+ update(initialNode)
+ return { dom, contentDOM, update(node) { current = node; return update(node) },
+ stopEvent: event => header.contains(event.target as Node),
+ ignoreMutation: mutation => mutation.type !== 'selection' && !contentDOM.contains(mutation.target) }
+ },
+ },
+ },
+}))
diff --git a/frontend/src/features/editor/diagramIntegration.spec.ts b/frontend/src/features/editor/diagramIntegration.spec.ts
new file mode 100644
index 0000000..a80913e
--- /dev/null
+++ b/frontend/src/features/editor/diagramIntegration.spec.ts
@@ -0,0 +1,28 @@
+// @vitest-environment happy-dom
+import { afterEach, expect, it, vi } from 'vitest'
+import { mount, type VueWrapper } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import { getMarkdown } from '@milkdown/kit/utils'
+import type { Editor } from '@milkdown/kit/core'
+import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
+
+vi.mock('@/services/mermaidService', () => ({ renderMermaid: vi.fn(async () => ({ svg: 'Diagram ', warnings: [], width: 400, height: 200 })) }))
+let wrapper: VueWrapper
+afterEach(() => { wrapper?.unmount(); document.body.innerHTML = ''; vi.unstubAllGlobals() })
+it('keeps diagram buttons usable after real Milkdown preview copying without changing Markdown', async () => {
+ localStorage.clear()
+ setActivePinia(createPinia())
+ vi.stubGlobal('IntersectionObserver', class {
+ constructor(private callback: IntersectionObserverCallback) {}
+ observe(target: Element) { queueMicrotask(() => this.callback([{ isIntersecting: true, target } as IntersectionObserverEntry], this as unknown as IntersectionObserver)) }
+ unobserve() {}
+ disconnect() {}
+ })
+ wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '```mermaid\ngraph TD; A-->B\n```' }, attachTo: document.body })
+ await vi.waitFor(() => expect(wrapper.find('[data-diagram-action="in"]').exists()).toBe(true), { timeout: 3000 })
+ const editor = (wrapper.vm as unknown as { getEditor(): Editor }).getEditor()
+ const before = editor.action(getMarkdown())
+ await wrapper.get('[data-diagram-action="in"]').trigger('click')
+ expect((wrapper.get('.editor-mermaid-preview svg').element as SVGSVGElement).style.width).toBe('480px')
+ expect(editor.action(getMarkdown())).toBe(before)
+})
diff --git a/frontend/src/features/editor/headingFolding.spec.ts b/frontend/src/features/editor/headingFolding.spec.ts
new file mode 100644
index 0000000..2b4f13b
--- /dev/null
+++ b/frontend/src/features/editor/headingFolding.spec.ts
@@ -0,0 +1,32 @@
+// @vitest-environment happy-dom
+import { expect, it } from 'vitest'
+import { Schema } from '@milkdown/kit/prose/model'
+import { EditorState, TextSelection } from '@milkdown/kit/prose/state'
+import { headingSections, headingFoldTransaction } from './headingFolding'
+
+const schema = new Schema({ nodes: {
+ doc: { content: 'block+' }, text: { group: 'inline' },
+ heading: { group: 'block', content: 'inline*', attrs: { level: { default: 1 } } },
+ paragraph: { group: 'block', content: 'inline*' },
+ blockquote: { group: 'block', content: 'block+' },
+} })
+const h = (level: number, text: string) => schema.nodes.heading!.create({ level }, schema.text(text))
+const p = (text: string) => schema.nodes.paragraph!.create(null, schema.text(text))
+it('ends sections at same-or-higher headings and confines nested quotes to their parent', () => {
+ const doc = schema.nodes.doc!.create(null, [h(1, 'A'), p('a'), h(2, 'B'), p('b'), h(1, 'C'), p('c'), schema.nodes.blockquote!.create(null, [h(2, 'D'), p('d')])])
+ const sections = headingSections(doc)
+ expect(sections.map(section => doc.nodeAt(section.from)?.textContent)).toEqual(['A', 'B', 'C', 'D'])
+ expect(sections[0]!.end).toBe(sections[2]!.from)
+ expect(sections[1]!.end).toBe(sections[2]!.from)
+ expect(sections[3]!.end).toBe(doc.content.size - 1)
+})
+it('moves the caret out of collapsed content without changing document content or history', () => {
+ const doc = schema.nodes.doc!.create(null, [h(1, 'A'), p('body'), h(1, 'B')])
+ const state = EditorState.create({ doc, selection: TextSelection.create(doc, 5) })
+ const tr = headingFoldTransaction(state, 'all')!
+ expect(tr.doc.eq(doc)).toBe(true)
+ expect(tr.docChanged).toBe(false)
+ expect(tr.selection.from).toBe(1)
+ expect(tr.getMeta('addToHistory')).toBe(false)
+ expect(headingSections(doc)).toHaveLength(1)
+})
diff --git a/frontend/src/features/editor/headingFolding.ts b/frontend/src/features/editor/headingFolding.ts
new file mode 100644
index 0000000..048591c
--- /dev/null
+++ b/frontend/src/features/editor/headingFolding.ts
@@ -0,0 +1,118 @@
+import { $prose } from '@milkdown/kit/utils'
+import { Plugin, PluginKey, TextSelection, type EditorState } from '@milkdown/kit/prose/state'
+import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
+import type { Node } from '@milkdown/kit/prose/model'
+import { t } from '@/i18n'
+
+export const headingFoldKey = new PluginKey>('heading-folding')
+type Section = { from: number; body: number; end: number; level: number }
+const sectionCache = new WeakMap()
+/** A section ends at the next sibling heading of the same or a higher rank. */
+export function headingSections(doc: Node): Section[] {
+ const cached = sectionCache.get(doc)
+ if (cached) return cached
+ const sections: Section[] = []
+ function visit(parent: Node, start: number) {
+ const children: { node: Node; pos: number }[] = []
+ parent.forEach((node, offset) => children.push({ node, pos: start + offset }))
+ const following: { pos: number; level: number }[] = []
+ for (let i = children.length - 1; i >= 0; i--) {
+ const { node, pos } = children[i]!
+ if (node.type.name === 'heading') {
+ while (following.length && following[following.length - 1]!.level > node.attrs.level) following.pop()
+ const next = following[following.length - 1]
+ const body = pos + node.nodeSize
+ const end = next?.pos ?? start + parent.content.size
+ if (end > body) sections.push({ from: pos, body, end, level: Number(node.attrs.level) })
+ following.push({ pos, level: Number(node.attrs.level) })
+ }
+ if (!node.isTextblock && node.childCount) visit(node, pos + 1)
+ }
+ }
+ visit(doc, 0)
+ sections.sort((a, b) => a.from - b.from)
+ sectionCache.set(doc, sections)
+ return sections
+}
+
+export function headingFoldTransaction(state: EditorState, action: 'toggle' | 'all' | 'none', position?: number) {
+ const sections = headingSections(state.doc)
+ const folded = new Set(headingFoldKey.getState(state) ?? [])
+ if (action === 'none') folded.clear()
+ else if (action === 'all') sections.forEach(section => folded.add(section.from))
+ else {
+ const section = position === undefined
+ ? sections.filter(item => item.from <= state.selection.from && item.end >= state.selection.from).pop()
+ : sections.find(item => item.from === position)
+ if (!section) return null
+ if (folded.has(section.from)) folded.delete(section.from)
+ else folded.add(section.from)
+ }
+ const tr = state.tr
+ const enclosing = sections.find(section => folded.has(section.from) && state.selection.to >= section.body && state.selection.from < section.end)
+ if (enclosing) tr.setSelection(TextSelection.near(state.doc.resolve(enclosing.from + 1)))
+ return tr.setMeta(headingFoldKey, folded).setMeta('addToHistory', false)
+}
+
+export const headingFoldingPlugin = $prose(() => new Plugin>({
+ key: headingFoldKey,
+ state: {
+ init: () => new Set(),
+ apply(tr, previous) {
+ const explicit = tr.getMeta(headingFoldKey) as Set | undefined
+ if (explicit) return explicit
+ const sections = headingSections(tr.doc)
+ const mapped = new Set()
+ for (const old of previous) {
+ const result = tr.mapping.mapResult(old, 1)
+ if (!result.deleted && sections.some(section => section.from === result.pos)) mapped.add(result.pos)
+ }
+ // Outline jumps, find and keyboard navigation must never leave a hidden caret.
+ if (tr.selectionSet || tr.docChanged) {
+ for (const section of sections) if (tr.selection.to >= section.body && tr.selection.from < section.end) mapped.delete(section.from)
+ }
+ return mapped
+ },
+ },
+ props: {
+ decorations(state) {
+ const folded = headingFoldKey.getState(state) ?? new Set()
+ const sections = headingSections(state.doc)
+ const decorations: Decoration[] = []
+ for (const section of sections) {
+ const collapsed = folded.has(section.from)
+ decorations.push(Decoration.widget(section.from + 1, view => {
+ const button = document.createElement('button')
+ button.type = 'button'; button.className = 'heading-fold-toggle'; button.contentEditable = 'false'
+ button.setAttribute('aria-expanded', String(!collapsed))
+ button.setAttribute('aria-label', `${collapsed ? t('展开', 'Expand') : t('折叠', 'Collapse')} H${section.level} ${state.doc.nodeAt(section.from)?.textContent ?? ''}`)
+ button.onmousedown = event => event.preventDefault()
+ button.onclick = event => {
+ event.preventDefault()
+ const tr = headingFoldTransaction(view.state, 'toggle', section.from)
+ if (tr) view.dispatch(tr)
+ }
+ return button
+ }, { key: `${section.from}:${collapsed}:${state.doc.nodeAt(section.from)?.textContent}`, side: -1, stopEvent: () => true }))
+ }
+ const hidden: { body: number; end: number }[] = []
+ for (const section of sections) {
+ if (!folded.has(section.from)) continue
+ const previous = hidden[hidden.length - 1]
+ if (previous && section.body <= previous.end) previous.end = Math.max(previous.end, section.end)
+ else hidden.push({ body: section.body, end: section.end })
+ }
+ let rangeIndex = 0
+ state.doc.descendants((node, pos) => {
+ if (!node.isBlock) return
+ while (hidden[rangeIndex] && pos >= hidden[rangeIndex]!.end) rangeIndex++
+ const range = hidden[rangeIndex]
+ if (range && pos >= range.body && pos + node.nodeSize <= range.end) {
+ decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: 'heading-fold-hidden' }))
+ return false
+ }
+ })
+ return DecorationSet.create(state.doc, decorations)
+ },
+ },
+}))
diff --git a/frontend/src/features/editor/inlineCodeInput.ts b/frontend/src/features/editor/inlineCodeInput.ts
new file mode 100644
index 0000000..16467ce
--- /dev/null
+++ b/frontend/src/features/editor/inlineCodeInput.ts
@@ -0,0 +1,99 @@
+import { $prose } from '@milkdown/kit/utils'
+import { Plugin } from '@milkdown/kit/prose/state'
+import type { EditorView } from '@milkdown/kit/prose/view'
+
+function reconcile(view: EditorView) {
+ if (view.isDestroyed || view.composing || !view.state.selection.empty) return
+ const { $from } = view.state.selection
+ if (!$from.parent.isTextblock || $from.parent.type.spec.code) return
+ const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\ufffc')
+ // Also inspect the closing delimiter AFTER the caret: users commonly type
+ // a pair of backticks first, move left, and then fill in the code.
+ const spans = /(^|[^\\`])`([^`\n\ufffc]+)`(?!`)/g
+ let candidate: { start: number; end: number } | undefined
+ for (const match of text.matchAll(spans)) {
+ const start = match.index! + match[1]!.length
+ const end = match.index! + match[0].length
+ if (match[2]!.trim() && $from.parentOffset > start && $from.parentOffset <= end) {
+ candidate = { start: $from.start() + start, end: $from.start() + end }
+ break
+ }
+ }
+ if (!candidate) return
+ const mark = view.state.schema.marks.inlineCode
+ if (!mark) return
+ const { start, end } = candidate
+ if (view.state.doc.rangeHasMark(start, end, mark)) return
+ const tr = view.state.tr.delete(end - 1, end).delete(start, start + 1)
+ tr.removeMark(start, end - 2).addMark(start, end - 2, mark.create())
+ // Filling an existing pair must keep subsequent letters inside code. Typing
+ // the closing delimiter explicitly should instead leave code as usual.
+ if ($from.pos < end) tr.setStoredMarks([mark.create()])
+ else tr.removeStoredMark(mark)
+ view.dispatch(tr)
+}
+
+// DOM input can bypass handleTextInput (IME, replacement text, missing event.data).
+// Observe it in capture phase, then wait for ProseMirror's DOM observer and its
+// composition cleanup before inspecting the document. Never reconcile on load,
+// paste, undo, or a selection change alone.
+export const inlineCodeInputPlugin = $prose(() => new Plugin({
+ view(view) {
+ let timer: ReturnType | undefined
+ let pending = false
+ let composing = false
+ let attempts = 0
+ const cancel = () => { clearTimeout(timer); pending = false }
+ const schedule = () => {
+ clearTimeout(timer)
+ if (!pending || composing) return
+ timer = setTimeout(() => {
+ if (view.isDestroyed) return cancel()
+ if (view.composing) {
+ if (++attempts < 10) schedule()
+ else cancel()
+ return
+ }
+ pending = false
+ reconcile(view)
+ }, 30)
+ }
+ const input = (event: Event) => {
+ const type = (event as InputEvent).inputType
+ if (type && (!type.startsWith('insert') || /paste|drop/i.test(type))) return cancel()
+ pending = true
+ attempts = 0
+ schedule()
+ }
+ const start = () => { composing = true; cancel() }
+ const end = () => { composing = false; pending = true; attempts = 0; schedule() }
+ const keydown = (event: KeyboardEvent) => {
+ // ProseMirror handles undo/paste itself, so those actions need not emit input.
+ if (event.ctrlKey || event.metaKey || ['Backspace', 'Delete', 'Escape'].includes(event.key)) cancel()
+ else if (pending && !composing && !view.composing && event.key === 'Enter') {
+ cancel()
+ reconcile(view)
+ }
+ }
+ view.dom.addEventListener('input', input, true)
+ view.dom.addEventListener('keydown', keydown, true)
+ view.dom.addEventListener('paste', cancel, true)
+ view.dom.addEventListener('drop', cancel, true)
+ view.dom.addEventListener('compositionstart', start, true)
+ view.dom.addEventListener('compositionend', end, true)
+ return {
+ update(current, previous) {
+ if (pending && !current.state.doc.eq(previous.doc)) schedule()
+ },
+ destroy() {
+ cancel()
+ view.dom.removeEventListener('input', input, true)
+ view.dom.removeEventListener('keydown', keydown, true)
+ view.dom.removeEventListener('paste', cancel, true)
+ view.dom.removeEventListener('drop', cancel, true)
+ view.dom.removeEventListener('compositionstart', start, true)
+ view.dom.removeEventListener('compositionend', end, true)
+ },
+ }
+ },
+}))
diff --git a/frontend/src/features/editor/mermaidPreview.spec.ts b/frontend/src/features/editor/mermaidPreview.spec.ts
index 951c739..30f3874 100644
--- a/frontend/src/features/editor/mermaidPreview.spec.ts
+++ b/frontend/src/features/editor/mermaidPreview.spec.ts
@@ -23,7 +23,7 @@ it('renders SVG with the requested theme and keeps async revisions isolated', as
expect(oldPublish).not.toHaveBeenCalled()
expect(latestPublish).toHaveBeenCalledWith(latest)
expect(latestPublish.mock.calls[0]![0]).not.toBe(latest)
- document.getElementById(latest.id)?.remove()
+ document.body.replaceChildren()
expect(renderMermaid).toHaveBeenLastCalledWith('graph TD; A-->C', { theme: 'dark' })
})
@@ -31,7 +31,7 @@ it('shows syntax errors as text without executing markup', async () => {
vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: '', warnings: [' '], width: 0, height: 0 })
const preview = createMermaidPreview('invalid', false, vi.fn())
await flushPromises()
- expect(preview.classList.contains('has-error')).toBe(true)
+ expect(preview.querySelector('.has-error')).not.toBeNull()
expect(preview.querySelector('img')).toBeNull()
expect(preview.textContent).toContain('点击编辑')
})
diff --git a/frontend/src/features/editor/mermaidPreview.ts b/frontend/src/features/editor/mermaidPreview.ts
index 64c6e36..bd0c23b 100644
--- a/frontend/src/features/editor/mermaidPreview.ts
+++ b/frontend/src/features/editor/mermaidPreview.ts
@@ -1,13 +1,19 @@
import { nextTick } from 'vue'
import { renderMermaid } from '@/services/mermaidService'
import { t } from '@/i18n'
+import { appendDiagramControls } from '@/utils/diagramControls'
let previewId = 0
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void): HTMLElement {
// Each revision owns its element, so a slow render cannot replace newer content.
+ // Milkdown sanitizes Element input to its inner HTML; retain the revision
+ // marker and controls inside an otherwise disposable envelope.
+ const envelope = document.createElement('div')
const container = document.createElement('div')
+ envelope.append(container)
container.className = 'editor-mermaid-preview'
container.id = `editor-mermaid-preview-${++previewId}`
+ envelope.dataset.previewId = container.id
container.setAttribute('aria-live', 'polite')
container.textContent = t('正在渲染图表…', 'Rendering diagram…')
const publish = async () => {
@@ -18,7 +24,7 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
if (visible) {
// PreviewPanel copies HTML instead of retaining the supplied element.
// Update the current copy through Milkdown's reactive callback.
- applyPreview(container.cloneNode(true) as HTMLElement)
+ applyPreview(envelope.cloneNode(true) as HTMLElement)
}
}
void renderMermaid(source, { theme: dark ? 'dark' : 'light' }).then(result => {
@@ -30,10 +36,11 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
}
// Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
container.innerHTML = result.svg
+ appendDiagramControls(container)
void publish()
}).catch(() => {
container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.')
void publish()
})
- return container
+ return envelope
}
diff --git a/frontend/src/features/mcp/McpServersView.spec.ts b/frontend/src/features/mcp/McpServersView.spec.ts
index 235999c..0f3d931 100644
--- a/frontend/src/features/mcp/McpServersView.spec.ts
+++ b/frontend/src/features/mcp/McpServersView.spec.ts
@@ -29,7 +29,6 @@ async function render(items: McpServer[] = []) {
beforeEach(() => {
vi.clearAllMocks()
- vi.stubGlobal('confirm', vi.fn(() => true))
})
describe('McpServersView', () => {
@@ -78,7 +77,9 @@ describe('McpServersView', () => {
vi.mocked(service.deleteMcpServer).mockResolvedValue({ status: 'completed' })
await wrapper.findAll('button').find(button => button.text().includes('删除'))!.trigger('click')
await flushPromises()
- expect(confirm).toHaveBeenCalled()
+ expect(service.deleteMcpServer).not.toHaveBeenCalled()
+ await wrapper.get('.action-dialog').trigger('submit')
+ await flushPromises()
expect(service.deleteMcpServer).toHaveBeenCalledWith('server-1')
})
@@ -89,7 +90,10 @@ describe('McpServersView', () => {
await wrapper.get('input[placeholder="network.request, notes.read"]').setValue('notes.read')
await wrapper.get('form').trigger('submit')
await flushPromises()
- expect(confirm).toHaveBeenCalledWith(expect.stringContaining('旧测试与授权会失效'))
+ expect(wrapper.get('.action-dialog').text()).toContain('旧测试与授权会失效')
+ expect(service.updateMcpServer).not.toHaveBeenCalled()
+ await wrapper.get('.action-dialog').trigger('submit')
+ await flushPromises()
expect(service.updateMcpServer).toHaveBeenCalled()
})
@@ -125,6 +129,8 @@ describe('McpServersView', () => {
expect(wrapper.get('.modal-card [role="alert"]').text()).toContain('服务器配置已保存,但密钥保存失败')
await wrapper.get('form').trigger('submit')
await flushPromises()
+ await wrapper.get('.action-dialog').trigger('submit')
+ await flushPromises()
expect(service.createMcpServer).toHaveBeenCalledTimes(1)
expect(service.updateMcpServer).toHaveBeenCalledWith('new-server', expect.objectContaining({ version: 1 }))
expect(service.putMcpServerSecret).toHaveBeenCalledTimes(2)
@@ -144,6 +150,8 @@ describe('McpServersView', () => {
vi.mocked(service.updateMcpServer).mockResolvedValue(server)
await wrapper.get('form').trigger('submit')
await flushPromises()
+ await wrapper.get('.action-dialog').trigger('submit')
+ await flushPromises()
expect(service.updateMcpServer).toHaveBeenCalledWith('server-1', expect.objectContaining({ version: 2, headers: {}, args: [] }))
expect(service.putMcpServerSecret).not.toHaveBeenCalled()
})
diff --git a/frontend/src/features/mcp/McpServersView.vue b/frontend/src/features/mcp/McpServersView.vue
index 22af623..247ab72 100644
--- a/frontend/src/features/mcp/McpServersView.vue
+++ b/frontend/src/features/mcp/McpServersView.vue
@@ -1,4 +1,8 @@
+
+
+
+
+
+
diff --git a/frontend/src/features/settings/ProviderForm.spec.ts b/frontend/src/features/settings/ProviderForm.spec.ts
index 119a3eb..1258e13 100644
--- a/frontend/src/features/settings/ProviderForm.spec.ts
+++ b/frontend/src/features/settings/ProviderForm.spec.ts
@@ -33,6 +33,34 @@ beforeEach(() => {
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
describe('ProviderForm', () => {
+ it('uses a top-layer dialog and restores the underlying scroll container', async () => {
+ const host = document.createElement('div')
+ host.style.overflow = 'auto'
+ document.body.appendChild(host)
+ const wrapper = mount(ProviderForm, {attachTo:host})
+ await flushPromises()
+ expect(wrapper.get('dialog').element.open).toBe(true)
+ expect(host.style.overflow).toBe('hidden')
+ wrapper.unmount()
+ expect(host.style.overflow).toBe('auto')
+ host.remove()
+ })
+ it('saves model-scoped context settings and restores them on edit', async () => {
+ const policy = {model:'old-model',context_window:65536,output_reserve:8192,threshold:0.8,mode:'detect' as const,prompt:'保留已确认事实'}
+ const wrapper = await render({...existing,context_policies:[policy]})
+ expect(wrapper.get('textarea').element.value).toBe(policy.prompt)
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(service.updateProvider).toHaveBeenCalledWith('p1', expect.objectContaining({context_policies:[policy]}))
+ })
+
+ it('does not reuse another endpoint context settings', async () => {
+ const wrapper = await render({...existing,context_policies:[{model:'old-model',context_window:65536,output_reserve:8192,threshold:0.8,mode:'detect',prompt:'摘要'}]})
+ await wrapper.get('[data-field="base-url"]').setValue('https://new.example.test/v1')
+ await wrapper.get('form').trigger('submit')
+ await flushPromises()
+ expect(service.updateProvider).toHaveBeenCalledWith('p1', expect.objectContaining({context_policies:[]}))
+ })
it('invalidates a pending inference result when JSON becomes invalid', async () => {
const wrapper = await render(existing)
let finish!: (value: {message: string}) => void
diff --git a/frontend/src/features/settings/ProviderForm.vue b/frontend/src/features/settings/ProviderForm.vue
index 5f600a2..4118694 100644
--- a/frontend/src/features/settings/ProviderForm.vue
+++ b/frontend/src/features/settings/ProviderForm.vue
@@ -4,6 +4,8 @@ import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType, RequestOv
import * as service from '@/services/providerService'
import ProviderPresetSelector from './ProviderPresetSelector.vue'
import RequestJsonEditor from './RequestJsonEditor.vue'
+import ProviderContextSettings from './ProviderContextSettings.vue'
+import type { ModelContextPolicy } from '@/contracts'
import { apiClient } from '@/services/apiClient'
import { t } from '@/i18n'
@@ -26,6 +28,7 @@ const presetsError = ref('')
const saving = ref(false)
const error = ref('')
const requestOverrides = ref(JSON.parse(JSON.stringify(props.provider?.request_overrides || [])))
+const contextPolicies = ref(JSON.parse(JSON.stringify(props.provider?.context_policies || [])))
const requestJsonValid = ref(true)
const requestPreview = ref('')
const probeResult = ref('')
@@ -33,7 +36,7 @@ const probing = ref(false)
const previewCapability = ref('chat')
const previewStream = ref(true)
let draftGeneration = 0
-watch([form, requestOverrides, requestJsonValid, apiKey, previewStream, previewCapability], () => { draftGeneration++; requestPreview.value = ''; probeResult.value = '' }, {deep:true, flush:'sync'})
+watch([form, contextPolicies, requestOverrides, requestJsonValid, apiKey, previewStream, previewCapability], () => { draftGeneration++; requestPreview.value = ''; probeResult.value = '' }, {deep:true, flush:'sync'})
async function previewRequest() {
const generation = draftGeneration
error.value = ''
@@ -41,7 +44,7 @@ async function previewRequest() {
if (!requestJsonValid.value) throw new Error(t('请先修正 JSON。', 'Fix the JSON first.'))
const response = await apiClient.post<{body:Record}>('/api/providers/request-preview', {
provider: {provider_type:form.provider_type,name:form.name || t('预览', 'Preview'),base_url:form.base_url || null,
- default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value,
+ default_model:form.default_model || null,context_policies:JSON.parse(JSON.stringify(contextPolicies.value)),request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value,
})
if (active && generation === draftGeneration) requestPreview.value = JSON.stringify(response.body, null, 2)
} catch(e) { if (active && generation === draftGeneration) error.value = (e as Error).message }
@@ -55,7 +58,7 @@ async function probeRequest() {
if (apiKey.value.trim()) throw new Error(t('请先保存新的 API Key,再进行推理验证。', 'Save the new API key before testing inference.'))
const result = await apiClient.post<{message:string}>('/api/providers/request-probe', {
provider: {provider_type:form.provider_type,name:form.name || t('推理验证', 'Inference test'),base_url:form.base_url || null,
- default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)),
+ default_model:form.default_model || null,context_policies:JSON.parse(JSON.stringify(contextPolicies.value)),request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)),
credential_id:configured.value ? credentialId.value : null}, stream:previewStream.value,
})
if (active && generation === draftGeneration) probeResult.value = result.message
@@ -64,6 +67,8 @@ async function probeRequest() {
}
const contextChanged = ref(false)
const dialog = ref()
+const backdrop = ref()
+const scrollLocks: Array<{element: HTMLElement; overflow: string}> = []
const previousFocus = document.activeElement as HTMLElement | null
let active = true
let credentialGeneration = 0
@@ -81,6 +86,11 @@ async function loadPresets() {
}
onMounted(async () => {
+ backdrop.value?.showModal()
+ for (let element = backdrop.value?.parentElement; element; element = element.parentElement) {
+ scrollLocks.push({element, overflow: element.style.overflow})
+ element.style.overflow = 'hidden'
+ }
void loadPresets()
if (props.provider?.credential_id) {
const generation = credentialGeneration
@@ -106,6 +116,7 @@ function detachCredential() {
credentialLoading.value = false
credentialError.value = ''
form.default_model = ''
+ contextPolicies.value = []
contextChanged.value = true
error.value = ''
}
@@ -130,6 +141,8 @@ function close() {
}
onBeforeUnmount(() => {
+ backdrop.value?.close()
+ for (const lock of scrollLocks) lock.element.style.overflow = lock.overflow
active = false
apiKey.value = ''
previousFocus?.focus()
@@ -138,7 +151,7 @@ onBeforeUnmount(() => {
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); close() }
if (event.key !== 'Tab') return
- const elements = Array.from(dialog.value?.querySelectorAll('button, input, select, [tabindex="0"]') ?? []).filter(element => !element.matches(':disabled'))
+ const elements = Array.from(dialog.value?.querySelectorAll('button, input, select, textarea, [tabindex="0"]') ?? []).filter(element => !element.matches(':disabled'))
const first = elements[0], last = elements[elements.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
@@ -153,7 +166,7 @@ async function save() {
if (!requestJsonValid.value) throw new Error(t('请先修正自定义请求 JSON。', 'Fix the custom request JSON first.'))
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error(t('请输入 API Key。密钥将由后端加密保存。', 'Enter an API key. It will be encrypted by the backend.'))
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
- const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false, request_overrides: requestOverrides.value }
+ const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false, request_overrides: requestOverrides.value, context_policies: JSON.parse(JSON.stringify(contextPolicies.value)) }
if (apiKey.value.trim()) {
// Rotate even an existing reference: older installations may share preset credential IDs.
const nextId = newCredentialId()
@@ -178,7 +191,7 @@ async function save() {
-
+
{{ t('关闭', 'Close') }}
{{ t('正在加载提供商预设…', 'Loading provider presets…') }}
@@ -197,6 +210,7 @@ async function save() {
{{ t('默认聊天模型', 'Default chat model') }} {{ model.name }}
{{ t('启用', 'Enabled') }}
+
{{ t('预览能力', 'Preview capability') }}{{ t('聊天', 'Chat') }} Embedding {{ t('转写', 'Transcription') }} {{ t('声纹', 'Speaker') }} {{ t('流式聊天', 'Streaming chat') }}
{{ t('预览最终请求(隐藏正文)', 'Preview final request (content hidden)') }}
@@ -208,10 +222,14 @@ async function save() {
-
+
diff --git a/frontend/src/features/settings/RequestJsonEditor.vue b/frontend/src/features/settings/RequestJsonEditor.vue
index 1f80060..a8bf3c3 100644
--- a/frontend/src/features/settings/RequestJsonEditor.vue
+++ b/frontend/src/features/settings/RequestJsonEditor.vue
@@ -72,7 +72,7 @@ async function exportRules() {
{{ t('导入替换当前请求规则,保存提供商后生效。导出仅包含请求规则,不包含凭据引用和 API Key。', 'Importing replaces the current request rules and takes effect after saving the provider. Exports contain rules only, without credential references or API keys.') }}
-
+
diff --git a/frontend/src/features/settings/SettingsView.spec.ts b/frontend/src/features/settings/SettingsView.spec.ts
new file mode 100644
index 0000000..b1077b9
--- /dev/null
+++ b/frontend/src/features/settings/SettingsView.spec.ts
@@ -0,0 +1,38 @@
+// @vitest-environment happy-dom
+import { beforeEach, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import SettingsView from './SettingsView.vue'
+import { useProviderStore } from '@/stores/provider'
+import { useSettingsStore } from '@/stores/settings'
+
+beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
+it('shows provider status and enables testing only after a successful enable', async () => {
+ const store = useProviderStore()
+ store.providers = [{ provider_id: 'p1', name: 'Example', provider_type: 'openai_compatible', enabled: false, default_model: '', capabilities: {}, has_credential: false }]
+ vi.spyOn(store, 'loadProviders').mockResolvedValue()
+ vi.spyOn(store, 'loadPresets').mockResolvedValue()
+ vi.spyOn(store, 'refreshEnabledModels').mockResolvedValue()
+ vi.spyOn(useSettingsStore(), 'loadDiagnostics').mockResolvedValue()
+ const update = vi.spyOn(store, 'updateProvider').mockImplementation(async (_id, data) => {
+ store.providers[0] = { ...store.providers[0]!, ...data }
+ return store.providers[0]!
+ })
+ const wrapper = mount(SettingsView, { global: { stubs: { UsageCard: true, LocalModelSettings: true, ModelRoutingSettings: true, ProviderLogo: true } } })
+ await flushPromises()
+ await wrapper.findAll('button').find(button => button.text() === '模型提供商')!.trigger('click')
+ expect(wrapper.text()).toContain('已停用')
+ const test = () => wrapper.findAll('button').find(button => button.text() === '测试')!
+ expect(test().attributes('disabled')).toBeDefined()
+ await wrapper.findAll('button').find(button => button.text() === '启用')!.trigger('click')
+ await flushPromises()
+ expect(update).toHaveBeenCalledWith('p1', { enabled: true })
+ expect(wrapper.text()).toContain('已启用')
+ expect(test().attributes('disabled')).toBeUndefined()
+ update.mockRejectedValueOnce(new Error('保存失败'))
+ await wrapper.findAll('button').find(button => button.text() === '停用')!.trigger('click')
+ await flushPromises()
+ expect(wrapper.text()).toContain('已启用')
+ expect(wrapper.text()).toContain('保存失败')
+ wrapper.unmount()
+})
diff --git a/frontend/src/features/settings/SettingsView.vue b/frontend/src/features/settings/SettingsView.vue
index b40d990..a2e3c88 100644
--- a/frontend/src/features/settings/SettingsView.vue
+++ b/frontend/src/features/settings/SettingsView.vue
@@ -1,7 +1,13 @@