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
+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()
@@ -1,5 +1,7 @@
# 第三阶段实施规划:桌面容器、扩展社区与多设备同步
> 2026-09-06 后续修复补充:本地扩展安装登记、摘要复核恢复、ZIP 卸载清理以及工作区 context_menu/toolbar 入口已在第二阶段补丁实现。下文原始基线仍保留用于追踪;第三阶段应在此基础上完成迁移、签名、升级事务及生产隔离,不重复建设基础登记。真实质量与厂商验收仍未闭环。
基线日期:2026-09-06。状态:**计划,尚未交付第三阶段**。本规划以当前第二阶段代码及本地验收记录为起点;本次用户明确要求将各社区、Sync Server、Tauri / Rust 容器纳入第三阶段。未勾选项均为待实施,不以文档编写或接口命名代替实现。
## 1. 阶段目标与完成口径
@@ -1,5 +1,13 @@
# 第二阶段团队分工表
## 2026-09-06 复核修复补充(不含杨侧验收)
- Agent 增加断点续读、有界重连和手动恢复;连接中断不再隐藏仍在运行任务的取消入口。
- 本地扩展安装库支持重启恢复、包摘要复核和受管理 ZIP 卸载清理。变更后的包需重新安装审查;目录安装保留用户源码。
- 工作区已挂载编辑器右键及扩展工具栏入口,与已有 palette/详情命令共同使用后端命令校验;执行上下文保留选区快照。
- 新增真实 Mermaid 6 图型 × 6 主题回归入口,以及参考转写 CER/WER/DER 评分和受限 Provider 连接探针。工具使用与边界见 `docs/development/第二阶段补充验收工具.md`
- 逐字强制对齐、多人重叠分离仍未实现;无标注录音不能完成质量验收,真实厂商专项也不能用一次连接测试替代。以下较早记录中的测试数和延期状态属于历史基线。
## 2026-09-05 当前完成情况补充(不含杨侧验收)
以下状态补充早期任务清单,历史未勾选项不再单独作为实时完成率依据。杨星萱负责的 Benchmark、检索调优、导出及函数图像不在本次验收范围。
@@ -0,0 +1,45 @@
# 第二阶段补充验收工具
这些工具补充证据采集,不以生成报告代替验收。没有标注的录音不能计算准确率;连接测试通过也不等于 Provider 全协议通过。
## 本地开发启动与扩展恢复
在 backend 目录执行 `.venv/Scripts/python.exe scripts/dev-server.py`。热重载仅监听 app,ZIP 解压目录不触发重载。
扩展安装库为应用数据目录下的 extension-installations.sqlite3。重启恢复前校验包摘要;包缺失或变化不会沿用原权限启动,管理页显示恢复提示。重新安装前需检查文件和权限。ZIP 卸载仅清理由导入器登记的管理目录,从目录安装不会删除用户源码。
## 转写参考数据评分
参考与预测文件均为 UTF-8 JSON 数组,每条包含秒单位的 start、end、text、speaker。参考必须来自人工校对或获准标注集,不能把同一份模型输出复制为参考。
也支持应用作业 JSON 的 segments 数组以及原生 start_time / end_time 字段,时间单位仍为秒。
```json
[{"start": 0, "end": 2.5, "text": "你好 世界", "speaker": "speaker_A"}]
```
```powershell
.venv/Scripts/python.exe scripts/score-transcript.py reference.json hypothesis.json --output scores.json
```
报告只保存聚合分数,不保存正文。CER 做 NFC 归一化并忽略空白;WER 按空白分词,中文连续文本优先看 CER。大小写和标点保留。空参考拒绝评分;空文本分母显示 null,不冒充 0%。比较超过 2000 万单元时拒绝,需分成较短且分别人工标注的录音进行验收。
说话人评分按全时间轴、零 collar、包含重叠语音计算 DER,使用一对一最优说话人映射,不要求预测编号与参考编号相同。最多 12 个说话人 ID;缺标签时不可用。该口径必须随结果保留,不能与不同 collar/UEM 规则的第三方分数直接比较。
脚本不设虚构达标阈值,quality_gate 固定 not_evaluated。阈值需在验收集和任务要求确定后另行批准。FAR/FRR 属于说话人验证专项,不能用这里的 DER 代替。当前 ASR 仍无逐字强制对齐和重叠分离,不因可对重叠参考评分就变成支持这些能力。
## Provider 专项证据
```powershell
.venv/Scripts/python.exe scripts/provider-acceptance.py --provider 已配置ID --model 已配置模型ID --output provider-plan.json
```
默认只生成待验收矩阵,不调用厂商。确认测试账号及可能费用后添加 `--execute`,经本地 AI Core 执行一次连接测试,不读取密钥,也不保存远端原始错误、请求头或响应正文。最多一次测试请求,不自动重试。
模型发现、工具往返、思考/正文流、取消、缓存命中/未命中、上下文限制、压缩仍逐项 pending,需要专门真实场景补证。overall 保持 not_accepted,禁止仅凭连接成功签署全部通过。当前用户录音无标注、各厂商专项未完整实测的状态保持不变。
## 浏览器回归
启动 frontend 后打开 `/tests/visual/mermaid-matrix.html`,使用真实 Mermaid 服务串行渲染 6 种图型 × 6 种内置/社区主题;顶部给出完成数和逐项结果。`?theme=paper-moments` 可单独检查该主题的最终外观。
该矩阵检查 SVG、可见尺寸、文本存在和错误;不把 DOM 文本存在当成所有字形都可见的截图结论。还需观察大图、缩放与窄屏。`/tests/visual/index.html?case=dialog` 用于弹窗内部滚动与焦点;主题预览样例涵盖输入、下拉、折叠、卡片、表格与代码。
@@ -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>