feat: 完善模型用量趋势与全局手账卡片并补齐阶段验收
This commit is contained in:
@@ -173,3 +173,17 @@ css_entry: styles/theme.css
|
||||
发布主题仓库时可提供原始 `.theme` 文件链接或 ZIP 发布附件直链,不要使用仓库 HTML 浏览页面地址。下载请求不携带 Cookie 或 HTTP 登录信息,服务器需允许应用来源的 CORS 请求;暂不支持私有仓库认证。
|
||||
|
||||
下载和本地文件限制为 5 MB;ZIP 解压总大小限制为 10 MB,最多 100 个条目。URL 下载超时为 30 秒。取消导入会取消下载,过期请求不会替换当前待安装主题。更新时递增清单版本号,并保持 `theme_id` 稳定。
|
||||
|
||||
|
||||
### 主题兼容性与安装前预览
|
||||
|
||||
当前应用版本从 `frontend/package.json` 读取(0.2.0)。清单的 `version`、`min_app_version` 必须使用有效 SemVer;最低版本高于应用版本时,检查、安装和启用都会拒绝。文件、URL、ZIP 导入共用此规则。
|
||||
|
||||
导入检查通过后可点击“预览主题效果”。预览使用无脚本的 sandbox iframe,与当前应用样式和主题存储隔离;CSP 禁止远程资源,仅允许内联样式及 data 图片/字体。预览不等同于安装。
|
||||
|
||||
|
||||
### 用量趋势与纸间时光 1.5
|
||||
|
||||
模型设置页将提供商、本地模型、用量统计分成独立卡片。用量趋势支持近 7 天、30 天、90 天及自定义时间,沿用提供商/模型/来源筛选;按本机 UTC 偏移分组(长区间自动合并到最多 90 组)。可切换输入、输出、总 Token 和请求次数,本地为芯片实色图例,提供商为连接斜纹图例。仅汇总已报告值,并提供覆盖数与可展开的数据表,缺失不补零。
|
||||
|
||||
纸间时光更新至 1.5.0,通用卡片、执行事件、引用、模型路由及弹窗统一使用纸张、虚线、胶带和叠纸阴影。已安装旧版本时,在主题社区点击“更新”应用新版样式。
|
||||
|
||||
@@ -273,7 +273,7 @@ class LocalSpeech:
|
||||
from app.contracts import TranscriptSegment
|
||||
result = await runtime.infer("qwen3-asr", "transcription", {"source": str(source.resolve()), "language": language})
|
||||
return RoutedTranscript(text=result["text"], source="local",
|
||||
segments=[TranscriptSegment(**s) for s in result["segments"]])
|
||||
segments=[TranscriptSegment(**s) for s in result["segments"]], warnings=result.get("warnings", []))
|
||||
|
||||
async def match(self, source, reference):
|
||||
result = await runtime.infer("eres2netv2", "speaker_matching",
|
||||
|
||||
@@ -9,27 +9,49 @@ import threading
|
||||
import time
|
||||
|
||||
|
||||
def decode(path, *, limit_seconds=3600):
|
||||
def decode(path, *, limit_seconds=3600, warnings=None):
|
||||
import av
|
||||
import numpy as np
|
||||
frames = []
|
||||
samples = 0
|
||||
corrupt = 0
|
||||
with av.open(path, options={"protocol_whitelist": "file,pipe"}) as container:
|
||||
if not container.streams.audio:
|
||||
raise ValueError("Media has no audio track")
|
||||
resampler = av.AudioResampler(format="fltp", layout="mono", rate=16000)
|
||||
for frame in container.decode(audio=0):
|
||||
for output in resampler.resample(frame):
|
||||
audio = output.to_ndarray().reshape(-1)
|
||||
samples += len(audio)
|
||||
for packet in container.demux(audio=0):
|
||||
try:
|
||||
decoded = packet.decode()
|
||||
except av.error.InvalidDataError:
|
||||
corrupt += 1
|
||||
if corrupt > 100:
|
||||
raise ValueError("Too many damaged audio packets")
|
||||
# Retain the missing packet's duration as silence so later timestamps do not shift.
|
||||
missing = max(0, round(float((packet.duration or 0) * (packet.time_base or 0)) * 16000))
|
||||
samples += missing
|
||||
if samples > limit_seconds * 16000:
|
||||
raise ValueError("Audio exceeds one hour")
|
||||
frames.append(audio)
|
||||
if missing:
|
||||
frames.append(np.zeros(missing, dtype=np.float32))
|
||||
continue
|
||||
for frame in decoded:
|
||||
for output in resampler.resample(frame):
|
||||
audio = output.to_ndarray().reshape(-1)
|
||||
samples += len(audio)
|
||||
if samples > limit_seconds * 16000:
|
||||
raise ValueError("Audio exceeds one hour")
|
||||
frames.append(audio)
|
||||
for output in resampler.resample(None):
|
||||
frames.append(output.to_ndarray().reshape(-1))
|
||||
audio = output.to_ndarray().reshape(-1)
|
||||
samples += len(audio)
|
||||
if samples > limit_seconds * 16000:
|
||||
raise ValueError("Audio exceeds one hour")
|
||||
frames.append(audio)
|
||||
if not frames:
|
||||
raise ValueError("Audio is empty")
|
||||
audio = np.concatenate(frames).astype(np.float32)
|
||||
if corrupt and warnings is not None:
|
||||
warnings.append(f"MEDIA_CORRUPT_PACKETS_SKIPPED:{corrupt}")
|
||||
if not np.isfinite(audio).all() or len(audio) < 1600:
|
||||
raise ValueError("Invalid or too short audio")
|
||||
return audio
|
||||
@@ -125,7 +147,8 @@ def run(request):
|
||||
model = Qwen3ASRModel.from_pretrained(path, dtype=torch.float32 if device == "cpu" else torch.float16,
|
||||
device_map=device, attn_implementation="sdpa", max_inference_batch_size=1, max_new_tokens=512)
|
||||
loaded = time.monotonic()
|
||||
audio = decode(payload["source"])
|
||||
decode_warnings = []
|
||||
audio = decode(payload["source"], warnings=decode_warnings)
|
||||
audio_seconds = len(audio) / 16000
|
||||
regions = speech_regions(audio)
|
||||
language = {"zh": "Chinese", "en": "English", "ja": "Japanese", "yue": "Cantonese"}.get(payload.get("language"), payload.get("language"))
|
||||
@@ -137,7 +160,7 @@ def run(request):
|
||||
"end_time": end / 16000, "text": output.text, "language": output.language})
|
||||
sys.__stdout__.write(json.dumps({"progress": end / len(audio), "segment": segments[-1]}, ensure_ascii=False) + "\n")
|
||||
sys.__stdout__.flush()
|
||||
result = {"text": "\n".join(s["text"] for s in segments), "segments": segments}
|
||||
result = {"text": "\n".join(s["text"] for s in segments), "segments": segments, "warnings": decode_warnings}
|
||||
elif operation == "speaker_matching":
|
||||
model = speaker_model(path, device)
|
||||
loaded = time.monotonic()
|
||||
|
||||
@@ -18,7 +18,9 @@ from app.services import transcription_service as jobs
|
||||
from app.services.attachment_service import attachment_path
|
||||
|
||||
router = APIRouter(prefix="/api/media", tags=["Media"])
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
||||
from app.providers.routing import MAX_LOCAL_MEDIA_BYTES
|
||||
|
||||
MAX_UPLOAD_BYTES = MAX_LOCAL_MEDIA_BYTES
|
||||
MEDIA_SUFFIXES = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".mp4", ".webm", ".txt", ".md"}
|
||||
|
||||
|
||||
@@ -40,7 +42,7 @@ async def upload_attachment(request: Request, filename: str = Query(min_length=1
|
||||
async for chunk in request.stream():
|
||||
size += len(chunk)
|
||||
if size > MAX_UPLOAD_BYTES:
|
||||
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment exceeds 25 MiB.")
|
||||
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment exceeds 128 MiB.")
|
||||
digest.update(chunk)
|
||||
stream.write(chunk)
|
||||
if not size:
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.retrieval.provenance import record_embedding
|
||||
CAPABILITIES = ("embedding", "transcription", "speaker_matching")
|
||||
HTTP_TYPES = {ProviderType.openai_chat, ProviderType.openai_compatible}
|
||||
MAX_MEDIA_BYTES = 25 * 1024 * 1024
|
||||
MAX_LOCAL_MEDIA_BYTES = 128 * 1024 * 1024
|
||||
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -58,6 +59,7 @@ class RoutedTranscript:
|
||||
source: str
|
||||
fallback_reason: str | None = None
|
||||
segments: list = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def invalid_response() -> ProviderError:
|
||||
@@ -276,21 +278,22 @@ class ModelRoutingService:
|
||||
dimensions=local_embedding.dim, fallback_reason=reason)
|
||||
|
||||
@staticmethod
|
||||
def _media_file(path: Path):
|
||||
def _media_file(path: Path, *, local_only: bool = False):
|
||||
try:
|
||||
handle = path.open("rb")
|
||||
except OSError as exc:
|
||||
raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Audio attachment was not found.") from exc
|
||||
import os
|
||||
if not 0 < os.fstat(handle.fileno()).st_size <= MAX_MEDIA_BYTES:
|
||||
limit = MAX_LOCAL_MEDIA_BYTES if local_only else MAX_MEDIA_BYTES
|
||||
if not 0 < os.fstat(handle.fileno()).st_size <= limit:
|
||||
handle.close()
|
||||
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Audio attachment must be between 1 byte and 25 MiB.")
|
||||
raise ApiError(413, "ATTACHMENT_TOO_LARGE", f"Audio attachment must be between 1 byte and {limit // (1024 * 1024)} MiB.")
|
||||
return handle
|
||||
|
||||
async def transcribe(self, source: Path, language: str | None, *, local_only: bool = False) -> RoutedTranscript:
|
||||
binding = None if local_only else self.configuration().transcription
|
||||
if binding is None:
|
||||
with self._media_file(source):
|
||||
with self._media_file(source, local_only=local_only):
|
||||
pass
|
||||
reason = None
|
||||
if binding:
|
||||
@@ -343,7 +346,7 @@ class ModelRoutingService:
|
||||
async def match_speakers(self, source: Path, reference: Path, *, local_only: bool = False) -> SpeakerMatchResult:
|
||||
binding = None if local_only else self.configuration().speaker_matching
|
||||
if binding is None:
|
||||
with self._media_file(source), self._media_file(reference):
|
||||
with self._media_file(source, local_only=local_only), self._media_file(reference, local_only=local_only):
|
||||
pass
|
||||
reason = None
|
||||
if binding:
|
||||
|
||||
@@ -82,8 +82,9 @@ async def create_transcription(attachment_id, language=None, *, diarization=Fals
|
||||
actual = source if source.is_file() else attachment_path(f"{attachment_id}.txt")
|
||||
if not actual.is_file():
|
||||
raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Attachment was not found.")
|
||||
if not 0 < actual.stat().st_size <= 25 * 1024 * 1024:
|
||||
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment must be between 1 byte and 25 MiB.")
|
||||
from app.providers.routing import MAX_LOCAL_MEDIA_BYTES, MAX_MEDIA_BYTES
|
||||
if not 0 < actual.stat().st_size <= (MAX_LOCAL_MEDIA_BYTES if local_only else MAX_MEDIA_BYTES):
|
||||
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "仅本地处理最大支持 128 MiB;超过 25 MiB 的录音请启用仅本地处理。")
|
||||
digest = await asyncio.to_thread(lambda: hashlib.sha256(actual.read_bytes()).hexdigest())
|
||||
from app.container import container
|
||||
from app.local_models.runtime import configuration
|
||||
@@ -160,6 +161,7 @@ async def _execute(job_id, request, routing=None):
|
||||
result = await (routing or container.model_routing).transcribe(source, request.language, local_only=request.local_only)
|
||||
job.text, job.source, job.fallback_reason = result.text, result.source, result.fallback_reason
|
||||
job.segments = getattr(result, "segments", []) or []
|
||||
job.warnings.extend(getattr(result, "warnings", []) or [])
|
||||
if not job.text or not job.text.strip():
|
||||
raise ApiError(422, "TRANSCRIPT_EMPTY", "Transcript is empty.")
|
||||
if request.diarization:
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
import math
|
||||
from contextlib import closing
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from app.database.db import connect
|
||||
@@ -107,8 +107,8 @@ class UsageAttempt:
|
||||
logger.warning("Usage persistence failed; model response remains available")
|
||||
|
||||
|
||||
def aggregate(start, end, provider_id=None, model=None, source=None):
|
||||
query = "SELECT counters_json,completed,capability FROM model_usage WHERE started_at>=? AND started_at<?"
|
||||
def aggregate(start, end, provider_id=None, model=None, source=None, timezone_offset=0):
|
||||
query = "SELECT counters_json,completed,capability,started_at,source FROM model_usage WHERE started_at>=? AND started_at<?"
|
||||
args = [start.astimezone(timezone.utc).isoformat(), end.astimezone(timezone.utc).isoformat()]
|
||||
for column, value in (("provider_id", provider_id), ("model", model), ("source", source)):
|
||||
if value:
|
||||
@@ -117,6 +117,18 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
|
||||
with closing(connection()) as conn:
|
||||
rows = conn.execute(query, args).fetchall()
|
||||
options = conn.execute("SELECT DISTINCT provider_id,model,source FROM model_usage ORDER BY provider_id,model").fetchall()
|
||||
# Calendar buckets use the caller's UTC offset; absent counters remain null.
|
||||
zone = timezone(timedelta(minutes=timezone_offset))
|
||||
first = start.astimezone(zone).date()
|
||||
last = (end - timedelta(microseconds=1)).astimezone(zone).date()
|
||||
days = (last - first).days + 1
|
||||
step = max(1, (days + 89) // 90)
|
||||
series = []
|
||||
for offset in range(0, days, step):
|
||||
date = first + timedelta(days=offset)
|
||||
series.append({"date": date.isoformat(), "end_date": (first + timedelta(days=min(days-1, offset+step-1))).isoformat(),
|
||||
"local": {"requests": 0, "totals": {key: None for key in METRICS}, "coverage": {key: 0 for key in METRICS}},
|
||||
"api": {"requests": 0, "totals": {key: None for key in METRICS}, "coverage": {key: 0 for key in METRICS}}})
|
||||
totals = {key: None for key in METRICS}
|
||||
coverage = {key: 0 for key in METRICS}
|
||||
hits, eligible_input, cache_requests = 0, 0, 0
|
||||
@@ -125,6 +137,13 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
|
||||
if row[2] in {"transcription", "speaker_matching"}:
|
||||
audio_requests += 1
|
||||
counts = json.loads(row[0])
|
||||
date = datetime.fromisoformat(row[3]).astimezone(zone).date()
|
||||
bucket = series[(date - first).days // step][row[4]]
|
||||
bucket['requests'] += 1
|
||||
for key in METRICS:
|
||||
if counts.get(key) is not None:
|
||||
bucket['totals'][key] = (bucket['totals'][key] or 0) + counts[key]
|
||||
bucket['coverage'][key] += 1
|
||||
if counts.get("audio_seconds") is not None:
|
||||
audio_covered += 1
|
||||
audio_seconds = (audio_seconds or 0) + counts["audio_seconds"]
|
||||
@@ -140,4 +159,4 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
|
||||
"complete_requests": sum(row[1] for row in rows), "cache_covered_requests": cache_requests,
|
||||
"cache_hit_rate": hits / eligible_input if eligible_input else None,
|
||||
"options": [dict(row) for row in options], "start": start, "end": end,
|
||||
"scope": "application_observed_usage"}
|
||||
"scope": "application_observed_usage", "series": series, "timezone_offset": timezone_offset}
|
||||
|
||||
@@ -9,11 +9,11 @@ router = APIRouter(prefix="/api/usage", tags=["Usage"])
|
||||
@router.get("")
|
||||
async def usage(start: datetime | None = None, end: datetime | None = None,
|
||||
provider_id: str | None = Query(None, max_length=200), model: str | None = Query(None, max_length=200),
|
||||
source: str | None = None):
|
||||
source: str | None = None, timezone_offset: int = Query(0, ge=-840, le=840)):
|
||||
end = end or datetime.now(timezone.utc)
|
||||
start = start or end - timedelta(days=7)
|
||||
if not start.tzinfo or not end.tzinfo or end <= start:
|
||||
raise ApiError(422, "INVALID_TIME_RANGE", "Provide timezone-aware start/end with end after start.")
|
||||
if source not in {None, "local", "api"}:
|
||||
raise ApiError(422, "INVALID_USAGE_SOURCE", "Unknown usage source.")
|
||||
return aggregate(start, end, provider_id, model, source)
|
||||
return aggregate(start, end, provider_id, model, source, timezone_offset)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.errors import ApiError
|
||||
from app.providers.routing import ModelRoutingService, MAX_LOCAL_MEDIA_BYTES, MAX_MEDIA_BYTES, RoutedTranscript
|
||||
from app.services import transcription_service as jobs
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def test_large_media_requires_local_only_and_respects_size_limit():
|
||||
path = get_settings().attachments_path / 'large.mp3'
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open('wb') as file:
|
||||
file.truncate(MAX_MEDIA_BYTES + 1)
|
||||
with pytest.raises(ApiError):
|
||||
ModelRoutingService._media_file(path)
|
||||
with ModelRoutingService._media_file(path, local_only=True):
|
||||
pass
|
||||
with pytest.raises(ApiError):
|
||||
asyncio.run(jobs.create_transcription('large.mp3', local_only=False))
|
||||
with path.open('wb') as file:
|
||||
file.truncate(MAX_LOCAL_MEDIA_BYTES + 1)
|
||||
with pytest.raises(ApiError):
|
||||
ModelRoutingService._media_file(path, local_only=True)
|
||||
|
||||
|
||||
def test_decode_recovers_one_corrupt_packet_without_shifting_following_audio(monkeypatch):
|
||||
from app.local_models.worker import decode
|
||||
class Samples(list):
|
||||
def reshape(self, *_): return self
|
||||
def astype(self, *_): return self
|
||||
def to_ndarray(self): return self
|
||||
class InvalidDataError(Exception): pass
|
||||
def broken(): raise InvalidDataError()
|
||||
packets = [SimpleNamespace(decode=lambda: [Samples([1] * 3200)]),
|
||||
SimpleNamespace(decode=broken, duration=100, time_base=.001),
|
||||
SimpleNamespace(decode=lambda: [Samples([2] * 3200)])]
|
||||
container = SimpleNamespace(streams=SimpleNamespace(audio=[1]), demux=lambda **_: iter(packets))
|
||||
fake_av = SimpleNamespace(open=lambda *_a, **_kw: nullcontext(container),
|
||||
error=SimpleNamespace(InvalidDataError=InvalidDataError),
|
||||
AudioResampler=lambda **_: SimpleNamespace(resample=lambda frame: [] if frame is None else [frame]))
|
||||
fake_numpy = SimpleNamespace(float32=float, zeros=lambda count, **_: Samples([0] * count),
|
||||
concatenate=lambda frames: Samples(value for frame in frames for value in frame),
|
||||
isfinite=lambda _: SimpleNamespace(all=lambda: True))
|
||||
monkeypatch.setitem(sys.modules, 'av', fake_av)
|
||||
monkeypatch.setitem(sys.modules, 'numpy', fake_numpy)
|
||||
warnings = []
|
||||
output = decode('test.mp3', warnings=warnings)
|
||||
assert output == [1] * 3200 + [0] * 1600 + [2] * 3200
|
||||
assert warnings == ['MEDIA_CORRUPT_PACKETS_SKIPPED:1']
|
||||
with pytest.raises(ValueError, match='one hour'):
|
||||
decode('test.mp3', limit_seconds=.25)
|
||||
|
||||
|
||||
def test_decode_warning_reaches_persisted_job(monkeypatch):
|
||||
from app.container import container
|
||||
path = get_settings().attachments_path / 'audio.mp3'
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b'audio')
|
||||
async def transcribe(*_args, **_kwargs):
|
||||
return RoutedTranscript(text='decoded', source='local', warnings=['MEDIA_CORRUPT_PACKETS_SKIPPED:1'])
|
||||
monkeypatch.setattr(container.model_routing, 'transcribe', transcribe)
|
||||
job = asyncio.run(jobs.create_transcription('audio.mp3', local_only=True))
|
||||
assert job.status == 'completed'
|
||||
assert jobs.require_job(job.job_id).warnings == ['MEDIA_CORRUPT_PACKETS_SKIPPED:1']
|
||||
@@ -92,3 +92,25 @@ def test_real_adapter_body_and_usage_persistence():
|
||||
result = summary()
|
||||
assert result["request_count"] == 1 and result["totals"]["input_tokens"] == 10
|
||||
assert result["complete_requests"] == 1
|
||||
|
||||
|
||||
def test_usage_calendar_series_splits_sources_and_preserves_missing_counters():
|
||||
start = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||
for source, hour, count in [('local', 15, 0), ('api', 16, 12), ('api', 17, None)]:
|
||||
attempt = UsageAttempt('p', 'm', 'openai_compatible', source=source)
|
||||
attempt.started_at = (start + timedelta(hours=hour)).isoformat()
|
||||
if count is not None:
|
||||
attempt.observe({'usage': {'input_tokens': count}})
|
||||
attempt.persist()
|
||||
result = aggregate(start, start + timedelta(days=2), timezone_offset=480)
|
||||
assert result['series'][0]['local']['totals']['input_tokens'] == 0
|
||||
second = result['series'][1]
|
||||
assert second['date'] == '2026-09-02'
|
||||
assert second['api']['requests'] == 2
|
||||
assert second['api']['totals']['input_tokens'] == 12
|
||||
assert second['api']['coverage']['input_tokens'] == 1
|
||||
assert second['api']['totals']['output_tokens'] is None
|
||||
assert sum(b['api']['requests'] + b['local']['requests'] for b in result['series']) == result['request_count']
|
||||
filtered = aggregate(start, start + timedelta(days=2), source='local', timezone_offset=480)
|
||||
assert all(b['api']['requests'] == 0 for b in filtered['series'])
|
||||
assert len(aggregate(start, start + timedelta(days=3660))['series']) <= 90
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 第二阶段团队分工表
|
||||
|
||||
## 2026-09-05 当前完成情况补充(不含杨侧验收)
|
||||
|
||||
以下状态补充早期任务清单,历史未勾选项不再单独作为实时完成率依据。杨星萱负责的 Benchmark、检索调优、导出及函数图像不在本次验收范围。
|
||||
|
||||
- A/B/C/C.1/D 基础工程已落地;E 的离线协议验证已通过,真实目标厂商专项待完成。
|
||||
- Theme:补齐 SemVer 最低版本拒绝、文件/URL/ZIP 共用校验及安装前隔离视觉预览。
|
||||
- Mermaid:真实编辑器及 Markdown 预览接入缩放、重置、大图查看;编辑器预览复制导致的异步标记丢失已修复。
|
||||
- Agent Trace:支持关键词、事件类型、工具及仅错误筛选,保留树的祖先节点和引用定位。
|
||||
- F:本地 Qwen3-ASR、ERes2NetV2、Bekko 已完成 37 分 16 秒录音的 CUDA 转写、片段聚类、笔记及向量检索闭环,约 661 秒生成 441 片段;无参考标注,质量专项保持未完成。
|
||||
- Provider:卡片显示启用状态,支持直接启停与保存失败反馈。
|
||||
- Plugin context_menu/toolbar 按已有本地计划仍是后续增强,当前实际挂载 command_palette 与详情命令;不将声明 Contract 视为已挂载。
|
||||
- 逐字强制对齐、多人重叠语音未实现;说话人阈值、准确率、真实厂商专项未验收。Tauri/Rust、生产沙箱仍属于后续阶段。
|
||||
|
||||
本轮自动化基线为后端 577 项、前端 280 项及前端生产构建通过。长录音无标注,测试通过不等于质量或所有第二阶段专项全部完成。详细证据见 `docs/development/阶段F收尾验收记录.md`;本地运行文件不提交。
|
||||
|
||||
|
||||
## 一、阶段目标
|
||||
|
||||
第二阶段延续第一阶段已经形成的模块边界,重点推进多模态输入、MCP 与 Plugin 扩展、更多 Provider、RAG / Agent Benchmark、多格式导出、主题社区格式、Agent Trace 可视化、Mermaid 渲染和函数图像绘制。
|
||||
|
||||
@@ -144,3 +144,11 @@ cd backend
|
||||
.venv/Scripts/python scripts/local-model-smoke.py qwen3-asr --download --audio C:/path/to/speech.wav
|
||||
.venv/Scripts/python scripts/local-model-smoke.py eres2netv2 --download --audio C:/path/to/speech.wav --reference C:/path/to/reference.wav
|
||||
```
|
||||
|
||||
|
||||
## 2026-09-05 长录音与 Provider 状态补充
|
||||
|
||||
- 附件上传上限为 128 MiB。超过 25 MiB 的音频必须明确选择 `local_only=true`;可联网任务仍限制为 25 MiB,前后端及路由均检查。解码时长仍限制为一小时。
|
||||
- 本地解码允许跳过少量损坏音频包,按包中可取得的时长补静音,并返回 `MEDIA_CORRUPT_PACKETS_SKIPPED:<数量>`。超过 100 个损坏包则失败;缺少有效包时长时无法承诺时间对齐,应结合原音频复核。此处理不能恢复丢失语音。
|
||||
- Provider 卡片显示“已启用/已停用”,支持直接启停。保存成功后更新状态;失败保留原状态。停用或保存期间禁用测试与刷新模型操作。
|
||||
- 37 分 16 秒的用户录音已完成 CUDA 转写、片段级说话人聚类、笔记生成、本地向量索引及检索命中。无参考标注,不报告准确率。详见《阶段F收尾验收记录》的长录音补充;此前短样本记录保留为历史证据。
|
||||
|
||||
@@ -47,9 +47,29 @@ CPU 与 CUDA 分别创建隔离 Vault、附件目录和 SQLite,只读取固定
|
||||
|
||||
## 未关闭的专项验收
|
||||
|
||||
- 带参考转写和说话人标注的真实课程长录音尚未提供,不能报告 CER/WER、DER、阈值或长音频吞吐达标。
|
||||
- 已提供无标注长录音,CUDA 功能与单次耗时验证见下文。参考转写和说话人标注仍缺失,不能报告 CER/WER、DER、阈值或业务吞吐达标。
|
||||
- 现阶段时间戳为片段级;逐字强制对齐、同段多人/重叠语音仍未实现,不将片段聚类视为完整说话人分离。
|
||||
- 外部供应商特殊 JSON 的兼容性,需要在目标账号和模型上点击实际推理验证;离线协议通过不替代厂商验收。
|
||||
- Tauri/Rust Host 和生产 MCP 沙箱按后续阶段安排;本轮数据持久化在后端 SQLite/Vault,为桌面集成保留稳定接口。
|
||||
|
||||
结论:阶段 F 本轮工程收尾已实现并完成 CPU/CUDA 功能验收;上述质量及外部服务专项保持待验收状态,不标记为全部通过。分支仍需独立审阅后决定合并。
|
||||
|
||||
|
||||
## 2026-09-05 长录音补充验收
|
||||
|
||||
用户授权使用本机 CUDA,只做本地处理。使用隔离的 SQLite、附件目录与 Vault,读取已安装的固定 revision 权重;原音频、转写正文和独立运行报告保留在被忽略的 `.local-plans`,不入库。
|
||||
|
||||
| 观察项 | 本次结果 |
|
||||
| --- | --- |
|
||||
| 输入 | MP3,89,424,101 字节,2235.60 秒(约 37 分 16 秒) |
|
||||
| 转写与片段聚类 | completed;441 个片段,5 个说话人聚类 |
|
||||
| 两环节总耗时 | 约 661 秒(轮询含最多约 10 秒误差),RTF 约 0.296 |
|
||||
| Qwen3-ASR 实际设备 / 加载 / 推理 | cuda:0 / 10.52 秒 / 607.78 秒 |
|
||||
| ERes2NetV2 实际设备 / 加载 / 推理 | cuda:0 / 3.97 秒 / 28.88 秒 |
|
||||
| 后续链路 | Markdown 笔记生成、本地 Bekko 索引、向量检索命中均通过 |
|
||||
| 隐私标记 | 导出笔记保留 `embedding_local_only: true` |
|
||||
| 警告 | 1 个损坏音频包按时长补静音;说话人结果为片段级 |
|
||||
|
||||
最初实测暴露了 25 MiB 限制和单个损坏 MP3 包导致整任务失败,已修复并用同一输入重新跑通。5 个聚类不是已确认的真实人数;没有参考转写或说话人标注,因此不计算 CER/WER、DER 或 FAR/FRR。单次耗时也不作为跨设备吞吐承诺。逐字对齐、重叠语音和目标厂商真实验收仍未关闭。
|
||||
|
||||
本轮自动化基线:后端 577 项、前端 280 项通过,前端类型与生产构建通过;构建仍有既有大 chunk 提示。离线 Provider 测试不代替目标账号实测。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notes-agent-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -39,6 +39,7 @@
|
||||
"marked": "^15.0.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"pinia": "^4.0.0",
|
||||
"semver": "^7.8.5",
|
||||
"shiki": "^4.4.3",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^5.0.0",
|
||||
@@ -46,6 +47,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/semver": "^7.8.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"@vue/test-utils": "^2.5.0",
|
||||
"happy-dom": "^20.11.15",
|
||||
|
||||
Generated
+11
@@ -92,6 +92,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3))
|
||||
semver:
|
||||
specifier: ^7.8.5
|
||||
version: 7.8.5
|
||||
shiki:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
@@ -108,6 +111,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.20.1
|
||||
'@types/semver':
|
||||
specifier: ^7.8.0
|
||||
version: 7.8.0
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.0.0
|
||||
version: 5.2.4(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))
|
||||
@@ -895,6 +901,9 @@ packages:
|
||||
'@types/node@22.20.1':
|
||||
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
|
||||
|
||||
'@types/semver@7.8.0':
|
||||
resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
@@ -3247,6 +3256,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/semver@7.8.0': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
theme_id: paper-moments
|
||||
name: 纸间时光 · Paper Moments
|
||||
version: 1.4.1
|
||||
version: 1.5.0
|
||||
author: NotesAgent
|
||||
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
|
||||
min_app_version: 0.2.0
|
||||
@@ -266,3 +266,36 @@ license: MIT
|
||||
@media (max-width: 720px) {
|
||||
[data-theme="paper-moments"] .note-metadata { width: 100%; padding: 22px 18px; }
|
||||
}
|
||||
|
||||
|
||||
/* Shared paper surfaces across settings, search, agents, media and extensions. */
|
||||
[data-theme="paper-moments"] :is(.panel, .item-card, .event-card, .citation-card, .routing-card, .vault-card, .modal-card, .modal, .usage-chart) {
|
||||
position: relative;
|
||||
border: 1px solid #b5a693;
|
||||
border-radius: 8px 14px 8px 8px;
|
||||
outline: 1px dashed #d5c8b5;
|
||||
outline-offset: -6px;
|
||||
background-color: #fffdf5;
|
||||
background-image: repeating-linear-gradient(transparent 0 31px, #b6c7bd18 31px 32px);
|
||||
box-shadow: 3px 4px 0 #d8e6e2, 6px 7px 0 #f0d8cf;
|
||||
}
|
||||
[data-theme="paper-moments"] :is(.panel, .item-card, .event-card, .citation-card, .routing-card, .vault-card, .modal-card, .modal)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 24px auto auto;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
width: 48px;
|
||||
height: 9px;
|
||||
background: repeating-linear-gradient(45deg, #c5dfe0b0 0 6px, #daeceba0 6px 12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-theme="paper-moments"] :is(.item-card, .event-card, .citation-card, .routing-card):nth-child(2n)::before {
|
||||
background: repeating-linear-gradient(45deg, #e7bcb3b0 0 6px, #f2d4cba0 6px 12px);
|
||||
}
|
||||
[data-theme="paper-moments"] :is(.panel, .item-card, .routing-card, .vault-card) :is(h2, h3, h4) {
|
||||
font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif;
|
||||
color: #875343;
|
||||
}
|
||||
[data-theme="paper-moments"] .usage-chart { background-color: #fbf7ea; }
|
||||
[data-theme="paper-moments"] .usage-grid > div { padding: 12px; border: 1px dashed #d5c8b5; border-radius: 5px; background: #fffdf580; }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import DiagramInteractions from './DiagramInteractions.vue'
|
||||
import { appendDiagramControls } from '@/utils/diagramControls'
|
||||
|
||||
it.each(['markdown-mermaid', 'editor-mermaid-preview'])('handles copied SVG controls in %s', async className => {
|
||||
const container = document.createElement('div')
|
||||
container.className = className
|
||||
container.innerHTML = '<svg viewBox="0 0 400 200"><text>Diagram</text></svg>'
|
||||
appendDiagramControls(container)
|
||||
const wrapper = mount(DiagramInteractions, { slots: { default: container.outerHTML }, attachTo: document.body })
|
||||
const svg = wrapper.get('svg').element as SVGSVGElement
|
||||
await wrapper.get('[data-diagram-action="in"]').trigger('click')
|
||||
expect(svg.style.width).toBe('480px')
|
||||
await wrapper.get('[data-diagram-action="reset"]').trigger('click')
|
||||
expect(svg.style.maxWidth).toBe('')
|
||||
const dialog = document.querySelector('dialog')!
|
||||
const show = vi.fn()
|
||||
dialog.showModal = show
|
||||
await wrapper.get('[data-diagram-action="view"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(show).toHaveBeenCalledOnce()
|
||||
expect(dialog.textContent).toContain('Diagram')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const viewer = ref<HTMLDialogElement | null>(null)
|
||||
const svgHtml = ref('')
|
||||
const scale = ref(1)
|
||||
const baseWidth = ref(800)
|
||||
let opener: HTMLElement | null = null
|
||||
function widthOf(svg: SVGSVGElement) {
|
||||
return svg.viewBox?.baseVal?.width || Number(svg.getAttribute('viewBox')?.split(/[ ,]+/)[2]) || svg.getBoundingClientRect().width || 800
|
||||
}
|
||||
async function interact(event: MouseEvent) {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const button = event.target.closest<HTMLElement>('[data-diagram-action]')
|
||||
const diagram = button?.closest<HTMLElement>('.editor-mermaid-preview, .markdown-mermaid')
|
||||
const svg = diagram?.querySelector<SVGSVGElement>('svg')
|
||||
if (!button || !diagram || !svg) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const action = button.dataset.diagramAction
|
||||
if (action === 'view') {
|
||||
opener = button
|
||||
baseWidth.value = widthOf(svg)
|
||||
svgHtml.value = DOMPurify.sanitize(svg.outerHTML, { USE_PROFILES: { svg: true, svgFilters: true, html: true } })
|
||||
scale.value = 1
|
||||
await nextTick()
|
||||
viewer.value?.showModal()
|
||||
return
|
||||
}
|
||||
const previous = Number(diagram.dataset.diagramScale || 1)
|
||||
const next = action === 'reset' ? 1 : Math.max(.2, Math.min(5, previous * (action === 'in' ? 1.2 : 1 / 1.2)))
|
||||
diagram.dataset.diagramScale = String(next)
|
||||
svg.style.width = next === 1 ? '' : `${widthOf(svg) * next}px`
|
||||
svg.style.maxWidth = next === 1 ? '' : 'none'
|
||||
svg.style.height = 'auto'
|
||||
}
|
||||
function close() { viewer.value?.close(); svgHtml.value = ''; opener?.focus() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="diagram-interactions" @click.capture="interact">
|
||||
<slot />
|
||||
<Teleport to="body">
|
||||
<dialog ref="viewer" class="diagram-viewer" aria-label="图表大图查看" @cancel.prevent="close">
|
||||
<header><strong>图表查看</strong><div class="diagram-controls">
|
||||
<button type="button" @click="scale = Math.max(.2, scale / 1.2)">缩小</button>
|
||||
<output>{{ Math.round(scale * 100) }}%</output>
|
||||
<button type="button" @click="scale = Math.min(5, scale * 1.2)">放大</button>
|
||||
<button type="button" @click="scale = 1">重置</button>
|
||||
<button type="button" autofocus @click="close">关闭</button>
|
||||
</div></header>
|
||||
<div class="diagram-viewer-scroll"><div class="diagram-viewer-image" :style="{ width: `${baseWidth * scale}px` }" v-html="svgHtml" /></div>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.diagram-interactions { min-width: 0; }
|
||||
.diagram-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
|
||||
.diagram-controls button { padding: 5px 10px; border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); color: var(--color-text-primary); background: var(--color-surface-primary); cursor: pointer; font: inherit; font-size: 12px; }
|
||||
.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-image { margin: auto; }
|
||||
.diagram-viewer-image svg { width: 100% !important; max-width: none !important; height: auto !important; }
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import DiagramInteractions from './DiagramInteractions.vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -19,7 +20,7 @@ watch([() => props.source, diagramTheme, () => themeStore.currentThemeId], async
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-content" v-html="html" />
|
||||
<DiagramInteractions><div class="markdown-content" v-html="html" /></DiagramInteractions>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -41,6 +41,31 @@ async function switchToTree(wrapper: ReturnType<typeof mountTree>) {
|
||||
}
|
||||
|
||||
describe('TraceTimeline 树形视图', () => {
|
||||
it('filters errors while retaining tree ancestors and final tool data', async () => {
|
||||
const events = sampleEvents()
|
||||
const result = events.find(item => item.event === 'ToolResult')!
|
||||
result.data.success = false
|
||||
result.data.error_code = 'TIMEOUT'
|
||||
const wrapper = mountTree(events)
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
expect(wrapper.findAll('.event-card')).toHaveLength(1)
|
||||
await switchToTree(wrapper)
|
||||
expect(wrapper.findAll('.tree-node')).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('TIMEOUT')
|
||||
await wrapper.get('[aria-label="搜索执行轨迹"]').setValue('no-match')
|
||||
expect(wrapper.text()).toContain('没有匹配的事件')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('filters a tool including its result and keeps citation navigation usable', async () => {
|
||||
const wrapper = mountTree(sampleEvents())
|
||||
await wrapper.get('[aria-label="工具筛选"]').setValue('read_note')
|
||||
expect(wrapper.findAll('.event-card')).toHaveLength(2)
|
||||
await wrapper.findAll('button').find(button => button.text() === '清除筛选')!.trigger('click')
|
||||
await wrapper.get('[aria-label="事件类型"]').setValue('Citation')
|
||||
await wrapper.get('.event-citation').trigger('click')
|
||||
expect(wrapper.emitted('open-citation')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('叶子节点点击后能看到自己的数据', async () => {
|
||||
// 回归:之前行的 click 是 `children.length && toggleExpand(id)`,
|
||||
// 而详情 v-if 又要求 children.length === 0 —— 两个条件互斥,
|
||||
|
||||
@@ -21,6 +21,32 @@ const expandedNodes = ref<Set<string>>(new Set())
|
||||
const detailNodes = ref<Set<string>>(new Set())
|
||||
const viewMode = ref<'timeline' | 'tree'>('timeline')
|
||||
const showDetails = ref(true)
|
||||
const query = ref('')
|
||||
const eventType = ref('')
|
||||
const toolName = ref('')
|
||||
const errorsOnly = ref(false)
|
||||
const eventTypes = computed(() => [...new Set(props.events.map(event => event.event))])
|
||||
const toolNames = computed(() => [...new Set(props.events.filter(event => event.event === 'ToolCall').map(event => String(event.data.name ?? '')))].filter(Boolean))
|
||||
const filtering = computed(() => Boolean(query.value.trim() || eventType.value || toolName.value || errorsOnly.value))
|
||||
const filteredEvents = computed(() => {
|
||||
const toolIds = new Set(props.events.filter(event => event.event === 'ToolCall' && event.data.name === toolName.value).map(event => event.data.tool_call_id))
|
||||
return props.events.filter(event => (!eventType.value || event.event === eventType.value)
|
||||
&& (!toolName.value || (event.data.tool_call_id != null && toolIds.has(event.data.tool_call_id)))
|
||||
&& (!errorsOnly.value || event.event.endsWith('Failed') || Boolean(event.data.error_code) || event.data.success === false || event.data.is_error === true)
|
||||
&& (!query.value.trim() || `${eventLabel(event.event)} ${event.event} ${JSON.stringify(event.data)}`.toLowerCase().includes(query.value.trim().toLowerCase())))
|
||||
})
|
||||
const filteredTree = computed(() => {
|
||||
if (!filtering.value) return traceNodes.value
|
||||
const matches = (node: TraceNode) => filteredEvents.value.some(event => event.sequence === node.sequence
|
||||
|| (node.type === 'tool_call' && node.data.tool_call_id != null && node.data.tool_call_id === event.data.tool_call_id)
|
||||
|| (node.type === 'model_call' && node.data.model_call_id != null && node.data.model_call_id === event.data.model_call_id))
|
||||
const prune = (nodes: TraceNode[]): TraceNode[] => nodes.flatMap(node => {
|
||||
const children = prune(node.children)
|
||||
return matches(node) || children.length ? [{ ...node, children }] : []
|
||||
})
|
||||
return prune(traceNodes.value)
|
||||
})
|
||||
function resetFilters() { query.value = ''; eventType.value = ''; toolName.value = ''; errorsOnly.value = false }
|
||||
|
||||
const traceNodes = computed(() => buildTraceNodes(props.events))
|
||||
const toolCalls = computed(() => getToolCallsFromEvents(props.events))
|
||||
@@ -119,14 +145,14 @@ function flatNodes(nodes: TraceNode[], depth = 0): Array<{ node: TraceNode; dept
|
||||
const result: Array<{ node: TraceNode; depth: number }> = []
|
||||
for (const node of nodes) {
|
||||
result.push({ node, depth })
|
||||
if (node.children.length > 0 && isExpanded(node.id)) {
|
||||
if (node.children.length > 0 && (filtering.value || isExpanded(node.id))) {
|
||||
result.push(...flatNodes(node.children, depth + 1))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const flatTrace = computed(() => flatNodes(traceNodes.value))
|
||||
const flatTrace = computed(() => flatNodes(filteredTree.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -165,10 +191,19 @@ const flatTrace = computed(() => flatNodes(traceNodes.value))
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trace-filters">
|
||||
<input v-model="query" class="input" aria-label="搜索执行轨迹" placeholder="搜索参数、结果或引用…" />
|
||||
<select v-model="eventType" class="select" aria-label="事件类型"><option value="">全部事件</option><option v-for="kind in eventTypes" :key="kind" :value="kind">{{ eventLabel(kind) }}</option></select>
|
||||
<select v-model="toolName" class="select" aria-label="工具筛选"><option value="">全部工具</option><option v-for="name in toolNames" :key="name" :value="name">{{ name }}</option></select>
|
||||
<label><input v-model="errorsOnly" type="checkbox" /> 仅错误</label>
|
||||
<button v-if="filtering" class="button-secondary" @click="resetFilters">清除筛选</button>
|
||||
<span aria-live="polite">{{ filteredEvents.length }} / {{ events.length }} 事件</span>
|
||||
</div>
|
||||
<p v-if="filtering && !filteredEvents.length" class="subtle" role="status">没有匹配的事件</p>
|
||||
<div v-if="viewMode === 'timeline'" class="timeline-view">
|
||||
<div class="timeline">
|
||||
<article
|
||||
v-for="event in events"
|
||||
v-for="event in filteredEvents"
|
||||
:key="event.sequence"
|
||||
class="event-card"
|
||||
:class="{ expanded: isDetailOpen(`event-${event.sequence}`) }"
|
||||
@@ -265,7 +300,7 @@ const flatTrace = computed(() => flatNodes(traceNodes.value))
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="toolCalls.length > 0 && viewMode === 'timeline'" class="tool-calls-summary panel">
|
||||
<div v-if="!filtering && toolCalls.length > 0 && viewMode === 'timeline'" class="tool-calls-summary panel">
|
||||
<h3 class="panel-title">工具调用统计</h3>
|
||||
<div class="tool-call-list">
|
||||
<div v-for="call in toolCalls" :key="call.tool_call_id" class="tool-call-item" :class="call.status">
|
||||
@@ -284,6 +319,10 @@ const flatTrace = computed(() => flatNodes(traceNodes.value))
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trace-filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-primary); }
|
||||
.trace-filters > input { flex: 1 1 220px; min-width: 0; }
|
||||
.trace-filters label { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.trace-filters span { color: var(--color-text-secondary); font-size: var(--font-size-sm); }
|
||||
.trace-visualization {
|
||||
display: grid;
|
||||
gap: var(--space-lg);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Link } from '@element-plus/icons-vue'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
@@ -68,7 +69,7 @@ function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
|
||||
if (entry.apply === apply) diagramPreviews.delete(id)
|
||||
}
|
||||
const element = createMermaidPreview(source, themeStore.isDark, apply)
|
||||
diagramPreviews.set(element.id, { source, apply })
|
||||
diagramPreviews.set(element.dataset.previewId!, { source, apply })
|
||||
return element
|
||||
}
|
||||
watch(() => themeStore.currentThemeId, () => {
|
||||
@@ -264,7 +265,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="visual-editor">
|
||||
<DiagramInteractions class="visual-editor">
|
||||
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
|
||||
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
|
||||
<span class="format-glyph heading-glyph">H</span>
|
||||
@@ -313,7 +314,7 @@ defineExpose({ getEditor: () => crepe?.editor })
|
||||
</section>
|
||||
<div ref="editorRoot" />
|
||||
</div>
|
||||
</div>
|
||||
</DiagramInteractions>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { getMarkdown } from '@milkdown/kit/utils'
|
||||
import type { Editor } from '@milkdown/kit/core'
|
||||
import VisualMarkdownEditor from './VisualMarkdownEditor.vue'
|
||||
|
||||
vi.mock('@/services/mermaidService', () => ({ renderMermaid: vi.fn(async () => ({ svg: '<svg viewBox="0 0 400 200"><text>Diagram</text></svg>', warnings: [], width: 400, height: 200 })) }))
|
||||
let wrapper: VueWrapper
|
||||
afterEach(() => { wrapper?.unmount(); document.body.innerHTML = ''; vi.unstubAllGlobals() })
|
||||
it('keeps diagram buttons usable after real Milkdown preview copying without changing Markdown', async () => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
vi.stubGlobal('IntersectionObserver', class {
|
||||
constructor(private callback: IntersectionObserverCallback) {}
|
||||
observe(target: Element) { queueMicrotask(() => this.callback([{ isIntersecting: true, target } as IntersectionObserverEntry], this as unknown as IntersectionObserver)) }
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
})
|
||||
wrapper = mount(VisualMarkdownEditor, { props: { initialContent: '```mermaid\ngraph TD; A-->B\n```' }, attachTo: document.body })
|
||||
await vi.waitFor(() => expect(wrapper.find('[data-diagram-action="in"]').exists()).toBe(true), { timeout: 3000 })
|
||||
const editor = (wrapper.vm as unknown as { getEditor(): Editor }).getEditor()
|
||||
const before = editor.action(getMarkdown())
|
||||
await wrapper.get('[data-diagram-action="in"]').trigger('click')
|
||||
expect((wrapper.get('.editor-mermaid-preview svg').element as SVGSVGElement).style.width).toBe('480px')
|
||||
expect(editor.action(getMarkdown())).toBe(before)
|
||||
})
|
||||
@@ -23,7 +23,7 @@ it('renders SVG with the requested theme and keeps async revisions isolated', as
|
||||
expect(oldPublish).not.toHaveBeenCalled()
|
||||
expect(latestPublish).toHaveBeenCalledWith(latest)
|
||||
expect(latestPublish.mock.calls[0]![0]).not.toBe(latest)
|
||||
document.getElementById(latest.id)?.remove()
|
||||
document.body.replaceChildren()
|
||||
expect(renderMermaid).toHaveBeenLastCalledWith('graph TD; A-->C', { theme: 'dark' })
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ it('shows syntax errors as text without executing markup', async () => {
|
||||
vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: '', warnings: ['<img src=x onerror=alert(1)>'], width: 0, height: 0 })
|
||||
const preview = createMermaidPreview('invalid', false, vi.fn())
|
||||
await flushPromises()
|
||||
expect(preview.classList.contains('has-error')).toBe(true)
|
||||
expect(preview.querySelector('.has-error')).not.toBeNull()
|
||||
expect(preview.querySelector('img')).toBeNull()
|
||||
expect(preview.textContent).toContain('点击编辑')
|
||||
})
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { renderMermaid } from '@/services/mermaidService'
|
||||
import { t } from '@/i18n'
|
||||
import { appendDiagramControls } from '@/utils/diagramControls'
|
||||
|
||||
let previewId = 0
|
||||
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void): HTMLElement {
|
||||
// Each revision owns its element, so a slow render cannot replace newer content.
|
||||
// Milkdown sanitizes Element input to its inner HTML; retain the revision
|
||||
// marker and controls inside an otherwise disposable envelope.
|
||||
const envelope = document.createElement('div')
|
||||
const container = document.createElement('div')
|
||||
envelope.append(container)
|
||||
container.className = 'editor-mermaid-preview'
|
||||
container.id = `editor-mermaid-preview-${++previewId}`
|
||||
envelope.dataset.previewId = container.id
|
||||
container.setAttribute('aria-live', 'polite')
|
||||
container.textContent = t('正在渲染图表…', 'Rendering diagram…')
|
||||
const publish = async () => {
|
||||
@@ -18,7 +24,7 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
|
||||
if (visible) {
|
||||
// PreviewPanel copies HTML instead of retaining the supplied element.
|
||||
// Update the current copy through Milkdown's reactive callback.
|
||||
applyPreview(container.cloneNode(true) as HTMLElement)
|
||||
applyPreview(envelope.cloneNode(true) as HTMLElement)
|
||||
}
|
||||
}
|
||||
void renderMermaid(source, { theme: dark ? 'dark' : 'light' }).then(result => {
|
||||
@@ -30,10 +36,11 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
|
||||
}
|
||||
// Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
|
||||
container.innerHTML = result.svg
|
||||
appendDiagramControls(container)
|
||||
void publish()
|
||||
}).catch(() => {
|
||||
container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.')
|
||||
void publish()
|
||||
})
|
||||
return container
|
||||
return envelope
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ const labels = computed(() => ({queued: t('排队中', 'Queued'), running: t('
|
||||
const speakers = computed(() => [...new Set(selected.value?.segments.map(s => s.speaker).filter((s): s is string => !!s) || [])])
|
||||
const active = (job: MediaJob) => ['queued', 'running', 'processing'].includes(job.status)
|
||||
const stamp = (seconds: number) => `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${Math.floor(seconds % 60).toString().padStart(2, '0')}`
|
||||
const warningLabel = (warning: string) => ({
|
||||
const warningLabel = (warning: string) => warning.startsWith('MEDIA_CORRUPT_PACKETS_SKIPPED:')
|
||||
? t(`已跳过 ${warning.split(':')[1]} 个损坏音频包;缺失时长以静音保留,请校对受影响内容。`, `Skipped ${warning.split(':')[1]} damaged audio packets; missing duration was retained as silence. Review the affected content.`)
|
||||
: ({
|
||||
DIARIZATION_UNAVAILABLE: t('当前无法分离说话人', 'Speaker identification is unavailable'),
|
||||
WORD_TIMESTAMPS_UNAVAILABLE: t('未提供逐字时间戳', 'Word-level timestamps are unavailable'),
|
||||
DIARIZATION_SEGMENT_LEVEL: t('说话人按音频段估计,同段多人或重叠发言需人工校对', 'Speakers are estimated per segment; multiple or overlapping speakers require manual correction'),
|
||||
@@ -57,6 +59,8 @@ async function action(work: () => Promise<void>) {
|
||||
async function submit() {
|
||||
if (!file.value) return
|
||||
await action(async () => {
|
||||
if (file.value!.size > 128 * 1024 * 1024) throw new Error(t('文件不能超过 128 MiB。', 'Files cannot exceed 128 MiB.'))
|
||||
if (file.value!.size > 25 * 1024 * 1024 && !localOnly.value) throw new Error(t('超过 25 MiB 的录音请先启用仅本地处理。', 'Enable local-only processing for audio above 25 MiB.'))
|
||||
let terms = {}
|
||||
if (terminology.value.trim()) {
|
||||
terms = JSON.parse(terminology.value)
|
||||
@@ -106,7 +110,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
|
||||
<template>
|
||||
<section class="media-page">
|
||||
<header><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t('上传音频或视频音轨,转写、校对后保存到知识库。单个文件最多 25 MiB。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Maximum file size: 25 MiB.') }}</p></header>
|
||||
<header><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></header>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
|
||||
<form class="panel upload" @submit.prevent="submit">
|
||||
<FilePicker :file="file" :label="t('选择附件', 'Choose attachment')" :empty-label="t('尚未选择文件', 'No file selected')" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @select="file = $event" />
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import SettingsView from './SettingsView.vue'
|
||||
import { useProviderStore } from '@/stores/provider'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
beforeEach(() => { localStorage.clear(); setActivePinia(createPinia()) })
|
||||
it('shows provider status and enables testing only after a successful enable', async () => {
|
||||
const store = useProviderStore()
|
||||
store.providers = [{ provider_id: 'p1', name: 'Example', provider_type: 'openai_compatible', enabled: false, default_model: '', capabilities: {}, has_credential: false }]
|
||||
vi.spyOn(store, 'loadProviders').mockResolvedValue()
|
||||
vi.spyOn(store, 'loadPresets').mockResolvedValue()
|
||||
vi.spyOn(store, 'refreshEnabledModels').mockResolvedValue()
|
||||
vi.spyOn(useSettingsStore(), 'loadDiagnostics').mockResolvedValue()
|
||||
const update = vi.spyOn(store, 'updateProvider').mockImplementation(async (_id, data) => {
|
||||
store.providers[0] = { ...store.providers[0]!, ...data }
|
||||
return store.providers[0]!
|
||||
})
|
||||
const wrapper = mount(SettingsView, { global: { stubs: { UsageCard: true, LocalModelSettings: true, ModelRoutingSettings: true, ProviderLogo: true } } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '模型提供商')!.trigger('click')
|
||||
expect(wrapper.text()).toContain('已停用')
|
||||
const test = () => wrapper.findAll('button').find(button => button.text() === '测试')!
|
||||
expect(test().attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('button').find(button => button.text() === '启用')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(update).toHaveBeenCalledWith('p1', { enabled: true })
|
||||
expect(wrapper.text()).toContain('已启用')
|
||||
expect(test().attributes('disabled')).toBeUndefined()
|
||||
update.mockRejectedValueOnce(new Error('保存失败'))
|
||||
await wrapper.findAll('button').find(button => button.text() === '停用')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('已启用')
|
||||
expect(wrapper.text()).toContain('保存失败')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -24,6 +24,18 @@ const showProviderForm = ref(false)
|
||||
const editingProvider = ref<ProviderConfig>()
|
||||
const providerAction = ref('')
|
||||
const testResults = ref<Record<string, string>>({})
|
||||
const providerBusy = ref<Record<string, boolean>>({})
|
||||
async function toggleProvider(provider: ProviderConfig) {
|
||||
if (providerBusy.value[provider.provider_id]) return
|
||||
providerBusy.value[provider.provider_id] = true
|
||||
providerAction.value = ''
|
||||
try {
|
||||
await providerStore.updateProvider(provider.provider_id, { enabled: !provider.enabled })
|
||||
delete testResults.value[provider.provider_id]
|
||||
} catch (error) {
|
||||
providerAction.value = error instanceof Error ? error.message : t('启停失败,请重试。', 'Could not change provider status. Please retry.')
|
||||
} finally { providerBusy.value[provider.provider_id] = false }
|
||||
}
|
||||
onMounted(async () => {
|
||||
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
|
||||
await providerStore.refreshEnabledModels()
|
||||
@@ -40,7 +52,7 @@ function openProvider(provider?: ProviderConfig) {
|
||||
editingProvider.value = provider
|
||||
providerAction.value = ''
|
||||
showProviderForm.value = true
|
||||
if (provider) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
|
||||
if (provider?.enabled) void providerStore.loadModels(provider.provider_id).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function providerSaved(provider: ProviderConfig) {
|
||||
@@ -68,18 +80,17 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
<div v-else-if="activeSection === 'editor'" class="panel settings-section"><h2>{{ t('编辑器', 'Editor') }}</h2><div class="setting-row"><span><strong>{{ t('默认模式', 'Default mode') }}</strong><small>{{ t('新打开文件使用的编辑器模式', 'Editor mode used for newly opened files') }}</small></span><select v-model="settingsStore.defaultEditorMode" class="select short"><option value="wysiwyg">{{ t('写作与预览', 'Writing and preview') }}</option><option value="source">{{ t('Markdown 源码', 'Markdown source') }}</option></select></div><div class="setting-row"><span><strong>{{ t('字号', 'Font size') }}</strong></span><input v-model.number="themeStore.fontEditorSize" class="input short" type="number" min="12" max="32" /></div><div class="setting-row"><span><strong>{{ t('行高', 'Line height') }}</strong></span><input v-model.number="themeStore.lineHeight" class="input short" type="number" min="1.2" max="2.4" step="0.1" /></div><div class="setting-row"><span><strong>{{ t('行宽', 'Line width') }}</strong><small>{{ t('Markdown 预览最大字符宽度', 'Maximum character width for Markdown preview') }}</small></span><input v-model.number="settingsStore.editorLineWidth" class="input short" type="number" min="40" max="140" /></div><label class="setting-row"><span><strong>{{ t('拼写检查', 'Spell check') }}</strong><small>{{ t('在写作与源码编辑器中使用系统拼写检查', 'Use system spell checking in visual and source editors') }}</small></span><input v-model="settingsStore.spellCheck" type="checkbox" /></label></div>
|
||||
|
||||
<div v-else-if="activeSection === 'providers'" class="settings-section">
|
||||
<section class="panel provider-settings-card">
|
||||
<div class="section-head">
|
||||
<div><h2>{{ t('模型提供商', 'Model Providers') }}</h2><p class="subtle">{{ t('选择国内外提供商预设,或配置自定义 API 与独立密钥。', 'Choose a provider preset or configure a custom API with separate credentials.') }}</p></div>
|
||||
<button class="button-primary" @click="openProvider()">{{ t('新增 Provider', 'Add Provider') }}</button>
|
||||
</div>
|
||||
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
|
||||
<LocalModelSettings />
|
||||
<UsageCard />
|
||||
<p v-if="!providerStore.providers.length" class="subtle">{{ providerStore.isLoading ? t('正在加载提供商…', 'Loading providers…') : t('尚无可用提供商,请添加真实 API 或本地 Ollama 配置。', 'No providers are available. Add a real API or local Ollama configuration.') }}</p>
|
||||
<div class="provider-list">
|
||||
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
|
||||
<div class="provider-main">
|
||||
<div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge" :class="{ success: provider.enabled }">{{ provider.provider_type }}</span></div>
|
||||
<div class="inline-actions"><ProviderLogo :logo-id="providerStore.presets.find(preset => preset.preset_id === presetIdFor(provider))?.logo_id || presetIdFor(provider)" /><strong>{{ provider.name }}</strong><span class="badge">{{ provider.provider_type }}</span><span class="badge" :class="{ success: provider.enabled }">{{ provider.enabled ? t('已启用', 'Enabled') : t('已停用', 'Disabled') }}</span></div>
|
||||
<p class="subtle">{{ provider.base_url || t('本地内置', 'Built in locally') }} · {{ t('默认模型', 'Default model') }} {{ provider.default_model || t('未设置', 'Not set') }}</p>
|
||||
<div class="tag-list"><span v-for="(_, capability) in provider.capabilities" :key="capability" class="badge">{{ capability }}</span></div>
|
||||
<div v-if="providerStore.modelsByProvider[provider.provider_id]?.length" class="model-picker">
|
||||
@@ -94,13 +105,17 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
<p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p>
|
||||
</div>
|
||||
<div class="inline-actions provider-actions">
|
||||
<button class="button-secondary" :disabled="providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? t('获取中…', 'Loading…') : t('刷新模型', 'Refresh models') }}</button>
|
||||
<button class="button-secondary" @click="testProvider(provider)">{{ t('测试', 'Test') }}</button>
|
||||
<button class="button-secondary" :disabled="providerBusy[provider.provider_id]" :aria-pressed="provider.enabled" @click="toggleProvider(provider)">{{ providerBusy[provider.provider_id] ? t('保存中…', 'Saving…') : provider.enabled ? t('停用', 'Disable') : t('启用', 'Enable') }}</button>
|
||||
<button class="button-secondary" :disabled="!provider.enabled || providerBusy[provider.provider_id] || providerStore.modelLoadingByProvider[provider.provider_id]" @click="refreshModels(provider)">{{ providerStore.modelLoadingByProvider[provider.provider_id] ? t('获取中…', 'Loading…') : t('刷新模型', 'Refresh models') }}</button>
|
||||
<button class="button-secondary" :disabled="!provider.enabled || providerBusy[provider.provider_id]" @click="testProvider(provider)">{{ t('测试', 'Test') }}</button>
|
||||
<button class="button-secondary" @click="openProvider(provider)">{{ t('编辑', 'Edit') }}</button>
|
||||
<button class="button-danger" @click="removeProvider(provider)">{{ t('删除', 'Delete') }}</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<LocalModelSettings class="panel local-settings-card" />
|
||||
<UsageCard />
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'index'" class="panel settings-section"><h2>{{ t('索引与模型', 'Index and Models') }}</h2><div class="index-summary"><div><span class="badge" :class="{ success: settingsStore.indexStatus.status === 'idle', error: settingsStore.indexStatus.status === 'error' }">{{ settingsStore.indexStatus.status }}</span><p>{{ t('待处理任务', 'Pending jobs') }} {{ settingsStore.indexStatus.pending_jobs }}</p></div><div><strong>{{ settingsStore.indexStatus.total_notes ?? t('未获取', 'Unavailable') }}</strong><small>{{ t('笔记', 'Notes') }}</small></div><div><strong>{{ settingsStore.indexStatus.total_blocks ?? t('未获取', 'Unavailable') }}</strong><small>Block</small></div></div><div v-if="settingsStore.indexStatus.error" class="error-banner">{{ settingsStore.indexStatus.error }}</div><div class="inline-actions"><button class="button-primary" @click="settingsStore.rebuildIndex('full')">{{ t('重建全部', 'Rebuild all') }}</button><span class="subtle">{{ t('当前后端支持全量重建。', 'The current backend supports a full rebuild.') }}</span></div><ModelRoutingSettings /></div>
|
||||
@@ -116,6 +131,9 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
<style scoped>
|
||||
.settings-page { max-width: 1120px; margin: 0 auto; }
|
||||
.settings-section { display: grid; gap: var(--space-md); }
|
||||
.provider-settings-card, .local-settings-card { padding: 20px; min-width: 0; }
|
||||
.provider-settings-card { display: grid; gap: var(--space-md); }
|
||||
.provider-settings-card .section-head { margin-bottom: 0; gap: var(--space-md); flex-wrap: wrap; }
|
||||
.settings-section h2 { margin-bottom: var(--space-sm); }
|
||||
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-xl); min-height: 58px; padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); transition: background-color var(--motion-fast); }
|
||||
.setting-row:hover { background: var(--color-background-secondary); }
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, type Component } from 'vue'
|
||||
import UsageChart from './UsageChart.vue'
|
||||
import type { UsageBucket } from './UsageChart.vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { DataAnalysis, Download, Upload, Coin, CircleCheck, CircleClose, FolderAdd, Cpu, PieChart, Microphone, Refresh } from '@element-plus/icons-vue'
|
||||
import { apiClient } from '@/services/apiClient'
|
||||
import { t } from '@/i18n'
|
||||
interface Usage {audio_request_count:number;audio_seconds:number|null;audio_covered_requests:number;totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]}
|
||||
interface Usage {series?:UsageBucket[];audio_request_count:number;audio_seconds:number|null;audio_covered_requests:number;totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]}
|
||||
const data = ref<Usage | null>(null)
|
||||
const period = ref('7')
|
||||
const provider = ref('')
|
||||
@@ -12,23 +16,31 @@ const start = ref('')
|
||||
const end = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
let generation = 0
|
||||
const metrics = computed<Record<string,string>>(() => ({input_tokens:t('输入 Token','Input tokens'),output_tokens:t('输出 Token','Output tokens'),total_tokens:t('总 Token','Total tokens'),cache_hit_tokens:t('缓存命中','Cache hits'),cache_miss_tokens:t('缓存未命中','Cache misses'),cache_write_tokens:t('缓存写入','Cache writes'),reasoning_tokens:t('推理 Token','Reasoning tokens')}))
|
||||
const metricIcons: Record<string, Component> = {
|
||||
input_tokens: Download, output_tokens: Upload, total_tokens: Coin,
|
||||
cache_hit_tokens: CircleCheck, cache_miss_tokens: CircleClose,
|
||||
cache_write_tokens: FolderAdd, reasoning_tokens: Cpu,
|
||||
}
|
||||
async function load() {
|
||||
const current = ++generation
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
const until = period.value === 'custom' ? new Date(end.value) : new Date()
|
||||
const from = period.value === 'custom' ? new Date(start.value) : new Date(until)
|
||||
if (period.value === 'today') from.setHours(0,0,0,0)
|
||||
else if (period.value !== 'custom') from.setDate(from.getDate() - Number(period.value))
|
||||
else if (period.value !== 'custom') { from.setDate(from.getDate() - Number(period.value) + 1); from.setHours(0,0,0,0) }
|
||||
if (!Number.isFinite(from.getTime()) || !Number.isFinite(until.getTime()) || until <= from) throw new Error(t('请选择有效的开始与结束时间。', 'Choose a valid start and end time.'))
|
||||
data.value = await apiClient.get<Usage>('/api/usage', {params: {start:from.toISOString(),end:until.toISOString(),provider_id:provider.value || undefined,model:model.value || undefined,source:source.value || undefined}})
|
||||
} catch(e) { error.value = (e as Error).message } finally { busy.value = false }
|
||||
const result = await apiClient.get<Usage>('/api/usage', {params: {timezone_offset: -new Date().getTimezoneOffset(), start:from.toISOString(),end:until.toISOString(),provider_id:provider.value || undefined,model:model.value || undefined,source:source.value || undefined}})
|
||||
if (current === generation) data.value = result
|
||||
} catch(e) { if (current === generation) { error.value = (e as Error).message; data.value = null } } finally { if (current === generation) busy.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<section class="panel usage-card"><header><h3>{{ t('Token 消耗情况', 'Token Usage') }}</h3><button class="button-secondary" :disabled="busy" @click="load">{{ busy ? t('加载中…', 'Loading…') : t('刷新统计', 'Refresh') }}</button></header>
|
||||
<div class="filters"><label>{{ t('时间', 'Period') }}<select v-model="period" class="select" @change="period !== 'custom' && load()"><option value="today">{{ t('今日', 'Today') }}</option><option value="7">{{ t('近 7 天', 'Last 7 days') }}</option><option value="30">{{ t('近 30 天', 'Last 30 days') }}</option><option value="custom">{{ t('自定义', 'Custom') }}</option></select></label>
|
||||
<section class="panel usage-card"><header><h3><AppIcon :icon="DataAnalysis" :size="20" />{{ t('Token 消耗情况', 'Token Usage') }}</h3><button class="button-secondary" :disabled="busy" @click="load"><AppIcon :icon="Refresh" :size="16" />{{ busy ? t('加载中…', 'Loading…') : t('刷新统计', 'Refresh') }}</button></header>
|
||||
<div class="filters"><label>{{ t('时间', 'Period') }}<select v-model="period" class="select" @change="period !== 'custom' && load()"><option value="today">{{ t('今日', 'Today') }}</option><option value="7">{{ t('近 7 天', 'Last 7 days') }}</option><option value="30">{{ t('近 30 天', 'Last 30 days') }}</option><option value="90">{{ t('近三个月(90 天)', 'Last 3 months (90 days)') }}</option><option value="custom">{{ t('自定义', 'Custom') }}</option></select></label>
|
||||
<label>{{ t('提供商', 'Provider') }}<select v-model="provider" class="select" @change="model = ''; load()"><option value="">{{ t('全部', 'All') }}</option><option v-for="id in [...new Set(data?.options.map(o => o.provider_id) || [])]" :key="id">{{ id }}</option></select></label>
|
||||
<label>{{ t('模型', 'Model') }}<select v-model="model" class="select" @change="load"><option value="">{{ t('全部', 'All') }}</option><option v-for="id in [...new Set(data?.options.filter(o => !provider || o.provider_id === provider).map(o => o.model) || [])]" :key="id">{{ id }}</option></select></label>
|
||||
<label>{{ t('来源', 'Source') }}<select v-model="source" class="select" @change="load"><option value="">{{ t('全部', 'All') }}</option><option value="api">{{ t('远程 API', 'Remote API') }}</option><option value="local">{{ t('本地服务', 'Local service') }}</option></select></label>
|
||||
@@ -36,11 +48,16 @@ onMounted(load)
|
||||
<div v-if="period === 'custom'" class="filters"><label>{{ t('开始', 'Start') }}<input v-model="start" class="input" type="datetime-local" /></label><label>{{ t('结束', 'End') }}<input v-model="end" class="input" type="datetime-local" /></label><button class="button-secondary" @click="load">{{ t('应用时间段', 'Apply period') }}</button></div>
|
||||
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
|
||||
<template v-if="data"><p v-if="!data.request_count" class="subtle">{{ t('该时间段没有已记录的模型请求。', 'No model requests were recorded during this period.') }}</p>
|
||||
<div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small>{{ label }}</small><strong>{{ data.totals[key] === null ? t('未提供', 'Unavailable') : data.totals[key]?.toLocaleString() }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.coverage[key] }} / {{ data.request_count }} {{ t('次', 'requests') }}</small></div>
|
||||
<div><small>{{ t('缓存命中率', 'Cache hit rate') }}</small><strong>{{ data.cache_hit_rate === null ? t('未提供', 'Unavailable') : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.cache_covered_requests }}</small></div></div>
|
||||
<p class="subtle">{{ t('音频调用', 'Audio calls') }} {{ data.audio_request_count ?? 0 }} · {{ t('时长', 'Duration') }} {{ data.audio_seconds == null ? t('未提供', 'Unavailable') : `${data.audio_seconds.toFixed(2)} ${t('秒', 'sec')}` }} ({{ t('覆盖', 'coverage') }} {{ data.audio_covered_requests ?? 0 }}; {{ t('重试分别计数', 'retries counted separately') }})</p>
|
||||
<UsageChart v-if="data.series" :buckets="data.series" />
|
||||
<div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small class="metric-label"><span class="metric-icon"><AppIcon :icon="metricIcons[key]!" :size="18" /></span>{{ label }}</small><strong>{{ data.totals[key] === null ? t('未提供', 'Unavailable') : data.totals[key]?.toLocaleString() }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.coverage[key] }} / {{ data.request_count }} {{ t('次', 'requests') }}</small></div>
|
||||
<div><small class="metric-label"><span class="metric-icon"><AppIcon :icon="PieChart" :size="18" /></span>{{ t('缓存命中率', 'Cache hit rate') }}</small><strong>{{ data.cache_hit_rate === null ? t('未提供', 'Unavailable') : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>{{ t('覆盖', 'Coverage') }} {{ data.cache_covered_requests }}</small></div></div>
|
||||
<p class="subtle audio-usage"><AppIcon :icon="Microphone" :size="16" />{{ t('音频调用', 'Audio calls') }} {{ data.audio_request_count ?? 0 }} · {{ t('时长', 'Duration') }} {{ data.audio_seconds == null ? t('未提供', 'Unavailable') : `${data.audio_seconds.toFixed(2)} ${t('秒', 'sec')}` }} ({{ t('覆盖', 'coverage') }} {{ data.audio_covered_requests ?? 0 }}; {{ t('重试分别计数', 'retries counted separately') }})</p>
|
||||
<p class="subtle">{{ t('请求', 'Requests') }} {{ data.request_count }}, {{ t('其中完整结束', 'completed') }} {{ data.complete_requests }}. {{ t('输入总量包含厂商已报告的缓存,推理 Token 不重复加入输出。', 'Input totals include provider-reported cache tokens; reasoning tokens are not added to output twice.') }}</p>
|
||||
</template><p class="subtle">{{ t('统计为本应用观测值,不是厂商账户账单。缺失指标显示“未提供”,历史未记录的数据不补估。', 'Statistics are application observations, not provider billing. Missing metrics stay unavailable and historical gaps are not estimated.') }}</p>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>.usage-card{display:grid;gap:16px;padding:20px}.usage-card header,.filters{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.usage-card header{justify-content:space-between}.filters label{display:grid;gap:5px}.usage-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:16px}.usage-grid>div{display:grid;gap:8px}.usage-grid strong{font-size:22px}</style>
|
||||
<style scoped>
|
||||
.usage-card h3, .usage-card header button, .metric-label, .audio-usage { display: flex; align-items: center; gap: 8px; }
|
||||
.usage-card h3 > .app-icon, .audio-usage > .app-icon { color: var(--color-accent-primary); }
|
||||
.metric-icon { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-sm); background: var(--color-background-secondary); color: var(--color-accent-primary); flex-shrink: 0; }
|
||||
.usage-card{display:grid;gap:16px;padding:20px}.usage-card header,.filters{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.usage-card header{justify-content:space-between}.filters label{display:grid;gap:5px}.usage-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:16px}.usage-grid>div{display:grid;gap:8px}.usage-grid strong{font-size:22px}</style>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { expect, it } from 'vitest'
|
||||
import UsageChart from './UsageChart.vue'
|
||||
it('distinguishes unreported tokens from zero and switches to request counts', async () => {
|
||||
const wrapper = mount(UsageChart, { props: { buckets: [{ date: '2026-09-05', end_date: '2026-09-05',
|
||||
local: { requests: 1, totals: { input_tokens: null }, coverage: { input_tokens: 0 } },
|
||||
api: { requests: 2, totals: { input_tokens: 0 }, coverage: { input_tokens: 1 } },
|
||||
}] } })
|
||||
expect(wrapper.findAll('.usage-bar.missing')).toHaveLength(1)
|
||||
expect(wrapper.get('.usage-bar.local').attributes('aria-label')).toContain('未提供')
|
||||
expect(wrapper.get('.usage-bar.api').attributes('aria-label')).toContain('覆盖 1/2')
|
||||
await wrapper.get('select').setValue('requests')
|
||||
expect(wrapper.findAll('.usage-bar.missing')).toHaveLength(0)
|
||||
expect(wrapper.get('.usage-bar.api').attributes('style')).toContain('160px')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Cpu, Connection } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
export interface UsageBucket {
|
||||
date: string
|
||||
end_date: string
|
||||
local: { requests: number; totals: Record<string, number | null>; coverage: Record<string, number> }
|
||||
api: { requests: number; totals: Record<string, number | null>; coverage: Record<string, number> }
|
||||
}
|
||||
const props = defineProps<{ buckets: UsageBucket[] }>()
|
||||
const metric = ref('input_tokens')
|
||||
const sources = ['local', 'api'] as const
|
||||
const labels = computed(() => ({ local: t('本地模型', 'Local models'), api: t('模型提供商', 'Providers') }))
|
||||
const metricLabel = computed(() => metric.value === 'requests' ? t('请求次数', 'Requests') : metric.value === 'input_tokens' ? t('输入 Token', 'Input tokens') : metric.value === 'output_tokens' ? t('输出 Token', 'Output tokens') : t('总 Token', 'Total tokens'))
|
||||
function value(bucket: UsageBucket, source: 'local' | 'api') {
|
||||
const item = bucket[source]
|
||||
return metric.value === 'requests' ? item.requests : item.requests === 0 ? 0 : item.totals[metric.value] ?? null
|
||||
}
|
||||
function description(bucket: UsageBucket, source: 'local' | 'api') {
|
||||
const count = value(bucket, source)
|
||||
const coverage = metric.value === 'requests' ? '' : ` · ${t('覆盖', 'Coverage')} ${bucket[source].coverage[metric.value] ?? 0}/${bucket[source].requests}`
|
||||
return `${bucket.date}${bucket.end_date !== bucket.date ? ' – ' + bucket.end_date : ''} · ${labels.value[source]} · ${metricLabel.value}: ${count === null ? t('未提供', 'Unavailable') : count.toLocaleString()}${coverage}`
|
||||
}
|
||||
const maximum = computed(() => Math.max(1, ...props.buckets.flatMap(bucket => sources.map(source => value(bucket, source) ?? 0))))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="usage-chart" :aria-label="t('使用趋势', 'Usage trend')">
|
||||
<div class="chart-toolbar">
|
||||
<h4>{{ t('使用趋势', 'Usage trend') }}</h4>
|
||||
<select v-model="metric" class="select" :aria-label="t('图表统计指标', 'Chart metric')">
|
||||
<option value="input_tokens">{{ t('输入 Token', 'Input tokens') }}</option>
|
||||
<option value="output_tokens">{{ t('输出 Token', 'Output tokens') }}</option>
|
||||
<option value="total_tokens">{{ t('总 Token', 'Total tokens') }}</option>
|
||||
<option value="requests">{{ t('请求次数', 'Requests') }}</option>
|
||||
</select>
|
||||
<div class="chart-legend"><span class="local"><AppIcon :icon="Cpu" :size="16" />{{ labels.local }} · {{ t('实色', 'Solid') }}</span><span class="api"><AppIcon :icon="Connection" :size="16" />{{ labels.api }} · {{ t('斜纹', 'Striped') }}</span></div>
|
||||
</div>
|
||||
<p class="subtle chart-scale">{{ metricLabel }} · 0 — {{ maximum.toLocaleString() }}</p>
|
||||
<div class="chart-scroll" tabindex="0" :aria-label="t('按日期横向滚动查看柱状图', 'Scroll the chart by date')">
|
||||
<div class="chart-columns" :style="{ minWidth: `${buckets.length * 48}px` }">
|
||||
<div v-for="bucket in buckets" :key="bucket.date" class="chart-column">
|
||||
<div class="chart-bars">
|
||||
<div v-for="source in sources" :key="source" class="usage-bar" :class="[source, { missing: value(bucket, source) === null }]"
|
||||
:style="{ height: value(bucket, source) === null ? '8px' : `${(value(bucket, source) ?? 0) / maximum * 160}px` }"
|
||||
role="img" :aria-label="description(bucket, source)" :title="description(bucket, source)" />
|
||||
</div>
|
||||
<small :title="`${bucket.date} – ${bucket.end_date}`">{{ bucket.date.slice(5) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="subtle">{{ t('按本机时区分组;柱高仅汇总已报告值,悬停可查看覆盖请求数。虚线表示有请求但未提供该指标,不作为零消耗。', 'Grouped by your local UTC offset. Bars sum reported values; hover for coverage. Dashed markers mean requests with unavailable counters, not zero usage.') }}</p>
|
||||
<details><summary>{{ t('查看图表数据', 'View chart data') }}</summary><div class="chart-scroll"><table><thead><tr><th>{{ t('日期', 'Date') }}</th><th>{{ labels.local }}</th><th>{{ labels.api }}</th></tr></thead><tbody><tr v-for="bucket in buckets" :key="bucket.date"><th>{{ bucket.date }}<template v-if="bucket.date !== bucket.end_date"> – {{ bucket.end_date }}</template></th><td v-for="source in sources" :key="source">{{ description(bucket, source) }}</td></tr></tbody></table></div></details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usage-chart { min-width: 0; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); background: var(--color-background-primary); }
|
||||
.chart-toolbar, .chart-legend, .chart-legend span { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.chart-toolbar { justify-content: space-between; }.chart-toolbar .select { width: auto; }.chart-legend { font-size: 12px; }
|
||||
.local { color: var(--color-info); }.api { color: var(--color-accent-primary); }
|
||||
.chart-scale { margin: 12px 0 0; font-size: 12px; }.chart-scroll { overflow-x: auto; padding-bottom: 8px; }.chart-columns { display: flex; gap: 8px; }
|
||||
.chart-column { flex: 1; min-width: 40px; text-align: center; }.chart-column small { font-size: 11px; color: var(--color-text-secondary); white-space: nowrap; }
|
||||
.chart-bars { height: 176px; display: flex; align-items: flex-end; justify-content: center; gap: 4px; border-bottom: 1px solid var(--color-border-default); background: repeating-linear-gradient(to top, transparent 0 39px, var(--color-border-subtle) 39px 40px); }
|
||||
.usage-bar { width: 14px; max-width: 35%; background: currentColor; border-radius: 3px 3px 0 0; }.usage-bar.api { background: repeating-linear-gradient(45deg, currentColor 0 4px, color-mix(in srgb, currentColor 45%, var(--color-surface-primary)) 4px 7px); }
|
||||
.usage-bar.missing { background: transparent; border: 1px dashed currentColor; box-sizing: border-box; }
|
||||
.usage-chart > p:last-of-type { font-size: 12px; margin: 12px 0; }.usage-chart summary { cursor: pointer; color: var(--color-text-link); font-size: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }th, td { padding: 8px; text-align: left; border-bottom: 1px solid var(--color-border-subtle); }
|
||||
</style>
|
||||
@@ -4,16 +4,20 @@ import { t } from '@/i18n'
|
||||
import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
|
||||
import tokensCss from '@/styles/tokens.css?raw'
|
||||
|
||||
const props = defineProps<{ themeId: string }>()
|
||||
const props = defineProps<{ themeId: string; name?: string; css?: string }>()
|
||||
const emit = defineEmits<{ (event: 'close'): void }>()
|
||||
const theme = computed(() => mockCommunityThemes.find(item => item.theme_id === props.themeId))
|
||||
const theme = computed(() => props.name ? { name: props.name } : mockCommunityThemes.find(item => item.theme_id === props.themeId))
|
||||
const previewDocument = computed(() => {
|
||||
// Only bundled community CSS enters this script-free, isolated document.
|
||||
// Both imported and bundled CSS are previewed in a script-free isolated document.
|
||||
// Previewing never installs a theme or changes application styles/storage.
|
||||
const doc = document.implementation.createHTMLDocument(theme.value?.name ?? '')
|
||||
doc.documentElement.dataset.theme = props.themeId
|
||||
const policy = doc.createElement('meta')
|
||||
policy.httpEquiv = 'Content-Security-Policy'
|
||||
policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'"
|
||||
doc.head.append(policy)
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = `${tokensCss}\n${getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }`
|
||||
style.textContent = `${tokensCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }`
|
||||
doc.head.append(style)
|
||||
const article = doc.createElement('article')
|
||||
article.className = 'panel'
|
||||
|
||||
@@ -21,6 +21,12 @@ it('downloads a URL for inspection without automatically installing it', async (
|
||||
await vi.waitFor(() => expect(useThemeStore().pendingInspection?.compatible).toBe(true))
|
||||
expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false)
|
||||
expect(wrapper.get('.inspection-result').text()).toContain('纸间时光')
|
||||
await wrapper.findAll('button').find(button => button.text() === '预览主题效果')!.trigger('click')
|
||||
const preview = wrapper.get('iframe')
|
||||
expect(preview.attributes('sandbox')).toBe('')
|
||||
expect(preview.attributes('srcdoc')).toContain('Content-Security-Policy')
|
||||
expect(preview.attributes('srcdoc')).toContain('data-theme="paper-moments"')
|
||||
expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a URL response after the dialog is cancelled', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import CommunityThemePreview from './CommunityThemePreview.vue'
|
||||
import paperMomentsUrl from '@/assets/themes/paper-moments.theme?url'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const previewImport = ref(false)
|
||||
|
||||
const activeTab = ref<'installed' | 'community'>('installed')
|
||||
const showImportDialog = ref(false)
|
||||
@@ -22,6 +23,7 @@ let importGeneration = 0
|
||||
let downloadController: AbortController | undefined
|
||||
|
||||
function resetImport() {
|
||||
previewImport.value = false
|
||||
importGeneration++
|
||||
downloadController?.abort()
|
||||
importing.value = false
|
||||
@@ -253,6 +255,7 @@ onMounted(() => {
|
||||
<summary>将要安装的 CSS({{ themeStore.pendingInspection.css.length }} 字符)</summary>
|
||||
<pre>{{ themeStore.pendingInspection.css }}</pre>
|
||||
</details>
|
||||
<button type="button" class="button-secondary" @click="previewImport = true">预览主题效果</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="upload-area">
|
||||
@@ -280,6 +283,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<CommunityThemePreview v-if="previewImport && themeStore.pendingInspection?.compatible" :theme-id="themeStore.pendingInspection.manifest.theme_id" :name="themeStore.pendingInspection.manifest.name" :css="themeStore.pendingInspection.css" @close="previewImport = false" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { decodeThemePackage, fetchThemePackage, inspectThemePackage, MAX_THEME_BYTES } from './themePackageService'
|
||||
import { decodeThemePackage, fetchThemePackage, inspectThemePackage, installTheme, MAX_THEME_BYTES, THEME_APP_VERSION } from './themePackageService'
|
||||
import paper from '@/assets/themes/paper-moments.theme?raw'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
it.each(['999.0.0', 'bad', '0.2'])('rejects unsupported minimum app version %s at inspection and install', async version => {
|
||||
const source = paper.replace(/min_app_version:.*\r?\n/, `min_app_version: ${version}\n`)
|
||||
expect((await inspectThemePackage(source)).compatible).toBe(false)
|
||||
const { manifest, css } = await inspectThemePackage(paper)
|
||||
await expect(installTheme({ ...manifest, min_app_version: version }, css)).rejects.toThrow()
|
||||
})
|
||||
it('accepts the current version and preserves real YAML list metadata', async () => {
|
||||
const result = await inspectThemePackage(paper.replace(/min_app_version:.*\r?\n/, `min_app_version: ${THEME_APP_VERSION}\ntags: [paper, "a,b"]\n`))
|
||||
expect(result.compatible).toBe(true)
|
||||
expect(Array.isArray(result.manifest.tags)).toBe(true)
|
||||
})
|
||||
it('reads ZIP manifests under repository folders and validates the bundled CSS', async () => {
|
||||
const [yaml, css] = paper.split('\n---\n')
|
||||
const [yaml, css] = paper.split(/\r?\n---\r?\n/)
|
||||
const zip = zipSync({ 'repo-main/theme.yaml': strToU8(yaml!), 'repo-main/theme.css': strToU8(css!) })
|
||||
const result = await inspectThemePackage(await decodeThemePackage(zip))
|
||||
expect(result.compatible).toBe(true)
|
||||
expect(result.css).toBe(css!.trim())
|
||||
expect(result.css).toBe(css!.replace(/\r\n/g, '\n').trim())
|
||||
})
|
||||
it('accepts a zipped single-file theme', async () => {
|
||||
expect(await decodeThemePackage(zipSync({ 'paper.theme': strToU8(paper) }))).toBe(paper)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { InstalledTheme, ThemeManifest, ThemePackageInspection } from '@/contracts'
|
||||
import paperMomentsPackage from '@/assets/themes/paper-moments.theme?raw'
|
||||
import { valid, gt } from 'semver'
|
||||
import appPackage from '../../package.json'
|
||||
import { isMap, parseDocument } from 'yaml'
|
||||
|
||||
export const THEME_APP_VERSION = appPackage.version
|
||||
|
||||
const STORAGE_KEY = 'installed-themes'
|
||||
const ACTIVE_CUSTOM_KEY = 'active-custom-theme'
|
||||
@@ -103,9 +108,11 @@ function validateManifest(raw: Record<string, unknown>): { manifest: ThemeManife
|
||||
if (!/^[a-z0-9_-]+$/.test(String(raw.theme_id))) {
|
||||
throw new Error('THEME_MANIFEST_INVALID: theme_id must match [a-z0-9_-]+')
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+/.test(String(raw.version))) {
|
||||
warnings.push('版本号格式建议使用 semver(如 1.0.0)')
|
||||
for (const field of ['version', 'min_app_version']) {
|
||||
if (typeof raw[field] !== 'string' || !valid(raw[field] as string)) throw new Error(`THEME_MANIFEST_INVALID: ${field} 必须是有效的 semver 版本号`)
|
||||
}
|
||||
if (gt(raw.min_app_version as string, THEME_APP_VERSION)) throw new Error(`THEME_VERSION_INCOMPATIBLE: 主题需要应用 ${raw.min_app_version},当前版本为 ${THEME_APP_VERSION}`)
|
||||
if (raw.is_dark !== undefined && typeof raw.is_dark !== 'boolean') throw new Error('THEME_MANIFEST_INVALID: is_dark 必须是布尔值')
|
||||
const cssEntry = String(raw.css_entry)
|
||||
if (cssEntry.includes('://') || cssEntry.startsWith('data:')) {
|
||||
throw new Error('THEME_SECURITY_VIOLATION: css_entry must be a relative path within the package')
|
||||
@@ -158,24 +165,9 @@ function removeThemeCss(themeId: string) {
|
||||
}
|
||||
|
||||
function inspectYamlContent(yamlText: string): ThemeManifest {
|
||||
const lines = yamlText.split('\n')
|
||||
const result: Record<string, unknown> = {}
|
||||
let currentKey: string | null = null
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const match = trimmed.match(/^([a-z_]+):\s*(.*)$/i)
|
||||
if (match) {
|
||||
currentKey = match[1]
|
||||
let value = match[2].trim()
|
||||
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1)
|
||||
else if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1)
|
||||
else if (value === 'true') result[currentKey] = true
|
||||
else if (value === 'false') result[currentKey] = false
|
||||
else if (/^\d+$/.test(value)) result[currentKey] = Number(value)
|
||||
if (currentKey && !(currentKey in result)) result[currentKey] = value
|
||||
}
|
||||
}
|
||||
const document = parseDocument(yamlText)
|
||||
if (document.errors.length || document.warnings.length || !isMap(document.contents)) throw new Error('THEME_MANIFEST_INVALID: 主题清单必须是有效的 YAML 映射')
|
||||
const result = document.toJS({ maxAliasCount: 20 }) as Record<string, unknown>
|
||||
const { manifest } = validateManifest(result)
|
||||
return manifest
|
||||
}
|
||||
@@ -278,6 +270,7 @@ export async function installTheme(
|
||||
manifest: ThemeManifest,
|
||||
cssContent: string,
|
||||
): Promise<InstalledTheme> {
|
||||
validateManifest(manifest as unknown as Record<string, unknown>)
|
||||
// validateCssSafety 会对 @import / expression() / javascript: 抛错,
|
||||
// 必须在 applyThemeCss 之前调用 —— 未校验的 CSS 一律不许进入页面。
|
||||
const warnings = validateCssSafety(cssContent)
|
||||
@@ -314,6 +307,7 @@ export async function enableTheme(themeId: string): Promise<InstalledTheme> {
|
||||
const themes = loadStoredThemes()
|
||||
const theme = themes.find((t) => t.theme_id === themeId)
|
||||
if (!theme) throw new Error('THEME_PACKAGE_NOT_FOUND')
|
||||
validateManifest(theme.manifest as unknown as Record<string, unknown>)
|
||||
theme.enabled = true
|
||||
saveThemes(themes)
|
||||
return theme
|
||||
@@ -346,6 +340,11 @@ export function getActiveCustomTheme(): string | null {
|
||||
}
|
||||
|
||||
export function setActiveCustomTheme(themeId: string | null) {
|
||||
if (themeId) {
|
||||
const theme = loadStoredThemes().find(item => item.theme_id === themeId)
|
||||
if (!theme) throw new Error('THEME_PACKAGE_NOT_FOUND')
|
||||
validateManifest(theme.manifest as unknown as Record<string, unknown>)
|
||||
}
|
||||
const css = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
|
||||
// Validate before changing the current page. Only the selected theme owns a style node.
|
||||
if (css) validateCssSafety(css)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Markup survives Milkdown's preview copying; the enclosing Vue component handles clicks. */
|
||||
export function appendDiagramControls(container: HTMLElement) {
|
||||
const controls = document.createElement('div')
|
||||
controls.className = 'diagram-controls'
|
||||
controls.setAttribute('contenteditable', 'false')
|
||||
for (const [action, label] of [['out', '缩小图表'], ['in', '放大图表'], ['reset', '重置缩放'], ['view', '大图查看']]) {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.dataset.diagramAction = action
|
||||
button.textContent = label
|
||||
controls.append(button)
|
||||
}
|
||||
container.append(controls)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { bundledLanguagesInfo } from 'shiki/langs'
|
||||
import githubDark from '@shikijs/themes/github-dark'
|
||||
import githubLight from '@shikijs/themes/github-light'
|
||||
import { renderMermaid } from '@/services/mermaidService'
|
||||
import { appendDiagramControls } from './diagramControls'
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true })
|
||||
|
||||
@@ -82,6 +83,7 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
|
||||
const container = document.createElement('div')
|
||||
container.className = 'markdown-mermaid'
|
||||
container.innerHTML = result.svg
|
||||
if (!result.warnings.length) appendDiagramControls(container)
|
||||
pre.replaceWith(container)
|
||||
} catch {
|
||||
const fallback = document.createElement('pre')
|
||||
|
||||
Reference in New Issue
Block a user