fix(phase2): restore agent streams and persist extension installations
This commit is contained in:
@@ -23,6 +23,7 @@ backend/data/vault/验收/
|
||||
# 本机 MCP 配置、授权状态及服务器工作目录不得提交。
|
||||
backend/data/mcp/
|
||||
backend/data/extension-packages/
|
||||
backend/data/extension-installations.sqlite3*
|
||||
server.json
|
||||
servers.json
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -673,13 +673,18 @@ async def read_extension_zip(request: Request) -> bytes:
|
||||
@router.post('/skills/install-zip', response_model=Skill, status_code=202, tags=['Skills'])
|
||||
async def install_skill_zip(request: Request) -> Skill:
|
||||
data = await read_extension_zip(request)
|
||||
return extension_call(lambda: install_zip(data, 'skill', get_settings().data_dir / 'extension-packages', container.skills.install))
|
||||
return extension_call(lambda: install_zip(data, 'skill', get_settings().data_dir / 'extension-packages', container.skills.install, managed_install=lambda root, owned: container.skills.install(root, managed_root=owned)))
|
||||
|
||||
|
||||
@router.post('/plugins/install-zip', response_model=Plugin, status_code=202, tags=['Plugins'])
|
||||
async def install_plugin_zip(request: Request) -> Plugin:
|
||||
data = await read_extension_zip(request)
|
||||
return extension_call(lambda: install_zip(data, 'plugin', get_settings().data_dir / 'extension-packages', container.plugins.install))
|
||||
return extension_call(lambda: install_zip(data, 'plugin', get_settings().data_dir / 'extension-packages', container.plugins.install, managed_install=lambda root, owned: container.plugins.install(root, managed_root=owned)))
|
||||
|
||||
|
||||
@router.get('/extensions/restore-errors', tags=['Plugins', 'Skills'])
|
||||
async def extension_restore_errors():
|
||||
return {'items': container.plugins.restore_errors + container.skills.restore_errors}
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -6,6 +6,7 @@ commands:
|
||||
locations:
|
||||
- command_palette
|
||||
- context_menu
|
||||
- toolbar
|
||||
when:
|
||||
- editor.has_selection
|
||||
context:
|
||||
|
||||
@@ -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')])
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
from app.acceptance import score
|
||||
|
||||
|
||||
def segment(text, speaker='A', start=0, end=1):
|
||||
return dict(text=text, speaker=speaker, start=start, end=end)
|
||||
|
||||
|
||||
def test_exact_and_renamed_speakers():
|
||||
result = score([segment('你好 世界')], [segment('你好 世界', 'cluster_4')])
|
||||
assert result['text']['cer']['rate'] == 0
|
||||
assert result['speaker']['der'] == 0
|
||||
assert result['quality_gate'] == 'not_evaluated'
|
||||
|
||||
|
||||
def test_edits_missed_and_false_alarms():
|
||||
result = score([segment('a b')], [segment('a c', start=0, end=2)])
|
||||
assert result['text']['wer']['rate'] == 0.5
|
||||
assert result['speaker']['false_alarm_seconds'] == 1
|
||||
result = score([segment('a')], [])
|
||||
assert result['speaker']['der'] == 1
|
||||
|
||||
|
||||
def test_overlap_and_confusion():
|
||||
result = score([segment('a'), segment('b', 'B')], [segment('a')])
|
||||
assert result['speaker']['der'] == 0.5
|
||||
result = score([segment('a'), segment('b','B',1,2)], [segment('a','X',0,2)])
|
||||
assert result['speaker']['confusion_seconds'] == 1
|
||||
|
||||
|
||||
def test_requires_reference_and_valid_timing():
|
||||
with pytest.raises(ValueError): score([], [])
|
||||
with pytest.raises(ValueError): score([segment('a', end=float('nan'))], [])
|
||||
@@ -0,0 +1,68 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from app.agent.tools import ToolRegistry
|
||||
from app.extensions import SkillRuntime
|
||||
from app.extensions.installed import InstalledRuntime
|
||||
|
||||
|
||||
def package(root):
|
||||
root.mkdir(parents=True)
|
||||
(root / 'skill.yaml').write_text('skill_id: audit\nname: Audit\nversion: 1.0.0\npermissions: []\ntools: []\n', encoding='utf-8')
|
||||
return root
|
||||
|
||||
|
||||
def runtime(data):
|
||||
return InstalledRuntime(SkillRuntime(ToolRegistry()), 'skill', data)
|
||||
|
||||
|
||||
def test_restores_enabled_and_disabled_without_deleting_directory_install(tmp_path):
|
||||
root = package(tmp_path / 'user-source')
|
||||
data = tmp_path / 'data'
|
||||
first = runtime(data); first.install(root); first.enable('audit')
|
||||
second = runtime(data); second.restore()
|
||||
assert second.get('audit').enabled
|
||||
second.disable('audit')
|
||||
third = runtime(data); third.restore()
|
||||
assert not third.get('audit').enabled
|
||||
third.uninstall('audit')
|
||||
assert root.exists()
|
||||
fourth = runtime(data); fourth.restore()
|
||||
assert fourth.list() == []
|
||||
|
||||
|
||||
def test_owned_zip_removed_and_changed_packages_not_auto_enabled(tmp_path):
|
||||
data = tmp_path / 'data'
|
||||
owned = data / 'extension-packages/skill-test'
|
||||
root = package(owned / 'nested')
|
||||
first = runtime(data); first.install(root, managed_root=owned); first.enable('audit')
|
||||
(root / 'prompt.md').write_text('changed', encoding='utf-8')
|
||||
first.disable('audit')
|
||||
with pytest.raises(Exception, match='Package changed'):
|
||||
first.enable('audit')
|
||||
second = runtime(data); second.restore()
|
||||
assert second.list() == []
|
||||
assert second.restore_errors[0]['id'] == 'audit'
|
||||
first.uninstall('audit')
|
||||
assert not owned.exists()
|
||||
|
||||
|
||||
def test_rejects_claiming_user_directory_as_managed(tmp_path):
|
||||
root = package(tmp_path / 'source')
|
||||
with pytest.raises(ValueError, match='managed'):
|
||||
runtime(tmp_path / 'data').install(root, managed_root=root)
|
||||
assert root.exists()
|
||||
|
||||
|
||||
def test_builtin_disabled_plugin_does_not_break_startup():
|
||||
from app.container import build_container
|
||||
first = build_container()
|
||||
first.plugins.disable('text-tools')
|
||||
second = build_container()
|
||||
assert not second.plugins.get('text-tools').enabled
|
||||
assert second.skills.get('knowledge-assistant').missing_dependencies
|
||||
second.plugins.enable('text-tools')
|
||||
third = build_container()
|
||||
assert third.plugins.get('text-tools').enabled
|
||||
assert third.skills.get('knowledge-assistant').enabled
|
||||
for container in (first, second, third):
|
||||
container.plugins.shutdown(); container.mcp_servers.shutdown()
|
||||
@@ -1,5 +1,7 @@
|
||||
# 第三阶段实施规划:桌面容器、扩展社区与多设备同步
|
||||
|
||||
> 2026-09-06 后续修复补充:本地扩展安装登记、摘要复核恢复、ZIP 卸载清理以及工作区 context_menu/toolbar 入口已在第二阶段补丁实现。下文原始基线仍保留用于追踪;第三阶段应在此基础上完成迁移、签名、升级事务及生产隔离,不重复建设基础登记。真实质量与厂商验收仍未闭环。
|
||||
|
||||
基线日期:2026-09-06。状态:**计划,尚未交付第三阶段**。本规划以当前第二阶段代码及本地验收记录为起点;本次用户明确要求将各社区、Sync Server、Tauri / Rust 容器纳入第三阶段。未勾选项均为待实施,不以文档编写或接口命名代替实现。
|
||||
|
||||
## 1. 阶段目标与完成口径
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 第二阶段团队分工表
|
||||
|
||||
## 2026-09-06 复核修复补充(不含杨侧验收)
|
||||
|
||||
- Agent 增加断点续读、有界重连和手动恢复;连接中断不再隐藏仍在运行任务的取消入口。
|
||||
- 本地扩展安装库支持重启恢复、包摘要复核和受管理 ZIP 卸载清理。变更后的包需重新安装审查;目录安装保留用户源码。
|
||||
- 工作区已挂载编辑器右键及扩展工具栏入口,与已有 palette/详情命令共同使用后端命令校验;执行上下文保留选区快照。
|
||||
- 新增真实 Mermaid 6 图型 × 6 主题回归入口,以及参考转写 CER/WER/DER 评分和受限 Provider 连接探针。工具使用与边界见 `docs/development/第二阶段补充验收工具.md`。
|
||||
- 逐字强制对齐、多人重叠分离仍未实现;无标注录音不能完成质量验收,真实厂商专项也不能用一次连接测试替代。以下较早记录中的测试数和延期状态属于历史基线。
|
||||
|
||||
## 2026-09-05 当前完成情况补充(不含杨侧验收)
|
||||
|
||||
以下状态补充早期任务清单,历史未勾选项不再单独作为实时完成率依据。杨星萱负责的 Benchmark、检索调优、导出及函数图像不在本次验收范围。
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 第二阶段补充验收工具
|
||||
|
||||
这些工具补充证据采集,不以生成报告代替验收。没有标注的录音不能计算准确率;连接测试通过也不等于 Provider 全协议通过。
|
||||
|
||||
## 本地开发启动与扩展恢复
|
||||
|
||||
在 backend 目录执行 `.venv/Scripts/python.exe scripts/dev-server.py`。热重载仅监听 app,ZIP 解压目录不触发重载。
|
||||
|
||||
扩展安装库为应用数据目录下的 extension-installations.sqlite3。重启恢复前校验包摘要;包缺失或变化不会沿用原权限启动,管理页显示恢复提示。重新安装前需检查文件和权限。ZIP 卸载仅清理由导入器登记的管理目录,从目录安装不会删除用户源码。
|
||||
|
||||
## 转写参考数据评分
|
||||
|
||||
参考与预测文件均为 UTF-8 JSON 数组,每条包含秒单位的 start、end、text、speaker。参考必须来自人工校对或获准标注集,不能把同一份模型输出复制为参考。
|
||||
|
||||
也支持应用作业 JSON 的 segments 数组以及原生 start_time / end_time 字段,时间单位仍为秒。
|
||||
|
||||
```json
|
||||
[{"start": 0, "end": 2.5, "text": "你好 世界", "speaker": "speaker_A"}]
|
||||
```
|
||||
|
||||
```powershell
|
||||
.venv/Scripts/python.exe scripts/score-transcript.py reference.json hypothesis.json --output scores.json
|
||||
```
|
||||
|
||||
报告只保存聚合分数,不保存正文。CER 做 NFC 归一化并忽略空白;WER 按空白分词,中文连续文本优先看 CER。大小写和标点保留。空参考拒绝评分;空文本分母显示 null,不冒充 0%。比较超过 2000 万单元时拒绝,需分成较短且分别人工标注的录音进行验收。
|
||||
|
||||
说话人评分按全时间轴、零 collar、包含重叠语音计算 DER,使用一对一最优说话人映射,不要求预测编号与参考编号相同。最多 12 个说话人 ID;缺标签时不可用。该口径必须随结果保留,不能与不同 collar/UEM 规则的第三方分数直接比较。
|
||||
|
||||
脚本不设虚构达标阈值,quality_gate 固定 not_evaluated。阈值需在验收集和任务要求确定后另行批准。FAR/FRR 属于说话人验证专项,不能用这里的 DER 代替。当前 ASR 仍无逐字强制对齐和重叠分离,不因可对重叠参考评分就变成支持这些能力。
|
||||
|
||||
## Provider 专项证据
|
||||
|
||||
```powershell
|
||||
.venv/Scripts/python.exe scripts/provider-acceptance.py --provider 已配置ID --model 已配置模型ID --output provider-plan.json
|
||||
```
|
||||
|
||||
默认只生成待验收矩阵,不调用厂商。确认测试账号及可能费用后添加 `--execute`,经本地 AI Core 执行一次连接测试,不读取密钥,也不保存远端原始错误、请求头或响应正文。最多一次测试请求,不自动重试。
|
||||
|
||||
模型发现、工具往返、思考/正文流、取消、缓存命中/未命中、上下文限制、压缩仍逐项 pending,需要专门真实场景补证。overall 保持 not_accepted,禁止仅凭连接成功签署全部通过。当前用户录音无标注、各厂商专项未完整实测的状态保持不变。
|
||||
|
||||
## 浏览器回归
|
||||
|
||||
启动 frontend 后打开 `/tests/visual/mermaid-matrix.html`,使用真实 Mermaid 服务串行渲染 6 种图型 × 6 种内置/社区主题;顶部给出完成数和逐项结果。`?theme=paper-moments` 可单独检查该主题的最终外观。
|
||||
|
||||
该矩阵检查 SVG、可见尺寸、文本存在和错误;不把 DOM 文本存在当成所有字形都可见的截图结论。还需观察大图、缩放与窄屏。`/tests/visual/index.html?case=dialog` 用于弹窗内部滚动与焦点;主题预览样例涵盖输入、下拉、折叠、卡片、表格与代码。
|
||||
@@ -123,9 +123,11 @@ function close() { disarm(); viewer.value?.close(); svgHtml.value = ''; opener?.
|
||||
.diagram-controls button:hover { border-color: var(--color-accent-primary); }
|
||||
.diagram-controls button:focus-visible { outline: 2px solid var(--color-accent-primary); }
|
||||
.diagram-viewer { width: min(1200px, 94vw); max-width: 94vw; height: 85vh; padding: 16px; color: var(--color-text-primary); background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); }
|
||||
.diagram-viewer::backdrop { background: #0008; }
|
||||
.diagram-viewer header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.diagram-viewer-scroll { height: calc(100% - 64px); overflow: auto; }
|
||||
.diagram-viewer[open] { display: flex; flex-direction: column; gap: var(--space-md); overflow: hidden; }
|
||||
.diagram-viewer::backdrop { background: var(--color-background-overlay); }
|
||||
.diagram-viewer header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); flex-shrink: 0; }
|
||||
.diagram-viewer header .diagram-controls { flex-wrap: wrap; }
|
||||
.diagram-viewer-scroll { flex: 1; min-height: 0; overflow: auto; }
|
||||
.diagram-viewer-image { margin: auto; transition: width 180ms ease-out; }
|
||||
.diagram-viewer-image svg { width: 100% !important; max-width: none !important; height: auto !important; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import apiClient from '@/services/apiClient'
|
||||
import { t } from '@/i18n'
|
||||
const props = defineProps<{ kind: 'skill' | 'plugin' }>()
|
||||
const errors = ref<{ kind: string; id: string; message: string }[]>([])
|
||||
const failure = ref('')
|
||||
onMounted(async () => {
|
||||
try { errors.value = (await apiClient.get<{ items: typeof errors.value }>('/api/extensions/restore-errors')).items.filter(item => item.kind === props.kind) }
|
||||
catch { failure.value = t('无法读取扩展恢复状态。', 'Unable to read extension recovery status.') }
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="errors.length || failure" class="notice-banner" role="status">
|
||||
<p v-if="failure">{{ failure }}</p>
|
||||
<p v-for="item in errors" :key="item.id">{{ item.id }}:{{ t('启动恢复未完成,请检查包文件并重新安装;原授权不会自动用于变更后的包。', 'Startup recovery failed. Check and reinstall the package; previous grants are not applied to changed packages.') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -128,6 +128,8 @@ async function handleOpenCitation(data: Record<string, unknown>) {
|
||||
</p>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<span v-if="agentStore.connectionState === 'reconnecting'">{{ t('正在恢复连接…', 'Reconnecting…') }}</span>
|
||||
<button v-if="agentStore.connectionState === 'disconnected'" class="button-secondary" @click="agentStore.reconnect()">{{ t('恢复连接', 'Reconnect') }}</button>
|
||||
<button v-if="agentStore.isRunning" class="button-danger" @click="agentStore.cancelRun(agentStore.activeRunId!)">{{ t('取消运行', 'Cancel run') }}</button>
|
||||
<button class="button-secondary" @click="agentStore.loadRun(agentStore.activeRunId!)">重新加载</button>
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('EditorPane file switching', () => {
|
||||
|
||||
expect(store.currentFilePath).toBe('/数据结构/红黑树.md')
|
||||
expect(wrapper.text()).not.toContain('祝你写作愉快')
|
||||
})
|
||||
}, 15000) // Real Milkdown is now imported lazily; cold module transforms count toward this integration test.
|
||||
|
||||
it('applies the saved spell-check and language settings to source mode', async () => {
|
||||
const editor = useEditorStore()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
||||
const VisualMarkdownEditor = defineAsyncComponent(() => import('./VisualMarkdownEditor.vue'))
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
@@ -113,6 +113,11 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
|
||||
<template>
|
||||
<section class="media-page">
|
||||
<details class="ui-disclosure">
|
||||
<summary>{{ t('当前转写能力与验收范围', 'Transcription capabilities and validation') }}</summary>
|
||||
<p>{{ t('本地转写提供片段级时间戳与说话人聚类,不提供逐字强制对齐或重叠语音分离。聚类编号不代表已确认的真实人数。', 'Local transcription provides segment timestamps and speaker clusters, without forced word alignment or overlapping speech separation. Cluster IDs are not verified speaker counts.') }}</p>
|
||||
<p>{{ t('无参考转写或说话人标注时,只能验证功能与耗时,不能据此判断准确率。请通过播放与人工校对确认内容。', 'Without reference transcripts or speaker labels, runs validate functionality and timing, not accuracy. Review the audio and correct the transcript.') }}</p>
|
||||
</details>
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。最多 128 MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to 128 MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.') }}</p></div></header>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ExtensionRestoreNotice from '@/components/common/ExtensionRestoreNotice.vue'
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
@@ -64,6 +65,7 @@ const hasCommandContribution = computed(() =>
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<ExtensionRestoreNotice kind="plugin" />
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<ExtensionInstallDialog v-if="showInstall" kind="Plugin" :install="pluginStore.installPlugin" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
|
||||
<header class="feature-header">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ExtensionRestoreNotice from '@/components/common/ExtensionRestoreNotice.vue'
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
@@ -26,6 +27,7 @@ async function uninstall(skillId: string, name: string) {
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<ExtensionRestoreNotice kind="skill" />
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<ExtensionInstallDialog v-if="showInstall" kind="Skill" :install="skillStore.installSkill" @close="showInstall = false" @installed="showInstall = false; actionError = ''" />
|
||||
<header class="feature-header"><div><h1>{{ t('Skill 管理', 'Skill Management') }}</h1><p>{{ t('查看工作流使用的 Tool、权限、检索配置和模型要求。', 'Review the tools, permissions, retrieval settings, and model requirements used by workflows.') }}</p></div><button class="button-primary" @click="showInstall = true">{{ t('安装 Skill', 'Install Skill') }}</button></header>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import Commands from './WorkspacePluginCommands.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { listPluginCommands, executePluginCommand } from '@/services/pluginService'
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/services/pluginService', () => ({ listPluginCommands: vi.fn(), executePluginCommand: vi.fn() }))
|
||||
const command = { command_id:'inspect',plugin_id:'p', title:'Inspect', enabled:true, when:['editor.has_selection'], parameters:{type:'object',properties:{}}, locations:['context_menu','toolbar'] }
|
||||
afterEach(() => { document.body.replaceChildren(); vi.clearAllMocks() })
|
||||
it('captures source selection for context commands and sends the snapshot', async () => {
|
||||
vi.mocked(listPluginCommands).mockResolvedValue([command as any])
|
||||
vi.mocked(executePluginCommand).mockResolvedValue({ effect:{type:'notification',payload:{message:'done'}} } as any)
|
||||
const pinia = createPinia(); const editor = useEditorStore(pinia); editor.currentFilePath = '/note.md'
|
||||
const wrapper = mount(Commands,{attachTo:document.body,global:{plugins:[pinia],stubs:{AppDialog:{template:'<div><slot/></div>'}}},slots:{default:'<textarea class="source">abcdef</textarea>'}})
|
||||
const input = wrapper.get('textarea').element as HTMLTextAreaElement
|
||||
input.focus(); input.setSelectionRange(1,4)
|
||||
await wrapper.get('textarea').trigger('contextmenu'); await flushPromises()
|
||||
expect(listPluginCommands).toHaveBeenCalledWith('context_menu')
|
||||
await wrapper.findAll('button').find(button=>button.text()==='Inspect')!.trigger('click')
|
||||
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||
expect(executePluginCommand).toHaveBeenCalledWith('inspect',{},expect.objectContaining({selection:'bcd',file_path:'/note.md'}))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('filters disabled commands and invalidates an open form when changing file', async () => {
|
||||
vi.mocked(listPluginCommands).mockResolvedValue([{...command,when:[],enabled:false} as any])
|
||||
const pinia=createPinia(); const editor=useEditorStore(pinia); editor.currentFilePath='/one.md'
|
||||
const wrapper=mount(Commands,{global:{plugins:[pinia],stubs:{AppDialog:{template:'<div><slot/></div>'}}}})
|
||||
await wrapper.get('button').trigger('click'); await flushPromises()
|
||||
expect(listPluginCommands).toHaveBeenCalledWith('toolbar')
|
||||
expect(wrapper.text()).not.toContain('Inspect')
|
||||
editor.currentFilePath='/two.md'; await flushPromises()
|
||||
expect(wrapper.find('section.modal').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AppDialog from '@/components/common/AppDialog.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { usePluginStore } from '@/stores/plugin'
|
||||
import { listPluginCommands, executePluginCommand } from '@/services/pluginService'
|
||||
import { applyCommandEffect, commandFields, initialArguments, coerceArgument, cleanArguments, missingRequiredFields } from '@/services/pluginCommandForm'
|
||||
import type { PluginCommand, PluginCommandContext, PluginCommandLocation } from '@/contracts'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const editor = useEditorStore(), workspace = useWorkspaceStore(), plugins = usePluginStore(), router = useRouter()
|
||||
const open = ref(false), busy = ref(false), error = ref(''), notice = ref('')
|
||||
const commands = ref<PluginCommand[]>([]), selected = ref<PluginCommand | null>(null)
|
||||
const args = ref<Record<string, unknown>>({})
|
||||
const snapshot = ref<PluginCommandContext>({ vault_id: null, note_id: null, file_path: null, selection: null })
|
||||
let revision = 0
|
||||
function capture() {
|
||||
const input = document.activeElement
|
||||
const selection = input instanceof HTMLTextAreaElement
|
||||
? input.value.slice(input.selectionStart, input.selectionEnd)
|
||||
: window.getSelection()?.toString() || ''
|
||||
snapshot.value = { vault_id: workspace.hasVault ? workspace.vaultId : null, note_id: editor.currentNoteId, file_path: editor.currentFilePath, selection: selection || null }
|
||||
}
|
||||
function available(command: PluginCommand) {
|
||||
const context = snapshot.value
|
||||
return command.enabled && command.when.every(condition => ({
|
||||
'workspace.has_vault': Boolean(context.vault_id), 'editor.has_note': Boolean(context.note_id), 'editor.has_selection': Boolean(context.selection),
|
||||
})[condition])
|
||||
}
|
||||
async function show(location: PluginCommandLocation) {
|
||||
const version = ++revision
|
||||
open.value = true; error.value = ''; selected.value = null; commands.value = []
|
||||
try {
|
||||
const result = await listPluginCommands(location)
|
||||
if (version === revision) commands.value = result.filter(available)
|
||||
} catch (reason) { if (version === revision) error.value = String(reason) }
|
||||
}
|
||||
function contextMenu(event: MouseEvent) {
|
||||
if (!(event.target instanceof Element) || !event.target.closest('.ProseMirror, .source')) return
|
||||
event.preventDefault(); capture(); void show('context_menu')
|
||||
}
|
||||
function choose(command: PluginCommand) { selected.value = command; args.value = initialArguments(command) }
|
||||
function close() { if (!busy.value) { open.value = false; revision++ } }
|
||||
watch(() => editor.currentFilePath, () => { open.value = false; revision++ })
|
||||
watch(() => plugins.plugins, () => { if (!busy.value) close() }, { deep: true })
|
||||
const fields = computed(() => selected.value ? commandFields(selected.value) : [])
|
||||
async function run() {
|
||||
const command = selected.value
|
||||
if (!command || busy.value || !available(command)) return
|
||||
if (snapshot.value.file_path !== editor.currentFilePath) { close(); return }
|
||||
if (missingRequiredFields(command, args.value).length) { error.value = t('请填写必填参数', 'Complete required fields'); return }
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
// Runtime rechecks enabled state, schema, when conditions and permissions.
|
||||
const result = await executePluginCommand(command.command_id, cleanArguments(args.value), { ...snapshot.value })
|
||||
await applyCommandEffect(result.effect, {
|
||||
navigate: path => router.push(path),
|
||||
refresh: async scope => {
|
||||
if (scope === 'workspace') await workspace.refreshFileTree()
|
||||
else if (scope === 'plugins') await plugins.loadPlugins()
|
||||
else if (scope === 'commands') commands.value = (await listPluginCommands()).filter(available)
|
||||
else { plugins.selectPlugin(command.plugin_id); await router.push('/extensions/plugins') }
|
||||
},
|
||||
notify: value => { notice.value = value },
|
||||
})
|
||||
open.value = false
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : String(reason) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workspace-plugin-host" @contextmenu="contextMenu">
|
||||
<div class="workspace-plugin-toolbar" role="toolbar" :aria-label="t('扩展工具栏', 'Extension toolbar')">
|
||||
<button class="button-secondary" @pointerdown.prevent="capture" @click="event => { if (!event.detail) capture(); show('toolbar') }">{{ t('扩展命令', 'Extension commands') }}</button>
|
||||
<span v-if="notice" role="status">{{ notice }}</span>
|
||||
</div>
|
||||
<slot />
|
||||
<AppDialog v-if="open" :label="t('扩展命令', 'Extension commands')" :dismissible="!busy" @close="close">
|
||||
<section class="modal">
|
||||
<div class="section-head"><h2>{{ t('扩展命令', 'Extension commands') }}</h2><button class="button-secondary" :disabled="busy" @click="close">{{ t('关闭', 'Close') }}</button></div>
|
||||
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
|
||||
<div class="workspace-command-list">
|
||||
<button v-for="command in commands" :key="command.command_id" class="button-secondary" :disabled="busy" :aria-pressed="selected?.command_id === command.command_id" @click="choose(command)">{{ command.title }}</button>
|
||||
<p v-if="!commands.length">{{ t('当前上下文没有可用的扩展命令。', 'No extension commands are available in this context.') }}</p>
|
||||
</div>
|
||||
<form v-if="selected" @submit.prevent="run">
|
||||
<p>{{ selected.description }}</p>
|
||||
<label v-for="field in fields" :key="field.key" class="form-field">
|
||||
<span>{{ field.title }}{{ field.required ? ' *' : '' }}</span>
|
||||
<select v-if="field.enum || field.type === 'boolean'" class="select" :value="String(args[field.key] ?? '')" @change="args[field.key] = coerceArgument(field, ($event.target as HTMLSelectElement).value)">
|
||||
<option value="">{{ t('请选择', 'Select') }}</option><option v-for="value in field.enum || ['false', 'true']" :key="value" :value="value">{{ value }}</option>
|
||||
</select>
|
||||
<input v-else class="input" :required="field.required" :type="['number','integer'].includes(field.type) ? 'number' : 'text'" :value="String(args[field.key] ?? '')" @input="args[field.key] = coerceArgument(field, ($event.target as HTMLInputElement).value)" />
|
||||
<small>{{ field.description }}</small>
|
||||
</label>
|
||||
<button class="button-primary" :disabled="busy">{{ t('执行', 'Run') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-plugin-host { display: contents; }
|
||||
.workspace-plugin-toolbar { display: flex; gap: var(--space-sm); align-items: center; padding: var(--space-xs) var(--space-md); background: var(--color-background-secondary); border-bottom: 1px solid var(--color-border-default); }
|
||||
.workspace-plugin-toolbar span { overflow-wrap: anywhere; font-size: var(--font-size-sm); }
|
||||
.workspace-command-list, form { display: grid; gap: var(--space-sm); margin-block: var(--space-md); }
|
||||
.section-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); }
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import EditorHeader from '@/features/editor/EditorHeader.vue'
|
||||
import EditorPane from '@/features/editor/EditorPane.vue'
|
||||
import WorkspacePluginCommands from './WorkspacePluginCommands.vue'
|
||||
import { EditPen } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
@@ -13,7 +14,7 @@ const workspaceStore = useWorkspaceStore()
|
||||
<div class="workspace-view">
|
||||
<template v-if="workspaceStore.activeFilePath">
|
||||
<EditorHeader />
|
||||
<EditorPane />
|
||||
<WorkspacePluginCommands><EditorPane /></WorkspacePluginCommands>
|
||||
</template>
|
||||
<div v-else class="empty-workspace">
|
||||
<div class="empty-content">
|
||||
|
||||
@@ -59,12 +59,16 @@ export function initialArguments(command: PluginCommand): Record<string, unknown
|
||||
|
||||
/** 按字段类型把输入框的字符串转成 schema 期望的类型。 */
|
||||
export function coerceArgument(field: CommandField, raw: string): unknown {
|
||||
if (raw.trim() === '') return undefined
|
||||
if (field.type === 'boolean') return raw === 'true'
|
||||
if (field.type === 'number' || field.type === 'integer') {
|
||||
if (raw.trim() === '') return undefined
|
||||
const parsed = Number(raw)
|
||||
return Number.isNaN(parsed) ? undefined : parsed
|
||||
}
|
||||
if (field.type === 'object' || field.type === 'array') {
|
||||
try { return JSON.parse(raw) } catch { return raw } // Backend reports the schema error without discarding the input.
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export class SseClient {
|
||||
this.controller.abort()
|
||||
}
|
||||
|
||||
// TODO(streaming): 桌面网络策略确定后,在 Store 层增加有上限的指数退避重连。
|
||||
// 传输层不自动重试 POST;Agent Store 使用 sequence 游标执行有界 GET 重连。
|
||||
|
||||
isConnected() {
|
||||
return this.connected
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onScopeDispose } from 'vue'
|
||||
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
|
||||
import * as agentService from '@/services/agentService'
|
||||
import type { SseClient } from '@/services/sseClient'
|
||||
@@ -17,6 +17,29 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
const error = ref<string | null>(null)
|
||||
let eventStream: SseClient | null = null
|
||||
let selectionVersion = 0
|
||||
let streamVersion = 0
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let retryCount = 0
|
||||
const seenSequences = new Set<number>()
|
||||
let lastSequence = -1
|
||||
const connectionState = ref<'idle' | 'connected' | 'reconnecting' | 'disconnected'>('idle')
|
||||
const terminal = (status?: string) => ['completed', 'failed', 'cancelled'].includes(status || '')
|
||||
|
||||
function stopStream() {
|
||||
streamVersion++
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
eventStream?.cancel()
|
||||
eventStream = null
|
||||
}
|
||||
function resetEvents() {
|
||||
events.value = []
|
||||
toolCalls.value = []
|
||||
seenSequences.clear()
|
||||
lastSequence = -1
|
||||
retryCount = 0
|
||||
}
|
||||
onScopeDispose(stopStream)
|
||||
|
||||
const activeRun = computed(() =>
|
||||
runs.value.find((r) => r.run_id === activeRunId.value) || null
|
||||
@@ -42,10 +65,9 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
|
||||
async function loadRun(runId: string) {
|
||||
const version = ++selectionVersion
|
||||
eventStream?.cancel()
|
||||
stopStream()
|
||||
activeRunId.value = runId
|
||||
events.value = []
|
||||
toolCalls.value = []
|
||||
resetEvents()
|
||||
permissionRequest.value = null
|
||||
isRunning.value = false
|
||||
const run = await agentService.getAgentRun(runId)
|
||||
@@ -61,9 +83,14 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
|
||||
function processEvent(event: AgentEvent) {
|
||||
// 服务端会先回放历史再发送实时事件,以 run_id + sequence 去重保证幂等。
|
||||
if (events.value.some((item) => item.run_id === event.run_id && item.sequence === event.sequence)) return
|
||||
events.value.push(event)
|
||||
events.value.sort((a, b) => a.sequence - b.sequence)
|
||||
if (seenSequences.has(event.sequence)) return
|
||||
seenSequences.add(event.sequence)
|
||||
if (event.sequence > lastSequence) events.value.push(event)
|
||||
else {
|
||||
const index = events.value.findIndex(item => item.sequence > event.sequence)
|
||||
events.value.splice(index < 0 ? events.value.length : index, 0, event)
|
||||
}
|
||||
lastSequence = Math.max(lastSequence, event.sequence)
|
||||
const data = event.data
|
||||
const run = runs.value.find((item) => item.run_id === event.run_id)
|
||||
if (event.event === 'RunStarted' && run) run.status = 'running'
|
||||
@@ -108,15 +135,49 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
}
|
||||
|
||||
function subscribe(runId: string) {
|
||||
// 任一时刻只保留当前运行的事件流,防止切换详情后旧事件污染新页面。
|
||||
eventStream?.cancel()
|
||||
isRunning.value = true
|
||||
error.value = null
|
||||
stopStream()
|
||||
const version = streamVersion
|
||||
const current = () => activeRunId.value === runId && version === streamVersion
|
||||
isRunning.value = !terminal(activeRun.value?.status)
|
||||
const interrupted = (cause?: Error) => {
|
||||
if (!current() || retryTimer) return
|
||||
eventStream?.cancel()
|
||||
eventStream = null
|
||||
if (terminal(activeRun.value?.status)) {
|
||||
isRunning.value = false
|
||||
connectionState.value = 'idle'
|
||||
return
|
||||
}
|
||||
error.value = cause?.message || t('事件连接中断', 'Event connection interrupted')
|
||||
connectionState.value = retryCount >= 5 ? 'disconnected' : 'reconnecting'
|
||||
if (retryCount >= 5) return
|
||||
const delay = Math.min(1000 * 2 ** retryCount++, 16000)
|
||||
retryTimer = setTimeout(async () => {
|
||||
retryTimer = null
|
||||
try {
|
||||
const run = await agentService.getAgentRun(runId)
|
||||
if (!current()) return
|
||||
const index = runs.value.findIndex(item => item.run_id === runId)
|
||||
if (index >= 0) runs.value[index] = run
|
||||
// 即使已结束仍续读一次缺失的尾部事件,保留完整 Trace。
|
||||
subscribe(runId)
|
||||
} catch (cause) {
|
||||
if (current()) interrupted(cause instanceof Error ? cause : new Error(String(cause)))
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
eventStream = agentService.streamAgentEvents(runId, {
|
||||
onEvent(event) { if (activeRunId.value === runId) processEvent(event) },
|
||||
onError(streamError) { if (activeRunId.value === runId) { error.value = streamError.message; isRunning.value = false } },
|
||||
onDone() { if (activeRunId.value === runId) { isRunning.value = false; eventStream = null } },
|
||||
})
|
||||
onOpen() { if (current()) { connectionState.value = 'connected'; error.value = null } },
|
||||
onEvent(event) { if (current()) processEvent(event) },
|
||||
onError: interrupted,
|
||||
onDone() { interrupted() },
|
||||
}, lastSequence)
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
if (!activeRunId.value) return
|
||||
retryCount = 0
|
||||
subscribe(activeRunId.value)
|
||||
}
|
||||
|
||||
async function createRun(request: agentService.CreateAgentRunRequest) {
|
||||
@@ -126,8 +187,7 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
selectionVersion++
|
||||
runs.value.unshift(run)
|
||||
activeRunId.value = run.run_id
|
||||
events.value = []
|
||||
toolCalls.value = []
|
||||
resetEvents()
|
||||
subscribe(run.run_id)
|
||||
return run
|
||||
} finally {
|
||||
@@ -139,9 +199,12 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
await agentService.cancelAgentRun(runId)
|
||||
const run = runs.value.find((r) => r.run_id === runId)
|
||||
if (run) run.status = 'cancelled'
|
||||
isRunning.value = false
|
||||
eventStream?.cancel()
|
||||
eventStream = null
|
||||
if (activeRunId.value === runId) {
|
||||
isRunning.value = false
|
||||
permissionRequest.value = null
|
||||
stopStream()
|
||||
connectionState.value = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' = 'once') {
|
||||
@@ -163,6 +226,8 @@ export const useAgentStore = defineStore('agent', () => {
|
||||
permissionRequest,
|
||||
toolCalls,
|
||||
error,
|
||||
connectionState,
|
||||
reconnect,
|
||||
currentStep,
|
||||
loadTools,
|
||||
loadRuns,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia, disposePinia } from 'pinia'
|
||||
import { useAgentStore } from './agent'
|
||||
const mock = vi.hoisted(() => ({ stream: vi.fn(), get: vi.fn(), cancel: vi.fn() }))
|
||||
vi.mock('@/services/agentService', () => ({ streamAgentEvents: mock.stream, getAgentRun: mock.get, cancelAgentRun: mock.cancel }))
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); pinia = createPinia(); setActivePinia(pinia); mock.stream.mockReturnValue({ cancel: vi.fn() }); mock.get.mockImplementation(async id => ({ run_id: id, status: 'running' })) })
|
||||
afterEach(() => { disposePinia(pinia); vi.useRealTimers() })
|
||||
const handler = () => mock.stream.mock.calls.at(-1)![1]
|
||||
const event = (sequence: number, name = 'RunStarted') => ({ run_id: 'r', sequence, event: name, data: {}, timestamp: 'now' })
|
||||
it.each(['error', 'eof'])('retains cancellation and resumes after sequence on %s', async kind => {
|
||||
const store = useAgentStore(); await store.loadRun('r')
|
||||
handler().onEvent(event(5))
|
||||
kind === 'error' ? handler().onError(new Error('offline')) : handler().onDone()
|
||||
expect(store.isRunning).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(mock.stream.mock.calls.at(-1)![2]).toBe(5)
|
||||
handler().onEvent(event(5)); handler().onEvent(event(6, 'RunCompleted')); handler().onDone()
|
||||
expect(store.events).toHaveLength(2)
|
||||
expect(store.isRunning).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(40000)
|
||||
expect(mock.stream).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('invalidates old streams and pending retry when changing run or cancelling', async () => {
|
||||
const store = useAgentStore(); await store.loadRun('r')
|
||||
const old = handler(); old.onError(new Error('offline'))
|
||||
await store.loadRun('s'); old.onEvent(event(8))
|
||||
expect(store.events).toHaveLength(0)
|
||||
handler().onError(new Error('offline')); await store.cancelRun('s')
|
||||
await vi.advanceTimersByTimeAsync(40000)
|
||||
expect(mock.stream).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('bounds retry attempts and supports explicit retry without losing events', async () => {
|
||||
const store = useAgentStore(); await store.loadRun('r')
|
||||
handler().onEvent(event(1))
|
||||
for (let attempt = 0; attempt < 6; attempt++) { handler().onError(new Error('offline')); await vi.advanceTimersByTimeAsync(16000) }
|
||||
expect(mock.stream).toHaveBeenCalledTimes(6)
|
||||
expect(store.connectionState).toBe('disconnected')
|
||||
expect(store.isRunning).toBe(true)
|
||||
store.reconnect()
|
||||
expect(mock.stream.mock.calls.at(-1)![2]).toBe(1)
|
||||
})
|
||||
@@ -32,11 +32,12 @@ marked.use({extensions:[
|
||||
marked.setOptions({ gfm: true, breaks: true })
|
||||
|
||||
// Highlighter 是昂贵的单例;复用初始化 Promise,避免每个代码块重复加载语法与主题。
|
||||
const highlighter = createHighlighterCore({
|
||||
let highlighter: ReturnType<typeof createHighlighterCore> | undefined
|
||||
function getHighlighter() { return highlighter ??= createHighlighterCore({
|
||||
themes: [githubLight, githubDark],
|
||||
langs: [],
|
||||
engine: createOnigurumaEngine(import('shiki/wasm')),
|
||||
})
|
||||
}).catch(error => { highlighter = undefined; throw error }) }
|
||||
|
||||
const languageAliases = new Map(bundledLanguagesInfo.flatMap(info =>
|
||||
[info.id, info.name, ...(info.aliases ?? [])].map(alias => [alias.toLowerCase(), info.id] as const),
|
||||
@@ -45,7 +46,7 @@ const languageLoads = new Map<string, Promise<void>>()
|
||||
const languageLoaders = new Map(bundledLanguagesInfo.map(info => [info.id, info.import]))
|
||||
|
||||
async function loadCodeLanguage(requestedLanguage: string) {
|
||||
const shiki = await highlighter
|
||||
const shiki = await getHighlighter()
|
||||
const language = languageAliases.get(requestedLanguage.toLowerCase())
|
||||
if (!language) return { shiki, language: 'text' as const }
|
||||
let loading = languageLoads.get(language)
|
||||
@@ -133,4 +134,4 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(performance): 编辑器首屏稳定后评估将 Shiki 延迟加载或迁移到 Web Worker。
|
||||
// 高亮器首次需要代码高亮时才创建;语法保持按语言加载。Worker 可在性能测量后进一步引入。
|
||||
|
||||
@@ -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/无脚本隔离。自动结构检查不代替浏览器截图、布局和对比度检查。
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>Mermaid 真实渲染验收</title></head>
|
||||
<body><h1>Mermaid 真实渲染验收</h1><p id="status">运行中</p><main id="results"></main>
|
||||
<script type="module">
|
||||
import { renderMermaid } from '/src/services/mermaidService.ts';
|
||||
import { getCommunityThemePreviewCss, mockCommunityThemes } from '/src/services/themePackageService.ts';
|
||||
import '/src/styles/tokens.css';
|
||||
import '/src/styles/features.css';
|
||||
const fixtures = {
|
||||
flowchart: 'flowchart TD\n A[开始] --> B[完成]',
|
||||
sequence: 'sequenceDiagram\n participant A as 用户\n participant B as 服务\n A->>B: 请求\n B-->>A: 结果',
|
||||
class: 'classDiagram\n class Note {\n +String title\n }\n class Vault\n Vault --> Note',
|
||||
state: 'stateDiagram-v2\n [*] --> Ready\n Ready --> Done\n Done --> [*]',
|
||||
er: 'erDiagram\n VAULT ||--o{ NOTE : contains',
|
||||
gantt: 'gantt\n title Plan\n dateFormat YYYY-MM-DD\n section Work\n Build :a, 2026-09-01, 2d',
|
||||
};
|
||||
const allThemes = [{theme_id:'light',is_dark:false},{theme_id:'dark',is_dark:true},{theme_id:'sepia',is_dark:false},...mockCommunityThemes];
|
||||
const requested = new URLSearchParams(location.search).get('theme');
|
||||
const themes = requested ? allThemes.filter(t=>t.theme_id===requested) : allThemes;
|
||||
const style = document.createElement('style'); document.head.append(style);
|
||||
const results = [];
|
||||
for (const theme of themes) {
|
||||
document.documentElement.dataset.theme = theme.theme_id;
|
||||
style.textContent = getCommunityThemePreviewCss(theme.theme_id) || '';
|
||||
for (const [type,source] of Object.entries(fixtures)) {
|
||||
const rendered = await renderMermaid(source,{theme:theme.is_dark?'dark':'light'});
|
||||
const article = document.createElement('article'); article.className='item-card';
|
||||
const label = document.createElement('h2'); label.textContent=theme.theme_id+' / '+type;
|
||||
const preview = document.createElement('div'); preview.innerHTML=rendered.svg; preview.style.overflow='auto';
|
||||
article.append(label,preview); document.querySelector('#results').append(article);
|
||||
const svg=preview.querySelector('svg');
|
||||
const valid = !rendered.warnings.length && svg && svg.getBoundingClientRect().height>0 && svg.textContent.trim().length>0 && !svg.querySelector('.error-icon');
|
||||
results.push({theme:theme.theme_id,type,passed:Boolean(valid),warnings:rendered.warnings});
|
||||
article.dataset.passed=String(Boolean(valid));
|
||||
}
|
||||
}
|
||||
document.querySelector('#status').textContent=JSON.stringify({passed:results.filter(r=>r.passed).length,total:results.length,results});
|
||||
document.documentElement.dataset.complete='true';
|
||||
</script></body></html>
|
||||
Reference in New Issue
Block a user