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 @@ + + diff --git a/frontend/src/features/agent/AgentView.vue b/frontend/src/features/agent/AgentView.vue index a7048a5..798d1d5 100644 --- a/frontend/src/features/agent/AgentView.vue +++ b/frontend/src/features/agent/AgentView.vue @@ -128,6 +128,8 @@ async function handleOpenCitation(data: Record) {

+ {{ t('正在恢复连接…', 'Reconnecting…') }} +
diff --git a/frontend/src/features/editor/EditorPane.spec.ts b/frontend/src/features/editor/EditorPane.spec.ts index 2f0e4a7..c8f139a 100644 --- a/frontend/src/features/editor/EditorPane.spec.ts +++ b/frontend/src/features/editor/EditorPane.spec.ts @@ -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() diff --git a/frontend/src/features/editor/EditorPane.vue b/frontend/src/features/editor/EditorPane.vue index 5a70ebc..5232145 100644 --- a/frontend/src/features/editor/EditorPane.vue +++ b/frontend/src/features/editor/EditorPane.vue @@ -1,9 +1,9 @@ + + + + diff --git a/frontend/src/features/workspace/WorkspaceView.vue b/frontend/src/features/workspace/WorkspaceView.vue index b1a0bb0..961b628 100644 --- a/frontend/src/features/workspace/WorkspaceView.vue +++ b/frontend/src/features/workspace/WorkspaceView.vue @@ -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()
diff --git a/frontend/src/services/pluginCommandForm.ts b/frontend/src/services/pluginCommandForm.ts index 8782cf5..0d4642b 100644 --- a/frontend/src/services/pluginCommandForm.ts +++ b/frontend/src/services/pluginCommandForm.ts @@ -59,12 +59,16 @@ export function initialArguments(command: PluginCommand): Record { const error = ref(null) let eventStream: SseClient | null = null let selectionVersion = 0 + let streamVersion = 0 + let retryTimer: ReturnType | null = null + let retryCount = 0 + const seenSequences = new Set() + 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, diff --git a/frontend/src/stores/agentRecovery.spec.ts b/frontend/src/stores/agentRecovery.spec.ts new file mode 100644 index 0000000..2ffab58 --- /dev/null +++ b/frontend/src/stores/agentRecovery.spec.ts @@ -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 +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) +}) diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts index 9067109..eb28bf3 100644 --- a/frontend/src/utils/markdown.ts +++ b/frontend/src/utils/markdown.ts @@ -32,11 +32,12 @@ marked.use({extensions:[ marked.setOptions({ gfm: true, breaks: true }) // Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。 -const highlighter = createHighlighterCore({ +let highlighter: ReturnType | 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>() 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 可在性能测量后进一步引入。 diff --git a/frontend/tests/visual/README.md b/frontend/tests/visual/README.md index 2f04dcb..f48e6ba 100644 --- a/frontend/tests/visual/README.md +++ b/frontend/tests/visual/README.md @@ -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/无脚本隔离。自动结构检查不代替浏览器截图、布局和对比度检查。 diff --git a/frontend/tests/visual/mermaid-matrix.html b/frontend/tests/visual/mermaid-matrix.html new file mode 100644 index 0000000..ddfd2f6 --- /dev/null +++ b/frontend/tests/visual/mermaid-matrix.html @@ -0,0 +1,38 @@ +Mermaid 真实渲染验收 +

Mermaid 真实渲染验收

运行中

+