chore(frontend): 补充状态与服务边界注释

This commit is contained in:
2026-08-30 22:59:58 +08:00
parent 629a6bda9c
commit 49c856a69c
11 changed files with 31 additions and 2 deletions
@@ -39,6 +39,7 @@ type ToolbarCommand = 'bold' | 'italic' | 'ordered-list' | 'bullet-list' | 'inli
function runCommand(command: ToolbarCommand) { function runCommand(command: ToolbarCommand) {
const editor = crepe?.editor const editor = crepe?.editor
if (!editor) return if (!editor) return
// 顶部工具栏复用 Milkdown 命令,因此选区与浮动工具栏共享同一文档事务。
const actions = { const actions = {
bold: callCommand(toggleStrongCommand.key), bold: callCommand(toggleStrongCommand.key),
italic: callCommand(toggleEmphasisCommand.key), italic: callCommand(toggleEmphasisCommand.key),
@@ -55,6 +56,7 @@ function runCommand(command: ToolbarCommand) {
function applyLink() { function applyLink() {
if (!crepe) return if (!crepe) return
// TODO(editor): 用受控 Element Plus 对话框替换 prompt,补充 URL 校验和键盘焦点管理。
const href = window.prompt('请输入链接地址', 'https://')?.trim() const href = window.prompt('请输入链接地址', 'https://')?.trim()
if (!href) return if (!href) return
@@ -162,6 +164,7 @@ onMounted(async () => {
crepe.editor.use(fontSizeMarkdownPlugin) crepe.editor.use(fontSizeMarkdownPlugin)
crepe.on((listener) => { crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, previousMarkdown) => { listener.markdownUpdated((_ctx, markdown, previousMarkdown) => {
// 忽略编辑器初始化/回显事件,防止无内容变化时触发自动保存循环。
if (markdown === previousMarkdown || markdown === editorStore.content) return if (markdown === previousMarkdown || markdown === editorStore.content) return
editorStore.updateContent(markdown) editorStore.updateContent(markdown)
editorStore.scheduleAutoSave(settingsStore.autoSaveInterval) editorStore.scheduleAutoSave(settingsStore.autoSaveInterval)
+2
View File
@@ -3,6 +3,7 @@ import { SseClient } from './sseClient'
import type { AgentRun, AgentEvent, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts' import type { AgentRun, AgentEvent, ApiAgentRun, OperationResponse, PageMeta, ToolDefinition, PermissionRequest } from '@/contracts'
function toAgentRun(run: ApiAgentRun): AgentRun { function toAgentRun(run: ApiAgentRun): AgentRun {
// API 的 token_usage 是累计值,UI 模型预留了输入/输出拆分字段。
return { return {
run_id: run.run_id, run_id: run.run_id,
status: run.status, status: run.status,
@@ -67,6 +68,7 @@ export function streamAgentEvents(
onOpen?: () => void onOpen?: () => void
} }
): SseClient { ): SseClient {
// 将通用 SSE 包装成领域事件,Store 无需了解传输层 envelope。
const client = new SseClient({ const client = new SseClient({
url: `/api/agent/runs/${runId}/events`, url: `/api/agent/runs/${runId}/events`,
method: 'GET', method: 'GET',
+2
View File
@@ -1,5 +1,6 @@
import type { ApiError, ErrorResponse } from '@/contracts' import type { ApiError, ErrorResponse } from '@/contracts'
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? '' const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
export function resolveApiUrl(path: string): string { export function resolveApiUrl(path: string): string {
@@ -63,6 +64,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
return resp as unknown as T return resp as unknown as T
} }
// 后端约定返回 ErrorResponse;代理或网关的非 JSON 错误仍降级为 HTTP 状态码。
let errBody: ErrorResponse | null = null let errBody: ErrorResponse | null = null
try { try {
errBody = (await resp.json()) as ErrorResponse errBody = (await resp.json()) as ErrorResponse
+3
View File
@@ -54,6 +54,7 @@ export class SseClient {
this.connected = true this.connected = true
onOpen?.() onOpen?.()
// 一个 UTF-8 字符或 SSE 行可能横跨多个网络分片,必须累积后再按空行派发。
const decoder = new TextDecoder('utf-8') const decoder = new TextDecoder('utf-8')
let eventName = 'message' let eventName = 'message'
let dataLines: string[] = [] let dataLines: string[] = []
@@ -117,6 +118,8 @@ export class SseClient {
this.controller.abort() this.controller.abort()
} }
// TODO(streaming): Agent 事件持久化后,增加 Last-Event-ID 与指数退避重连。
isConnected() { isConnected() {
return this.connected return this.connected
} }
+5 -2
View File
@@ -1,7 +1,7 @@
import type { FileNode } from '@/contracts' import type { FileNode } from '@/contracts'
// Mock workspace service for web dev mode // Web 开发模式使用内存实现,服务签名保持与未来桌面文件系统适配器一致。
// In Tauri environment this will use Tauri IPC commands // TODO(desktop): Tauri Host 就绪后通过 IPC 替换 Mock,并保留路径规范化与错误映射。
export interface VaultInfo { export interface VaultInfo {
path: string path: string
@@ -251,5 +251,8 @@ export function deleteFile(path: string): Promise<void> {
} }
export function moveFile(sourcePath: string, targetPath: string): Promise<void> { export function moveFile(sourcePath: string, targetPath: string): Promise<void> {
// Mock 文件树由 Store 同步更新;真实实现必须在 Host 侧执行原子移动。
void sourcePath
void targetPath
return Promise.resolve() return Promise.resolve()
} }
+2
View File
@@ -53,6 +53,7 @@ export const useAgentStore = defineStore('agent', () => {
} }
function processEvent(event: AgentEvent) { function processEvent(event: AgentEvent) {
// 服务端会先回放历史再发送实时事件,以 run_id + sequence 去重保证幂等。
if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return
events.value.push(event) events.value.push(event)
events.value.sort((a, b) => a.sequence - b.sequence) events.value.sort((a, b) => a.sequence - b.sequence)
@@ -100,6 +101,7 @@ export const useAgentStore = defineStore('agent', () => {
} }
function subscribe(runId: string) { function subscribe(runId: string) {
// 任一时刻只保留当前运行的事件流,防止切换详情后旧事件污染新页面。
eventStream?.cancel() eventStream?.cancel()
isRunning.value = true isRunning.value = true
error.value = null error.value = null
+3
View File
@@ -16,6 +16,8 @@ export const useChatStore = defineStore('chat', () => {
const selectedModel = ref('mock-1') const selectedModel = ref('mock-1')
let sseClient: SseClient | null = null let sseClient: SseClient | null = null
// TODO(chat): 会话持久化接口完成后移除 mockConversations/mockMessages 数据源。
const activeConversation = computed(() => const activeConversation = computed(() =>
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
) )
@@ -56,6 +58,7 @@ export const useChatStore = defineStore('chat', () => {
inputText.value = '' inputText.value = ''
isStreaming.value = true isStreaming.value = true
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
const aiMsg: ChatMessage = { const aiMsg: ChatMessage = {
message_id: `msg-${Date.now() + 1}`, message_id: `msg-${Date.now() + 1}`,
conversation_id: conversationId, conversation_id: conversationId,
+4
View File
@@ -47,6 +47,7 @@ export const useEditorStore = defineStore('editor', () => {
async function save() { async function save() {
if (!currentFilePath.value) return if (!currentFilePath.value) return
if (pendingSave) return pendingSave if (pendingSave) return pendingSave
// 保存路径与正文都取快照;请求完成时用户可能已继续输入或切换文件。
const targetPath = currentFilePath.value const targetPath = currentFilePath.value
const snapshot = content.value const snapshot = content.value
saveStatus.value = 'saving' saveStatus.value = 'saving'
@@ -82,6 +83,7 @@ export const useEditorStore = defineStore('editor', () => {
if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') { if (saveStatus.value === 'dirty' || saveStatus.value === 'save_failed') {
throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。') throw new Error('当前文件保存失败,已阻止切换以避免内容丢失。')
} }
// 版本号使较慢的旧读取不能覆盖用户后选择的新文件。
const version = ++loadVersion const version = ++loadVersion
const previousStatus = saveStatus.value const previousStatus = saveStatus.value
saveStatus.value = 'saving' saveStatus.value = 'saving'
@@ -117,6 +119,8 @@ export const useEditorStore = defineStore('editor', () => {
} }
} }
// TODO(editor): 桌面文件监听接入后提供冲突对比/合并界面,而非只阻止切换。
function closeFile() { function closeFile() {
loadVersion++ loadVersion++
if (saveTimer) clearTimeout(saveTimer) if (saveTimer) clearTimeout(saveTimer)
+2
View File
@@ -49,6 +49,7 @@ export const useThemeStore = defineStore('theme', () => {
} }
function initTheme() { function initTheme() {
// 先恢复外观再开放 watch 持久化,避免 immediate watcher 覆盖本地设置。
const savedAppearance = localStorage.getItem('editor-appearance') const savedAppearance = localStorage.getItem('editor-appearance')
if (savedAppearance) { if (savedAppearance) {
try { try {
@@ -90,6 +91,7 @@ export const useThemeStore = defineStore('theme', () => {
})) }))
watch(resolvedCodeBlockTheme, (theme) => { watch(resolvedCodeBlockTheme, (theme) => {
// CSS 与 Shiki 共用该属性,确保代码块背景和 token 配色始终成套切换。
document.documentElement.setAttribute('data-code-theme', theme) document.documentElement.setAttribute('data-code-theme', theme)
}, { immediate: true }) }, { immediate: true })
+1
View File
@@ -118,6 +118,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
function renamePath(oldPath: string, newPath: string, newName: string) { function renamePath(oldPath: string, newPath: string, newName: string) {
const node = findNodeByPath(fileTree.value, oldPath) const node = findNodeByPath(fileTree.value, oldPath)
if (!node) return if (!node) return
// 文件夹重命名必须同步改写所有后代、标签页和当前文件路径。
const updateNodePath = (current: FileNode) => { const updateNodePath = (current: FileNode) => {
if (current.path === oldPath) current.name = newName if (current.path === oldPath) current.name = newName
if (current.path === oldPath || current.path.startsWith(`${oldPath}/`)) { if (current.path === oldPath || current.path.startsWith(`${oldPath}/`)) {
+4
View File
@@ -16,6 +16,7 @@ import githubLight from '@shikijs/themes/github-light'
marked.setOptions({ gfm: true, breaks: true }) marked.setOptions({ gfm: true, breaks: true })
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
const highlighter = createHighlighterCore({ const highlighter = createHighlighterCore({
themes: [githubLight, githubDark], themes: [githubLight, githubDark],
langs: [markdown, html, css, javascript, typescript, json, python, shell, sql], langs: [markdown, html, css, javascript, typescript, json, python, shell, sql],
@@ -47,5 +48,8 @@ export async function renderMarkdown(source: string): Promise<string> {
code.parentElement?.replaceWith(fragment) code.parentElement?.replaceWith(fragment)
} }
// Markdown 可能来自模型或外部笔记,高亮完成后仍必须在最终出口统一净化。
return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } }) return DOMPurify.sanitize(documentNode.body.innerHTML, { USE_PROFILES: { html: true } })
} }
// TODO(performance): 编辑器首屏稳定后评估将 Shiki 延迟加载或迁移到 Web Worker。