From 8d626ee16b8e2dcb57ceb10365f01b4d3ca5c0a2 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 5 Sep 2026 20:40:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E8=B6=8B=E5=8A=BF=E4=B8=8E=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=89=8B=E8=B4=A6=E5=8D=A1=E7=89=87=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 14 ++++ backend/app/local_models/runtime.py | 2 +- backend/app/local_models/worker.py | 41 ++++++++--- backend/app/media_routes.py | 6 +- backend/app/providers/routing.py | 13 ++-- backend/app/services/transcription_service.py | 6 +- backend/app/services/usage_service.py | 27 +++++-- backend/app/usage_routes.py | 4 +- backend/tests/test_large_local_media.py | 69 ++++++++++++++++++ backend/tests/test_usage_overrides.py | 22 ++++++ docs/architecture/第二阶段团队分工表.md | 16 +++++ .../多模态管线与模型运行开发说明.md | 8 +++ docs/development/阶段F收尾验收记录.md | 22 +++++- frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 11 +++ .../src/assets/themes/paper-moments.theme | 35 ++++++++- .../common/DiagramInteractions.spec.ts | 26 +++++++ .../components/common/DiagramInteractions.vue | 71 +++++++++++++++++++ .../src/components/common/MarkdownContent.vue | 3 +- .../src/features/agent/TraceTimeline.spec.ts | 25 +++++++ frontend/src/features/agent/TraceTimeline.vue | 47 ++++++++++-- .../features/editor/VisualMarkdownEditor.vue | 7 +- .../editor/diagramIntegration.spec.ts | 28 ++++++++ .../features/editor/mermaidPreview.spec.ts | 4 +- .../src/features/editor/mermaidPreview.ts | 11 ++- frontend/src/features/media/MediaView.vue | 8 ++- .../features/settings/SettingsView.spec.ts | 38 ++++++++++ .../src/features/settings/SettingsView.vue | 30 ++++++-- frontend/src/features/settings/UsageCard.vue | 39 +++++++--- .../src/features/settings/UsageChart.spec.ts | 17 +++++ frontend/src/features/settings/UsageChart.vue | 71 +++++++++++++++++++ .../features/themes/CommunityThemePreview.vue | 12 ++-- .../src/features/themes/ThemesView.spec.ts | 6 ++ frontend/src/features/themes/ThemesView.vue | 4 ++ frontend/src/services/themeImport.spec.ts | 17 ++++- frontend/src/services/themePackageService.ts | 39 +++++----- frontend/src/utils/diagramControls.ts | 14 ++++ frontend/src/utils/markdown.ts | 2 + 38 files changed, 733 insertions(+), 86 deletions(-) create mode 100644 backend/tests/test_large_local_media.py create mode 100644 frontend/src/components/common/DiagramInteractions.spec.ts create mode 100644 frontend/src/components/common/DiagramInteractions.vue create mode 100644 frontend/src/features/editor/diagramIntegration.spec.ts create mode 100644 frontend/src/features/settings/SettingsView.spec.ts create mode 100644 frontend/src/features/settings/UsageChart.spec.ts create mode 100644 frontend/src/features/settings/UsageChart.vue create mode 100644 frontend/src/utils/diagramControls.ts diff --git a/README.md b/README.md index 641878a..fbb686f 100644 --- a/README.md +++ b/README.md @@ -173,3 +173,17 @@ css_entry: styles/theme.css 发布主题仓库时可提供原始 `.theme` 文件链接或 ZIP 发布附件直链,不要使用仓库 HTML 浏览页面地址。下载请求不携带 Cookie 或 HTTP 登录信息,服务器需允许应用来源的 CORS 请求;暂不支持私有仓库认证。 下载和本地文件限制为 5 MB;ZIP 解压总大小限制为 10 MB,最多 100 个条目。URL 下载超时为 30 秒。取消导入会取消下载,过期请求不会替换当前待安装主题。更新时递增清单版本号,并保持 `theme_id` 稳定。 + + +### 主题兼容性与安装前预览 + +当前应用版本从 `frontend/package.json` 读取(0.2.0)。清单的 `version`、`min_app_version` 必须使用有效 SemVer;最低版本高于应用版本时,检查、安装和启用都会拒绝。文件、URL、ZIP 导入共用此规则。 + +导入检查通过后可点击“预览主题效果”。预览使用无脚本的 sandbox iframe,与当前应用样式和主题存储隔离;CSP 禁止远程资源,仅允许内联样式及 data 图片/字体。预览不等同于安装。 + + +### 用量趋势与纸间时光 1.5 + +模型设置页将提供商、本地模型、用量统计分成独立卡片。用量趋势支持近 7 天、30 天、90 天及自定义时间,沿用提供商/模型/来源筛选;按本机 UTC 偏移分组(长区间自动合并到最多 90 组)。可切换输入、输出、总 Token 和请求次数,本地为芯片实色图例,提供商为连接斜纹图例。仅汇总已报告值,并提供覆盖数与可展开的数据表,缺失不补零。 + +纸间时光更新至 1.5.0,通用卡片、执行事件、引用、模型路由及弹窗统一使用纸张、虚线、胶带和叠纸阴影。已安装旧版本时,在主题社区点击“更新”应用新版样式。 diff --git a/backend/app/local_models/runtime.py b/backend/app/local_models/runtime.py index ed86af0..62edf73 100644 --- a/backend/app/local_models/runtime.py +++ b/backend/app/local_models/runtime.py @@ -273,7 +273,7 @@ class LocalSpeech: from app.contracts import TranscriptSegment result = await runtime.infer("qwen3-asr", "transcription", {"source": str(source.resolve()), "language": language}) return RoutedTranscript(text=result["text"], source="local", - segments=[TranscriptSegment(**s) for s in result["segments"]]) + segments=[TranscriptSegment(**s) for s in result["segments"]], warnings=result.get("warnings", [])) async def match(self, source, reference): result = await runtime.infer("eres2netv2", "speaker_matching", diff --git a/backend/app/local_models/worker.py b/backend/app/local_models/worker.py index e3b5d50..a60151d 100644 --- a/backend/app/local_models/worker.py +++ b/backend/app/local_models/worker.py @@ -9,27 +9,49 @@ import threading import time -def decode(path, *, limit_seconds=3600): +def decode(path, *, limit_seconds=3600, warnings=None): import av import numpy as np frames = [] samples = 0 + corrupt = 0 with av.open(path, options={"protocol_whitelist": "file,pipe"}) as container: if not container.streams.audio: raise ValueError("Media has no audio track") resampler = av.AudioResampler(format="fltp", layout="mono", rate=16000) - for frame in container.decode(audio=0): - for output in resampler.resample(frame): - audio = output.to_ndarray().reshape(-1) - samples += len(audio) + for packet in container.demux(audio=0): + try: + decoded = packet.decode() + except av.error.InvalidDataError: + corrupt += 1 + if corrupt > 100: + raise ValueError("Too many damaged audio packets") + # Retain the missing packet's duration as silence so later timestamps do not shift. + missing = max(0, round(float((packet.duration or 0) * (packet.time_base or 0)) * 16000)) + samples += missing if samples > limit_seconds * 16000: raise ValueError("Audio exceeds one hour") - frames.append(audio) + if missing: + frames.append(np.zeros(missing, dtype=np.float32)) + continue + for frame in decoded: + for output in resampler.resample(frame): + audio = output.to_ndarray().reshape(-1) + samples += len(audio) + if samples > limit_seconds * 16000: + raise ValueError("Audio exceeds one hour") + frames.append(audio) for output in resampler.resample(None): - frames.append(output.to_ndarray().reshape(-1)) + audio = output.to_ndarray().reshape(-1) + samples += len(audio) + if samples > limit_seconds * 16000: + raise ValueError("Audio exceeds one hour") + frames.append(audio) if not frames: raise ValueError("Audio is empty") audio = np.concatenate(frames).astype(np.float32) + if corrupt and warnings is not None: + warnings.append(f"MEDIA_CORRUPT_PACKETS_SKIPPED:{corrupt}") if not np.isfinite(audio).all() or len(audio) < 1600: raise ValueError("Invalid or too short audio") return audio @@ -125,7 +147,8 @@ def run(request): model = Qwen3ASRModel.from_pretrained(path, dtype=torch.float32 if device == "cpu" else torch.float16, device_map=device, attn_implementation="sdpa", max_inference_batch_size=1, max_new_tokens=512) loaded = time.monotonic() - audio = decode(payload["source"]) + decode_warnings = [] + audio = decode(payload["source"], warnings=decode_warnings) audio_seconds = len(audio) / 16000 regions = speech_regions(audio) language = {"zh": "Chinese", "en": "English", "ja": "Japanese", "yue": "Cantonese"}.get(payload.get("language"), payload.get("language")) @@ -137,7 +160,7 @@ def run(request): "end_time": end / 16000, "text": output.text, "language": output.language}) sys.__stdout__.write(json.dumps({"progress": end / len(audio), "segment": segments[-1]}, ensure_ascii=False) + "\n") sys.__stdout__.flush() - result = {"text": "\n".join(s["text"] for s in segments), "segments": segments} + result = {"text": "\n".join(s["text"] for s in segments), "segments": segments, "warnings": decode_warnings} elif operation == "speaker_matching": model = speaker_model(path, device) loaded = time.monotonic() diff --git a/backend/app/media_routes.py b/backend/app/media_routes.py index 221ffb1..40cceef 100644 --- a/backend/app/media_routes.py +++ b/backend/app/media_routes.py @@ -18,7 +18,9 @@ from app.services import transcription_service as jobs from app.services.attachment_service import attachment_path router = APIRouter(prefix="/api/media", tags=["Media"]) -MAX_UPLOAD_BYTES = 25 * 1024 * 1024 +from app.providers.routing import MAX_LOCAL_MEDIA_BYTES + +MAX_UPLOAD_BYTES = MAX_LOCAL_MEDIA_BYTES MEDIA_SUFFIXES = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".mp4", ".webm", ".txt", ".md"} @@ -40,7 +42,7 @@ async def upload_attachment(request: Request, filename: str = Query(min_length=1 async for chunk in request.stream(): size += len(chunk) if size > MAX_UPLOAD_BYTES: - raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment exceeds 25 MiB.") + raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment exceeds 128 MiB.") digest.update(chunk) stream.write(chunk) if not size: diff --git a/backend/app/providers/routing.py b/backend/app/providers/routing.py index d4630fe..ec247b4 100644 --- a/backend/app/providers/routing.py +++ b/backend/app/providers/routing.py @@ -31,6 +31,7 @@ from app.retrieval.provenance import record_embedding CAPABILITIES = ("embedding", "transcription", "speaker_matching") HTTP_TYPES = {ProviderType.openai_chat, ProviderType.openai_compatible} MAX_MEDIA_BYTES = 25 * 1024 * 1024 +MAX_LOCAL_MEDIA_BYTES = 128 * 1024 * 1024 MAX_RESPONSE_BYTES = 16 * 1024 * 1024 @@ -58,6 +59,7 @@ class RoutedTranscript: source: str fallback_reason: str | None = None segments: list = field(default_factory=list) + warnings: list[str] = field(default_factory=list) def invalid_response() -> ProviderError: @@ -276,21 +278,22 @@ class ModelRoutingService: dimensions=local_embedding.dim, fallback_reason=reason) @staticmethod - def _media_file(path: Path): + def _media_file(path: Path, *, local_only: bool = False): try: handle = path.open("rb") except OSError as exc: raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Audio attachment was not found.") from exc import os - if not 0 < os.fstat(handle.fileno()).st_size <= MAX_MEDIA_BYTES: + limit = MAX_LOCAL_MEDIA_BYTES if local_only else MAX_MEDIA_BYTES + if not 0 < os.fstat(handle.fileno()).st_size <= limit: handle.close() - raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Audio attachment must be between 1 byte and 25 MiB.") + raise ApiError(413, "ATTACHMENT_TOO_LARGE", f"Audio attachment must be between 1 byte and {limit // (1024 * 1024)} MiB.") return handle async def transcribe(self, source: Path, language: str | None, *, local_only: bool = False) -> RoutedTranscript: binding = None if local_only else self.configuration().transcription if binding is None: - with self._media_file(source): + with self._media_file(source, local_only=local_only): pass reason = None if binding: @@ -343,7 +346,7 @@ class ModelRoutingService: async def match_speakers(self, source: Path, reference: Path, *, local_only: bool = False) -> SpeakerMatchResult: binding = None if local_only else self.configuration().speaker_matching if binding is None: - with self._media_file(source), self._media_file(reference): + with self._media_file(source, local_only=local_only), self._media_file(reference, local_only=local_only): pass reason = None if binding: diff --git a/backend/app/services/transcription_service.py b/backend/app/services/transcription_service.py index aef3d7c..da4f808 100644 --- a/backend/app/services/transcription_service.py +++ b/backend/app/services/transcription_service.py @@ -82,8 +82,9 @@ async def create_transcription(attachment_id, language=None, *, diarization=Fals actual = source if source.is_file() else attachment_path(f"{attachment_id}.txt") if not actual.is_file(): raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Attachment was not found.") - if not 0 < actual.stat().st_size <= 25 * 1024 * 1024: - raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment must be between 1 byte and 25 MiB.") + from app.providers.routing import MAX_LOCAL_MEDIA_BYTES, MAX_MEDIA_BYTES + if not 0 < actual.stat().st_size <= (MAX_LOCAL_MEDIA_BYTES if local_only else MAX_MEDIA_BYTES): + raise ApiError(413, "ATTACHMENT_TOO_LARGE", "仅本地处理最大支持 128 MiB;超过 25 MiB 的录音请启用仅本地处理。") digest = await asyncio.to_thread(lambda: hashlib.sha256(actual.read_bytes()).hexdigest()) from app.container import container from app.local_models.runtime import configuration @@ -160,6 +161,7 @@ async def _execute(job_id, request, routing=None): result = await (routing or container.model_routing).transcribe(source, request.language, local_only=request.local_only) job.text, job.source, job.fallback_reason = result.text, result.source, result.fallback_reason job.segments = getattr(result, "segments", []) or [] + job.warnings.extend(getattr(result, "warnings", []) or []) if not job.text or not job.text.strip(): raise ApiError(422, "TRANSCRIPT_EMPTY", "Transcript is empty.") if request.diarization: diff --git a/backend/app/services/usage_service.py b/backend/app/services/usage_service.py index fe13413..0be5ab5 100644 --- a/backend/app/services/usage_service.py +++ b/backend/app/services/usage_service.py @@ -6,7 +6,7 @@ import logging import math from contextlib import closing from contextvars import ContextVar -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from uuid import uuid4 from app.database.db import connect @@ -107,8 +107,8 @@ class UsageAttempt: logger.warning("Usage persistence failed; model response remains available") -def aggregate(start, end, provider_id=None, model=None, source=None): - query = "SELECT counters_json,completed,capability FROM model_usage WHERE started_at>=? AND started_at=? AND started_at`。超过 100 个损坏包则失败;缺少有效包时长时无法承诺时间对齐,应结合原音频复核。此处理不能恢复丢失语音。 +- Provider 卡片显示“已启用/已停用”,支持直接启停。保存成功后更新状态;失败保留原状态。停用或保存期间禁用测试与刷新模型操作。 +- 37 分 16 秒的用户录音已完成 CUDA 转写、片段级说话人聚类、笔记生成、本地向量索引及检索命中。无参考标注,不报告准确率。详见《阶段F收尾验收记录》的长录音补充;此前短样本记录保留为历史证据。 diff --git a/docs/development/阶段F收尾验收记录.md b/docs/development/阶段F收尾验收记录.md index 53141de..474f1a3 100644 --- a/docs/development/阶段F收尾验收记录.md +++ b/docs/development/阶段F收尾验收记录.md @@ -47,9 +47,29 @@ CPU 与 CUDA 分别创建隔离 Vault、附件目录和 SQLite,只读取固定 ## 未关闭的专项验收 -- 带参考转写和说话人标注的真实课程长录音尚未提供,不能报告 CER/WER、DER、阈值或长音频吞吐达标。 +- 已提供无标注长录音,CUDA 功能与单次耗时验证见下文。参考转写和说话人标注仍缺失,不能报告 CER/WER、DER、阈值或业务吞吐达标。 - 现阶段时间戳为片段级;逐字强制对齐、同段多人/重叠语音仍未实现,不将片段聚类视为完整说话人分离。 - 外部供应商特殊 JSON 的兼容性,需要在目标账号和模型上点击实际推理验证;离线协议通过不替代厂商验收。 - Tauri/Rust Host 和生产 MCP 沙箱按后续阶段安排;本轮数据持久化在后端 SQLite/Vault,为桌面集成保留稳定接口。 结论:阶段 F 本轮工程收尾已实现并完成 CPU/CUDA 功能验收;上述质量及外部服务专项保持待验收状态,不标记为全部通过。分支仍需独立审阅后决定合并。 + + +## 2026-09-05 长录音补充验收 + +用户授权使用本机 CUDA,只做本地处理。使用隔离的 SQLite、附件目录与 Vault,读取已安装的固定 revision 权重;原音频、转写正文和独立运行报告保留在被忽略的 `.local-plans`,不入库。 + +| 观察项 | 本次结果 | +| --- | --- | +| 输入 | MP3,89,424,101 字节,2235.60 秒(约 37 分 16 秒) | +| 转写与片段聚类 | completed;441 个片段,5 个说话人聚类 | +| 两环节总耗时 | 约 661 秒(轮询含最多约 10 秒误差),RTF 约 0.296 | +| Qwen3-ASR 实际设备 / 加载 / 推理 | cuda:0 / 10.52 秒 / 607.78 秒 | +| ERes2NetV2 实际设备 / 加载 / 推理 | cuda:0 / 3.97 秒 / 28.88 秒 | +| 后续链路 | Markdown 笔记生成、本地 Bekko 索引、向量检索命中均通过 | +| 隐私标记 | 导出笔记保留 `embedding_local_only: true` | +| 警告 | 1 个损坏音频包按时长补静音;说话人结果为片段级 | + +最初实测暴露了 25 MiB 限制和单个损坏 MP3 包导致整任务失败,已修复并用同一输入重新跑通。5 个聚类不是已确认的真实人数;没有参考转写或说话人标注,因此不计算 CER/WER、DER 或 FAR/FRR。单次耗时也不作为跨设备吞吐承诺。逐字对齐、重叠语音和目标厂商真实验收仍未关闭。 + +本轮自动化基线:后端 577 项、前端 280 项通过,前端类型与生产构建通过;构建仍有既有大 chunk 提示。离线 Provider 测试不代替目标账号实测。 diff --git a/frontend/package.json b/frontend/package.json index f6bfc1b..f56283b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "notes-agent-frontend", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", @@ -39,6 +39,7 @@ "marked": "^15.0.0", "mermaid": "^11.17.2", "pinia": "^4.0.0", + "semver": "^7.8.5", "shiki": "^4.4.3", "vue": "^3.5.0", "vue-router": "^5.0.0", @@ -46,6 +47,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "@types/semver": "^7.8.0", "@vitejs/plugin-vue": "^5.0.0", "@vue/test-utils": "^2.5.0", "happy-dom": "^20.11.15", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5a0cce2..e22cc37 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: pinia: specifier: ^4.0.0 version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) + semver: + specifier: ^7.8.5 + version: 7.8.5 shiki: specifier: ^4.4.3 version: 4.4.3 @@ -108,6 +111,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.20.1 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 '@vitejs/plugin-vue': specifier: ^5.0.0 version: 5.2.4(vite@6.4.3(@types/node@22.20.1)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) @@ -895,6 +901,9 @@ packages: '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3247,6 +3256,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/semver@7.8.0': {} + '@types/trusted-types@2.0.7': optional: true diff --git a/frontend/src/assets/themes/paper-moments.theme b/frontend/src/assets/themes/paper-moments.theme index f92ee31..3caac8f 100644 --- a/frontend/src/assets/themes/paper-moments.theme +++ b/frontend/src/assets/themes/paper-moments.theme @@ -1,6 +1,6 @@ theme_id: paper-moments name: 纸间时光 · Paper Moments -version: 1.4.1 +version: 1.5.0 author: NotesAgent description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 min_app_version: 0.2.0 @@ -266,3 +266,36 @@ license: MIT @media (max-width: 720px) { [data-theme="paper-moments"] .note-metadata { width: 100%; padding: 22px 18px; } } + + +/* Shared paper surfaces across settings, search, agents, media and extensions. */ +[data-theme="paper-moments"] :is(.panel, .item-card, .event-card, .citation-card, .routing-card, .vault-card, .modal-card, .modal, .usage-chart) { + position: relative; + border: 1px solid #b5a693; + border-radius: 8px 14px 8px 8px; + outline: 1px dashed #d5c8b5; + outline-offset: -6px; + background-color: #fffdf5; + background-image: repeating-linear-gradient(transparent 0 31px, #b6c7bd18 31px 32px); + box-shadow: 3px 4px 0 #d8e6e2, 6px 7px 0 #f0d8cf; +} +[data-theme="paper-moments"] :is(.panel, .item-card, .event-card, .citation-card, .routing-card, .vault-card, .modal-card, .modal)::before { + content: ''; + position: absolute; + inset: 0 24px auto auto; + opacity: 1; + transform: none; + width: 48px; + height: 9px; + background: repeating-linear-gradient(45deg, #c5dfe0b0 0 6px, #daeceba0 6px 12px); + pointer-events: none; +} +[data-theme="paper-moments"] :is(.item-card, .event-card, .citation-card, .routing-card):nth-child(2n)::before { + background: repeating-linear-gradient(45deg, #e7bcb3b0 0 6px, #f2d4cba0 6px 12px); +} +[data-theme="paper-moments"] :is(.panel, .item-card, .routing-card, .vault-card) :is(h2, h3, h4) { + font-family: Georgia, 'Noto Serif SC', 'Songti SC', SimSun, serif; + color: #875343; +} +[data-theme="paper-moments"] .usage-chart { background-color: #fbf7ea; } +[data-theme="paper-moments"] .usage-grid > div { padding: 12px; border: 1px dashed #d5c8b5; border-radius: 5px; background: #fffdf580; } diff --git a/frontend/src/components/common/DiagramInteractions.spec.ts b/frontend/src/components/common/DiagramInteractions.spec.ts new file mode 100644 index 0000000..2d1c239 --- /dev/null +++ b/frontend/src/components/common/DiagramInteractions.spec.ts @@ -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 = 'Diagram' + 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() +}) diff --git a/frontend/src/components/common/DiagramInteractions.vue b/frontend/src/components/common/DiagramInteractions.vue new file mode 100644 index 0000000..faea966 --- /dev/null +++ b/frontend/src/components/common/DiagramInteractions.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/frontend/src/components/common/MarkdownContent.vue b/frontend/src/components/common/MarkdownContent.vue index dd11226..956efdc 100644 --- a/frontend/src/components/common/MarkdownContent.vue +++ b/frontend/src/components/common/MarkdownContent.vue @@ -1,4 +1,5 @@ + diff --git a/frontend/src/features/settings/UsageChart.spec.ts b/frontend/src/features/settings/UsageChart.spec.ts new file mode 100644 index 0000000..aa33584 --- /dev/null +++ b/frontend/src/features/settings/UsageChart.spec.ts @@ -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() +}) diff --git a/frontend/src/features/settings/UsageChart.vue b/frontend/src/features/settings/UsageChart.vue new file mode 100644 index 0000000..6df515c --- /dev/null +++ b/frontend/src/features/settings/UsageChart.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/frontend/src/features/themes/CommunityThemePreview.vue b/frontend/src/features/themes/CommunityThemePreview.vue index f643ecf..c8af27b 100644 --- a/frontend/src/features/themes/CommunityThemePreview.vue +++ b/frontend/src/features/themes/CommunityThemePreview.vue @@ -4,16 +4,20 @@ import { t } from '@/i18n' import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/themePackageService' import tokensCss from '@/styles/tokens.css?raw' -const props = defineProps<{ themeId: string }>() +const props = defineProps<{ themeId: string; name?: string; css?: string }>() const emit = defineEmits<{ (event: 'close'): void }>() -const theme = computed(() => mockCommunityThemes.find(item => item.theme_id === props.themeId)) +const theme = computed(() => props.name ? { name: props.name } : mockCommunityThemes.find(item => item.theme_id === props.themeId)) const previewDocument = computed(() => { - // Only bundled community CSS enters this script-free, isolated document. + // Both imported and bundled CSS are previewed in a script-free isolated document. // Previewing never installs a theme or changes application styles/storage. const doc = document.implementation.createHTMLDocument(theme.value?.name ?? '') doc.documentElement.dataset.theme = props.themeId + const policy = doc.createElement('meta') + policy.httpEquiv = 'Content-Security-Policy' + policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'" + doc.head.append(policy) const style = doc.createElement('style') - style.textContent = `${tokensCss}\n${getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }` + style.textContent = `${tokensCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nbody { margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { padding:20px; border:1px solid var(--color-border-default); border-radius:8px; background:var(--color-surface-primary); } p { color:var(--color-text-secondary); } button { padding:8px 16px; border:0; border-radius:6px; background:var(--color-accent-primary); color:white; }` doc.head.append(style) const article = doc.createElement('article') article.className = 'panel' diff --git a/frontend/src/features/themes/ThemesView.spec.ts b/frontend/src/features/themes/ThemesView.spec.ts index 9ad8e1b..76c2b19 100644 --- a/frontend/src/features/themes/ThemesView.spec.ts +++ b/frontend/src/features/themes/ThemesView.spec.ts @@ -21,6 +21,12 @@ it('downloads a URL for inspection without automatically installing it', async ( await vi.waitFor(() => expect(useThemeStore().pendingInspection?.compatible).toBe(true)) expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false) expect(wrapper.get('.inspection-result').text()).toContain('纸间时光') + await wrapper.findAll('button').find(button => button.text() === '预览主题效果')!.trigger('click') + const preview = wrapper.get('iframe') + expect(preview.attributes('sandbox')).toBe('') + expect(preview.attributes('srcdoc')).toContain('Content-Security-Policy') + expect(preview.attributes('srcdoc')).toContain('data-theme="paper-moments"') + expect(useThemeStore().isThemeInstalled('paper-moments')).toBe(false) }) it('ignores a URL response after the dialog is cancelled', async () => { diff --git a/frontend/src/features/themes/ThemesView.vue b/frontend/src/features/themes/ThemesView.vue index 5f60437..7685bf6 100644 --- a/frontend/src/features/themes/ThemesView.vue +++ b/frontend/src/features/themes/ThemesView.vue @@ -9,6 +9,7 @@ import CommunityThemePreview from './CommunityThemePreview.vue' import paperMomentsUrl from '@/assets/themes/paper-moments.theme?url' const themeStore = useThemeStore() +const previewImport = ref(false) const activeTab = ref<'installed' | 'community'>('installed') const showImportDialog = ref(false) @@ -22,6 +23,7 @@ let importGeneration = 0 let downloadController: AbortController | undefined function resetImport() { + previewImport.value = false importGeneration++ downloadController?.abort() importing.value = false @@ -253,6 +255,7 @@ onMounted(() => { 将要安装的 CSS({{ themeStore.pendingInspection.css.length }} 字符)
{{ themeStore.pendingInspection.css }}
+
@@ -280,6 +283,7 @@ onMounted(() => {
+