feat: 完善模型用量趋势与全局手账卡片并补齐阶段验收

This commit is contained in:
2026-09-05 20:40:36 +08:00
parent 02dd585a4e
commit 8d626ee16b
38 changed files with 733 additions and 86 deletions
+14
View File
@@ -173,3 +173,17 @@ css_entry: styles/theme.css
发布主题仓库时可提供原始 `.theme` 文件链接或 ZIP 发布附件直链,不要使用仓库 HTML 浏览页面地址。下载请求不携带 Cookie 或 HTTP 登录信息,服务器需允许应用来源的 CORS 请求;暂不支持私有仓库认证。 发布主题仓库时可提供原始 `.theme` 文件链接或 ZIP 发布附件直链,不要使用仓库 HTML 浏览页面地址。下载请求不携带 Cookie 或 HTTP 登录信息,服务器需允许应用来源的 CORS 请求;暂不支持私有仓库认证。
下载和本地文件限制为 5 MB;ZIP 解压总大小限制为 10 MB,最多 100 个条目。URL 下载超时为 30 秒。取消导入会取消下载,过期请求不会替换当前待安装主题。更新时递增清单版本号,并保持 `theme_id` 稳定。 下载和本地文件限制为 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,通用卡片、执行事件、引用、模型路由及弹窗统一使用纸张、虚线、胶带和叠纸阴影。已安装旧版本时,在主题社区点击“更新”应用新版样式。
+1 -1
View File
@@ -273,7 +273,7 @@ class LocalSpeech:
from app.contracts import TranscriptSegment from app.contracts import TranscriptSegment
result = await runtime.infer("qwen3-asr", "transcription", {"source": str(source.resolve()), "language": language}) result = await runtime.infer("qwen3-asr", "transcription", {"source": str(source.resolve()), "language": language})
return RoutedTranscript(text=result["text"], source="local", 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): async def match(self, source, reference):
result = await runtime.infer("eres2netv2", "speaker_matching", result = await runtime.infer("eres2netv2", "speaker_matching",
+32 -9
View File
@@ -9,27 +9,49 @@ import threading
import time import time
def decode(path, *, limit_seconds=3600): def decode(path, *, limit_seconds=3600, warnings=None):
import av import av
import numpy as np import numpy as np
frames = [] frames = []
samples = 0 samples = 0
corrupt = 0
with av.open(path, options={"protocol_whitelist": "file,pipe"}) as container: with av.open(path, options={"protocol_whitelist": "file,pipe"}) as container:
if not container.streams.audio: if not container.streams.audio:
raise ValueError("Media has no audio track") raise ValueError("Media has no audio track")
resampler = av.AudioResampler(format="fltp", layout="mono", rate=16000) resampler = av.AudioResampler(format="fltp", layout="mono", rate=16000)
for frame in container.decode(audio=0): for packet in container.demux(audio=0):
for output in resampler.resample(frame): try:
audio = output.to_ndarray().reshape(-1) decoded = packet.decode()
samples += len(audio) 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: if samples > limit_seconds * 16000:
raise ValueError("Audio exceeds one hour") 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): 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: if not frames:
raise ValueError("Audio is empty") raise ValueError("Audio is empty")
audio = np.concatenate(frames).astype(np.float32) 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: if not np.isfinite(audio).all() or len(audio) < 1600:
raise ValueError("Invalid or too short audio") raise ValueError("Invalid or too short audio")
return audio return audio
@@ -125,7 +147,8 @@ def run(request):
model = Qwen3ASRModel.from_pretrained(path, dtype=torch.float32 if device == "cpu" else torch.float16, 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) device_map=device, attn_implementation="sdpa", max_inference_batch_size=1, max_new_tokens=512)
loaded = time.monotonic() loaded = time.monotonic()
audio = decode(payload["source"]) decode_warnings = []
audio = decode(payload["source"], warnings=decode_warnings)
audio_seconds = len(audio) / 16000 audio_seconds = len(audio) / 16000
regions = speech_regions(audio) regions = speech_regions(audio)
language = {"zh": "Chinese", "en": "English", "ja": "Japanese", "yue": "Cantonese"}.get(payload.get("language"), payload.get("language")) 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}) "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__.write(json.dumps({"progress": end / len(audio), "segment": segments[-1]}, ensure_ascii=False) + "\n")
sys.__stdout__.flush() 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": elif operation == "speaker_matching":
model = speaker_model(path, device) model = speaker_model(path, device)
loaded = time.monotonic() loaded = time.monotonic()
+4 -2
View File
@@ -18,7 +18,9 @@ from app.services import transcription_service as jobs
from app.services.attachment_service import attachment_path from app.services.attachment_service import attachment_path
router = APIRouter(prefix="/api/media", tags=["Media"]) 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"} 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(): async for chunk in request.stream():
size += len(chunk) size += len(chunk)
if size > MAX_UPLOAD_BYTES: 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) digest.update(chunk)
stream.write(chunk) stream.write(chunk)
if not size: if not size:
+8 -5
View File
@@ -31,6 +31,7 @@ from app.retrieval.provenance import record_embedding
CAPABILITIES = ("embedding", "transcription", "speaker_matching") CAPABILITIES = ("embedding", "transcription", "speaker_matching")
HTTP_TYPES = {ProviderType.openai_chat, ProviderType.openai_compatible} HTTP_TYPES = {ProviderType.openai_chat, ProviderType.openai_compatible}
MAX_MEDIA_BYTES = 25 * 1024 * 1024 MAX_MEDIA_BYTES = 25 * 1024 * 1024
MAX_LOCAL_MEDIA_BYTES = 128 * 1024 * 1024
MAX_RESPONSE_BYTES = 16 * 1024 * 1024 MAX_RESPONSE_BYTES = 16 * 1024 * 1024
@@ -58,6 +59,7 @@ class RoutedTranscript:
source: str source: str
fallback_reason: str | None = None fallback_reason: str | None = None
segments: list = field(default_factory=list) segments: list = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def invalid_response() -> ProviderError: def invalid_response() -> ProviderError:
@@ -276,21 +278,22 @@ class ModelRoutingService:
dimensions=local_embedding.dim, fallback_reason=reason) dimensions=local_embedding.dim, fallback_reason=reason)
@staticmethod @staticmethod
def _media_file(path: Path): def _media_file(path: Path, *, local_only: bool = False):
try: try:
handle = path.open("rb") handle = path.open("rb")
except OSError as exc: except OSError as exc:
raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Audio attachment was not found.") from exc raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Audio attachment was not found.") from exc
import os 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() 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 return handle
async def transcribe(self, source: Path, language: str | None, *, local_only: bool = False) -> RoutedTranscript: async def transcribe(self, source: Path, language: str | None, *, local_only: bool = False) -> RoutedTranscript:
binding = None if local_only else self.configuration().transcription binding = None if local_only else self.configuration().transcription
if binding is None: if binding is None:
with self._media_file(source): with self._media_file(source, local_only=local_only):
pass pass
reason = None reason = None
if binding: if binding:
@@ -343,7 +346,7 @@ class ModelRoutingService:
async def match_speakers(self, source: Path, reference: Path, *, local_only: bool = False) -> SpeakerMatchResult: async def match_speakers(self, source: Path, reference: Path, *, local_only: bool = False) -> SpeakerMatchResult:
binding = None if local_only else self.configuration().speaker_matching binding = None if local_only else self.configuration().speaker_matching
if binding is None: 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 pass
reason = None reason = None
if binding: 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") actual = source if source.is_file() else attachment_path(f"{attachment_id}.txt")
if not actual.is_file(): if not actual.is_file():
raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Attachment was not found.") raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Attachment was not found.")
if not 0 < actual.stat().st_size <= 25 * 1024 * 1024: from app.providers.routing import MAX_LOCAL_MEDIA_BYTES, MAX_MEDIA_BYTES
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment must be between 1 byte and 25 MiB.") 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()) digest = await asyncio.to_thread(lambda: hashlib.sha256(actual.read_bytes()).hexdigest())
from app.container import container from app.container import container
from app.local_models.runtime import configuration 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) 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.text, job.source, job.fallback_reason = result.text, result.source, result.fallback_reason
job.segments = getattr(result, "segments", []) or [] job.segments = getattr(result, "segments", []) or []
job.warnings.extend(getattr(result, "warnings", []) or [])
if not job.text or not job.text.strip(): if not job.text or not job.text.strip():
raise ApiError(422, "TRANSCRIPT_EMPTY", "Transcript is empty.") raise ApiError(422, "TRANSCRIPT_EMPTY", "Transcript is empty.")
if request.diarization: if request.diarization:
+23 -4
View File
@@ -6,7 +6,7 @@ import logging
import math import math
from contextlib import closing from contextlib import closing
from contextvars import ContextVar from contextvars import ContextVar
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from uuid import uuid4 from uuid import uuid4
from app.database.db import connect from app.database.db import connect
@@ -107,8 +107,8 @@ class UsageAttempt:
logger.warning("Usage persistence failed; model response remains available") logger.warning("Usage persistence failed; model response remains available")
def aggregate(start, end, provider_id=None, model=None, source=None): def aggregate(start, end, provider_id=None, model=None, source=None, timezone_offset=0):
query = "SELECT counters_json,completed,capability FROM model_usage WHERE started_at>=? AND started_at<?" 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()] args = [start.astimezone(timezone.utc).isoformat(), end.astimezone(timezone.utc).isoformat()]
for column, value in (("provider_id", provider_id), ("model", model), ("source", source)): for column, value in (("provider_id", provider_id), ("model", model), ("source", source)):
if value: if value:
@@ -117,6 +117,18 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
with closing(connection()) as conn: with closing(connection()) as conn:
rows = conn.execute(query, args).fetchall() rows = conn.execute(query, args).fetchall()
options = conn.execute("SELECT DISTINCT provider_id,model,source FROM model_usage ORDER BY provider_id,model").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} totals = {key: None for key in METRICS}
coverage = {key: 0 for key in METRICS} coverage = {key: 0 for key in METRICS}
hits, eligible_input, cache_requests = 0, 0, 0 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"}: if row[2] in {"transcription", "speaker_matching"}:
audio_requests += 1 audio_requests += 1
counts = json.loads(row[0]) 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: if counts.get("audio_seconds") is not None:
audio_covered += 1 audio_covered += 1
audio_seconds = (audio_seconds or 0) + counts["audio_seconds"] 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, "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, "cache_hit_rate": hits / eligible_input if eligible_input else None,
"options": [dict(row) for row in options], "start": start, "end": end, "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}
+2 -2
View File
@@ -9,11 +9,11 @@ router = APIRouter(prefix="/api/usage", tags=["Usage"])
@router.get("") @router.get("")
async def usage(start: datetime | None = None, end: datetime | None = None, 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), 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) end = end or datetime.now(timezone.utc)
start = start or end - timedelta(days=7) start = start or end - timedelta(days=7)
if not start.tzinfo or not end.tzinfo or end <= start: 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.") raise ApiError(422, "INVALID_TIME_RANGE", "Provide timezone-aware start/end with end after start.")
if source not in {None, "local", "api"}: if source not in {None, "local", "api"}:
raise ApiError(422, "INVALID_USAGE_SOURCE", "Unknown usage source.") 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)
+69
View File
@@ -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']
+22
View File
@@ -92,3 +92,25 @@ def test_real_adapter_body_and_usage_persistence():
result = summary() result = summary()
assert result["request_count"] == 1 and result["totals"]["input_tokens"] == 10 assert result["request_count"] == 1 and result["totals"]["input_tokens"] == 10
assert result["complete_requests"] == 1 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 渲染和函数图像绘制。 第二阶段延续第一阶段已经形成的模块边界,重点推进多模态输入、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 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 .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收尾验收记录》的长录音补充;此前短样本记录保留为历史证据。
+21 -1
View File
@@ -47,9 +47,29 @@ CPU 与 CUDA 分别创建隔离 Vault、附件目录和 SQLite,只读取固定
## 未关闭的专项验收 ## 未关闭的专项验收
- 带参考转写和说话人标注的真实课程长录音尚未提供,不能报告 CER/WER、DER、阈值或长音频吞吐达标。 - 已提供无标注长录音,CUDA 功能与单次耗时验证见下文。参考转写和说话人标注仍缺失,不能报告 CER/WER、DER、阈值或业务吞吐达标。
- 现阶段时间戳为片段级;逐字强制对齐、同段多人/重叠语音仍未实现,不将片段聚类视为完整说话人分离。 - 现阶段时间戳为片段级;逐字强制对齐、同段多人/重叠语音仍未实现,不将片段聚类视为完整说话人分离。
- 外部供应商特殊 JSON 的兼容性,需要在目标账号和模型上点击实际推理验证;离线协议通过不替代厂商验收。 - 外部供应商特殊 JSON 的兼容性,需要在目标账号和模型上点击实际推理验证;离线协议通过不替代厂商验收。
- Tauri/Rust Host 和生产 MCP 沙箱按后续阶段安排;本轮数据持久化在后端 SQLite/Vault,为桌面集成保留稳定接口。 - Tauri/Rust Host 和生产 MCP 沙箱按后续阶段安排;本轮数据持久化在后端 SQLite/Vault,为桌面集成保留稳定接口。
结论:阶段 F 本轮工程收尾已实现并完成 CPU/CUDA 功能验收;上述质量及外部服务专项保持待验收状态,不标记为全部通过。分支仍需独立审阅后决定合并。 结论:阶段 F 本轮工程收尾已实现并完成 CPU/CUDA 功能验收;上述质量及外部服务专项保持待验收状态,不标记为全部通过。分支仍需独立审阅后决定合并。
## 2026-09-05 长录音补充验收
用户授权使用本机 CUDA,只做本地处理。使用隔离的 SQLite、附件目录与 Vault,读取已安装的固定 revision 权重;原音频、转写正文和独立运行报告保留在被忽略的 `.local-plans`,不入库。
| 观察项 | 本次结果 |
| --- | --- |
| 输入 | MP389,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 测试不代替目标账号实测。
+3 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "notes-agent-frontend", "name": "notes-agent-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -39,6 +39,7 @@
"marked": "^15.0.0", "marked": "^15.0.0",
"mermaid": "^11.17.2", "mermaid": "^11.17.2",
"pinia": "^4.0.0", "pinia": "^4.0.0",
"semver": "^7.8.5",
"shiki": "^4.4.3", "shiki": "^4.4.3",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-router": "^5.0.0", "vue-router": "^5.0.0",
@@ -46,6 +47,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/semver": "^7.8.0",
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
"@vue/test-utils": "^2.5.0", "@vue/test-utils": "^2.5.0",
"happy-dom": "^20.11.15", "happy-dom": "^20.11.15",
+11
View File
@@ -92,6 +92,9 @@ importers:
pinia: pinia:
specifier: ^4.0.0 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)) 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: shiki:
specifier: ^4.4.3 specifier: ^4.4.3
version: 4.4.3 version: 4.4.3
@@ -108,6 +111,9 @@ importers:
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.20.1 version: 22.20.1
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^5.0.0 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)) 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': '@types/node@22.20.1':
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
'@types/semver@7.8.0':
resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==}
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -3247,6 +3256,8 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/semver@7.8.0': {}
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
optional: true optional: true
+34 -1
View File
@@ -1,6 +1,6 @@
theme_id: paper-moments theme_id: paper-moments
name: 纸间时光 · Paper Moments name: 纸间时光 · Paper Moments
version: 1.4.1 version: 1.5.0
author: NotesAgent author: NotesAgent
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
min_app_version: 0.2.0 min_app_version: 0.2.0
@@ -266,3 +266,36 @@ license: MIT
@media (max-width: 720px) { @media (max-width: 720px) {
[data-theme="paper-moments"] .note-metadata { width: 100%; padding: 22px 18px; } [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"> <script setup lang="ts">
import DiagramInteractions from './DiagramInteractions.vue'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { renderMarkdown } from '@/utils/markdown' import { renderMarkdown } from '@/utils/markdown'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
@@ -19,7 +20,7 @@ watch([() => props.source, diagramTheme, () => themeStore.currentThemeId], async
</script> </script>
<template> <template>
<div class="markdown-content" v-html="html" /> <DiagramInteractions><div class="markdown-content" v-html="html" /></DiagramInteractions>
</template> </template>
<style> <style>
@@ -41,6 +41,31 @@ async function switchToTree(wrapper: ReturnType<typeof mountTree>) {
} }
describe('TraceTimeline 树形视图', () => { 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 () => { it('叶子节点点击后能看到自己的数据', async () => {
// 回归:之前行的 click 是 `children.length && toggleExpand(id)` // 回归:之前行的 click 是 `children.length && toggleExpand(id)`
// 而详情 v-if 又要求 children.length === 0 —— 两个条件互斥, // 而详情 v-if 又要求 children.length === 0 —— 两个条件互斥,
+43 -4
View File
@@ -21,6 +21,32 @@ const expandedNodes = ref<Set<string>>(new Set())
const detailNodes = ref<Set<string>>(new Set()) const detailNodes = ref<Set<string>>(new Set())
const viewMode = ref<'timeline' | 'tree'>('timeline') const viewMode = ref<'timeline' | 'tree'>('timeline')
const showDetails = ref(true) 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 traceNodes = computed(() => buildTraceNodes(props.events))
const toolCalls = computed(() => getToolCallsFromEvents(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 }> = [] const result: Array<{ node: TraceNode; depth: number }> = []
for (const node of nodes) { for (const node of nodes) {
result.push({ node, depth }) 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)) result.push(...flatNodes(node.children, depth + 1))
} }
} }
return result return result
} }
const flatTrace = computed(() => flatNodes(traceNodes.value)) const flatTrace = computed(() => flatNodes(filteredTree.value))
</script> </script>
<template> <template>
@@ -165,10 +191,19 @@ const flatTrace = computed(() => flatNodes(traceNodes.value))
</div> </div>
</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 v-if="viewMode === 'timeline'" class="timeline-view">
<div class="timeline"> <div class="timeline">
<article <article
v-for="event in events" v-for="event in filteredEvents"
:key="event.sequence" :key="event.sequence"
class="event-card" class="event-card"
:class="{ expanded: isDetailOpen(`event-${event.sequence}`) }" :class="{ expanded: isDetailOpen(`event-${event.sequence}`) }"
@@ -265,7 +300,7 @@ const flatTrace = computed(() => flatNodes(traceNodes.value))
</div> </div>
</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> <h3 class="panel-title">工具调用统计</h3>
<div class="tool-call-list"> <div class="tool-call-list">
<div v-for="call in toolCalls" :key="call.tool_call_id" class="tool-call-item" :class="call.status"> <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> </template>
<style scoped> <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 { .trace-visualization {
display: grid; display: grid;
gap: var(--space-lg); gap: var(--space-lg);
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import DiagramInteractions from '@/components/common/DiagramInteractions.vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Link } from '@element-plus/icons-vue' import { Link } from '@element-plus/icons-vue'
import { Crepe } from '@milkdown/crepe' import { Crepe } from '@milkdown/crepe'
@@ -68,7 +69,7 @@ function renderDiagram(source: string, apply: (value: HTMLElement) => void) {
if (entry.apply === apply) diagramPreviews.delete(id) if (entry.apply === apply) diagramPreviews.delete(id)
} }
const element = createMermaidPreview(source, themeStore.isDark, apply) const element = createMermaidPreview(source, themeStore.isDark, apply)
diagramPreviews.set(element.id, { source, apply }) diagramPreviews.set(element.dataset.previewId!, { source, apply })
return element return element
} }
watch(() => themeStore.currentThemeId, () => { watch(() => themeStore.currentThemeId, () => {
@@ -264,7 +265,7 @@ defineExpose({ getEditor: () => crepe?.editor })
</script> </script>
<template> <template>
<div class="visual-editor"> <DiagramInteractions class="visual-editor">
<div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')"> <div class="markdown-toolbar" role="toolbar" :aria-label="t('Markdown 格式工具栏', 'Markdown formatting toolbar')">
<label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')"> <label class="toolbar-select heading-select" :title="t('设置标题级别', 'Set heading level')">
<span class="format-glyph heading-glyph">H</span> <span class="format-glyph heading-glyph">H</span>
@@ -313,7 +314,7 @@ defineExpose({ getEditor: () => crepe?.editor })
</section> </section>
<div ref="editorRoot" /> <div ref="editorRoot" />
</div> </div>
</div> </DiagramInteractions>
</template> </template>
<style scoped> <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(oldPublish).not.toHaveBeenCalled()
expect(latestPublish).toHaveBeenCalledWith(latest) expect(latestPublish).toHaveBeenCalledWith(latest)
expect(latestPublish.mock.calls[0]![0]).not.toBe(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' }) 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 }) vi.mocked(renderMermaid).mockResolvedValueOnce({ svg: '', warnings: ['<img src=x onerror=alert(1)>'], width: 0, height: 0 })
const preview = createMermaidPreview('invalid', false, vi.fn()) const preview = createMermaidPreview('invalid', false, vi.fn())
await flushPromises() await flushPromises()
expect(preview.classList.contains('has-error')).toBe(true) expect(preview.querySelector('.has-error')).not.toBeNull()
expect(preview.querySelector('img')).toBeNull() expect(preview.querySelector('img')).toBeNull()
expect(preview.textContent).toContain('点击编辑') expect(preview.textContent).toContain('点击编辑')
}) })
@@ -1,13 +1,19 @@
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { renderMermaid } from '@/services/mermaidService' import { renderMermaid } from '@/services/mermaidService'
import { t } from '@/i18n' import { t } from '@/i18n'
import { appendDiagramControls } from '@/utils/diagramControls'
let previewId = 0 let previewId = 0
export function createMermaidPreview(source: string, dark: boolean, applyPreview: (value: HTMLElement) => void): HTMLElement { 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. // 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') const container = document.createElement('div')
envelope.append(container)
container.className = 'editor-mermaid-preview' container.className = 'editor-mermaid-preview'
container.id = `editor-mermaid-preview-${++previewId}` container.id = `editor-mermaid-preview-${++previewId}`
envelope.dataset.previewId = container.id
container.setAttribute('aria-live', 'polite') container.setAttribute('aria-live', 'polite')
container.textContent = t('正在渲染图表…', 'Rendering diagram…') container.textContent = t('正在渲染图表…', 'Rendering diagram…')
const publish = async () => { const publish = async () => {
@@ -18,7 +24,7 @@ export function createMermaidPreview(source: string, dark: boolean, applyPreview
if (visible) { if (visible) {
// PreviewPanel copies HTML instead of retaining the supplied element. // PreviewPanel copies HTML instead of retaining the supplied element.
// Update the current copy through Milkdown's reactive callback. // 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 => { 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. // Mermaid runs in strict mode; Milkdown sanitizes the preview before insertion.
container.innerHTML = result.svg container.innerHTML = result.svg
appendDiagramControls(container)
void publish() void publish()
}).catch(() => { }).catch(() => {
container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.') container.textContent = t('图表渲染失败,请点击编辑检查源码。', 'Unable to render diagram. Choose Edit to inspect the source.')
void publish() void publish()
}) })
return container return envelope
} }
+6 -2
View File
@@ -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 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 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 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'), DIARIZATION_UNAVAILABLE: t('当前无法分离说话人', 'Speaker identification is unavailable'),
WORD_TIMESTAMPS_UNAVAILABLE: t('未提供逐字时间戳', 'Word-level timestamps are 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'), 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() { async function submit() {
if (!file.value) return if (!file.value) return
await action(async () => { 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 = {} let terms = {}
if (terminology.value.trim()) { if (terminology.value.trim()) {
terms = JSON.parse(terminology.value) terms = JSON.parse(terminology.value)
@@ -106,7 +110,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<template> <template>
<section class="media-page"> <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> <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"> <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" /> <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 editingProvider = ref<ProviderConfig>()
const providerAction = ref('') const providerAction = ref('')
const testResults = ref<Record<string, string>>({}) 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 () => { onMounted(async () => {
await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()]) await Promise.all([providerStore.loadProviders(), providerStore.loadPresets(), settingsStore.loadDiagnostics()])
await providerStore.refreshEnabledModels() await providerStore.refreshEnabledModels()
@@ -40,7 +52,7 @@ function openProvider(provider?: ProviderConfig) {
editingProvider.value = provider editingProvider.value = provider
providerAction.value = '' providerAction.value = ''
showProviderForm.value = true 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) { 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 === '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"> <div v-else-if="activeSection === 'providers'" class="settings-section">
<section class="panel provider-settings-card">
<div class="section-head"> <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> <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> <button class="button-primary" @click="openProvider()">{{ t('新增 Provider', 'Add Provider') }}</button>
</div> </div>
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</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> <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"> <div class="provider-list">
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card"> <article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
<div class="provider-main"> <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> <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 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"> <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> <p v-if="testResults[provider.provider_id]" class="test-result">{{ testResults[provider.provider_id] }}</p>
</div> </div>
<div class="inline-actions provider-actions"> <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" :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" @click="testProvider(provider)">{{ t('测试', 'Test') }}</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-secondary" @click="openProvider(provider)">{{ t('编辑', 'Edit') }}</button>
<button class="button-danger" @click="removeProvider(provider)">{{ t('删除', 'Delete') }}</button> <button class="button-danger" @click="removeProvider(provider)">{{ t('删除', 'Delete') }}</button>
</div> </div>
</article> </article>
</div> </div>
</section>
<LocalModelSettings class="panel local-settings-card" />
<UsageCard />
</div> </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> <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> <style scoped>
.settings-page { max-width: 1120px; margin: 0 auto; } .settings-page { max-width: 1120px; margin: 0 auto; }
.settings-section { display: grid; gap: var(--space-md); } .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); } .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 { 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); } .setting-row:hover { background: var(--color-background-secondary); }
+28 -11
View File
@@ -1,8 +1,12 @@
<script setup lang="ts"> <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 { apiClient } from '@/services/apiClient'
import { t } from '@/i18n' 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 data = ref<Usage | null>(null)
const period = ref('7') const period = ref('7')
const provider = ref('') const provider = ref('')
@@ -12,23 +16,31 @@ const start = ref('')
const end = ref('') const end = ref('')
const busy = ref(false) const busy = ref(false)
const error = ref('') 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 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() { async function load() {
const current = ++generation
busy.value = true; error.value = '' busy.value = true; error.value = ''
try { try {
const until = period.value === 'custom' ? new Date(end.value) : new Date() const until = period.value === 'custom' ? new Date(end.value) : new Date()
const from = period.value === 'custom' ? new Date(start.value) : new Date(until) const from = period.value === 'custom' ? new Date(start.value) : new Date(until)
if (period.value === 'today') from.setHours(0,0,0,0) 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.')) 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}}) 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}})
} catch(e) { error.value = (e as Error).message } finally { busy.value = false } 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) onMounted(load)
</script> </script>
<template> <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> <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="custom">{{ t('自定义', 'Custom') }}</option></select></label> <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('提供商', '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('模型', '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> <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> <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> <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> <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> <UsageChart v-if="data.series" :buckets="data.series" />
<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> <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>
<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> <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> <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> </template><p class="subtle">{{ t('统计为本应用观测值,不是厂商账户账单。缺失指标显示“未提供”,历史未记录的数据不补估。', 'Statistics are application observations, not provider billing. Missing metrics stay unavailable and historical gaps are not estimated.') }}</p>
</section> </section>
</template> </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 { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService'
import tokensCss from '@/styles/tokens.css?raw' 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 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(() => { 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. // Previewing never installs a theme or changes application styles/storage.
const doc = document.implementation.createHTMLDocument(theme.value?.name ?? '') const doc = document.implementation.createHTMLDocument(theme.value?.name ?? '')
doc.documentElement.dataset.theme = props.themeId 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') 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) doc.head.append(style)
const article = doc.createElement('article') const article = doc.createElement('article')
article.className = 'panel' 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)) await vi.waitFor(() => expect(useThemeStore().pendingInspection?.compatible).toBe(true))
expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false) expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false)
expect(wrapper.get('.inspection-result').text()).toContain('纸间时光') 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 () => { 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' import paperMomentsUrl from '@/assets/themes/paper-moments.theme?url'
const themeStore = useThemeStore() const themeStore = useThemeStore()
const previewImport = ref(false)
const activeTab = ref<'installed' | 'community'>('installed') const activeTab = ref<'installed' | 'community'>('installed')
const showImportDialog = ref(false) const showImportDialog = ref(false)
@@ -22,6 +23,7 @@ let importGeneration = 0
let downloadController: AbortController | undefined let downloadController: AbortController | undefined
function resetImport() { function resetImport() {
previewImport.value = false
importGeneration++ importGeneration++
downloadController?.abort() downloadController?.abort()
importing.value = false importing.value = false
@@ -253,6 +255,7 @@ onMounted(() => {
<summary>将要安装的 CSS{{ themeStore.pendingInspection.css.length }} 字符</summary> <summary>将要安装的 CSS{{ themeStore.pendingInspection.css.length }} 字符</summary>
<pre>{{ themeStore.pendingInspection.css }}</pre> <pre>{{ themeStore.pendingInspection.css }}</pre>
</details> </details>
<button type="button" class="button-secondary" @click="previewImport = true">预览主题效果</button>
</div> </div>
<div v-else class="upload-area"> <div v-else class="upload-area">
@@ -280,6 +283,7 @@ onMounted(() => {
</div> </div>
</div> </div>
</section> </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> </template>
<style scoped> <style scoped>
+14 -3
View File
@@ -1,16 +1,27 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest' import { afterEach, expect, it, vi } from 'vitest'
import { strToU8, zipSync } from 'fflate' 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' import paper from '@/assets/themes/paper-moments.theme?raw'
afterEach(() => vi.unstubAllGlobals()) 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 () => { 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 zip = zipSync({ 'repo-main/theme.yaml': strToU8(yaml!), 'repo-main/theme.css': strToU8(css!) })
const result = await inspectThemePackage(await decodeThemePackage(zip)) const result = await inspectThemePackage(await decodeThemePackage(zip))
expect(result.compatible).toBe(true) 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 () => { it('accepts a zipped single-file theme', async () => {
expect(await decodeThemePackage(zipSync({ 'paper.theme': strToU8(paper) }))).toBe(paper) expect(await decodeThemePackage(zipSync({ 'paper.theme': strToU8(paper) }))).toBe(paper)
+19 -20
View File
@@ -1,5 +1,10 @@
import type { InstalledTheme, ThemeManifest, ThemePackageInspection } from '@/contracts' import type { InstalledTheme, ThemeManifest, ThemePackageInspection } from '@/contracts'
import paperMomentsPackage from '@/assets/themes/paper-moments.theme?raw' 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 STORAGE_KEY = 'installed-themes'
const ACTIVE_CUSTOM_KEY = 'active-custom-theme' 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))) { if (!/^[a-z0-9_-]+$/.test(String(raw.theme_id))) {
throw new Error('THEME_MANIFEST_INVALID: theme_id must match [a-z0-9_-]+') throw new Error('THEME_MANIFEST_INVALID: theme_id must match [a-z0-9_-]+')
} }
if (!/^\d+\.\d+\.\d+/.test(String(raw.version))) { for (const field of ['version', 'min_app_version']) {
warnings.push('版本号格式建议使用 semver(如 1.0.0') 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) const cssEntry = String(raw.css_entry)
if (cssEntry.includes('://') || cssEntry.startsWith('data:')) { if (cssEntry.includes('://') || cssEntry.startsWith('data:')) {
throw new Error('THEME_SECURITY_VIOLATION: css_entry must be a relative path within the package') 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 { function inspectYamlContent(yamlText: string): ThemeManifest {
const lines = yamlText.split('\n') const document = parseDocument(yamlText)
const result: Record<string, unknown> = {} if (document.errors.length || document.warnings.length || !isMap(document.contents)) throw new Error('THEME_MANIFEST_INVALID: 主题清单必须是有效的 YAML 映射')
let currentKey: string | null = null const result = document.toJS({ maxAliasCount: 20 }) as Record<string, unknown>
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 { manifest } = validateManifest(result) const { manifest } = validateManifest(result)
return manifest return manifest
} }
@@ -278,6 +270,7 @@ export async function installTheme(
manifest: ThemeManifest, manifest: ThemeManifest,
cssContent: string, cssContent: string,
): Promise<InstalledTheme> { ): Promise<InstalledTheme> {
validateManifest(manifest as unknown as Record<string, unknown>)
// validateCssSafety 会对 @import / expression() / javascript: 抛错, // validateCssSafety 会对 @import / expression() / javascript: 抛错,
// 必须在 applyThemeCss 之前调用 —— 未校验的 CSS 一律不许进入页面。 // 必须在 applyThemeCss 之前调用 —— 未校验的 CSS 一律不许进入页面。
const warnings = validateCssSafety(cssContent) const warnings = validateCssSafety(cssContent)
@@ -314,6 +307,7 @@ export async function enableTheme(themeId: string): Promise<InstalledTheme> {
const themes = loadStoredThemes() const themes = loadStoredThemes()
const theme = themes.find((t) => t.theme_id === themeId) const theme = themes.find((t) => t.theme_id === themeId)
if (!theme) throw new Error('THEME_PACKAGE_NOT_FOUND') if (!theme) throw new Error('THEME_PACKAGE_NOT_FOUND')
validateManifest(theme.manifest as unknown as Record<string, unknown>)
theme.enabled = true theme.enabled = true
saveThemes(themes) saveThemes(themes)
return theme return theme
@@ -346,6 +340,11 @@ export function getActiveCustomTheme(): string | null {
} }
export function setActiveCustomTheme(themeId: 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 const css = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
// Validate before changing the current page. Only the selected theme owns a style node. // Validate before changing the current page. Only the selected theme owns a style node.
if (css) validateCssSafety(css) if (css) validateCssSafety(css)
+14
View File
@@ -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)
}
+2
View File
@@ -6,6 +6,7 @@ import { bundledLanguagesInfo } from 'shiki/langs'
import githubDark from '@shikijs/themes/github-dark' import githubDark from '@shikijs/themes/github-dark'
import githubLight from '@shikijs/themes/github-light' import githubLight from '@shikijs/themes/github-light'
import { renderMermaid } from '@/services/mermaidService' import { renderMermaid } from '@/services/mermaidService'
import { appendDiagramControls } from './diagramControls'
marked.setOptions({ gfm: true, breaks: true }) marked.setOptions({ gfm: true, breaks: true })
@@ -82,6 +83,7 @@ export async function renderMarkdown(source: string, options?: { theme?: 'light'
const container = document.createElement('div') const container = document.createElement('div')
container.className = 'markdown-mermaid' container.className = 'markdown-mermaid'
container.innerHTML = result.svg container.innerHTML = result.svg
if (!result.warnings.length) appendDiagramControls(container)
pre.replaceWith(container) pre.replaceWith(container)
} catch { } catch {
const fallback = document.createElement('pre') const fallback = document.createElement('pre')