Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/desktop/src/main/avatar_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ export async function handleAvatarRequest(request: Request): Promise<Response> {
status: 200,
headers: {
'Content-Type': blob.contentType,
// Let the renderer / Chromium memory-cache it too; the on-disk cache
// is authoritative, this just avoids re-asking the protocol.
'Cache-Control': 'public, max-age=86400',
// Let the renderer / Chromium memory-cache it too; the on-disk cache is
// authoritative (and applies the TTL), so keep this short — a long
// max-age would outlive the disk entry and pin a stale avatar.
'Cache-Control': 'public, max-age=300',
},
});
} catch {
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/main/context/app_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,12 @@ export interface AppContext {
* no account is open / QQ is offline / harvest failed.
*/
refreshRkeysNow(): Promise<boolean>;
/**
* Transcribe a SILK file on disk with the globally selected model. Account
* independent (the model is a global setting), so callers that already have a
* path — e.g. the Ptt cache browser — can skip the message-lookup dance.
*/
transcribeSilk(silkPath: string): Promise<{ ok: boolean; text?: string; error?: string }>;
}

let cached: AppContext | undefined;
Expand Down Expand Up @@ -529,6 +535,9 @@ export function initAppContext(): AppContext {
refreshRkeysNow(): Promise<boolean> {
return Promise.resolve(false);
},
transcribeSilk(): Promise<{ ok: boolean; text?: string; error?: string }> {
return Promise.resolve({ ok: false, error: '原生组件未就绪' });
},
};
return cached;
}
Expand Down Expand Up @@ -605,7 +614,7 @@ export function initAppContext(): AppContext {
keys: new Win32KeyService(platform, stubHooks),
userConfig,
globalConfig: new GlobalConfigService(platform, userConfig),
avatarCache: new AvatarCacheService(platform, userConfig),
avatarCache: new AvatarCacheService(userConfig),
linkPreview,
agentLabConfig: new AgentLabConfigService(userConfig),
voiceTranscribe: new VoiceTranscribeService(platform),
Expand Down Expand Up @@ -663,6 +672,7 @@ export function initAppContext(): AppContext {
account: null,
services: null,
scheduler: null,
transcribeSilk,
async setAccount(accountCtx: AccountContext, metadata: AccountConfigMetadata = {}): Promise<void> {
logger.info('opening account session', {
event: 'open-account-start',
Expand Down
31 changes: 30 additions & 1 deletion apps/desktop/src/main/file_response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,47 @@ function parseRange(header: string | null, size: number): { start: number; end:
/**
* Stream `path` as a `Response`, honoring the request's `Range` header.
* Returns 404 when the file is missing, 206 for a partial response.
*
* `revalidate` opts the file into `Last-Modified` + `Cache-Control: no-cache`,
* so Chromium re-asks with `If-Modified-Since` and we answer 304 when the file
* hasn't changed. Use it for paths whose NAME is stable but whose CONTENT is
* rewritten in place — QQ's `nt_data/avatar` files are keyed by a uid hash, so
* changing your avatar overwrites the same path and a plain `max-age` would
* pin the old picture in the renderer's cache until it expired.
*/
export async function fileResponse(path: string, request?: Request): Promise<Response> {
export async function fileResponse(
path: string,
request?: Request,
opts: { revalidate?: boolean } = {},
): Promise<Response> {
let size: number;
let mtimeMs: number;
try {
const st = await stat(path);
if (!st.isFile()) return new Response('not a file', { status: 404 });
size = st.size;
mtimeMs = st.mtimeMs;
} catch {
return new Response('not found', { status: 404 });
}

const type = mimeOf(path);
const range = parseRange(request?.headers.get('range') ?? null, size);

// `Last-Modified` has one-second granularity, so compare at that resolution —
// otherwise a sub-second mtime always reads as "newer" and we never 304.
const lastModified = new Date(Math.floor(mtimeMs / 1000) * 1000);
const revalidateHeaders = opts.revalidate
? { 'Last-Modified': lastModified.toUTCString(), 'Cache-Control': 'no-cache' }
: undefined;

if (revalidateHeaders && !range) {
const since = Date.parse(request?.headers.get('if-modified-since') ?? '');
if (Number.isFinite(since) && lastModified.getTime() <= since) {
return new Response(null, { status: 304, headers: revalidateHeaders });
}
}

if (range) {
const stream = createReadStream(path, { start: range.start, end: range.end });
return new Response(Readable.toWeb(stream) as ReadableStream, {
Expand All @@ -95,6 +122,7 @@ export async function fileResponse(path: string, request?: Request): Promise<Res
'Content-Length': String(range.end - range.start + 1),
'Content-Range': `bytes ${range.start}-${range.end}/${size}`,
'Accept-Ranges': 'bytes',
...revalidateHeaders,
},
});
}
Expand All @@ -106,6 +134,7 @@ export async function fileResponse(path: string, request?: Request): Promise<Res
'Content-Type': type,
'Content-Length': String(size),
'Accept-Ranges': 'bytes',
...revalidateHeaders,
},
});
}
22 changes: 21 additions & 1 deletion apps/desktop/src/main/ipc/routers/media_resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* listings. Thin tRPC skin over `MediaResourceService` (see `@weq/service`); all
* scanning / merging lives there. Bytes are NOT returned here — the renderer
* points `<img>`/`<video>` at `weq-media://localmedia`, resolved via the same
* service. Read-only.
* service. Read-only apart from `transcribeVoice`, which runs the recognition
* engine on a cached clip (it writes nothing).
*/

import { z } from 'zod';
Expand Down Expand Up @@ -66,4 +67,23 @@ export const mediaResourceRouter = router({
analyzeTree: procedure.input(z.object({ key: treeKey })).query(({ input }) => {
return requireServices().mediaResource.analyzeTree(input.key);
}),

/**
* Transcribe one cached voice clip (语音 → 转文字). `rel` is the same Ptt-tree
* path the browser streams through `weq-media://localvoice`; the service
* re-validates it stays inside the tree. There's no message behind a cache
* entry, so unlike `account.transcribeVoice` nothing is written back — the
* text only lives in the browser's UI.
*
* Returns `{ success:false, error }` for every failure mode so the card can
* show a friendly message instead of throwing.
*/
transcribeVoice: procedure
.input(z.object({ rel: z.string() }))
.mutation(async ({ input }): Promise<{ success: boolean; text?: string; error?: string }> => {
const silk = await requireServices().mediaResource.resolveFile('ptt', input.rel);
if (!silk) return { success: false, error: '语音文件不存在' };
const r = await getAppContext().transcribeSilk(silk);
return r.ok ? { success: true, text: r.text ?? '' } : { success: false, error: r.error };
}),
});
17 changes: 15 additions & 2 deletions apps/desktop/src/main/media_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,24 @@ function fileResponse(path: string): Promise<Response> {
return streamFile(path, currentRequest.getStore());
}

/**
* Like {@link fileResponse} but asking the renderer to revalidate (304 on an
* unchanged mtime). For the local avatar cache: the file NAME is a uid hash, so
* QQ overwrites it in place when someone changes their picture — caching by
* max-age would keep showing the old one.
*/
function revalidatingFileResponse(path: string): Promise<Response> {
return streamFile(path, currentRequest.getStore(), { revalidate: true });
}

/**
* CDN fallback for an avatar the local cache didn't have. Routes through the
* shared {@link AvatarCacheService} disk cache (so the fetched bytes warm the
* same cache the rest of the app reads), returning 404 on any failure so the
* renderer shows its glyph.
*
* Short `max-age`: the disk cache is the real cache (and applies its own TTL),
* so a long browser cache would only delay a changed avatar from showing.
*/
async function avatarFallbackResponse(src: string): Promise<Response> {
if (!/^https?:\/\//i.test(src)) return notFound('avatar fallback needs http url');
Expand All @@ -124,7 +137,7 @@ async function avatarFallbackResponse(src: string): Promise<Response> {
const blob = await cache.get(src);
return new Response(new Uint8Array(blob.data), {
status: 200,
headers: { 'Content-Type': blob.contentType, 'Cache-Control': 'public, max-age=86400' },
headers: { 'Content-Type': blob.contentType, 'Cache-Control': 'public, max-age=300' },
});
} catch {
return notFound('avatar fallback failed');
Expand Down Expand Up @@ -394,7 +407,7 @@ export function handleMediaRequest(request: Request): Promise<Response> {
if (hash) path = await services.avatarResource.resolveFile(scope, hash, variant);
else if (uid) path = await services.avatarResource.resolveByUid(scope, uid, variant);
else if (uin) path = await services.avatarResource.resolveByUin(scope, uin, variant);
if (path) return fileResponse(path);
if (path) return revalidatingFileResponse(path);
// Local miss → CDN fallback (disk-cached by AvatarCacheService).
if (fb) return avatarFallbackResponse(fb);
return notFound('avatar not found');
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/renderer/src/styles/cache.css
Original file line number Diff line number Diff line change
Expand Up @@ -3946,6 +3946,58 @@ html[data-theme="dark"] .weq-filerow-name {
font-variant-numeric: tabular-nums;
}

/* 转文字入口:常态低调,hover 卡片时点亮。 */
.weq-voice-t9n-btn {
flex: none;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 7px;
border: 1px solid color-mix(in srgb, var(--weq-accent-effective) 30%, transparent);
border-radius: 999px;
color: var(--weq-accent-effective);
font-size: 10.5px;
line-height: 1.4;
white-space: nowrap;
cursor: pointer;
opacity: 0;
transition: opacity 120ms ease, background-color 120ms ease;
}

.weq-voice-card:hover .weq-voice-t9n-btn,
.weq-voice-t9n-btn:focus-visible,
.weq-voice-t9n-btn:disabled {
opacity: 1;
}

.weq-voice-t9n-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--weq-accent-effective) 12%, transparent);
}

.weq-voice-t9n-btn:disabled {
cursor: progress;
color: var(--weq-fg-muted);
border-color: color-mix(in srgb, var(--weq-fg-primary) 14%, transparent);
}

.weq-voice-t9n {
padding: 7px 10px;
border: 1px solid var(--weq-border-subtle);
border-radius: 10px;
background: color-mix(in srgb, var(--weq-accent-effective) 6%, transparent);
color: var(--weq-fg-secondary);
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}

.weq-voice-t9n.is-error {
border-color: color-mix(in srgb, #d94c4c 30%, transparent);
background: color-mix(in srgb, #d94c4c 8%, transparent);
color: #c0392b;
}

/* ══════════════════════════════════════════════════════════════════════════
* 整体资源分析弹窗
* ════════════════════════════════════════════════════════════════════════ */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* - {@link VoiceExplorer} — 语音 (Ptt): month-bucketed SILK clips, rendered
* as cards with a (simulated) waveform + duration + play/pause. Clicking
* decodes the SILK to WAV via `weq-media://localvoice` and plays it; only one
* clip plays at a time.
* clip plays at a time. With a transcription model configured, each card also
* offers 转文字.
*
* All share {@link useCursorPaged}, a cursor-based infinite-scroll loader (the
* backend pages by bucket, so a cursor — not an offset — resumes the walk). All
Expand All @@ -19,9 +20,9 @@
*/

import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
import { RefreshCw, Play, Pause } from 'lucide-react';
import { RefreshCw, Play, Pause, FileText, Loader2 } from 'lucide-react';
import type { FlatMediaEntry, MonthMediaEntry, VoiceMediaEntry } from '@weq/service';
import { client } from '../../trpc/client';
import { client, trpc } from '../../trpc/client';
import { localMediaUrl, localVoiceUrl } from '../../lib/resourceUrl';
import { openLightbox } from '../../components/ImageLightbox';
import { openVideoLightbox } from '../../components/VideoLightbox';
Expand Down Expand Up @@ -326,6 +327,13 @@ export function VoiceExplorer(): ReactElement {
);
const { entries, loading, error, done, sentinelRef } = useCursorPaged<VoiceMediaEntry>(fetchPage);

// 转文字 only shows when a transcription model is selected in the settings.
const settings = trpc.bootstrap.getSettings.useQuery(undefined, {
refetchOnWindowFocus: false,
staleTime: 60_000,
});
const canTranscribe = Boolean(settings.data?.voiceTranscribe.modelId);

if (error && entries.length === 0) {
return <div className="weq-cache-grid-state is-error">{error}</div>;
}
Expand All @@ -343,7 +351,7 @@ export function VoiceExplorer(): ReactElement {
</div>,
);
}
nodes.push(<VoiceCard key={entry.rel} entry={entry} />);
nodes.push(<VoiceCard key={entry.rel} entry={entry} canTranscribe={canTranscribe} />);
}

return (
Expand All @@ -360,14 +368,24 @@ export function VoiceExplorer(): ReactElement {
* One voice clip: a simulated waveform + duration + play/pause. The SILK bytes
* are decoded to WAV on demand (first play), and the real duration replaces the
* byte-estimated one once the audio's metadata loads. Playback progress lights
* up the waveform left-to-right.
* up the waveform left-to-right. With a transcription model configured, a
* 转文字 button runs the recognizer over the clip and shows the text below.
*/
function VoiceCard({ entry }: { entry: VoiceMediaEntry }): ReactElement {
function VoiceCard({
entry,
canTranscribe,
}: {
entry: VoiceMediaEntry;
canTranscribe: boolean;
}): ReactElement {
const bars = useMemo(() => fakeWaveform(entry.hash), [entry.hash]);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [realDur, setRealDur] = useState<number | null>(null);
const transcribe = trpc.account.mediaResource.transcribeVoice.useMutation();
const [transcript, setTranscript] = useState<string | null>(null);
const [transcribeError, setTranscribeError] = useState<string | null>(null);

// Stop + drop the audio if the card is recycled to a different clip.
useEffect(() => {
Expand Down Expand Up @@ -413,8 +431,21 @@ function VoiceCard({ entry }: { entry: VoiceMediaEntry }): ReactElement {
}
};

const runTranscribe = (): void => {
if (transcribe.isLoading) return;
setTranscribeError(null);
transcribe
.mutateAsync({ rel: entry.rel })
.then((res) => {
if (res.success) setTranscript(res.text ?? '');
else setTranscribeError(res.error ?? '识别失败');
})
.catch((err) => setTranscribeError(err instanceof Error ? err.message : String(err)));
};

const seconds = realDur ?? Math.min(60, Math.max(1, Math.round(entry.bytes / SILK_BYTES_PER_SEC)));
const filled = Math.round(progress * bars.length);
const hasResult = transcript !== null || transcribeError !== null;

return (
<figure className="weq-voice-card" title={entry.name}>
Expand Down Expand Up @@ -443,8 +474,29 @@ function VoiceCard({ entry }: { entry: VoiceMediaEntry }): ReactElement {
</button>
<figcaption className="weq-voice-meta">
<span className="weq-voice-hash">{entry.hash.slice(0, 8)}…</span>
{canTranscribe && !hasResult ? (
<button
type="button"
className="weq-voice-t9n-btn"
title="转文字"
onClick={runTranscribe}
disabled={transcribe.isLoading}
>
{transcribe.isLoading ? (
<Loader2 size={11} strokeWidth={2} className="weq-spin" aria-hidden />
) : (
<FileText size={11} strokeWidth={2} aria-hidden />
)}
<span>{transcribe.isLoading ? '转写中' : '转文字'}</span>
</button>
) : null}
<span className="weq-voice-size">{fmtBytes(entry.bytes)}</span>
</figcaption>
{hasResult ? (
<div className={`weq-voice-t9n${transcribeError ? ' is-error' : ''}`}>
{transcribeError ?? (transcript || '(未识别到语音内容)')}
</div>
) : null}
</figure>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export function SysEmojiExplorer(): ReactElement {
<span className="weq-cache-data-meta">
{total ?? entries.length} 个 · 默认动图(APNG)预览,点击查看全部格式
{status && !status.qqRoot ? ' · QQ 资源目录缺失,已改用下载补全' : ''}
{status?.usingFallback ? ' · emoji.db 无表情数据,用内置地址表兜底' : ''}
</span>
{missing > 0 ? (
<button
Expand Down
Loading
Loading