From 7001794a22b1a5ef784d573b624b7a3c6460614d Mon Sep 17 00:00:00 2001
From: KiriAky 107
Date: Sun, 6 Sep 2026 02:21:27 +0800
Subject: [PATCH] fix(phase2): restore agent streams and persist extension
installations
---
.gitignore | 1 +
README.md | 2 +-
backend/app/acceptance.py | 85 +++++++++
backend/app/container.py | 8 +-
backend/app/extensions/archive.py | 4 +-
backend/app/extensions/installed.py | 172 ++++++++++++++++++
backend/app/extensions/runtime.py | 2 +-
backend/app/routes.py | 9 +-
.../plugins/text-tools/commands.yaml | 1 +
backend/scripts/dev-server.py | 8 +
backend/scripts/provider-acceptance.py | 49 +++++
backend/scripts/score-transcript.py | 16 ++
backend/tests/test_acceptance.py | 33 ++++
backend/tests/test_installed_extensions.py | 68 +++++++
docs/architecture/第三阶段实施规划.md | 2 +
docs/architecture/第二阶段团队分工表.md | 8 +
docs/development/第二阶段补充验收工具.md | 45 +++++
.../components/common/DiagramInteractions.vue | 8 +-
.../common/ExtensionRestoreNotice.vue | 18 ++
frontend/src/features/agent/AgentView.vue | 2 +
.../src/features/editor/EditorPane.spec.ts | 2 +-
frontend/src/features/editor/EditorPane.vue | 4 +-
frontend/src/features/media/MediaView.vue | 5 +
frontend/src/features/plugins/PluginsView.vue | 2 +
frontend/src/features/skills/SkillsView.vue | 2 +
.../workspace/WorkspacePluginCommands.spec.ts | 36 ++++
.../workspace/WorkspacePluginCommands.vue | 112 ++++++++++++
.../src/features/workspace/WorkspaceView.vue | 3 +-
frontend/src/services/pluginCommandForm.ts | 4 +
frontend/src/services/sseClient.ts | 2 +-
frontend/src/stores/agent.ts | 105 +++++++++--
frontend/src/stores/agentRecovery.spec.ts | 43 +++++
frontend/src/utils/markdown.ts | 9 +-
frontend/tests/visual/README.md | 1 +
frontend/tests/visual/mermaid-matrix.html | 38 ++++
35 files changed, 870 insertions(+), 39 deletions(-)
create mode 100644 backend/app/acceptance.py
create mode 100644 backend/app/extensions/installed.py
create mode 100644 backend/scripts/dev-server.py
create mode 100644 backend/scripts/provider-acceptance.py
create mode 100644 backend/scripts/score-transcript.py
create mode 100644 backend/tests/test_acceptance.py
create mode 100644 backend/tests/test_installed_extensions.py
create mode 100644 docs/development/第二阶段补充验收工具.md
create mode 100644 frontend/src/components/common/ExtensionRestoreNotice.vue
create mode 100644 frontend/src/features/workspace/WorkspacePluginCommands.spec.ts
create mode 100644 frontend/src/features/workspace/WorkspacePluginCommands.vue
create mode 100644 frontend/src/stores/agentRecovery.spec.ts
create mode 100644 frontend/tests/visual/mermaid-matrix.html
diff --git a/.gitignore b/.gitignore
index a8d968e..5639423 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ backend/data/vault/验收/
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
backend/data/mcp/
backend/data/extension-packages/
+backend/data/extension-installations.sqlite3*
server.json
servers.json
diff --git a/README.md b/README.md
index 38b8681..ee15d07 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/backend/app/acceptance.py b/backend/app/acceptance.py
new file mode 100644
index 0000000..f413b84
--- /dev/null
+++ b/backend/app/acceptance.py
@@ -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)}
diff --git a/backend/app/container.py b/backend/app/container.py
index d5801db..503217b 100644
--- a/backend/app/container.py
+++ b/backend/app/container.py
@@ -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)
diff --git a/backend/app/extensions/archive.py b/backend/app/extensions/archive.py
index a1c8582..75711fd 100644
--- a/backend/app/extensions/archive.py
+++ b/backend/app/extensions/archive.py
@@ -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):
diff --git a/backend/app/extensions/installed.py b/backend/app/extensions/installed.py
new file mode 100644
index 0000000..36e2052
--- /dev/null
+++ b/backend/app/extensions/installed.py
@@ -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
diff --git a/backend/app/extensions/runtime.py b/backend/app/extensions/runtime.py
index 960554e..e8b94cf 100644
--- a/backend/app/extensions/runtime.py
+++ b/backend/app/extensions/runtime.py
@@ -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:
diff --git a/backend/app/routes.py b/backend/app/routes.py
index ae9f80e..8211bc0 100644
--- a/backend/app/routes.py
+++ b/backend/app/routes.py
@@ -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(
diff --git a/backend/extensions/plugins/text-tools/commands.yaml b/backend/extensions/plugins/text-tools/commands.yaml
index 611b516..d7610e5 100644
--- a/backend/extensions/plugins/text-tools/commands.yaml
+++ b/backend/extensions/plugins/text-tools/commands.yaml
@@ -6,6 +6,7 @@ commands:
locations:
- command_palette
- context_menu
+ - toolbar
when:
- editor.has_selection
context:
diff --git a/backend/scripts/dev-server.py b/backend/scripts/dev-server.py
new file mode 100644
index 0000000..33af3b7
--- /dev/null
+++ b/backend/scripts/dev-server.py
@@ -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')])
diff --git a/backend/scripts/provider-acceptance.py b/backend/scripts/provider-acceptance.py
new file mode 100644
index 0000000..d17c617
--- /dev/null
+++ b/backend/scripts/provider-acceptance.py
@@ -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()
diff --git a/backend/scripts/score-transcript.py b/backend/scripts/score-transcript.py
new file mode 100644
index 0000000..350f53e
--- /dev/null
+++ b/backend/scripts/score-transcript.py
@@ -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')
diff --git a/backend/tests/test_acceptance.py b/backend/tests/test_acceptance.py
new file mode 100644
index 0000000..a247503
--- /dev/null
+++ b/backend/tests/test_acceptance.py
@@ -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'))], [])
diff --git a/backend/tests/test_installed_extensions.py b/backend/tests/test_installed_extensions.py
new file mode 100644
index 0000000..88174d1
--- /dev/null
+++ b/backend/tests/test_installed_extensions.py
@@ -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()
diff --git a/docs/architecture/第三阶段实施规划.md b/docs/architecture/第三阶段实施规划.md
index a1a9ff8..40846ee 100644
--- a/docs/architecture/第三阶段实施规划.md
+++ b/docs/architecture/第三阶段实施规划.md
@@ -1,5 +1,7 @@
# 第三阶段实施规划:桌面容器、扩展社区与多设备同步
+> 2026-09-06 后续修复补充:本地扩展安装登记、摘要复核恢复、ZIP 卸载清理以及工作区 context_menu/toolbar 入口已在第二阶段补丁实现。下文原始基线仍保留用于追踪;第三阶段应在此基础上完成迁移、签名、升级事务及生产隔离,不重复建设基础登记。真实质量与厂商验收仍未闭环。
+
基线日期:2026-09-06。状态:**计划,尚未交付第三阶段**。本规划以当前第二阶段代码及本地验收记录为起点;本次用户明确要求将各社区、Sync Server、Tauri / Rust 容器纳入第三阶段。未勾选项均为待实施,不以文档编写或接口命名代替实现。
## 1. 阶段目标与完成口径
diff --git a/docs/architecture/第二阶段团队分工表.md b/docs/architecture/第二阶段团队分工表.md
index 1ddca6c..f85bd78 100644
--- a/docs/architecture/第二阶段团队分工表.md
+++ b/docs/architecture/第二阶段团队分工表.md
@@ -1,5 +1,13 @@
# 第二阶段团队分工表
+## 2026-09-06 复核修复补充(不含杨侧验收)
+
+- Agent 增加断点续读、有界重连和手动恢复;连接中断不再隐藏仍在运行任务的取消入口。
+- 本地扩展安装库支持重启恢复、包摘要复核和受管理 ZIP 卸载清理。变更后的包需重新安装审查;目录安装保留用户源码。
+- 工作区已挂载编辑器右键及扩展工具栏入口,与已有 palette/详情命令共同使用后端命令校验;执行上下文保留选区快照。
+- 新增真实 Mermaid 6 图型 × 6 主题回归入口,以及参考转写 CER/WER/DER 评分和受限 Provider 连接探针。工具使用与边界见 `docs/development/第二阶段补充验收工具.md`。
+- 逐字强制对齐、多人重叠分离仍未实现;无标注录音不能完成质量验收,真实厂商专项也不能用一次连接测试替代。以下较早记录中的测试数和延期状态属于历史基线。
+
## 2026-09-05 当前完成情况补充(不含杨侧验收)
以下状态补充早期任务清单,历史未勾选项不再单独作为实时完成率依据。杨星萱负责的 Benchmark、检索调优、导出及函数图像不在本次验收范围。
diff --git a/docs/development/第二阶段补充验收工具.md b/docs/development/第二阶段补充验收工具.md
new file mode 100644
index 0000000..0c8ed1a
--- /dev/null
+++ b/docs/development/第二阶段补充验收工具.md
@@ -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` 用于弹窗内部滚动与焦点;主题预览样例涵盖输入、下拉、折叠、卡片、表格与代码。
diff --git a/frontend/src/components/common/DiagramInteractions.vue b/frontend/src/components/common/DiagramInteractions.vue
index 8d16eda..1a104b8 100644
--- a/frontend/src/components/common/DiagramInteractions.vue
+++ b/frontend/src/components/common/DiagramInteractions.vue
@@ -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; }
diff --git a/frontend/src/components/common/ExtensionRestoreNotice.vue b/frontend/src/components/common/ExtensionRestoreNotice.vue
new file mode 100644
index 0000000..dc22f0b
--- /dev/null
+++ b/frontend/src/components/common/ExtensionRestoreNotice.vue
@@ -0,0 +1,18 @@
+
+
+
+
{{ failure }}
+
{{ item.id }}:{{ t('启动恢复未完成,请检查包文件并重新安装;原授权不会自动用于变更后的包。', 'Startup recovery failed. Check and reinstall the package; previous grants are not applied to changed packages.') }}