fix(phase2): restore agent streams and persist extension installations

This commit is contained in:
2026-09-06 02:21:27 +08:00
parent 9497519e8b
commit 7001794a22
35 changed files with 870 additions and 39 deletions
@@ -123,9 +123,11 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
.diagram-controls button:hover { border-color: var(--color-accent-primary); }
.diagram-controls button:focus-visible { outline: 2px solid var(--color-accent-primary); }
.diagram-viewer { width: min(1200px, 94vw); max-width: 94vw; height: 85vh; padding: 16px; color: var(--color-text-primary); background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
.diagram-viewer::backdrop { background: #0008; }
.diagram-viewer header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.diagram-viewer-scroll { height: calc(100% - 64px); overflow: auto; }
.diagram-viewer[open] { display: flex; flex-direction: column; gap: var(--space-md); overflow: hidden; }
.diagram-viewer::backdrop { background: var(--color-background-overlay); }
.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 { flex: 1; min-height: 0; overflow: auto; }
.diagram-viewer-image { margin: auto; transition: width 180ms ease-out; }
.diagram-viewer-image svg { width: 100% !important; max-width: none !important; height: auto !important; }
</style>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import apiClient from '@/services/apiClient'
import { t } from '@/i18n'
const props = defineProps<{ kind: 'skill' | 'plugin' }>()
const errors = ref<{ kind: string; id: string; message: string }[]>([])
const failure = ref('')
onMounted(async () => {
try { errors.value = (await apiClient.get<{ items: typeof errors.value }>('/api/extensions/restore-errors')).items.filter(item => item.kind === props.kind) }
catch { failure.value = t('无法读取扩展恢复状态。', 'Unable to read extension recovery status.') }
})
</script>
<template>
<div v-if="errors.length || failure" class="notice-banner" role="status">
<p v-if="failure">{{ failure }}</p>
<p v-for="item in errors" :key="item.id">{{ item.id }}{{ t('启动恢复未完成请检查包文件并重新安装原授权不会自动用于变更后的包', 'Startup recovery failed. Check and reinstall the package; previous grants are not applied to changed packages.') }}</p>
</div>
</template>
@@ -128,6 +128,8 @@ async function handleOpenCitation(data: Record<string, unknown>) {
</p>
</div>
<div class="inline-actions">
<span v-if="agentStore.connectionState === 'reconnecting'">{{ t('正在恢复连接', 'Reconnecting') }}</span>
<button v-if="agentStore.connectionState === 'disconnected'" class="button-secondary" @click="agentStore.reconnect()">{{ t('恢复连接', 'Reconnect') }}</button>
<button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">{{ t('取消运行', 'Cancel run') }}</button>
<button class="button-secondary" @click="agentStore.loadRun(agentStore.activeRunId!)">重新加载</button>
</div>
@@ -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.
it('applies the saved spell-check and language settings to source mode', async () => {
const editor = useEditorStore()
+2 -2
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { defineAsyncComponent, ref, watch } from 'vue'
import { useEditorStore } from '@/stores/editor'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
const VisualMarkdownEditor = defineAsyncComponent(() => import('./VisualMarkdownEditor.vue'))
const editorStore = useEditorStore()
const settingsStore = useSettingsStore()
@@ -113,6 +113,11 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<template>
<section class="media-page">
<details class="ui-disclosure">
<summary>{{ t('当前转写能力与验收范围', 'Transcription capabilities and validation') }}</summary>
<p>{{ t('本地转写提供片段级时间戳与说话人聚类,不提供逐字强制对齐或重叠语音分离。聚类编号不代表已确认的真实人数。', 'Local transcription provides segment timestamps and speaker clusters, without forced word alignment or overlapping speech separation. Cluster IDs are not verified speaker counts.') }}</p>
<p>{{ t('无参考转写或说话人标注时,只能验证功能与耗时,不能据此判断准确率。请通过播放与人工校对确认内容。', 'Without reference transcripts or speaker labels, runs validate functionality and timing, not accuracy. Review the audio and correct the transcript.') }}</p>
</details>
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></div></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExtensionRestoreNotice from '@/components/common/ExtensionRestoreNotice.vue'
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
@@ -64,6 +65,7 @@ const hasCommandContribution = computed(() =>
<template>
<section class="feature-page">
<ExtensionRestoreNotice kind="plugin" />
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<ExtensionInstallDialog v-if="showInstall" kind="Plugin" :install="pluginStore.installPlugin" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
<header class="feature-header">
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExtensionRestoreNotice from '@/components/common/ExtensionRestoreNotice.vue'
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
@@ -26,6 +27,7 @@ async function uninstall(skillId: string, name: string) {
<template>
<section class="feature-page">
<ExtensionRestoreNotice kind="skill" />
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<ExtensionInstallDialog v-if="showInstall" kind="Skill" :install="skillStore.installSkill" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
<header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="showInstall = true">{{ t('安装 Skill', 'Install Skill') }}</button></header>
@@ -0,0 +1,36 @@
// @vitest-environment happy-dom
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { afterEach, expect, it, vi } from 'vitest'
import Commands from './WorkspacePluginCommands.vue'
import { useEditorStore } from '@/stores/editor'
import { listPluginCommands, executePluginCommand } from '@/services/pluginService'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/services/pluginService', () => ({ listPluginCommands: vi.fn(), executePluginCommand: vi.fn() }))
const command = { command_id:'inspect',plugin_id:'p', title:'Inspect', enabled:true, when:['editor.has_selection'], parameters:{type:'object',properties:{}}, locations:['context_menu','toolbar'] }
afterEach(() => { document.body.replaceChildren(); vi.clearAllMocks() })
it('captures source selection for context commands and sends the snapshot', async () => {
vi.mocked(listPluginCommands).mockResolvedValue([command as any])
vi.mocked(executePluginCommand).mockResolvedValue({ effect:{type:'notification',payload:{message:'done'}} } as any)
const pinia = createPinia(); const editor = useEditorStore(pinia); editor.currentFilePath = '/note.md'
const wrapper = mount(Commands,{attachTo:document.body,global:{plugins:[pinia],stubs:{AppDialog:{template:'<div><slot/></div>'}}},slots:{default:'<textarea class="source">abcdef</textarea>'}})
const input = wrapper.get('textarea').element as HTMLTextAreaElement
input.focus(); input.setSelectionRange(1,4)
await wrapper.get('textarea').trigger('contextmenu'); await flushPromises()
expect(listPluginCommands).toHaveBeenCalledWith('context_menu')
await wrapper.findAll('button').find(button=>button.text()==='Inspect')!.trigger('click')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(executePluginCommand).toHaveBeenCalledWith('inspect',{},expect.objectContaining({selection:'bcd',file_path:'/note.md'}))
wrapper.unmount()
})
it('filters disabled commands and invalidates an open form when changing file', async () => {
vi.mocked(listPluginCommands).mockResolvedValue([{...command,when:[],enabled:false} as any])
const pinia=createPinia(); const editor=useEditorStore(pinia); editor.currentFilePath='/one.md'
const wrapper=mount(Commands,{global:{plugins:[pinia],stubs:{AppDialog:{template:'<div><slot/></div>'}}}})
await wrapper.get('button').trigger('click'); await flushPromises()
expect(listPluginCommands).toHaveBeenCalledWith('toolbar')
expect(wrapper.text()).not.toContain('Inspect')
editor.currentFilePath='/two.md'; await flushPromises()
expect(wrapper.find('section.modal').exists()).toBe(false)
wrapper.unmount()
})
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AppDialog from '@/components/common/AppDialog.vue'
import { useEditorStore } from '@/stores/editor'
import { useWorkspaceStore } from '@/stores/workspace'
import { usePluginStore } from '@/stores/plugin'
import { listPluginCommands, executePluginCommand } from '@/services/pluginService'
import { applyCommandEffect, commandFields, initialArguments, coerceArgument, cleanArguments, missingRequiredFields } from '@/services/pluginCommandForm'
import type { PluginCommand, PluginCommandContext, PluginCommandLocation } from '@/contracts'
import { t } from '@/i18n'
const editor = useEditorStore(), workspace = useWorkspaceStore(), plugins = usePluginStore(), router = useRouter()
const open = ref(false), busy = ref(false), error = ref(''), notice = ref('')
const commands = ref<PluginCommand[]>([]), selected = ref<PluginCommand | null>(null)
const args = ref<Record<string, unknown>>({})
const snapshot = ref<PluginCommandContext>({ vault_id: null, note_id: null, file_path: null, selection: null })
let revision = 0
function capture() {
const input = document.activeElement
const selection = input instanceof HTMLTextAreaElement
? input.value.slice(input.selectionStart, input.selectionEnd)
: window.getSelection()?.toString() || ''
snapshot.value = { vault_id: workspace.hasVault ? workspace.vaultId : null, note_id: editor.currentNoteId, file_path: editor.currentFilePath, selection: selection || null }
}
function available(command: PluginCommand) {
const context = snapshot.value
return command.enabled && command.when.every(condition => ({
'workspace.has_vault': Boolean(context.vault_id), 'editor.has_note': Boolean(context.note_id), 'editor.has_selection': Boolean(context.selection),
})[condition])
}
async function show(location: PluginCommandLocation) {
const version = ++revision
open.value = true; error.value = ''; selected.value = null; commands.value = []
try {
const result = await listPluginCommands(location)
if (version === revision) commands.value = result.filter(available)
} catch (reason) { if (version === revision) error.value = String(reason) }
}
function contextMenu(event: MouseEvent) {
if (!(event.target instanceof Element) || !event.target.closest('.ProseMirror, .source')) return
event.preventDefault(); capture(); void show('context_menu')
}
function choose(command: PluginCommand) { selected.value = command; args.value = initialArguments(command) }
function close() { if (!busy.value) { open.value = false; revision++ } }
watch(() => editor.currentFilePath, () => { open.value = false; revision++ })
watch(() => plugins.plugins, () => { if (!busy.value) close() }, { deep: true })
const fields = computed(() => selected.value ? commandFields(selected.value) : [])
async function run() {
const command = selected.value
if (!command || busy.value || !available(command)) return
if (snapshot.value.file_path !== editor.currentFilePath) { close(); return }
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),
refresh: async scope => {
if (scope === 'workspace') await workspace.refreshFileTree()
else if (scope === 'plugins') await plugins.loadPlugins()
else if (scope === 'commands') commands.value = (await listPluginCommands()).filter(available)
else { plugins.selectPlugin(command.plugin_id); await router.push('/extensions/plugins') }
},
notify: value => { notice.value = value },
})
open.value = false
} catch (reason) { error.value = reason instanceof Error ? reason.message : String(reason) }
finally { busy.value = false }
}
</script>
<template>
<div class="workspace-plugin-host" @contextmenu="contextMenu">
<div class="workspace-plugin-toolbar" role="toolbar" :aria-label="t('扩展工具栏', 'Extension toolbar')">
<button class="button-secondary" @pointerdown.prevent="capture" @click="event => { if (!event.detail) capture(); show('toolbar') }">{{ t('扩展命令', 'Extension commands') }}</button>
<span v-if="notice" role="status">{{ notice }}</span>
</div>
<slot />
<AppDialog v-if="open" :label="t('扩展命令', 'Extension commands')" :dismissible="!busy" @close="close">
<section class="modal">
<div class="section-head"><h2>{{ t('扩展命令', 'Extension commands') }}</h2><button class="button-secondary" :disabled="busy" @click="close">{{ t('关闭', 'Close') }}</button></div>
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
<div class="workspace-command-list">
<button v-for="command in commands" :key="command.command_id" class="button-secondary" :disabled="busy" :aria-pressed="selected?.command_id === command.command_id" @click="choose(command)">{{ command.title }}</button>
<p v-if="!commands.length">{{ t('当前上下文没有可用的扩展命令', 'No extension commands are available in this context.') }}</p>
</div>
<form v-if="selected" @submit.prevent="run">
<p>{{ selected.description }}</p>
<label v-for="field in fields" :key="field.key" class="form-field">
<span>{{ field.title }}{{ field.required ? ' *' : '' }}</span>
<select v-if="field.enum || field.type === 'boolean'" class="select" :value="String(args[field.key] ?? '')" @change="args[field.key] = coerceArgument(field, ($event.target as HTMLSelectElement).value)">
<option value="">{{ t('请选择', 'Select') }}</option><option v-for="value in field.enum || ['false', 'true']" :key="value" :value="value">{{ value }}</option>
</select>
<input v-else class="input" :required="field.required" :type="['number','integer'].includes(field.type) ? 'number' : 'text'" :value="String(args[field.key] ?? '')" @input="args[field.key] = coerceArgument(field, ($event.target as HTMLInputElement).value)" />
<small>{{ field.description }}</small>
</label>
<button class="button-primary" :disabled="busy">{{ t('执行', 'Run') }}</button>
</form>
</section>
</AppDialog>
</div>
</template>
<style scoped>
.workspace-plugin-host { display: contents; }
.workspace-plugin-toolbar { display: flex; gap: var(--space-sm); align-items: center; padding: var(--space-xs) var(--space-md); background: var(--color-background-secondary); border-bottom: 1px solid var(--color-border-default); }
.workspace-plugin-toolbar span { overflow-wrap: anywhere; font-size: var(--font-size-sm); }
.workspace-command-list, form { display: grid; gap: var(--space-sm); margin-block: var(--space-md); }
.section-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); }
</style>
@@ -2,6 +2,7 @@
import { useWorkspaceStore } from '@/stores/workspace'
import EditorHeader from '@/features/editor/EditorHeader.vue'
import EditorPane from '@/features/editor/EditorPane.vue'
import WorkspacePluginCommands from './WorkspacePluginCommands.vue'
import { EditPen } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
@@ -13,7 +14,7 @@ const workspaceStore = useWorkspaceStore()
<div class="workspace-view">
<template v-if="workspaceStore.activeFilePath">
<EditorHeader />
<EditorPane />
<WorkspacePluginCommands><EditorPane /></WorkspacePluginCommands>
</template>
<div v-else class="empty-workspace">
<div class="empty-content">
@@ -59,12 +59,16 @@ export function initialArguments(command: PluginCommand): Record<string, unknown
/** 按字段类型把输入框的字符串转成 schema 期望的类型。 */
export function coerceArgument(field: CommandField, raw: string): unknown {
if (raw.trim() === '') return undefined
if (field.type === 'boolean') return raw === 'true'
if (field.type === 'number' || field.type === 'integer') {
if (raw.trim() === '') return undefined
const parsed = Number(raw)
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.
}
return raw
}
+1 -1
View File
@@ -130,7 +130,7 @@ export class SseClient {
this.controller.abort()
}
// TODO(streaming): 桌面网络策略确定后,在 Store 层增加有上限的指数退避重连。
// 传输层不自动重试 POSTAgent Store 使用 sequence 游标执行有界 GET 重连。
isConnected() {
return this.connected
+85 -20
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ref, computed, onScopeDispose } from 'vue'
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
import * as agentService from '@/services/agentService'
import type { SseClient } from '@/services/sseClient'
@@ -17,6 +17,29 @@ export const useAgentStore = defineStore('agent', () => {
const error = ref<string | null>(null)
let eventStream: SseClient | null = null
let selectionVersion = 0
let streamVersion = 0
let retryTimer: ReturnType<typeof setTimeout> | null = null
let retryCount = 0
const seenSequences = new Set<number>()
let lastSequence = -1
const connectionState = ref<'idle' | 'connected' | 'reconnecting' | 'disconnected'>('idle')
const terminal = (status?: string) => ['completed', 'failed', 'cancelled'].includes(status || '')
function stopStream() {
streamVersion++
if (retryTimer) clearTimeout(retryTimer)
retryTimer = null
eventStream?.cancel()
eventStream = null
}
function resetEvents() {
events.value = []
toolCalls.value = []
seenSequences.clear()
lastSequence = -1
retryCount = 0
}
onScopeDispose(stopStream)
const activeRun = computed(() =>
runs.value.find((r) => r.run_id === activeRunId.value) || null
@@ -42,10 +65,9 @@ export const useAgentStore = defineStore('agent', () => {
async function loadRun(runId: string) {
const version = ++selectionVersion
eventStream?.cancel()
stopStream()
activeRunId.value = runId
events.value = []
toolCalls.value = []
resetEvents()
permissionRequest.value = null
isRunning.value = false
const run = await agentService.getAgentRun(runId)
@@ -61,9 +83,14 @@ 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)
if (seenSequences.has(event.sequence)) return
seenSequences.add(event.sequence)
if (event.sequence > lastSequence) events.value.push(event)
else {
const index = events.value.findIndex(item => item.sequence > event.sequence)
events.value.splice(index < 0 ? events.value.length : index, 0, event)
}
lastSequence = Math.max(lastSequence, event.sequence)
const data = event.data
const run = runs.value.find((item) => item.run_id === event.run_id)
if (event.event === 'RunStarted' && run) run.status = 'running'
@@ -108,15 +135,49 @@ export const useAgentStore = defineStore('agent', () => {
}
function subscribe(runId: string) {
// 任一时刻只保留当前运行的事件流,防止切换详情后旧事件污染新页面。
eventStream?.cancel()
isRunning.value = true
error.value = null
stopStream()
const version = streamVersion
const current = () => activeRunId.value === runId && version === streamVersion
isRunning.value = !terminal(activeRun.value?.status)
const interrupted = (cause?: Error) => {
if (!current() || retryTimer) return
eventStream?.cancel()
eventStream = null
if (terminal(activeRun.value?.status)) {
isRunning.value = false
connectionState.value = 'idle'
return
}
error.value = cause?.message || t('事件连接中断', 'Event connection interrupted')
connectionState.value = retryCount >= 5 ? 'disconnected' : 'reconnecting'
if (retryCount >= 5) return
const delay = Math.min(1000 * 2 ** retryCount++, 16000)
retryTimer = setTimeout(async () => {
retryTimer = null
try {
const run = await agentService.getAgentRun(runId)
if (!current()) return
const index = runs.value.findIndex(item => item.run_id === runId)
if (index >= 0) runs.value[index] = run
// 即使已结束仍续读一次缺失的尾部事件,保留完整 Trace。
subscribe(runId)
} catch (cause) {
if (current()) interrupted(cause instanceof Error ? cause : new Error(String(cause)))
}
}, delay)
}
eventStream = agentService.streamAgentEvents(runId, {
onEvent(event) { if (activeRunId.value === runId) processEvent(event) },
onError(streamError) { if (activeRunId.value === runId) { error.value = streamError.message; isRunning.value = false } },
onDone() { if (activeRunId.value === runId) { isRunning.value = false; eventStream = null } },
})
onOpen() { if (current()) { connectionState.value = 'connected'; error.value = null } },
onEvent(event) { if (current()) processEvent(event) },
onError: interrupted,
onDone() { interrupted() },
}, lastSequence)
}
function reconnect() {
if (!activeRunId.value) return
retryCount = 0
subscribe(activeRunId.value)
}
async function createRun(request: agentService.CreateAgentRunRequest) {
@@ -126,8 +187,7 @@ export const useAgentStore = defineStore('agent', () => {
selectionVersion++
runs.value.unshift(run)
activeRunId.value = run.run_id
events.value = []
toolCalls.value = []
resetEvents()
subscribe(run.run_id)
return run
} finally {
@@ -139,9 +199,12 @@ export const useAgentStore = defineStore('agent', () => {
await agentService.cancelAgentRun(runId)
const run = runs.value.find((r) => r.run_id === runId)
if (run) run.status = 'cancelled'
isRunning.value = false
eventStream?.cancel()
eventStream = null
if (activeRunId.value === runId) {
isRunning.value = false
permissionRequest.value = null
stopStream()
connectionState.value = 'idle'
}
}
async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' = 'once') {
@@ -163,6 +226,8 @@ export const useAgentStore = defineStore('agent', () => {
permissionRequest,
toolCalls,
error,
connectionState,
reconnect,
currentStep,
loadTools,
loadRuns,
+43
View File
@@ -0,0 +1,43 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia, disposePinia } from 'pinia'
import { useAgentStore } from './agent'
const mock = vi.hoisted(() => ({ stream: vi.fn(), get: vi.fn(), cancel: vi.fn() }))
vi.mock('@/services/agentService', () => ({ streamAgentEvents: mock.stream, getAgentRun: mock.get, cancelAgentRun: mock.cancel }))
let pinia: ReturnType<typeof createPinia>
beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); pinia = createPinia(); setActivePinia(pinia); mock.stream.mockReturnValue({ cancel: vi.fn() }); mock.get.mockImplementation(async id => ({ run_id: id, status: 'running' })) })
afterEach(() => { disposePinia(pinia); vi.useRealTimers() })
const handler = () => mock.stream.mock.calls.at(-1)![1]
const event = (sequence: number, name = 'RunStarted') => ({ run_id: 'r', sequence, event: name, data: {}, timestamp: 'now' })
it.each(['error', 'eof'])('retains cancellation and resumes after sequence on %s', async kind => {
const store = useAgentStore(); await store.loadRun('r')
handler().onEvent(event(5))
kind === 'error' ? handler().onError(new Error('offline')) : handler().onDone()
expect(store.isRunning).toBe(true)
await vi.advanceTimersByTimeAsync(1000)
expect(mock.stream.mock.calls.at(-1)![2]).toBe(5)
handler().onEvent(event(5)); handler().onEvent(event(6, 'RunCompleted')); handler().onDone()
expect(store.events).toHaveLength(2)
expect(store.isRunning).toBe(false)
await vi.advanceTimersByTimeAsync(40000)
expect(mock.stream).toHaveBeenCalledTimes(2)
})
it('invalidates old streams and pending retry when changing run or cancelling', async () => {
const store = useAgentStore(); await store.loadRun('r')
const old = handler(); old.onError(new Error('offline'))
await store.loadRun('s'); old.onEvent(event(8))
expect(store.events).toHaveLength(0)
handler().onError(new Error('offline')); await store.cancelRun('s')
await vi.advanceTimersByTimeAsync(40000)
expect(mock.stream).toHaveBeenCalledTimes(2)
})
it('bounds retry attempts and supports explicit retry without losing events', async () => {
const store = useAgentStore(); await store.loadRun('r')
handler().onEvent(event(1))
for (let attempt = 0; attempt < 6; attempt++) { handler().onError(new Error('offline')); await vi.advanceTimersByTimeAsync(16000) }
expect(mock.stream).toHaveBeenCalledTimes(6)
expect(store.connectionState).toBe('disconnected')
expect(store.isRunning).toBe(true)
store.reconnect()
expect(mock.stream.mock.calls.at(-1)![2]).toBe(1)
})
+5 -4
View File
@@ -32,11 +32,12 @@ marked.use({extensions:[
marked.setOptions({ gfm: true, breaks: true })
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
const highlighter = createHighlighterCore({
let highlighter: ReturnType<typeof createHighlighterCore> | undefined
function getHighlighter() { return highlighter ??= createHighlighterCore({
themes: [githubLight, githubDark],
langs: [],
engine: createOnigurumaEngine(import('shiki/wasm')),
})
}).catch(error => { highlighter = undefined; throw error }) }
const languageAliases = new Map(bundledLanguagesInfo.flatMap(info =>
[info.id, info.name, ...(info.aliases ?? [])].map(alias => [alias.toLowerCase(), info.id] as const),
@@ -45,7 +46,7 @@ const languageLoads = new Map<string, Promise<void>>()
const languageLoaders = new Map(bundledLanguagesInfo.map(info => [info.id, info.import]))
async function loadCodeLanguage(requestedLanguage: string) {
const shiki = await highlighter
const shiki = await getHighlighter()
const language = languageAliases.get(requestedLanguage.toLowerCase())
if (!language) return { shiki, language: 'text' as const }
let loading = languageLoads.get(language)
@@ -133,4 +134,4 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
})
}
// TODO(performance): 编辑器首屏稳定后评估将 Shiki 延迟加载或迁移到 Web Worker
// 高亮器首次需要代码高亮时才创建;语法保持按语言加载。Worker 可在性能测量后进一步引入