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

This commit is contained in:
2026-09-06 02:21:27 +08:00
parent 670f4c076b
commit 9e8603d010
32 changed files with 815 additions and 39 deletions
+1
View File
@@ -23,6 +23,7 @@ backend/data/vault/验收/
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
backend/data/mcp/
backend/data/extension-packages/
backend/data/extension-installations.sqlite3*
server.json
servers.json
+1 -1
View File
@@ -68,7 +68,7 @@ cd ..
```powershell
# 终端一
cd backend
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
uv run python scripts/dev-server.py
# 终端二
cd frontend
+85
View File
@@ -0,0 +1,85 @@
"""Offline reference scoring. No inference, uploads or fabricated reference labels."""
from __future__ import annotations
import math
import unicodedata
def edit_distance(reference, hypothesis):
if len(reference) * len(hypothesis) > 20_000_000:
raise ValueError('Text comparison exceeds 20 million cells; score shorter annotated recordings separately')
row = list(range(len(hypothesis) + 1))
for i, a in enumerate(reference, 1):
next_row = [i]
for j, b in enumerate(hypothesis, 1):
next_row.append(min(next_row[-1] + 1, row[j] + 1, row[j-1] + (a != b)))
row = next_row
return row[-1]
def validate_segments(items):
if isinstance(items, dict):
items = items.get('segments')
if not isinstance(items, list) or len(items) > 10000:
raise ValueError('segments must be an array with at most 10000 entries')
items = [dict(item, start=item.get('start', item.get('start_time')), end=item.get('end', item.get('end_time'))) for item in items]
for item in items:
start, end = item['start'], item['end']
if not all(isinstance(value, (int, float)) and math.isfinite(value) for value in (start, end)) or start < 0 or end <= start:
raise ValueError('Each segment needs finite 0 <= start < end times in seconds')
if not isinstance(item.get('text', ''), str):
raise ValueError('Segment text must be a string')
return sorted(items, key=lambda item: (item['start'], item['end']))
def speaker_score(reference, hypothesis):
if not reference or any(not isinstance(item.get('speaker'), str) or not item['speaker'] for item in reference + hypothesis):
return {'status': 'unavailable', 'reason': 'Reference and hypothesis speaker labels are required'}
refs = sorted({item['speaker'] for item in reference})
hyps = sorted({item['speaker'] for item in hypothesis})
count = max(len(refs), len(hyps))
if count > 12:
raise ValueError('Speaker scoring supports at most 12 speaker IDs per recording')
boundaries = sorted({item[key] for item in reference + hypothesis for key in ('start', 'end')})
weights = [[0.0] * count for _ in range(count)]
denominator = missed = false_alarm = common = 0.0
for start, end in zip(boundaries, boundaries[1:]):
r = {item['speaker'] for item in reference if item['start'] < end and item['end'] > start}
h = {item['speaker'] for item in hypothesis if item['start'] < end and item['end'] > start}
duration = end - start
denominator += duration * len(r)
missed += duration * max(0, len(r) - len(h))
false_alarm += duration * max(0, len(h) - len(r))
common += duration * min(len(r), len(h))
for a in r:
for b in h:
weights[refs.index(a)][hyps.index(b)] += duration
# Exact maximum-weight one-to-one mapping, padded with silent dummy speakers.
dp = {0: 0.0}
for index in range(count):
next_dp = {}
for mask, score in dp.items():
for column in range(count):
if not mask & (1 << column):
key = mask | (1 << column)
next_dp[key] = max(next_dp.get(key, -1), score + weights[index][column])
dp = next_dp
confusion = max(0.0, common - max(dp.values()))
return {'status': 'scored', 'collar_seconds': 0, 'overlap_included': True,
'reference_speaker_seconds': denominator, 'missed_seconds': missed,
'false_alarm_seconds': false_alarm, 'confusion_seconds': confusion,
'der': (missed + false_alarm + confusion) / denominator if denominator else None}
def score(reference, hypothesis):
reference, hypothesis = validate_segments(reference), validate_segments(hypothesis)
if not reference:
raise ValueError('A non-empty human reference is required')
texts = [' '.join(unicodedata.normalize('NFC', item.get('text', '')) for item in items) for items in (reference, hypothesis)]
metrics = {}
for name, units in [('cer', [[c for c in text if not c.isspace()] for text in texts]), ('wer', [text.split() for text in texts])]:
expected, actual = units
edits = edit_distance(expected, actual)
metrics[name] = {'edits': edits, 'reference_units': len(expected), 'rate': edits / len(expected) if expected else None}
return {'text': metrics, 'speaker': speaker_score(reference, hypothesis),
'normalization': 'NFC; punctuation/case retained; CER ignores whitespace; WER uses whitespace tokens',
'quality_gate': 'not_evaluated', 'reference_segments': len(reference), 'hypothesis_segments': len(hypothesis)}
+7 -1
View File
@@ -5,6 +5,7 @@ from app.agent.builtin_tools import register_builtin_tools
from app.contracts import ModelCapability, ProviderConfig, ProviderType
from app.config import BACKEND_DIR, get_settings
from app.extensions import PluginRuntime, SkillRuntime
from app.extensions.installed import InstalledRuntime
from app.extensions.mcp_registry import McpServerRegistry
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
from app.providers.routing import ModelRoutingService
@@ -64,6 +65,8 @@ def build_container() -> ApplicationContainer:
)
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
plugins.enable("text-tools")
plugins = InstalledRuntime(plugins, 'plugin', settings.data_dir)
plugins.restore()
mcp_servers = McpServerRegistry(
tools,
@@ -75,7 +78,10 @@ def build_container() -> ApplicationContainer:
skills = SkillRuntime(tools)
skills.install(BACKEND_DIR / "extensions" / "skills" / "knowledge-assistant")
skills.enable("knowledge-assistant")
if not skills.get("knowledge-assistant").missing_dependencies:
skills.enable("knowledge-assistant")
skills = InstalledRuntime(skills, 'skill', settings.data_dir)
skills.restore()
policy = PermissionPolicy()
permissions = PermissionManager(policy)
+2 -2
View File
@@ -25,7 +25,7 @@ def invalid(message: str) -> ApiError:
return ApiError(422, 'EXTENSION_ZIP_INVALID', message)
def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path], T]) -> T:
def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path], T], *, managed_install: Callable[[Path, Path], T] | None = None) -> T:
if len(data) > MAX_ZIP_BYTES:
raise ApiError(413, 'EXTENSION_ZIP_TOO_LARGE', 'ZIP 文件不能超过 10 MiB。')
if kind not in ('skill', 'plugin'):
@@ -89,7 +89,7 @@ def install_zip(data: bytes, kind: str, storage: Path, install: Callable[[Path],
if len(children) != 1 or not children[0].is_dir() or not (children[0] / manifest).is_file():
raise invalid(f'ZIP 根目录或唯一顶层文件夹中须包含 {manifest}')
root = children[0]
return install(root)
return managed_install(root, destination) if managed_install else install(root)
except BaseException as error:
shutil.rmtree(destination)
if isinstance(error, ExtensionError):
+172
View File
@@ -0,0 +1,172 @@
"""Local installation journal. Only explicitly managed ZIP roots may be removed."""
from __future__ import annotations
import hashlib
import json
import logging
import shutil
import sqlite3
import threading
from contextlib import contextmanager
from pathlib import Path
from app.extensions.errors import ExtensionError
log = logging.getLogger(__name__)
def package_digest(root: Path) -> str:
digest = hashlib.sha256()
total = 0
files = sorted(root.rglob('*'))
for path in files:
if path.is_symlink():
raise ValueError('Package links cannot be restored automatically')
if not path.is_file() or '__pycache__' in path.parts or path.suffix == '.pyc':
continue
total += path.stat().st_size
if total > 50 * 1024 * 1024 or len(files) > 4096:
raise ValueError('Package exceeds restoration limits')
digest.update(path.relative_to(root).as_posix().encode())
digest.update(b'\0')
digest.update(path.read_bytes())
return digest.hexdigest()
class InstalledRuntime:
def __init__(self, runtime, kind: str, data_dir: Path):
self.runtime = runtime
self.kind = kind
self.storage = (data_dir / 'extension-packages').resolve()
self.path = data_dir / 'extension-installations.sqlite3'
self.path.parent.mkdir(parents=True, exist_ok=True)
self.lock = threading.RLock()
self.restoring = False
self.restore_errors: list[dict[str, str]] = []
with self._db() as db:
db.execute('CREATE TABLE IF NOT EXISTS installations (kind TEXT, id TEXT, data TEXT, PRIMARY KEY(kind,id))')
@contextmanager
def _db(self):
db = sqlite3.connect(self.path)
try:
with db:
yield db
finally:
db.close()
def __getattr__(self, name):
return getattr(self.runtime, name)
def _read(self, identifier):
with self._db() as db:
row = db.execute('SELECT data FROM installations WHERE kind=? AND id=?', (self.kind, identifier)).fetchone()
return json.loads(row[0]) if row else {}
def _write(self, identifier, data):
with self._db() as db:
db.execute('INSERT OR REPLACE INTO installations VALUES (?,?,?)', (self.kind, identifier, json.dumps(data)))
def _save(self, identifier, managed_root=None, *, installing=False):
if self.restoring:
return
record = self.runtime._records[identifier]
item = self.runtime.get(identifier)
previous = self._read(identifier)
self._write(identifier, {
'path': str(record.package_path), 'digest': package_digest(record.package_path) if installing or not previous else previous['digest'],
'enabled': item.enabled, 'permissions': getattr(item, 'granted_permissions', []),
'managed_root': (str(managed_root) if managed_root else None) if installing else previous.get('managed_root'),
'removed': False,
})
def install(self, package_path, *, managed_root=None):
with self.lock:
root = Path(package_path).resolve()
package_digest(root) # Check before changing runtime state.
if managed_root is not None:
owned = Path(managed_root).resolve()
if owned.parent != self.storage or not root.is_relative_to(owned):
raise ValueError('Invalid managed package root')
item = self.runtime.install(root)
identifier = getattr(item.manifest, f'{self.kind}_id')
try:
self._save(identifier, managed_root, installing=True)
except Exception:
self.runtime.uninstall(identifier)
raise
self.restore_errors = [error for error in self.restore_errors if error['id'] != identifier]
return item
def enable(self, identifier):
with self.lock:
# Changed packages must be reinstalled to re-parse their declarations.
saved = self._read(identifier)
root = self.runtime._record(identifier).package_path
if saved and saved.get('digest') != package_digest(root):
raise ExtensionError('EXTENSION_PACKAGE_CHANGED', 'Package changed; reinstall and review its permissions.', status_code=409)
item = self.runtime.enable(identifier)
self._save(identifier)
return item
def disable(self, identifier):
with self.lock:
item = self.runtime.disable(identifier)
self._save(identifier)
return item
def set_permissions(self, identifier, permissions):
with self.lock:
item = self.runtime.set_permissions(identifier, permissions)
self._save(identifier)
return item
def uninstall(self, identifier, *args, **kwargs):
with self.lock:
saved = self._read(identifier)
self.runtime.uninstall(identifier, *args, **kwargs)
saved['removed'] = True
self._write(identifier, saved)
self._cleanup(saved)
def _cleanup(self, saved):
raw = saved.get('managed_root')
if not raw:
return # Directory installs belong to the user.
path = Path(raw)
if path.is_symlink() or path.resolve().parent != self.storage:
raise ValueError('Refusing to remove an unmanaged package directory')
if path.exists():
shutil.rmtree(path)
def restore(self):
with self.lock:
with self._db() as db:
rows = db.execute('SELECT id,data FROM installations WHERE kind=?', (self.kind,)).fetchall()
self.restoring = True
try:
for identifier, raw in rows:
try:
saved = json.loads(raw)
if identifier in self.runtime._records:
self.runtime.uninstall(identifier)
if saved.get('removed'):
self._cleanup(saved)
continue
root = Path(saved['path'])
if not root.is_dir() or package_digest(root) != saved['digest']:
raise ValueError('Package missing or changed; reinstall and review permissions')
item = self.runtime.install(root)
actual_id = getattr(item.manifest, f'{self.kind}_id')
if actual_id != identifier:
self.runtime.uninstall(actual_id)
raise ValueError('Package identity changed')
if self.kind == 'plugin':
self.runtime.set_permissions(identifier, saved.get('permissions', []))
if saved.get('enabled'):
self.runtime.enable(identifier)
except Exception as error:
self.restore_errors.append({'kind': self.kind, 'id': identifier, 'message': 'Package recovery failed; inspect the package and reinstall or enable it again.'})
log.warning('Extension restore failed: %s/%s (%s)', self.kind, identifier, type(error).__name__)
finally:
self.restoring = False
+1 -1
View File
@@ -90,7 +90,7 @@ class SkillRuntime:
self._records: dict[str, _SkillRecord] = {}
def install(self, package_path: str | Path) -> Skill:
# TODO(extension): 将安装记录持久化,应用重启后从可信包目录恢复状态
# 应用层 InstalledRuntime 负责安装记录和可信包恢复;此类保留独立可测试的运行时
root = _package_dir(package_path)
raw = _read_yaml(root / "skill.yaml")
if "id" in raw and "skill_id" not in raw:
+7 -2
View File
@@ -673,13 +673,18 @@ async def read_extension_zip(request: Request) -> bytes:
@router.post('/skills/install-zip', response_model=Skill, status_code=202, tags=['Skills'])
async def install_skill_zip(request: Request) -> Skill:
data = await read_extension_zip(request)
return extension_call(lambda: install_zip(data, 'skill', get_settings().data_dir / 'extension-packages', container.skills.install))
return extension_call(lambda: install_zip(data, 'skill', get_settings().data_dir / 'extension-packages', container.skills.install, managed_install=lambda root, owned: container.skills.install(root, managed_root=owned)))
@router.post('/plugins/install-zip', response_model=Plugin, status_code=202, tags=['Plugins'])
async def install_plugin_zip(request: Request) -> Plugin:
data = await read_extension_zip(request)
return extension_call(lambda: install_zip(data, 'plugin', get_settings().data_dir / 'extension-packages', container.plugins.install))
return extension_call(lambda: install_zip(data, 'plugin', get_settings().data_dir / 'extension-packages', container.plugins.install, managed_install=lambda root, owned: container.plugins.install(root, managed_root=owned)))
@router.get('/extensions/restore-errors', tags=['Plugins', 'Skills'])
async def extension_restore_errors():
return {'items': container.plugins.restore_errors + container.skills.restore_errors}
@router.post(
@@ -6,6 +6,7 @@ commands:
locations:
- command_palette
- context_menu
- toolbar
when:
- editor.has_selection
context:
+8
View File
@@ -0,0 +1,8 @@
"""Development reload watches application code, never imported extension packages."""
from pathlib import Path
import uvicorn
if __name__ == '__main__':
backend = Path(__file__).resolve().parents[1]
uvicorn.run('app.main:app', host='127.0.0.1', port=8000, app_dir=str(backend),
reload=True, reload_dirs=[str(backend / 'app')])
+49
View File
@@ -0,0 +1,49 @@
"""Explicit, bounded connection smoke against an already configured local Provider.
Defaults to a plan. --execute performs one test request, never reads credentials.
The output deliberately keeps untested protocol scenarios pending.
"""
import argparse
from datetime import datetime, timezone
import json
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
SCENARIOS = ['model_discovery', 'tool_roundtrip', 'stream_reasoning_and_content',
'stream_cancel', 'cache_hit_and_miss', 'context_limit', 'context_compression']
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--base-url', default='http://127.0.0.1:8000')
parser.add_argument('--provider', required=True)
parser.add_argument('--model', required=True)
parser.add_argument('--output', required=True, type=Path)
parser.add_argument('--execute', action='store_true', help='Perform one provider connection test; may incur provider charges')
args = parser.parse_args()
target = urlparse(args.base_url)
if target.scheme != 'http' or target.hostname not in ('127.0.0.1', 'localhost', '::1') or target.username or target.password or target.query or target.fragment:
parser.error('Use a local HTTP AI Core address without credentials or query parameters')
result = {'date': datetime.now(timezone.utc).isoformat(), 'provider': args.provider, 'model': args.model,
'max_test_requests': 1, 'connection': 'pending',
'scenarios': {name: 'pending' for name in SCENARIOS}, 'overall': 'not_accepted'}
if args.execute:
body = json.dumps({'provider_id': args.provider, 'model': args.model}).encode()
request = Request(args.base_url.rstrip('/') + '/api/providers/test', data=body, headers={'Content-Type': 'application/json'}, method='POST')
try:
with urlopen(request, timeout=60) as response:
payload = json.load(response)
result['connection'] = 'passed' if payload.get('success') is True else 'failed'
result['latency_ms'] = payload.get('latency_ms')
except HTTPError as error:
result['connection'] = 'failed'
result['http_status'] = error.code # Do not persist remote error bodies or headers.
except (URLError, TimeoutError, ValueError):
result['connection'] = 'unavailable'
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
if __name__ == '__main__':
main()
+16
View File
@@ -0,0 +1,16 @@
"""Score authorized reference/hypothesis JSON segment arrays without a model or network."""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.acceptance import score
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('reference', type=Path)
parser.add_argument('hypothesis', type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
result = score(json.loads(args.reference.read_text(encoding='utf-8-sig')), json.loads(args.hypothesis.read_text(encoding='utf-8-sig')))
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
+33
View File
@@ -0,0 +1,33 @@
import pytest
from app.acceptance import score
def segment(text, speaker='A', start=0, end=1):
return dict(text=text, speaker=speaker, start=start, end=end)
def test_exact_and_renamed_speakers():
result = score([segment('你好 世界')], [segment('你好 世界', 'cluster_4')])
assert result['text']['cer']['rate'] == 0
assert result['speaker']['der'] == 0
assert result['quality_gate'] == 'not_evaluated'
def test_edits_missed_and_false_alarms():
result = score([segment('a b')], [segment('a c', start=0, end=2)])
assert result['text']['wer']['rate'] == 0.5
assert result['speaker']['false_alarm_seconds'] == 1
result = score([segment('a')], [])
assert result['speaker']['der'] == 1
def test_overlap_and_confusion():
result = score([segment('a'), segment('b', 'B')], [segment('a')])
assert result['speaker']['der'] == 0.5
result = score([segment('a'), segment('b','B',1,2)], [segment('a','X',0,2)])
assert result['speaker']['confusion_seconds'] == 1
def test_requires_reference_and_valid_timing():
with pytest.raises(ValueError): score([], [])
with pytest.raises(ValueError): score([segment('a', end=float('nan'))], [])
@@ -0,0 +1,68 @@
from pathlib import Path
import pytest
from app.agent.tools import ToolRegistry
from app.extensions import SkillRuntime
from app.extensions.installed import InstalledRuntime
def package(root):
root.mkdir(parents=True)
(root / 'skill.yaml').write_text('skill_id: audit\nname: Audit\nversion: 1.0.0\npermissions: []\ntools: []\n', encoding='utf-8')
return root
def runtime(data):
return InstalledRuntime(SkillRuntime(ToolRegistry()), 'skill', data)
def test_restores_enabled_and_disabled_without_deleting_directory_install(tmp_path):
root = package(tmp_path / 'user-source')
data = tmp_path / 'data'
first = runtime(data); first.install(root); first.enable('audit')
second = runtime(data); second.restore()
assert second.get('audit').enabled
second.disable('audit')
third = runtime(data); third.restore()
assert not third.get('audit').enabled
third.uninstall('audit')
assert root.exists()
fourth = runtime(data); fourth.restore()
assert fourth.list() == []
def test_owned_zip_removed_and_changed_packages_not_auto_enabled(tmp_path):
data = tmp_path / 'data'
owned = data / 'extension-packages/skill-test'
root = package(owned / 'nested')
first = runtime(data); first.install(root, managed_root=owned); first.enable('audit')
(root / 'prompt.md').write_text('changed', encoding='utf-8')
first.disable('audit')
with pytest.raises(Exception, match='Package changed'):
first.enable('audit')
second = runtime(data); second.restore()
assert second.list() == []
assert second.restore_errors[0]['id'] == 'audit'
first.uninstall('audit')
assert not owned.exists()
def test_rejects_claiming_user_directory_as_managed(tmp_path):
root = package(tmp_path / 'source')
with pytest.raises(ValueError, match='managed'):
runtime(tmp_path / 'data').install(root, managed_root=root)
assert root.exists()
def test_builtin_disabled_plugin_does_not_break_startup():
from app.container import build_container
first = build_container()
first.plugins.disable('text-tools')
second = build_container()
assert not second.plugins.get('text-tools').enabled
assert second.skills.get('knowledge-assistant').missing_dependencies
second.plugins.enable('text-tools')
third = build_container()
assert third.plugins.get('text-tools').enabled
assert third.skills.get('knowledge-assistant').enabled
for container in (first, second, third):
container.plugins.shutdown(); container.mcp_servers.shutdown()
@@ -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 可在性能测量后进一步引入
+1
View File
@@ -5,5 +5,6 @@
- `/tests/visual/index.html?theme=light`:将 theme 依次替换为 dark、sepia、paper-moments、ocean-blue、midnight-purple。检查输入、禁用、焦点、悬停、展开/折叠、Markdown、图表及长标识;在宽屏和窄窗口重复。
- `/tests/visual/index.html?case=dialog`:检查满视口遮罩、背景无法滚动、内部长内容可滚动、Tab 焦点限定、Escape 关闭与再次打开。
- `/tests/visual/index.html?case=editor`:逐字输入行内代码;先输入两个反引号、向左移再填字;连续普通/软换行;输入法提交;选区替换;撤销/重做。编辑器单元测试另覆盖 Markdown 序列化往返。
- `/tests/visual/mermaid-matrix.html`:真实 Mermaid(无渲染 mock)的 6 图型 × 6 主题矩阵。顶部报告检查结果;加 `?theme=paper-moments` 可查看单主题外观。
运行自动回归:`pnpm test`。AppDialog 测试覆盖滚动锁引用计数、恢复焦点、禁止隐式关闭;主题预览矩阵覆盖六主题的实际共享 CSS、控件状态和 CSP/无脚本隔离。自动结构检查不代替浏览器截图、布局和对比度检查。
+38
View File
@@ -0,0 +1,38 @@
<!doctype html><html><head><meta charset="utf-8"><title>Mermaid 真实渲染验收</title></head>
<body><h1>Mermaid 真实渲染验收</h1><p id="status">运行中</p><main id="results"></main>
<script type="module">
import { renderMermaid } from '/src/services/mermaidService.ts';
import { getCommunityThemePreviewCss, mockCommunityThemes } from '/src/services/themePackageService.ts';
import '/src/styles/tokens.css';
import '/src/styles/features.css';
const fixtures = {
flowchart: 'flowchart TD\n A[开始] --> B[完成]',
sequence: 'sequenceDiagram\n participant A as 用户\n participant B as 服务\n A->>B: 请求\n B-->>A: 结果',
class: 'classDiagram\n class Note {\n +String title\n }\n class Vault\n Vault --> Note',
state: 'stateDiagram-v2\n [*] --> Ready\n Ready --> Done\n Done --> [*]',
er: 'erDiagram\n VAULT ||--o{ NOTE : contains',
gantt: 'gantt\n title Plan\n dateFormat YYYY-MM-DD\n section Work\n Build :a, 2026-09-01, 2d',
};
const allThemes = [{theme_id:'light',is_dark:false},{theme_id:'dark',is_dark:true},{theme_id:'sepia',is_dark:false},...mockCommunityThemes];
const requested = new URLSearchParams(location.search).get('theme');
const themes = requested ? allThemes.filter(t=>t.theme_id===requested) : allThemes;
const style = document.createElement('style'); document.head.append(style);
const results = [];
for (const theme of themes) {
document.documentElement.dataset.theme = theme.theme_id;
style.textContent = getCommunityThemePreviewCss(theme.theme_id) || '';
for (const [type,source] of Object.entries(fixtures)) {
const rendered = await renderMermaid(source,{theme:theme.is_dark?'dark':'light'});
const article = document.createElement('article'); article.className='item-card';
const label = document.createElement('h2'); label.textContent=theme.theme_id+' / '+type;
const preview = document.createElement('div'); preview.innerHTML=rendered.svg; preview.style.overflow='auto';
article.append(label,preview); document.querySelector('#results').append(article);
const svg=preview.querySelector('svg');
const valid = !rendered.warnings.length && svg && svg.getBoundingClientRect().height>0 && svg.textContent.trim().length>0 && !svg.querySelector('.error-icon');
results.push({theme:theme.theme_id,type,passed:Boolean(valid),warnings:rendered.warnings});
article.dataset.passed=String(Boolean(valid));
}
}
document.querySelector('#status').textContent=JSON.stringify({passed:results.filter(r=>r.passed).length,total:results.length,results});
document.documentElement.dataset.complete='true';
</script></body></html>