docs: 将仓库代码注释统一为中文
CI / docs-check (push) Canceled after 0s
CI / backend-test (push) Canceled after 0s
CI / service-test (push) Canceled after 0s
CI / frontend-test (push) Canceled after 0s
CI / rust-core (push) Canceled after 0s
CI / docs-check (pull_request) Canceled after 0s
CI / backend-test (pull_request) Canceled after 0s
CI / service-test (pull_request) Canceled after 0s
CI / frontend-test (pull_request) Canceled after 0s
CI / rust-core (pull_request) Canceled after 0s
CI / docs-check (push) Canceled after 0s
CI / backend-test (push) Canceled after 0s
CI / service-test (push) Canceled after 0s
CI / frontend-test (push) Canceled after 0s
CI / rust-core (push) Canceled after 0s
CI / docs-check (pull_request) Canceled after 0s
CI / backend-test (pull_request) Canceled after 0s
CI / service-test (pull_request) Canceled after 0s
CI / frontend-test (pull_request) Canceled after 0s
CI / rust-core (pull_request) Canceled after 0s
This commit is contained in:
@@ -106,8 +106,7 @@ const toolDescriptions: Record<string, string> = {
|
||||
'text.uppercase': '将输入文本中的字母转换为大写。',
|
||||
}
|
||||
|
||||
// MCP IDs contain a server-specific namespace. Localize the remote tool name
|
||||
// for presentation only; requests must keep using the complete original ID.
|
||||
// MCP ID 包含特定于服务器的命名空间。本地化远程工具名称仅用于演示;要求必须继续使用完整的原装ID。
|
||||
const mcpTools: Record<string, { label: string; description: string }> = {
|
||||
web_search: {
|
||||
label: '网页搜索',
|
||||
|
||||
@@ -10,8 +10,8 @@ const panel = ref<HTMLElement | null>(null)
|
||||
const storageKey = 'notes-agent.workspace-chat.bounds.v1'
|
||||
const width = ref(640), height = ref(680)
|
||||
const x = ref(Math.max(8, window.innerWidth - 660)), y = ref(64)
|
||||
try { const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); if (saved && [saved.x,saved.y,saved.width,saved.height].every(Number.isFinite)) { x.value=saved.x; y.value=saved.y; width.value=saved.width; height.value=saved.height } } catch { /* storage unavailable */ }
|
||||
function save() { try { localStorage.setItem(storageKey, JSON.stringify({x:x.value,y:y.value,width:width.value,height:height.value})) } catch { /* storage unavailable */ } }
|
||||
try { const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); if (saved && [saved.x,saved.y,saved.width,saved.height].every(Number.isFinite)) { x.value=saved.x; y.value=saved.y; width.value=saved.width; height.value=saved.height } } catch { /* 存储不可用 */ }
|
||||
function save() { try { localStorage.setItem(storageKey, JSON.stringify({x:x.value,y:y.value,width:width.value,height:height.value})) } catch { /* 存储不可用 */ } }
|
||||
function reset() { width.value=640; height.value=680; x.value=window.innerWidth-660; y.value=32; clamp(); save() }
|
||||
let resizing: { x:number; y:number; width:number; height:number } | null = null
|
||||
function resizeStart(e: PointerEvent) { if (e.button !== 0) return; resizing={x:e.clientX,y:e.clientY,width:width.value,height:height.value}; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); e.preventDefault() }
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('EditorPane file switching', () => {
|
||||
|
||||
expect(store.currentFilePath).toBe('/数据结构/红黑树.md')
|
||||
expect(wrapper.text()).not.toContain('祝你写作愉快')
|
||||
}, 15000) // Real Milkdown is now imported lazily; cold module transforms count toward this integration test.
|
||||
}, 15000) // 真正的Milkdown现在被延迟导入;冷模块将计数转换为此集成测试。
|
||||
|
||||
it('applies the saved spell-check and language settings to source mode', async () => {
|
||||
const editor = useEditorStore()
|
||||
@@ -68,5 +68,5 @@ describe('EditorPane file switching', () => {
|
||||
expect(textarea.attributes('spellcheck')).toBe('true')
|
||||
expect(textarea.attributes('lang')).toBe('en')
|
||||
expect(textarea.attributes('aria-label')).toBe('Markdown source editor')
|
||||
}, 15000) // Lazy source-editor module transforms need the same cold-start budget.
|
||||
}, 15000) // 惰性源编辑器模块转换需要相同的冷启动预算。
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// The application has a doctype; happy-dom otherwise reports quirks mode to KaTeX.
|
||||
// 应用程序有一个文档类型; happy-dom 否则会向 KaTeX 报告怪癖模式。
|
||||
vi.hoisted(() => { Object.defineProperty(document, 'compatMode', {value:'CSS1Compat',configurable:true}) })
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
@@ -29,7 +29,7 @@ async function waitForEditor(wrapper: VueWrapper): Promise<Editor> {
|
||||
try {
|
||||
editor.action(getMarkdown())
|
||||
return editor
|
||||
} catch { /* editor is still creating */ }
|
||||
} catch { /* 编辑器仍在创建 */ }
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
@@ -263,7 +263,7 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
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.
|
||||
// Chromium/IME 可以省略数据并在输入事件后提交其 DOM 更改。
|
||||
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`')))
|
||||
@@ -292,8 +292,7 @@ describe('VisualMarkdownEditor formatting toolbars', () => {
|
||||
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'})
|
||||
|
||||
@@ -350,8 +350,8 @@ onMounted(async () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
// Crepe's defaultsDeep merges language arrays and theme extension internals.
|
||||
// Replace both AFTER feature configuration to avoid default grammar collisions.
|
||||
// Crepe 的 defaultsDeep 会合并语言数组与主题扩展内部配置。
|
||||
// 必须在功能配置完成后同时替换两者,以免默认语法发生冲突。
|
||||
crepe.editor.config(ctx => ctx.update(codeBlockConfig.key, config => ({
|
||||
...config,
|
||||
languages: shikiLanguages(themeStore.resolvedCodeBlockTheme),
|
||||
@@ -391,7 +391,7 @@ onMounted(async () => {
|
||||
const sections = headingSections(current.state.doc)
|
||||
const folded = headingFoldKey.getState(current.state)
|
||||
hasFoldableHeadings.value = sections.length > 0
|
||||
// Hidden descendants retain their own state but are not visible expanded sections.
|
||||
// 被隐藏的后代节点保留自身状态,但不算作可见的展开章节。
|
||||
let hiddenUntil = -1
|
||||
allHeadingsFolded.value = sections.length > 0 && sections.every(section => {
|
||||
if (section.from < hiddenUntil) return true
|
||||
@@ -585,8 +585,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
.milkdown-host :deep(.ProseMirror) { box-sizing: border-box; width: min(100%, var(--editor-line-width, 80ch)); min-height: 100%; margin: 0 auto; padding: var(--space-3xl) var(--space-xl); outline: none; font-family: var(--font-editor-sans); font-size: var(--font-editor-size); line-height: var(--font-editor-line-height); caret-color: var(--color-accent-primary); }
|
||||
.milkdown-host :deep(.ProseMirror-selectednode) { outline-color: var(--color-accent-primary); }
|
||||
.milkdown-host :deep(.ProseMirror p) { font-weight: 400; }
|
||||
/* Mermaid measures HTML labels outside the editor. Crepe's paragraph padding
|
||||
must not enlarge them after insertion into fixed-size SVG foreignObjects. */
|
||||
/* Mermaid 在编辑器外部测量 HTML 标签。 Crepe 的段落填充在插入固定大小的 SVGforeignObjects 后不得放大它们。 */
|
||||
.milkdown-host :deep(.editor-mermaid-preview svg foreignObject p) { margin: 0; padding: 0; line-height: inherit; font-weight: inherit; }
|
||||
.milkdown-host :deep(.ProseMirror h1), .milkdown-host :deep(.ProseMirror h2), .milkdown-host :deep(.ProseMirror h3), .milkdown-host :deep(.ProseMirror h4), .milkdown-host :deep(.ProseMirror h5), .milkdown-host :deep(.ProseMirror h6) { font-weight: 700; }
|
||||
.milkdown-host :deep(.font-size-marker) { display: none; }
|
||||
|
||||
@@ -16,7 +16,7 @@ export const configureCalloutSerialization: Parameters<Editor['config']>[0] = ct
|
||||
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]')
|
||||
} },
|
||||
}))
|
||||
@@ -36,8 +36,7 @@ function calloutMarkers(doc: ProseNode) {
|
||||
return markers
|
||||
}
|
||||
|
||||
// Keep native blockquotes in the document: typing, undo and Markdown serialization
|
||||
// remain Milkdown transactions; the view never rewrites a user's callout source.
|
||||
// 在文档中保留本机块引用:键入、撤消和 Markdown 序列化保留 Milkdown 事务;该视图永远不会重写用户的标注源。
|
||||
export const calloutPlugin = $prose(() => new Plugin({
|
||||
props: {
|
||||
decorations(state) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Mirror changed language labels without rescanning every code block on each DOM mutation. */
|
||||
/** 镜像更改的语言标签,无需重新扫描每个 DOM 突变上的每个代码块。 */
|
||||
export function installCodeBlockLabels(root: HTMLElement): () => void {
|
||||
const sync = (block: HTMLElement) => {
|
||||
const label = block.querySelector('.language-button')?.textContent?.trim() || 'Plain text'
|
||||
@@ -13,7 +13,7 @@ export function installCodeBlockLabels(root: HTMLElement): () => void {
|
||||
const changed = new Set<HTMLElement>()
|
||||
for (const record of records) {
|
||||
const element = record.target instanceof Element ? record.target : record.target.parentElement
|
||||
// CodeMirror viewport/text changes do not change the footer's language.
|
||||
// CodeMirror 视口/文本更改不会更改页脚的语言。
|
||||
const label = element?.closest('.language-button')
|
||||
const block = label?.closest<HTMLElement>('.milkdown-code-block')
|
||||
if (block) changed.add(block)
|
||||
|
||||
@@ -8,7 +8,7 @@ export const headingFoldKey = new PluginKey<Set<number>>('heading-folding')
|
||||
type Section = { from: number; body: number; end: number; level: number }
|
||||
const sectionCache = new WeakMap<Node, Section[]>()
|
||||
const decorationCache = new WeakMap<Node, WeakMap<Set<number>, DecorationSet>>()
|
||||
/** 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
|
||||
@@ -79,7 +79,7 @@ export const headingFoldingPlugin = $prose(() => new Plugin<Set<number>>({
|
||||
const result = tr.mapping.mapResult(old, 1)
|
||||
if (!result.deleted && positions.has(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)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ function reconcile(view: EditorView) {
|
||||
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.
|
||||
// 还要检查结束分隔符 AFTER 插入符号:用户通常首先键入一对反引号,向左移动,然后填写代码。
|
||||
const spans = /(^|[^\\`])`([^`\n\ufffc]+)`(?!`)/g
|
||||
let candidate: { start: number; end: number } | undefined
|
||||
for (const match of text.matchAll(spans)) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Generated by scripts/generate-language-icons.mjs. VSCode Icons (MIT); see language-icons-LICENSE.txt. */
|
||||
/* 由 scripts/generate-language-icons.mjs 自动生成。VSCode Icons 采用 MIT 许可证;详情见 language-icons-LICENSE.txt。 */
|
||||
.milkdown-host .language-list-item[data-language] { display: flex; align-items: center; gap: 8px; }
|
||||
.milkdown-host .language-list-item[data-language]::before { content: ''; flex: 0 0 20px; width: 20px; height: 20px; background: center / contain no-repeat url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23c5c5c5%22%20d%3D%22M20.414%202H5v28h22V8.586ZM7%2028V4h12v6h6v18Z%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
.milkdown-host .language-list-item[data-language][data-language="actionscript-3"]::before { background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23c41718%22%20d%3D%22M2%2015.281c1.918%200%202.11-1.055%202.11-1.918a17%2017%200%200%200-.192-2.205a19%2019%200%200%201-.192-2.205c0-2.4%201.63-3.452%203.836-3.452h.575v1.437h-.479c-1.534%200-2.11.767-2.11%202.205a14%2014%200%200%200%20.192%201.918a14%2014%200%200%201%20.192%202.014c0%201.726-.671%202.493-1.918%202.877v.1c1.151.288%201.918%201.151%201.918%202.877a14%2014%200%200%201-.192%202.014a13%2013%200%200%200-.192%201.918c0%201.438.575%202.3%202.11%202.3h.479V26.6h-.575c-2.205%200-3.836-.959-3.836-3.644a19%2019%200%200%201%20.192-2.205a16%2016%200%200%200%20.192-2.11c0-.863-.288-1.918-2.11-1.918Z%22%2F%3E%3Cpath%20fill%3D%22%23c41718%22%20d%3D%22M9.479%2018.062L8.233%2021.8H6.6l4.03-11.889h1.822L16.479%2021.8h-1.534L13.7%2018.062Zm3.932-1.151l-1.151-3.452a9.4%209.4%200%200%201-.575-2.205c-.192.671-.384%201.438-.575%202.11l-1.151%203.451h3.452Zm4.507%203.068a5.94%205.94%200%200%200%202.781.767c1.534%200%202.493-.863%202.493-2.014s-.671-1.726-2.205-2.4c-1.918-.671-3.164-1.726-3.164-3.356c0-1.822%201.534-3.26%203.836-3.26a5.14%205.14%200%200%201%202.589.575l-.384%201.247a5.5%205.5%200%200%200-2.3-.479c-1.63%200-2.205.959-2.205%201.822c0%201.151.767%201.63%202.4%202.3c2.014.767%203.068%201.726%203.068%203.452c0%201.822-1.342%203.452-4.123%203.452a5.8%205.8%200%200%201-3.068-.767Z%22%2F%3E%3Cpath%20fill%3D%22%23c41718%22%20d%3D%22M30%2016.623c-1.918%200-2.11%201.151-2.11%201.918a16%2016%200%200%200%20.192%202.11a16%2016%200%200%201%20.192%202.205c0%202.685-1.63%203.644-3.836%203.644h-.575v-1.438h.479c1.438%200%202.11-.863%202.11-2.3a13%2013%200%200%200-.192-1.918a14%2014%200%200%201-.192-2.014c0-1.726.767-2.589%201.918-2.877v-.1c-1.151-.288-1.918-1.151-1.918-2.877a14%2014%200%200%201%20.192-2.014a13%2013%200%200%200%20.192-1.918c0-1.438-.575-2.205-2.11-2.3h-.479V5.4h.575c2.205%200%203.836%201.055%203.836%203.452a17%2017%200%200%201-.192%202.205a17%2017%200%200%200-.192%202.205c0%20.959.288%201.918%202.11%201.918Z%22%2F%3E%3C%2Fsvg%3E"); }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Promote menus to the top layer; only open menus need scroll measurements. */
|
||||
/** 将菜单提升到顶层;只有打开的菜单才需要滚动测量。 */
|
||||
export function installLanguagePickerPopover(root: HTMLElement): () => void {
|
||||
const menus = new Set<HTMLElement>()
|
||||
const openMenus = new Set<HTMLElement>()
|
||||
@@ -55,7 +55,7 @@ export function installLanguagePickerPopover(root: HTMLElement): () => void {
|
||||
}
|
||||
root.querySelectorAll<HTMLElement>('.language-picker').forEach(sync)
|
||||
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-expanded'] })
|
||||
// The outer editor viewport is an ancestor of root, so listen in capture on the document.
|
||||
// 外部编辑器视口是根的祖先,因此在文档上侦听捕获。
|
||||
document.addEventListener('scroll', positionOpenMenus, { capture: true, passive: true })
|
||||
window.addEventListener('resize', positionOpenMenus)
|
||||
return () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Editable anchors need explicit navigation; plain clicks keep editing the link. */
|
||||
/** 可编辑锚点需要显式导航;简单的点击即可继续编辑链接。 */
|
||||
export function installLinkNavigation(root: HTMLElement): () => void {
|
||||
const navigate = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || !(event.ctrlKey || event.metaKey) || event.altKey) return
|
||||
@@ -7,7 +7,7 @@ export function installLinkNavigation(root: HTMLElement): () => void {
|
||||
if (!link || !root.contains(link)) return
|
||||
const href = link.getAttribute('href')?.trim()
|
||||
if (!href) return
|
||||
// Consume modified clicks before Milkdown's link editor or native navigation.
|
||||
// 在 Milkdown 的链接编辑器或本机导航之前消耗修改的点击。
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
let url: URL
|
||||
|
||||
@@ -6,9 +6,7 @@ import { appendDiagramControls } from '@/utils/diagramControls'
|
||||
|
||||
let previewId = 0
|
||||
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void, kind = 'mermaid', themeId = 'light'): 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.
|
||||
// 每个修订版本都拥有其元素,因此缓慢的渲染无法替换较新的内容。 Milkdown 清理其内部 HTML 的 Element 输入;将修订标记和控件保留在一次性信封内。
|
||||
const envelope = document.createElement('div')
|
||||
const container = document.createElement('div')
|
||||
envelope.append(container)
|
||||
@@ -19,12 +17,10 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
|
||||
container.textContent = t('正在渲染图表…', 'Rendering diagram…')
|
||||
const publish = async () => {
|
||||
await nextTick()
|
||||
// Milkdown sanitizes and copies this element. Publish only if its revision
|
||||
// still exists; edits, language changes and unmounts remove the old marker.
|
||||
// Milkdown 清理并复制该元素。仅当其修订版本仍然存在时才发布;编辑、语言更改和卸载会删除旧标记。
|
||||
const visible = document.getElementById(container.id)
|
||||
if (visible) {
|
||||
// PreviewPanel copies HTML instead of retaining the supplied element.
|
||||
// Update the current copy through Milkdown's reactive callback.
|
||||
// PreviewPanel 复制 HTML,而不是保留提供的元素。通过 Milkdown 的反应式回调更新当前副本。
|
||||
applyPreview(envelope.cloneNode(true) as HTMLElement)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +34,7 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
|
||||
void publish()
|
||||
return
|
||||
}
|
||||
// Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
|
||||
// Mermaid以严格模式运行; Milkdown 在插入之前清理预览。
|
||||
container.innerHTML = result.svg
|
||||
appendDiagramControls(container)
|
||||
if (result.warnings.length) { const warning = document.createElement('p'); warning.textContent = result.warnings.join('\n'); warning.setAttribute('role', 'status'); container.append(warning) }
|
||||
|
||||
@@ -7,8 +7,7 @@ type CodeTheme = 'github-light' | 'github-dark'
|
||||
|
||||
export async function shikiLanguage(language: string, theme: CodeTheme): Promise<LanguageSupport> {
|
||||
const tokenize = await getCodeTokenizer(theme, language)
|
||||
// Milkdown recreates off-screen CodeMirror views. Reuse immutable ranges for
|
||||
// identical code within this language/theme, with a bounded retention budget.
|
||||
// Milkdown 重新创建屏幕外 CodeMirror 视图。在该语言/主题内重复使用相同代码的不可变范围,并保留有限的预算。
|
||||
const cache = new Map<string, DecorationSet>()
|
||||
let cachedCharacters = 0
|
||||
const highlights = ViewPlugin.fromClass(class {
|
||||
@@ -53,7 +52,7 @@ export async function shikiLanguage(language: string, theme: CodeTheme): Promise
|
||||
}
|
||||
}, { decorations: value => value.decorations })
|
||||
|
||||
// CodeMirror still owns selection, input and undo. Shiki owns token colors.
|
||||
// CodeMirror仍然拥有选择、输入和撤消功能。 Shiki拥有令牌颜色。
|
||||
const parser = StreamLanguage.define({ token(stream) { stream.skipToEnd(); return null } })
|
||||
return new LanguageSupport(parser, highlights)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ async function load(reset = false, older = false) {
|
||||
const viewport = scroller.value
|
||||
const oldHeight = viewport?.scrollHeight ?? 0
|
||||
const oldTop = viewport?.scrollTop ?? 0
|
||||
// Preserve a visible row when adding history and trimming the opposite edge.
|
||||
// 添加历史记录并修剪相对边缘时保留可见行。
|
||||
const anchor = older && viewport ? [...viewport.querySelectorAll<HTMLElement>('[data-log-id]')].find(row => row.getBoundingClientRect().bottom > viewport.getBoundingClientRect().top) : undefined
|
||||
const anchorTop = anchor?.getBoundingClientRect().top
|
||||
const anchorId = anchor?.dataset.logId
|
||||
@@ -30,7 +30,7 @@ async function load(reset = false, older = false) {
|
||||
try {
|
||||
const result = await apiClient.get<LogPage>('/api/logs', { params: { limit: 50, before: older ? page.value.next_cursor ?? undefined : undefined, ...applied } })
|
||||
if (version !== revision) return
|
||||
// If the reader scrolled away during a refresh, leave their view untouched.
|
||||
// 如果读者在刷新期间滚动离开,请保持他们的视图不变。
|
||||
if (!reset && !older && !following.value) return
|
||||
const previous = page.value.items
|
||||
const overlaps = result.items.some(item => previous.some(old => old.id === item.id))
|
||||
|
||||
@@ -115,8 +115,7 @@ function formPayload(): McpServerInput {
|
||||
function payload(requireConnection = true): McpServerInput {
|
||||
const { config, secrets } = editorMode.value === 'form'
|
||||
? normalizeMcpConfig(formPayload(), '', requireConnection) : parseMcpJson(rawConfig.value, form.name, requireConnection)
|
||||
// Keep only still-declared drafts. A mode switch must not discard imported keys,
|
||||
// and editing the declaration must not later send a removed key to the Secret API.
|
||||
// 仅保留仍声明的草稿。模式开关不得丢弃导入的密钥,并且编辑声明后不得将删除的密钥发送到 Secret API。
|
||||
importedSecrets.value = mergeImportedSecrets(config, importedSecrets.value, secrets)
|
||||
if (editingId.value) config.version = form.version
|
||||
if (editorMode.value === 'json') rawConfig.value = JSON.stringify(config, null, 2)
|
||||
@@ -149,8 +148,7 @@ async function save() {
|
||||
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !(await askConfirm(t('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?', 'The command, address, or authentication settings changed. Previous tests and authorization will be invalidated. Save?')))) return
|
||||
busy.value = 'save'
|
||||
saved = editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
|
||||
// Commit the returned ID/version before saving secrets so a partial failure can
|
||||
// retry this server instead of creating a duplicate or sending a stale version.
|
||||
// 在保存机密之前提交返回的 ID/版本,以便部分失败可以重试此服务器,而不是创建重复版本或发送过时的版本。
|
||||
editingId.value = saved.server_id
|
||||
editingOriginal.value = saved
|
||||
resetEditor({ ...input, version: saved.version })
|
||||
|
||||
@@ -11,8 +11,7 @@ export function mergeImportedSecrets(config: McpServerInput, previous: ImportedS
|
||||
const keys = item.kind === 'header' ? config.secret_header_keys : config.secret_environment_keys
|
||||
const declared = keys.find(key => normalize(key) === normalize(item.key))
|
||||
if (declared === undefined) continue
|
||||
// HTTP identity is case-insensitive, but the Secret API requires the current
|
||||
// declared spelling. New inline values replace older drafts of that identity.
|
||||
// HTTP 身份不区分大小写,但 Secret API 需要当前声明的拼写。新的内联值取代了该身份的旧草稿。
|
||||
merged.set(`${item.kind}:${normalize(declared)}`, { ...item, key: declared })
|
||||
}
|
||||
return [...merged.values()]
|
||||
@@ -50,7 +49,7 @@ function timeout(value: unknown, fallback: number, max: number, label: string):
|
||||
return value
|
||||
}
|
||||
|
||||
// Do not silently rewrite executable arguments or secret values copied from chat.
|
||||
// 不要默默地重写从聊天复制的可执行参数或秘密值。
|
||||
function checkUrl(value: string, label: string) {
|
||||
if (/^\[https?:\/\//i.test(value)) throw new Error(`${label}${t('请填写纯 URL,不要粘贴 Markdown 链接', ': enter a plain URL instead of a Markdown link')}`)
|
||||
}
|
||||
@@ -62,9 +61,7 @@ export function parseMcpJson(raw: string, fallbackName = '', requireConnection =
|
||||
return normalizeMcpConfig(parsed, fallbackName, requireConnection)
|
||||
}
|
||||
|
||||
/** Normalize external client JSON before it reaches either the form or the API.
|
||||
* Inline secrets leave the public config here and are sent only to the Secret API.
|
||||
*/
|
||||
/** 在外部客户端 JSON 到达表单或 API 之前对其进行标准化。内联机密在此处保留公共配置,并且仅发送到机密 API。 */
|
||||
export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireConnection = true) {
|
||||
let raw = object(parsed, t('服务器配置', 'Server configuration'))
|
||||
if ('mcpServers' in raw) {
|
||||
@@ -75,7 +72,7 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo
|
||||
}
|
||||
const allowed = new Set([...Object.keys(emptyMcpConfig()), 'version', 'env', 'type', 'timeout', 'sse_read_timeout'])
|
||||
if (Object.keys(raw).some(key => !allowed.has(key))) {
|
||||
// Never echo arbitrary unknown keys: pasted secrets sometimes become JSON keys.
|
||||
// 永远不要回显任意未知密钥:粘贴的秘密有时会变成 JSON 密钥。
|
||||
throw new Error(t('服务器配置含不支持的字段;API Key 请放在 env/environment 的对应变量中,不要放在顶层', 'The server configuration contains unsupported fields. Put API keys in the corresponding env/environment variables, not at the top level.'))
|
||||
}
|
||||
if (raw.env !== undefined && raw.environment !== undefined) throw new Error(t('env 与 environment 请只保留一个,避免覆盖配置', 'Keep either env or environment, not both'))
|
||||
@@ -100,7 +97,7 @@ export function normalizeMcpConfig(parsed: unknown, fallbackName = '', requireCo
|
||||
config.secret_header_keys = [...new Set(strings(raw.secret_header_keys, 'secret_header_keys'))]
|
||||
config.permissions = strings(raw.permissions, 'permissions')
|
||||
config.startup_timeout_seconds = timeout(raw.startup_timeout_seconds ?? raw.timeout, 15, 120, t('启动超时', 'Startup timeout'))
|
||||
// Compatibility policy: legacy read timeout becomes the tool wait budget, not an SSE transport setting.
|
||||
// 兼容性策略:旧的读取超时成为工具等待预算,而不是 SSE 传输设置。
|
||||
config.tool_timeout_seconds = timeout(raw.tool_timeout_seconds ?? raw.sse_read_timeout, 30, 300, t('工具超时', 'Tool timeout'))
|
||||
if (config.transport === 'stdio') {
|
||||
if (requireConnection && !config.command) throw new Error(t('stdio 配置必须填写 command', 'stdio configuration requires command'))
|
||||
|
||||
@@ -21,7 +21,7 @@ const documents: Record<string, string> = {
|
||||
minimax: 'https://platform.minimaxi.com/docs/api-reference/text-openai-api',
|
||||
stepfun: 'https://platform.stepfun.com/docs/zh/guides/models/overview',
|
||||
}
|
||||
// Exact documented model IDs only; an unrecognised model is always manual.
|
||||
// 仅准确记录的型号 ID;无法识别的模型始终是手动的。
|
||||
const documentedWindow = computed(() => {
|
||||
if (props.preset === 'minimax') {
|
||||
if (props.model === 'MiniMax-M3') return 1000000
|
||||
|
||||
@@ -165,10 +165,10 @@ async function save() {
|
||||
if (!form.name.trim() || !form.base_url.trim()) throw new Error(t('请填写名称和 Base URL。', 'Enter a name and Base URL.'))
|
||||
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, context_policies: JSON.parse(JSON.stringify(contextPolicies.value)) }
|
||||
if (apiKey.value.trim()) {
|
||||
// Rotate even an existing reference: older installations may share preset credential IDs.
|
||||
// 甚至轮换现有参考:较旧的安装可能共享预设的凭据 ID。
|
||||
const nextId = newCredentialId()
|
||||
const request = service.putCredential(nextId, apiKey.value.trim())
|
||||
apiKey.value = ''
|
||||
@@ -178,7 +178,7 @@ async function save() {
|
||||
configured.value = true
|
||||
}
|
||||
const reference = configured.value ? credentialId.value : undefined
|
||||
// A failed status check must not silently unlink the provider's existing credential.
|
||||
// 失败的状态检查不得以静默方式取消链接提供者的现有凭证。
|
||||
if (credentialError.value && !reference) throw new Error(credentialError.value)
|
||||
const saved = props.provider
|
||||
? await service.updateProvider(props.provider.provider_id, { ...data, version: props.provider.version, credential_id: reference ?? null })
|
||||
|
||||
@@ -29,7 +29,7 @@ it('shades by consumed metric and gives equal usage equal shades', async () => {
|
||||
expect(segments[0]!.attributes('style')).not.toBe(segments[1]!.attributes('style'))
|
||||
expect(wrapper.get('.model-legend').text()).toContain('model-1')
|
||||
expect(segments[0]!.attributes('title')).toContain('100')
|
||||
// happy-dom drops color-mix declarations; inspect the bound color values.
|
||||
// happy-dom 删除颜色混合声明;检查绑定的颜色值。
|
||||
const colors = wrapper.vm as unknown as {modelColor:(key:string,source:'api') => string}
|
||||
expect(colors.modelColor('m0','api')).toContain('67.5%')
|
||||
expect(colors.modelColor('m1','api')).toContain('95%')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
// Vitest disables CSS by default, including CSS raw imports. Load the real files here.
|
||||
// Vitest 默认禁用 CSS,包括 CSS 原始导入。在这里加载真实的文件。
|
||||
vi.mock('@/styles/features.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/features.css', 'utf8') }))
|
||||
vi.mock('@/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/tokens.css', 'utf8') }))
|
||||
vi.mock('@/styles/callouts.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/callouts.css', 'utf8') }))
|
||||
@@ -29,7 +29,7 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
|
||||
expect(doc.querySelector('style')!.textContent).toContain('.button-primary:hover')
|
||||
expect(doc.querySelector('style')!.textContent).not.toContain('color:white')
|
||||
const rules = Array.from(doc.styleSheets[0]!.cssRules) as CSSStyleRule[]
|
||||
// The sandbox cannot inherit MarkdownContent's component stylesheet.
|
||||
// 沙箱无法继承MarkdownContent的组件样式表。
|
||||
const codeRule = rules.find(rule => rule.selectorText === '.markdown-content .shiki code')!
|
||||
const lineRule = rules.find(rule => rule.selectorText === '.markdown-content .shiki .line')!
|
||||
expect(codeRule.style.getPropertyValue('display')).toBe('block')
|
||||
@@ -37,7 +37,7 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
|
||||
expect(lineRule.style.getPropertyValue('min-height')).toBe('1lh')
|
||||
const rootRule = rules.filter(rule => rule.selectorText === 'html').pop()!
|
||||
const bodyRule = rules.filter(rule => rule.selectorText === 'body').pop()!
|
||||
// The embedded document must override the app-shell overflow lock.
|
||||
// 嵌入文档必须覆盖应用程序外壳溢出锁定。
|
||||
expect(rootRule.style.getPropertyValue('overflow-y')).toBe('auto')
|
||||
expect(rootRule.style.getPropertyPriority('overflow-y')).toBe('important')
|
||||
expect(bodyRule.style.getPropertyValue('height')).toBe('auto')
|
||||
|
||||
@@ -15,8 +15,7 @@ const props = defineProps<{ themeId: string; name?: string; css?: string }>()
|
||||
const emit = defineEmits<{ (event: 'close'): void }>()
|
||||
const theme = computed(() => props.name ? { name: props.name } : mockCommunityThemes.find(item => item.theme_id === props.themeId))
|
||||
const previewDocument = computed(() => {
|
||||
// Both imported and bundled CSS are previewed in a script-free isolated document.
|
||||
// Previewing never installs a theme or changes application styles/storage.
|
||||
// 导入和捆绑的 CSS 都可以在无脚本的独立文档中预览。预览永远不会安装主题或更改应用程序样式/存储。
|
||||
const doc = document.implementation.createHTMLDocument(theme.value?.name ?? '')
|
||||
doc.documentElement.dataset.theme = props.themeId
|
||||
const policy = doc.createElement('meta')
|
||||
|
||||
@@ -53,7 +53,7 @@ async function run() {
|
||||
if (missingRequiredFields(command, args.value).length) { error.value = t('请填写必填参数', 'Complete required fields'); return }
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
// Runtime rechecks enabled state, schema, when conditions and permissions.
|
||||
// 运行时重新检查启用状态、架构、条件和权限。
|
||||
const result = await executePluginCommand(command.command_id, cleanArguments(args.value), { ...snapshot.value })
|
||||
await applyCommandEffect(result.effect, {
|
||||
navigate: path => router.push(path),
|
||||
|
||||
Reference in New Issue
Block a user