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
@@ -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">