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

This commit is contained in:
2026-09-10 00:40:56 +08:00
parent 51c592841d
commit d703ab64e3
249 changed files with 707 additions and 900 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ const desktop = isDesktop()
let statusTimer: ReturnType<typeof setTimeout> | undefined
let disposed = false
async function pollIndex() {
try { const status = await getIndexStatus(); if (!disposed) settingsStore.indexStatus = status } catch { /* retain last status; retry */ }
try { const status = await getIndexStatus(); if (!disposed) settingsStore.indexStatus = status } catch { /* 保留最后状态;重试 */ }
const busy = settingsStore.indexStatus.status === 'indexing' || settingsStore.indexStatus.active_searches
if (!disposed) statusTimer = setTimeout(pollIndex, busy || route.name === 'settings' || route.name === 'search' ? 1000 : 5000)
}
@@ -34,8 +34,7 @@ function anchorZoom(svg: SVGSVGElement, event: WheelEvent) {
anchorUntil = performance.now() + 240
const follow = () => {
if (!svg.isConnected) return
// Inner horizontal overflow and the editor's outer vertical scroll may differ.
// Re-measure after each scroll, letting the outer container take the remainder.
// 内部水平溢出和编辑器的外部垂直滚动可能不同。每次滚动后重新测量,让外容器带走剩余的部分。
for (const node of scrollers) {
const current = svg.getBoundingClientRect()
node.scrollLeft += current.left + x * current.width - screenX
@@ -134,8 +133,7 @@ async function interact(event: MouseEvent) {
disarm()
opener = button
const intrinsicWidth = widthOf(svg)
// Mermaid HTML labels live in SVG foreignObject nodes. Preserve that
// integration point while still sanitizing the embedded HTML and handlers.
// Mermaid HTML 标签位于 SVGforeignObject 节点中。保留该集成点,同时仍然清理嵌入式 HTML 和处理程序。
const copy = svg.cloneNode(true) as SVGSVGElement
for (const label of copy.querySelectorAll('foreignObject, foreignobject')) {
label.innerHTML = DOMPurify.sanitize(label.innerHTML, { USE_PROFILES: { html: true } })
@@ -151,8 +149,7 @@ async function interact(event: MouseEvent) {
const viewport = viewer.value?.querySelector<HTMLElement>('.diagram-viewer-scroll')
const box = svg.getAttribute('viewBox')?.trim().split(/[ ,]+/).map(Number)
const intrinsicHeight = box?.length === 4 && box[3]! > 0 ? box[3]! : svg.getBoundingClientRect().height
// Opening is independent of the inline preview's zoom and any previous modal scroll.
// Keep native size for small diagrams; fit wide/tall diagrams completely at 100%.
// 打开独立于内联预览的缩放和任何先前的模式滚动。保持小图表的原始大小; 100% 完全适合宽/高图表。
baseWidth.value = Math.min(intrinsicWidth, viewport?.clientWidth || intrinsicWidth,
intrinsicHeight > 0 && viewport?.clientHeight ? viewport.clientHeight * intrinsicWidth / intrinsicHeight : intrinsicWidth)
await nextTick()
@@ -203,7 +200,7 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
.diagram-viewer header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); flex-shrink: 0; }
.diagram-viewer header .diagram-controls { flex-wrap: wrap; }
.diagram-viewer-scroll { display: flex; flex: 1; min-height: 0; overflow: auto; }
/* Auto margins center small diagrams and become zero on overflow, keeping all edges reachable. */
/* 自动边距使小图居中并在溢出时变为零,从而保持所有边缘可达。 */
.diagram-viewer-image { flex: 0 0 auto; margin: auto; transition: width 180ms ease-out; }
.diagram-viewer-image svg { display: block; width: 100% !important; max-width: none !important; height: auto !important; }
</style>
@@ -1,4 +1,4 @@
// Reference counts keep the underlying page locked when dialogs are nested.
// 当对话框嵌套时,引用计数会锁定底层页面。
const locks = new WeakMap<HTMLElement, { count: number; value: string; priority: string }>()
export function lockDialogScroll(dialog: HTMLElement): () => void {
const elements: HTMLElement[] = []
+2 -2
View File
@@ -2,7 +2,7 @@ import { nextTick, onBeforeUnmount, shallowRef } from 'vue'
export interface ActionDialogRequest { message: string; mode: 'confirm' | 'prompt'; initialValue: string }
/** Requests belong to the invoking view; leaving it cancels pending work. */
/** 请求属于调用视图;离开它会取消待处理的工作。 */
export function useActionDialog() {
const actionDialog = shallowRef<ActionDialogRequest | null>(null)
let pending: ((value: string | null) => void) | undefined
@@ -11,7 +11,7 @@ export function useActionDialog() {
const resolve = pending
pending = undefined
actionDialog.value = null
await nextTick() // Restore focus and release the modal before the caller continues.
await nextTick() // 在调用者继续之前恢复焦点并释放模式。
resolve?.(disposed ? null : value)
}
function request(mode: ActionDialogRequest['mode'], message: string, initialValue = '') {
@@ -2,7 +2,7 @@ import { onMounted, onUnmounted } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import { useEditorStore } from '@/stores/editor'
/** Web fallback until the desktop host supplies filesystem events. No overlapping polls. */
/** Web 回退,直到桌面主机提供文件系统事件。没有重叠的民意调查。 */
export function useWorkspaceRefresh() {
const workspace = useWorkspaceStore()
const editor = useEditorStore()
@@ -21,7 +21,7 @@ export function useWorkspaceRefresh() {
else await editor.checkExternalFile()
}
}
} catch { /* Keep the existing tree; the store exposes the error and retries. */ }
} catch { /* 保留现有树;商店暴露错误并重试。 */ }
finally {
running = false
if (!stopped) timer = setTimeout(refresh, 2000)
+18 -18
View File
@@ -1,4 +1,4 @@
// ============ Notes & Blocks ============
// ============ 笔记与内容块 ============
export interface Note {
note_id: string
@@ -34,7 +34,7 @@ export interface FileNode {
is_external_changed?: boolean
}
// ============ Search ============
// ============ 搜索 ============
export interface SearchRequest {
query: string
@@ -58,7 +58,7 @@ export interface SearchResult {
tags?: string[]
}
// ============ Chat ============
// ============ 对话 ============
export interface Conversation {
conversation_id: string
@@ -101,7 +101,7 @@ export interface Citation {
}
}
// ============ Model Events (SSE) ============
// ============ 模型事件(SSE ============
export type ModelEventType =
| 'ContextStatus'
@@ -122,7 +122,7 @@ export interface ModelEvent {
timestamp: string
}
// ============ Agent ============
// ============ 智能体 ============
export type AgentRunStatus =
| 'queued'
@@ -221,7 +221,7 @@ export interface TokenUsage {
total_tokens: number
}
// ============ Skill ============
// ============ Skill(技能) ============
export type SkillStatus =
| 'installed'
@@ -288,7 +288,7 @@ export interface UserSkillWriteRequest {
required_capabilities: string[]
}
// ============ Plugin ============
// ============ Plugin(插件) ============
export type PluginStatus =
| 'installed'
@@ -420,7 +420,7 @@ export interface Plugin {
dependent_skills?: string[]
}
// ============ Provider ============
// ============ 提供商 ============
export type ProviderType = ApiProviderType
@@ -514,7 +514,7 @@ export interface ModelRoutingResponse {
}>
}
// ============ Tasks ============
// ============ 任务 ============
export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled'
export type TaskPriority = 'low' | 'medium' | 'high'
@@ -534,7 +534,7 @@ export interface TaskItem {
updated_at: string
}
// ============ Theme ============
// ============ 主题 ============
export interface ThemeConfig {
theme_id: string
@@ -547,7 +547,7 @@ export interface ThemeConfig {
code_theme?: 'github-light' | 'github-dark'
}
// ============ Index ============
// ============ 索引 ============
export interface IndexStatus {
running_jobs?: number
@@ -568,7 +568,7 @@ export interface IndexStatus {
error?: string
}
// ============ System ============
// ============ 系统 ============
export interface ApiError {
code: string
@@ -598,9 +598,9 @@ export type SaveStatus =
export type AiCoreStatus = 'unknown' | 'starting' | 'running' | 'stopped' | 'error'
// ============ FastAPI wire contracts ============
// UI view models above may contain presentation-only fields. Services must use
// these DTOs at the HTTP boundary and explicitly map them to view models.
// ============ FastAPI 传输契约 ============
// 上方的 UI 视图模型可能含有仅用于展示的字段。服务必须在 HTTP 边界使用这些 DTO,
// 并将其显式映射为视图模型。
export interface PageMeta {
total: number
@@ -871,7 +871,7 @@ export interface ApiIndexJob {
created_at: string
}
// ============ Theme Package (Phase 2) ============
// ============ 主题包(第二阶段) ============
export interface ThemeManifest {
theme_id: string
@@ -924,7 +924,7 @@ export type ThemeErrorCode =
| 'THEME_INSTALL_FAILED'
| 'THEME_UNINSTALL_FAILED'
// ============ Mermaid Renderer (Phase 2) ============
// ============ Mermaid 渲染器(第二阶段) ============
export interface MermaidRenderResult {
svg: string
@@ -939,7 +939,7 @@ export interface MermaidParseError {
column?: number
}
// ============ Agent Trace Node (Phase 2 visualization) ============
// ============ Agent Trace 节点(第二阶段可视化) ============
export type TraceNodeType =
| 'run'
+1 -2
View File
@@ -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: '网页搜索',
+2 -2
View File
@@ -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)
}
+2 -2
View File
@@ -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))
+2 -4
View File
@@ -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 })
+5 -8
View File
@@ -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),
+1 -1
View File
@@ -18,7 +18,7 @@ watch(appLocale, (value) => {
if (typeof document !== 'undefined') document.documentElement.lang = value
}, { immediate: true })
/** Keep the Chinese source beside its English translation while the UI is migrated. */
/** 迁移 UI 时,将中文源保留在英文翻译旁边。 */
export function t(zh: string, en: string): string {
return appLocale.value === 'en' ? en : zh
}
+1 -1
View File
@@ -135,7 +135,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
try {
errBody = (await resp.json()) as ErrorResponse
} catch {
/* ignore */
/* 忽略 */
}
const code = errBody?.error?.code || `HTTP_${resp.status}`
+1 -2
View File
@@ -37,8 +37,7 @@ export const mediaService = {
},
}
// Keep one identity until the input/options change, including a lost HTTP response.
// Payloads remain in memory; durable uploads/jobs are owned by the backend.
// 保留一个身份,直到输入/选项发生变化,包括丢失 HTTP 响应。有效负载保留在内存中;持久上传/作业归后端所有。
export function createMediaSubmission() {
let pending: {file: File; options: string; uploadKey: string; jobKey: string; attachmentId?: string} | null = null
return {
+1 -1
View File
@@ -5,7 +5,7 @@ export function getModelRouting(): Promise<ModelRoutingResponse> {
return apiClient.get('/api/model-routing')
}
// version is the last version read from the server (optimistic concurrency).
// 版本是从服务器读取的最后一个版本(乐观并发)。
export function saveModelRouting(config: ModelRoutingConfig): Promise<ModelRoutingResponse> {
return apiClient.put('/api/model-routing', config)
}
@@ -2,7 +2,7 @@ import { hostInvoke } from './desktop'
export interface RequestProgress { issued: boolean; requestId?: string }
/** A reservation makes cancel-before-dispatch definitive even across IPC ordering. */
/** 即使在 IPC 订购中,预订也可以确保发货前取消。 */
export function coreRequest<T>(args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number, progress: RequestProgress): Promise<T> {
return new Promise((resolve, reject) => {
let settled = false
@@ -31,7 +31,7 @@ export function coreRequest<T>(args: Record<string, unknown>, signal: AbortSigna
if (!settled) { settled = true; reject(error) }
} finally {
signal.removeEventListener('abort', abort)
// Also discard a reservation if dispatch failed before Rust claimed it.
// 如果在 Rust 声明保留之前调度失败,则也丢弃保留。
cancel()
}
})()
+2 -2
View File
@@ -4,7 +4,7 @@ import { hostInvoke } from './desktop'
type Message = { kind: 'headers'; status: number } | { kind: 'chunk'; data: string }
| { kind: 'done' } | { kind: 'error'; code: string }
/** Native session credentials stay in Rust; this channel carries response bytes only. */
/** 本机会话凭证保留在 Rust 中;该通道仅承载响应字节。 */
export function coreStream(path: string, init: RequestInit): Promise<Response> {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID()
@@ -33,7 +33,7 @@ export function coreStream(path: string, init: RequestInit): Promise<Response> {
if (message.kind === 'chunk') {
const bytes = Uint8Array.from(atob(message.data), c => c.charCodeAt(0))
controller.enqueue(bytes)
// Bound queued data if a consumer stops reading without cancelling.
// 如果消费者停止读取而不取消,则绑定排队数据。
if ((controller.desiredSize ?? 0) < -4096) fail(new Error('CORE_STREAM_BACKPRESSURE'))
}
if (message.kind === 'error') fail(new Error(message.code))
@@ -1,4 +1,4 @@
/** Portable preference records are bound to the active Vault; local drafts retain their own Vault key. */
/** 可移植偏好记录与主用Vault绑定;本地草稿保留自己的 Vault 密钥。 */
import { ref, watch, nextTick } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme'
@@ -1,4 +1,4 @@
/** A durable preference draft keeps its original CAS base until the user resolves a conflict. */
/** 持久偏好草案保留其原始 CAS 基础,直到用户解决冲突。 */
export interface LogicalRecord<T> { schema: 1; kind: string; id: string; data: T }
export interface RecordDocument<T> { record: LogicalRecord<T>; hash: string; file_id: string }
interface Draft<T> { record: LogicalRecord<T>; expected: string; operation_id: string }
@@ -40,7 +40,7 @@ export class RecordBinding<T> {
if (this.stopped) return
const data = JSON.parse(JSON.stringify(this.options.read())) as T
if (!this.draft && this.remote && JSON.stringify(data) === JSON.stringify(this.remote.record.data)) return
// New edits while a request runs get a new operation, but preserve the unresolved base.
// 请求运行时的新编辑会获取新操作,但保留未解析的基础。
this.draft = { record: { schema: 1, kind: this.options.kind, id: this.options.id, data }, expected: this.draft?.expected ?? this.remote?.hash ?? '', operation_id: crypto.randomUUID() }
this.restored = false; this.invalidDraft = false
try { this.persist(); if (this.error === 'PREFERENCE_DRAFT_STORE_FAILED') this.error = '' } catch { this.error = 'PREFERENCE_DRAFT_STORE_FAILED'; this.options.changed?.() }
@@ -71,7 +71,7 @@ export class RecordBinding<T> {
if (this.draft.operation_id === draft.operation_id) {
this.persist(null); this.draft = null; this.appliedHash = committed.hash
} else {
// The next local edit follows the just-confirmed predecessor, not its older CAS base.
// 下一个本地编辑遵循刚刚确认的前身,而不是其较旧的 CAS 基础。
this.draft.expected = committed.hash; this.persist()
}
} else if (this.remote && this.remote.hash !== this.appliedHash) {
+1 -1
View File
@@ -67,7 +67,7 @@ export function coerceArgument(field: CommandField, raw: string): unknown {
return Number.isNaN(parsed) ? undefined : parsed
}
if (field.type === 'object' || field.type === 'array') {
try { return JSON.parse(raw) } catch { return raw } // Backend reports the schema error without discarding the input.
try { return JSON.parse(raw) } catch { return raw } // 后端报告模式错误而不丢弃输入。
}
return raw
}
+4 -7
View File
@@ -6,10 +6,7 @@ import { isMap, parseDocument } from 'yaml'
export const THEME_APP_VERSION = appPackage.version
/**
* Semantic colors every page and component may consume. Theme packages can
* override any subset; the compatibility layer supplies the rest.
*/
/** 每个页面和组件可能消耗的语义颜色。主题包可以覆盖任何子集;兼容层提供其余部分。 */
export const REQUIRED_THEME_COLOR_TOKENS = [
'background-primary', 'background-secondary', 'background-tertiary', 'background-hover', 'background-active', 'background-overlay',
'surface-primary', 'surface-secondary', 'surface-elevated',
@@ -29,7 +26,7 @@ const STORAGE_KEY = 'installed-themes'
const ACTIVE_CUSTOM_KEY = 'active-custom-theme'
export const MAX_THEME_BYTES = 5 * 1024 * 1024
/** Normalize all transports to the existing single-file inspection format. */
/** 将所有传输标准化为现有的单文件检查格式。 */
export async function decodeThemePackage(bytes: Uint8Array): Promise<string> {
if (bytes.length > MAX_THEME_BYTES) throw new Error('主题包不能超过 5 MB')
const decode = (data: Uint8Array) => new TextDecoder('utf-8', { fatal: true }).decode(data)
@@ -180,7 +177,7 @@ function applyThemeCss(themeId: string, css: string) {
const THEME_CONTRACT_MARKER = '/* opennexus-theme-contract */'
/** Fill incomplete third-party themes with an accessible semantic palette. */
/** 使用可访问的语义调色板填充不完整的第三方主题。 */
export function withThemeContract(themeId: string, isDark: boolean, css: string): string {
if (css.includes(THEME_CONTRACT_MARKER)) return css
const selector = `[data-theme="${themeId}"]`
@@ -408,7 +405,7 @@ export function setActiveCustomTheme(themeId: string | null) {
const theme = themeId ? loadStoredThemes().find(item => item.theme_id === themeId) : undefined
const storedCss = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
const css = themeId && theme && storedCss ? withThemeContract(themeId, theme.is_dark, storedCss) : storedCss
// Validate before changing the current page. Only the selected theme owns a style node.
// 更改当前页面之前进行验证。只有选定的主题才拥有样式节点。
if (css) validateCssSafety(css)
document.head.querySelectorAll('style[id^="theme-style-"]').forEach(style => style.remove())
if (themeId && css) applyThemeCss(themeId, css)
+2 -2
View File
@@ -147,7 +147,7 @@ export async function readFileContent(filePath: string): Promise<string> {
return note.markdown
}
/** Resolve the backend note identity already associated with a workspace path. */
/** 解析已与工作空间路径关联的后端笔记标识。 */
export async function getNoteId(filePath: string): Promise<string> {
return requireNoteId(filePath)
}
@@ -163,7 +163,7 @@ export async function saveFileContent(filePath: string, content: string, expecte
await noteService.updateNote(await requireNoteId(filePath), {
markdown: content,
...(expectedHash ? { expected_content_hash: expectedHash } : {}),
// Explicit [] clears the index; absent tags retain API-managed tags.
// 显式[]清除索引;缺失的标签保留 API 管理的标签。
...(metadata?.hasTags ? { tags: metadata.tags } : {}),
})
}
+1 -1
View File
@@ -385,7 +385,7 @@ it('restores each answer context after history reload, including explicitly abse
vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.()
await s.retryMessage(s.messages[1]!.message_id,undefined,null)
vi.mocked(streamChat).mock.calls.at(-1)![1].onDone?.()
// API serializes absent captured context as null; do not fall back to the original user snapshot.
// API 将缺失的捕获上下文序列化为 null;不要回退到原始用户快照。
vi.mocked(listConversationMessages).mockResolvedValue({items:JSON.parse(JSON.stringify(s.messages)),page:{total:2,limit:500,offset:0}})
await s.setActiveConversation(s.activeConversationId!)
await s.sendMessage('continue')
+3 -3
View File
@@ -175,7 +175,7 @@ export const useChatStore = defineStore('chat', () => {
historyError.value = ''
contextNotice.value = ''
const conversation = addLocalConversation(t('新对话', 'New conversation'))
try { await persistConversation(conversation) } catch { /* exposed through historyError */ }
try { await persistConversation(conversation) } catch { /* 通过historyError暴露 */ }
}
async function sendMessage(text: string, retryMessageId?: string, workspaceContext?: WorkspaceContext | null) {
@@ -206,7 +206,7 @@ export const useChatStore = defineStore('chat', () => {
finally {
if (version === streamVersion) isPreparing.value = false
}
// Switching, stopping or deleting cancels sends still waiting for creation.
// 切换、停止或删除会取消仍在等待创建的发送。
if (version !== streamVersion || activeConversationId.value !== conversation.conversation_id) return
const conversationId = conversation.conversation_id
@@ -279,7 +279,7 @@ export const useChatStore = defineStore('chat', () => {
if (call && typeof event.data.arguments_delta === 'string') {
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
argumentBuffers.set(call.tool_call_id, buffer)
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
try { call.parameters = JSON.parse(buffer) } catch { /* 不完整的JSON片段 */ }
}
if (call && event.data.arguments && typeof event.data.arguments === 'object') Object.assign(call.parameters, event.data.arguments)
}
+1 -1
View File
@@ -13,7 +13,7 @@ function validate(value: ChatPreferences) {
}
export const useChatPreferences = defineStore('chatPreferences', () => {
const settings = ref<ChatPreferences>(empty())
try { const stored = localStorage.getItem(storageKey); if (stored) settings.value = validate(JSON.parse(stored)) } catch { /* Invalid or unavailable local settings use defaults. */ }
try { const stored = localStorage.getItem(storageKey); if (stored) settings.value = validate(JSON.parse(stored)) } catch { /* 无效或不可用的本地设置使用默认值。 */ }
function save(value: ChatPreferences) {
const next = validate(value)
localStorage.setItem(storageKey, JSON.stringify(next))
+1 -1
View File
@@ -15,7 +15,7 @@ export const useLayoutPreferencesStore = defineStore('layoutPreferences', () =>
localStorage.setItem('primary-sidebar-expanded', String(primaryExpanded.value))
localStorage.setItem('workspace-sidebar-width', String(workspaceWidth.value))
localStorage.setItem('chat-sidebar-width', String(chatWidth.value))
} catch { /* Keep the current layout usable when local storage is unavailable. */ }
} catch { /* 当本地存储不可用时,保持当前布局可用。 */ }
}, { flush: 'sync' })
return { primaryExpanded, workspaceWidth, chatWidth }
})
+1 -1
View File
@@ -29,7 +29,7 @@ export const markdownPresets = {
const key = 'markdown-preferences'
export const useMarkdownPreferencesStore = defineStore('markdown-preferences', () => {
let saved: Record<string, unknown> = {}
try { saved = JSON.parse(localStorage.getItem(key) ?? '{}') ?? {} } catch { /* defaults */ }
try { saved = JSON.parse(localStorage.getItem(key) ?? '{}') ?? {} } catch { /* 默认值 */ }
const preferences = ref(normalizeMarkdownPreferences(saved.preferences))
const customPresets = ref<{ name: string; preferences: MarkdownPreferences }[]>(Array.isArray(saved.presets)
? saved.presets.filter(item => item && typeof item.name === 'string').slice(0, 20).map(item => ({ name: item.name.slice(0, 40), preferences: normalizeMarkdownPreferences(item.preferences) })) : [])
+5 -5
View File
@@ -12,23 +12,23 @@ export const useSettingsStore = defineStore('settings', () => {
try { return JSON.parse(localStorage.getItem('app-settings') ?? '{}') as Record<string, unknown> }
catch { localStorage.removeItem('app-settings'); return {} }
})()
// General
// 一般
const restoreLastVault = ref(saved.restoreLastVault !== false)
const autoSaveInterval = ref(typeof saved.autoSaveInterval === 'number' ? saved.autoSaveInterval : 1500)
const language = appLocale
const appVersion = ref(packageInfo.version)
const aiCoreVersion = ref('—')
// Editor
// 编辑
const defaultEditorMode = ref<'wysiwyg' | 'source'>(saved.defaultEditorMode === 'source' ? 'source' : 'wysiwyg')
const editorLineWidth = ref(typeof saved.editorLineWidth === 'number' ? saved.editorLineWidth : 80)
const spellCheck = ref(saved.spellCheck === true)
// AI Core
// AI 核心
const aiCoreStatus = ref<AiCoreStatus>('unknown')
const aiCoreAddress = ref(resolveApiUrl('/api') || '/api')
// Index
// 索引
const emptyIndex = (): IndexStatus => ({ status: 'unknown', pending_jobs: 0, total_notes: null, total_blocks: null })
const indexStatus = ref<IndexStatus>(emptyIndex())
const indexStatusLabel = computed(() => {
@@ -41,7 +41,7 @@ export const useSettingsStore = defineStore('settings', () => {
return t('索引就绪', 'Index ready')
})
// Permissions
// 权限
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({})
const diagnosticsError = ref<string | null>(null)
+1 -1
View File
@@ -77,7 +77,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
collect(fileTree.value)
const restore = (nodes: FileNode[]) => nodes.forEach(node => { if (node.type === 'folder') node.is_open = open.get(node.path) ?? false; if (node.children) restore(node.children) })
restore(fresh)
// Avoid redrawing an unchanged tree on every background check.
// 避免在每次背景检查时重绘未更改的树。
if (JSON.stringify(fresh) !== JSON.stringify(fileTree.value)) fileTree.value = fresh
treeRefreshError.value = null
} catch (error) {
+1 -1
View File
@@ -1,4 +1,4 @@
/* Semantic tokens inherit every installed theme, including imported themes. */
/* 语义标记继承每个已安装的主题,包括导入的主题。 */
:where(.markdown-callout) { --callout-color: var(--color-callout-info, var(--color-info)); }
.markdown-callout, .milkdown .ProseMirror blockquote.markdown-callout {
border: 1px solid color-mix(in srgb, var(--callout-color) 35%, transparent);
+1 -1
View File
@@ -189,7 +189,7 @@ progress:not([value]) { background: linear-gradient(90deg, var(--color-backgroun
}
/* Shared select and disclosure chrome, including the expanded surface. */
/* 共享选择和显示镶边,包括扩展表面。 */
:where(select:not([multiple]):not([size])), .select:not([multiple]):not([size]) {
appearance: none;
box-sizing: border-box;
+2 -3
View File
@@ -1,5 +1,4 @@
/* Shared interaction layer: colors follow the active theme; syntax preferences
continue to be handled by the parser/editor rather than CSS-generated content. */
/* 共享交互层:颜色遵循活动主题;语法首选项继续由解析器/编辑器处理,而不是由 CSS 生成的内容处理。 */
:is(.markdown-content, .milkdown .ProseMirror) a:not(.callout-title) {
color: var(--color-text-link); text-decoration: underline; text-underline-offset: .18em;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
@@ -33,7 +32,7 @@
.editor-scroll-buttons button:active { background: var(--color-accent-soft); }
.editor-scroll-buttons button:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
/* The read-only renderer uses the same framed code surface as the workspace. */
/* 只读渲染器使用与工作空间相同的框架代码表面。 */
.markdown-content .markdown-code-block { position: relative; margin: .85em 0; padding: 8px 20px 16px; border: 1px solid var(--color-code-border); border-radius: 6px; background: var(--color-code-background); color: var(--color-code-text); }
.markdown-content .markdown-code-block > .markdown-code-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 0 8px; min-height: 28px; font: 12px/1.4 var(--font-ui-mono); color: var(--color-code-muted); }
.markdown-content .markdown-code-block > .markdown-code-toolbar button { min-height: 24px; padding: 3px 10px; border: 0; border-radius: var(--radius-sm); box-shadow: none; background: var(--color-accent-soft); color: var(--color-code-muted); font: inherit; }
+19 -19
View File
@@ -2,14 +2,14 @@
--color-markdown-selection: color-mix(in srgb, var(--color-accent-primary) 24%, var(--color-background-primary));
--color-editor-scroll-background: var(--color-surface-elevated);
--color-editor-scroll-text: var(--color-accent-primary);
/* Callout heading colors also serve as borders; keep text readable on tint. */
/* 标注标题颜色也用作边框;保持文本在色调上可读。 */
--color-callout-info: var(--color-info);
--color-callout-success: var(--color-success);
--color-callout-warning: var(--color-warning);
--color-callout-danger: var(--color-error);
--color-callout-important: var(--color-accent-secondary);
--color-callout-quote: var(--color-text-secondary);
/* Background */
/* 背景 */
--color-background-primary: #ffffff;
--color-background-secondary: #f7f8fa;
--color-background-tertiary: #eef0f3;
@@ -17,12 +17,12 @@
--color-background-active: #e4e7eb;
--color-background-overlay: rgba(0, 0, 0, 0.45);
/* Surface */
/* 表面 */
--color-surface-primary: #ffffff;
--color-surface-secondary: #fafbfc;
--color-surface-elevated: #ffffff;
/* Text */
/* 文本 */
--color-text-primary: #1f2328;
--color-text-secondary: #656d76;
--color-text-tertiary: #9198a0;
@@ -30,7 +30,7 @@
--color-text-link: #5b67f1;
--color-text-disabled: #b0b4ba;
/* Accent */
/* 口音 */
--color-accent-primary: #5b67f1;
--color-accent-primary-hover: #4a55e0;
--color-accent-primary-active: #3d47cc;
@@ -38,7 +38,7 @@
--color-accent-soft: #eef0ff;
--color-accent-soft-hover: #e2e5ff;
/* Status */
/* 状态 */
--color-success: #2da44e;
--color-success-soft: #dafbe3;
--color-warning: #d4a72c;
@@ -54,31 +54,31 @@
--color-brand-surface-dark: #111111;
--color-highlight-overlay: #ffffff30;
/* Border */
/* 边框 */
--color-border-default: #e4e7eb;
--color-border-subtle: #eef0f3;
--color-border-focus: #5b67f1;
--color-border-disabled: #eef0f3;
/* Markdown */
/* Markdown 相关设置 */
--color-markdown-grid: #8b949e;
--color-markdown-marker: #343b44;
--color-markdown-table-header: #eef0f3;
/* Shadow */
/* 影子 */
--shadow-sm: 0 1px 2px rgba(31, 35, 40, 0.05), 0 1px 5px rgba(31, 35, 40, 0.03);
--shadow-md: 0 8px 22px rgba(31, 35, 40, 0.08), 0 2px 6px rgba(31, 35, 40, 0.04);
--shadow-lg: 0 16px 36px rgba(31, 35, 40, 0.11), 0 4px 12px rgba(31, 35, 40, 0.05);
--shadow-xl: 0 24px 64px rgba(31, 35, 40, 0.18), 0 8px 20px rgba(31, 35, 40, 0.08);
/* Radius */
/* 半径 */
--radius-sm: 6px;
--radius-md: 9px;
--radius-lg: 13px;
--radius-xl: 18px;
--radius-full: 9999px;
/* Spacing */
/* 间距 */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
@@ -87,18 +87,18 @@
--space-2xl: 28px;
--space-3xl: 36px;
/* Typography - UI */
/* 版式 - UI */
--font-ui-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', Helvetica, Arial, sans-serif;
--font-ui-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, 'Cascadia Code', monospace;
/* Typography - Editor */
/* 版式 - 编辑器 */
--font-editor-sans: var(--font-ui-sans);
--font-editor-mono: var(--font-ui-mono);
--font-editor-size: 15px;
--font-editor-line-height: 1.7;
/* Font sizes */
/* 字体大小 */
--font-size-xs: 12px;
--font-size-sm: 13px;
--font-size-md: 14px;
@@ -107,12 +107,12 @@
--font-size-2xl: 20px;
--font-size-3xl: 26px;
/* Line heights */
/* 行高 */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.7;
/* Z-index */
/* Z索引 */
--z-sidebar: 10;
--z-dropdown: 100;
--z-modal: 200;
@@ -120,12 +120,12 @@
--z-notification: 400;
--z-titlebar: 500;
/* Motion */
/* 运动 */
--motion-fast: 120ms cubic-bezier(0.2, 0, 0, 1);
--motion-normal: 190ms cubic-bezier(0.2, 0, 0, 1);
--motion-slow: 260ms cubic-bezier(0.2, 0, 0, 1);
/* Layout */
/* 布局 */
--titlebar-height: 42px;
--sidebar-primary-width: 58px;
--sidebar-primary-width-expanded: 180px;
@@ -369,7 +369,7 @@ ol {
}
/* Native form controls share the same surfaces as component controls. */
/* 本机表单控件与组件控件共享相同的表面。 */
:where(input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='color']), textarea, select) {
background-color: var(--color-surface-primary);
border-color: var(--color-border-default);
+1 -1
View File
@@ -1,4 +1,4 @@
/** GitHub alerts and Obsidian callouts share the same portable Markdown syntax. */
/** GitHub 警报和 Obsidian 标注共享相同的可移植 Markdown 语法。 */
export const calloutTypes = {
note: ['note'], abstract: ['abstract', 'summary', 'tldr'], info: ['info'],
todo: ['todo'], tip: ['tip', 'hint'], important: ['important'], success: ['success', 'check', 'done'],
+1 -1
View File
@@ -1,4 +1,4 @@
/** Markup survives Milkdown's preview copying; the enclosing Vue component handles clicks. */
/** 标记会在 Milkdown 复制预览时保留下来;外层 Vue 组件负责处理点击事件。 */
export function appendDiagramControls(container: HTMLElement) {
const controls = document.createElement('div')
controls.className = 'diagram-controls'
@@ -8,7 +8,7 @@ it('preserves diagram labels, switches preview/source, and copies original Merma
const source = 'graph TD; A-->B'
const html = await renderMarkdown('```mermaid\n' + source + '\n```')
const wrapper = mount(DiagramInteractions, { slots: { default: '<div></div>' }, attachTo: document.body })
// Preserve SVG foreignObject namespace while injecting sanitized rendered HTML.
// 在注入清理后的渲染 HTML 时保留 SVGforeignObject 命名空间。
wrapper.element.firstElementChild!.innerHTML = html
expect(wrapper.text()).toContain('系统验证')
expect(wrapper.find('[onerror]').exists()).toBe(false)
+2 -2
View File
@@ -11,7 +11,7 @@ export interface NoteMetadata {
function parseProperties(yaml: string) {
const document = parseDocument(yaml)
// Unsupported YAML stays available in source mode without partial rewriting.
// 不支持的 YAML 在源模式下保持可用,无需部分重写。
if (document.errors.length || document.warnings.length || !isMap(document.contents)) return null
return document
}
@@ -27,7 +27,7 @@ export function splitNoteMetadata(source: string): NoteMetadata | null {
const tagNode = document.get('tags', true)
let tags: string[] = []
if (isSeq(tagNode)) {
// Do not remove anchored list items that other properties may reference.
// 不要删除其他属性可能引用的锚定列表项。
if (!tagNode.items.every(item => isScalar(item) && typeof item.value === 'string' && !item.anchor)) return null
tags = tagNode.items.map(item => (item as { value: string }).value)
} else if (isScalar(tagNode)) {
+2 -2
View File
@@ -3,12 +3,12 @@ import type { Citation } from '@/contracts'
const parser = new Marked()
/** Candidate order is the source number sent to the model; never renumber a subset. */
/** 候选订单是发送给模型的源编号;永远不要对子集重新编号。 */
export function usedCitations(content: string, candidates: Citation[] = []) {
const numbers = new Set<number>()
const aliases = new Map(candidates.map((citation, index) => [citation.citation_id, index + 1]))
parser.walkTokens(parser.lexer(content), token => {
// Ignore code, escaped brackets, HTML and link destinations.
// 忽略代码、转义括号、HTML 和链接目标。
if (token.type !== 'text' || ('tokens' in token && token.tokens?.length)) return
for (const match of token.text.matchAll(/\[([1-9]\d*|cit_[A-Za-z0-9_-]+)\]/g)) {
const number = aliases.get(match[1]) ?? Number(match[1])