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