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"
+def aggregate(start, end, provider_id=None, model=None, source=None, timezone_offset=0):
+ query = "SELECT counters_json,completed,capability,started_at,source FROM model_usage WHERE started_at>=? AND started_at"
args = [start.astimezone(timezone.utc).isoformat(), end.astimezone(timezone.utc).isoformat()]
for column, value in (("provider_id", provider_id), ("model", model), ("source", source)):
if value:
@@ -117,6 +117,18 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
with closing(connection()) as conn:
rows = conn.execute(query, args).fetchall()
options = conn.execute("SELECT DISTINCT provider_id,model,source FROM model_usage ORDER BY provider_id,model").fetchall()
+ # Calendar buckets use the caller's UTC offset; absent counters remain null.
+ zone = timezone(timedelta(minutes=timezone_offset))
+ first = start.astimezone(zone).date()
+ last = (end - timedelta(microseconds=1)).astimezone(zone).date()
+ days = (last - first).days + 1
+ step = max(1, (days + 89) // 90)
+ series = []
+ for offset in range(0, days, step):
+ date = first + timedelta(days=offset)
+ series.append({"date": date.isoformat(), "end_date": (first + timedelta(days=min(days-1, offset+step-1))).isoformat(),
+ "local": {"requests": 0, "totals": {key: None for key in METRICS}, "coverage": {key: 0 for key in METRICS}},
+ "api": {"requests": 0, "totals": {key: None for key in METRICS}, "coverage": {key: 0 for key in METRICS}}})
totals = {key: None for key in METRICS}
coverage = {key: 0 for key in METRICS}
hits, eligible_input, cache_requests = 0, 0, 0
@@ -125,6 +137,13 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
if row[2] in {"transcription", "speaker_matching"}:
audio_requests += 1
counts = json.loads(row[0])
+ date = datetime.fromisoformat(row[3]).astimezone(zone).date()
+ bucket = series[(date - first).days // step][row[4]]
+ bucket['requests'] += 1
+ for key in METRICS:
+ if counts.get(key) is not None:
+ bucket['totals'][key] = (bucket['totals'][key] or 0) + counts[key]
+ bucket['coverage'][key] += 1
if counts.get("audio_seconds") is not None:
audio_covered += 1
audio_seconds = (audio_seconds or 0) + counts["audio_seconds"]
@@ -140,4 +159,4 @@ def aggregate(start, end, provider_id=None, model=None, source=None):
"complete_requests": sum(row[1] for row in rows), "cache_covered_requests": cache_requests,
"cache_hit_rate": hits / eligible_input if eligible_input else None,
"options": [dict(row) for row in options], "start": start, "end": end,
- "scope": "application_observed_usage"}
+ "scope": "application_observed_usage", "series": series, "timezone_offset": timezone_offset}
diff --git a/backend/app/usage_routes.py b/backend/app/usage_routes.py
index 4d096ce..a96d962 100644
--- a/backend/app/usage_routes.py
+++ b/backend/app/usage_routes.py
@@ -9,11 +9,11 @@ router = APIRouter(prefix="/api/usage", tags=["Usage"])
@router.get("")
async def usage(start: datetime | None = None, end: datetime | None = None,
provider_id: str | None = Query(None, max_length=200), model: str | None = Query(None, max_length=200),
- source: str | None = None):
+ source: str | None = None, timezone_offset: int = Query(0, ge=-840, le=840)):
end = end or datetime.now(timezone.utc)
start = start or end - timedelta(days=7)
if not start.tzinfo or not end.tzinfo or end <= start:
raise ApiError(422, "INVALID_TIME_RANGE", "Provide timezone-aware start/end with end after start.")
if source not in {None, "local", "api"}:
raise ApiError(422, "INVALID_USAGE_SOURCE", "Unknown usage source.")
- return aggregate(start, end, provider_id, model, source)
+ return aggregate(start, end, provider_id, model, source, timezone_offset)
diff --git a/backend/tests/test_large_local_media.py b/backend/tests/test_large_local_media.py
new file mode 100644
index 0000000..1a6f345
--- /dev/null
+++ b/backend/tests/test_large_local_media.py
@@ -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']
diff --git a/backend/tests/test_usage_overrides.py b/backend/tests/test_usage_overrides.py
index b29e8d6..9a310c5 100644
--- a/backend/tests/test_usage_overrides.py
+++ b/backend/tests/test_usage_overrides.py
@@ -92,3 +92,25 @@ def test_real_adapter_body_and_usage_persistence():
result = summary()
assert result["request_count"] == 1 and result["totals"]["input_tokens"] == 10
assert result["complete_requests"] == 1
+
+
+def test_usage_calendar_series_splits_sources_and_preserves_missing_counters():
+ start = datetime(2026, 9, 1, tzinfo=timezone.utc)
+ for source, hour, count in [('local', 15, 0), ('api', 16, 12), ('api', 17, None)]:
+ attempt = UsageAttempt('p', 'm', 'openai_compatible', source=source)
+ attempt.started_at = (start + timedelta(hours=hour)).isoformat()
+ if count is not None:
+ attempt.observe({'usage': {'input_tokens': count}})
+ attempt.persist()
+ result = aggregate(start, start + timedelta(days=2), timezone_offset=480)
+ assert result['series'][0]['local']['totals']['input_tokens'] == 0
+ second = result['series'][1]
+ assert second['date'] == '2026-09-02'
+ assert second['api']['requests'] == 2
+ assert second['api']['totals']['input_tokens'] == 12
+ assert second['api']['coverage']['input_tokens'] == 1
+ assert second['api']['totals']['output_tokens'] is None
+ assert sum(b['api']['requests'] + b['local']['requests'] for b in result['series']) == result['request_count']
+ filtered = aggregate(start, start + timedelta(days=2), source='local', timezone_offset=480)
+ assert all(b['api']['requests'] == 0 for b in filtered['series'])
+ assert len(aggregate(start, start + timedelta(days=3660))['series']) <= 90
diff --git a/docs/architecture/第二阶段团队分工表.md b/docs/architecture/第二阶段团队分工表.md
index 3ae6cc9..1ddca6c 100644
--- a/docs/architecture/第二阶段团队分工表.md
+++ b/docs/architecture/第二阶段团队分工表.md
@@ -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 渲染和函数图像绘制。
diff --git a/docs/development/多模态管线与模型运行开发说明.md b/docs/development/多模态管线与模型运行开发说明.md
index d6175e5..79eb184 100644
--- a/docs/development/多模态管线与模型运行开发说明.md
+++ b/docs/development/多模态管线与模型运行开发说明.md
@@ -144,3 +144,11 @@ cd backend
.venv/Scripts/python scripts/local-model-smoke.py qwen3-asr --download --audio C:/path/to/speech.wav
.venv/Scripts/python scripts/local-model-smoke.py eres2netv2 --download --audio C:/path/to/speech.wav --reference C:/path/to/reference.wav
```
+
+
+## 2026-09-05 长录音与 Provider 状态补充
+
+- 附件上传上限为 128 MiB。超过 25 MiB 的音频必须明确选择 `local_only=true`;可联网任务仍限制为 25 MiB,前后端及路由均检查。解码时长仍限制为一小时。
+- 本地解码允许跳过少量损坏音频包,按包中可取得的时长补静音,并返回 `MEDIA_CORRUPT_PACKETS_SKIPPED:<数量>`。超过 100 个损坏包则失败;缺少有效包时长时无法承诺时间对齐,应结合原音频复核。此处理不能恢复丢失语音。
+- Provider 卡片显示“已启用/已停用”,支持直接启停。保存成功后更新状态;失败保留原状态。停用或保存期间禁用测试与刷新模型操作。
+- 37 分 16 秒的用户录音已完成 CUDA 转写、片段级说话人聚类、笔记生成、本地向量索引及检索命中。无参考标注,不报告准确率。详见《阶段F收尾验收记录》的长录音补充;此前短样本记录保留为历史证据。
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 = ''
+ 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 @@
+
+
+
+ {{ metricLabel }} · 0 — {{ maximum.toLocaleString() }} {{ t('按本机时区分组;柱高仅汇总已报告值,悬停可查看覆盖请求数。虚线表示有请求但未提供该指标,不作为零消耗。', 'Grouped by your local UTC offset. Bars sum reported values; hover for coverage. Dashed markers mean requests with unavailable counters, not zero usage.') }}{{ t('查看图表数据', 'View chart data') }}
{{ t('日期', 'Date') }} {{ labels.local }} {{ labels.api }} {{ bucket.date }} – {{ bucket.end_date }} {{ description(bucket, source) }}
{{ themeStore.pendingInspection.css }}
+