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/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('当前上下文没有可用的扩展命令。', 'No extension commands are available in this context.') }}
+运行中
+