From f7a6cacccd8211cc71d973a2e779c8637e0d44db 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 ++++++ 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 + 35 files changed, 688 insertions(+), 85 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 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(() => {
+