From 3d1a559825ca7dd339cca808399bb15ddbf4e313 Mon Sep 17 00:00:00 2001 From: T4nzQ <2890888215@qq.com> Date: Wed, 1 Jul 2026 14:17:07 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix:deepseek=20v4=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agentlab/src/catalog.ts | 2 ++ packages/agentlab/src/extract.ts | 10 +++++----- packages/agentlab/src/http.ts | 19 ++++++++++++++----- packages/agentlab/src/index.ts | 2 +- packages/service/src/account/assistant.ts | 8 ++++---- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/agentlab/src/catalog.ts b/packages/agentlab/src/catalog.ts index 9369845..a0e25da 100644 --- a/packages/agentlab/src/catalog.ts +++ b/packages/agentlab/src/catalog.ts @@ -26,6 +26,8 @@ export const AGENTLAB_PROVIDER_CATALOG: AgentLabProviderCatalogEntry[] = [ models: [ { id: 'deepseek-chat', label: 'deepseek-chat', capabilities: ['chat'] }, { id: 'deepseek-reasoner', label: 'deepseek-reasoner', capabilities: ['chat'] }, + { id: 'deepseek-v4-flash', label: 'deepseek-v4-flash', capabilities: ['chat'] }, + { id: 'deepseek-v4-pro', label: 'deepseek-v4-pro', capabilities: ['chat'] }, ], }, { diff --git a/packages/agentlab/src/extract.ts b/packages/agentlab/src/extract.ts index b629cb4..77a25be 100644 --- a/packages/agentlab/src/extract.ts +++ b/packages/agentlab/src/extract.ts @@ -12,7 +12,7 @@ import type { AgentLabExpression, AgentLabFewShotPair, } from './types'; -import { reportUsage } from './http'; +import { reportUsage, pickMessageText } from './http'; function coerceString(value: unknown): string { if (typeof value === 'string') return value.trim(); @@ -63,9 +63,9 @@ async function chatCompletion( if (!res.ok) { throw new Error(`AgentLab 提炼接口调用失败: HTTP ${res.status}`); } - const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> }; + const data = (await res.json()) as { choices?: Array<{ message?: { content?: string; reasoning_content?: string } }> }; reportUsage(endpoint, data); - return data.choices?.[0]?.message?.content ?? ''; + return pickMessageText(data.choices?.[0]?.message); } /** @@ -103,9 +103,9 @@ async function visionCompletion( if (!res.ok) { throw new Error(`AgentLab 视觉接口调用失败: HTTP ${res.status}`); } - const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> }; + const data = (await res.json()) as { choices?: Array<{ message?: { content?: string; reasoning_content?: string } }> }; reportUsage(endpoint, data); - return data.choices?.[0]?.message?.content ?? ''; + return pickMessageText(data.choices?.[0]?.message); } /** generateText + 宽松解析 + 失败重试一次。 */ diff --git a/packages/agentlab/src/http.ts b/packages/agentlab/src/http.ts index 442b6fb..5cf93fc 100644 --- a/packages/agentlab/src/http.ts +++ b/packages/agentlab/src/http.ts @@ -345,20 +345,29 @@ async function postJson( return (await res.json()) as T; } +/** OpenAI 兼容消息里同时可能存在 content 和 reasoning_content(推理模型专用),取首个非空。 */ +export function pickMessageText(msg: { content?: string; reasoning_content?: string } | undefined): string { + if (!msg) return ''; + return (msg.content || msg.reasoning_content || '').trim(); +} + /** * 设置页「测试连通性」:用最小的 chat 请求探活,返回 ok + 详细错误(含 HTTP 状态码与响应体)。 * 不抛错——把失败包装成 { ok:false, error },让前端直接展示「模型不对 / key 不对」。 + * 推理模型(如 deepseek-v4 系列)会把思考过程放在 reasoning_content 中, + * 当 max_tokens 较小时 content 可能为空——因此同时检查两个字段。 */ export async function testChatEndpoint( endpoint: AgentLabEndpoint, ): Promise<{ ok: boolean; error?: string; reply?: string }> { try { - const data = await postJson<{ choices?: Array<{ message?: { content?: string } }> }>( + const data = await postJson<{ choices?: Array<{ message?: { content?: string; reasoning_content?: string } }> }>( endpoint, '/chat/completions', - { model: endpoint.model, messages: [{ role: 'user', content: '你好' }], max_tokens: 16 }, + // 推理模型需要更多 token 预算才能产出 content;64 足够短路"你好"又不浪费。 + { model: endpoint.model, messages: [{ role: 'user', content: '你好' }], max_tokens: 64 }, ); - const reply = data.choices?.[0]?.message?.content?.trim(); + const reply = pickMessageText(data.choices?.[0]?.message); if (!reply) return { ok: false, error: '接口可达,但返回内容为空(模型可能不支持 chat/completions)。' }; return { ok: true, reply }; } catch (e) { @@ -564,7 +573,7 @@ export async function runPersonaChat( ]; const data = await postJson<{ - choices?: Array<{ message?: { content?: string } }>; + choices?: Array<{ message?: { content?: string; reasoning_content?: string } }>; usage?: OpenAiUsage; }>(chat, '/chat/completions', { model: chat.model, @@ -573,7 +582,7 @@ export async function runPersonaChat( messages, }); reportUsage(chat, data); - const raw = data.choices?.[0]?.message?.content?.trim(); + const raw = pickMessageText(data.choices?.[0]?.message); if (!raw) { throw new Error('AgentLab chat 返回为空'); } diff --git a/packages/agentlab/src/index.ts b/packages/agentlab/src/index.ts index 2b6176b..4c61ea2 100644 --- a/packages/agentlab/src/index.ts +++ b/packages/agentlab/src/index.ts @@ -5,7 +5,7 @@ export { modelsWithCapability, resolveEndpoint, } from './provider'; -export { embedTexts, runPersonaChat, reportUsage, keywordsOf, testChatEndpoint } from './http'; +export { embedTexts, runPersonaChat, reportUsage, keywordsOf, testChatEndpoint, pickMessageText } from './http'; export { selectStickerByEmotion } from './sticker'; export { humanizeText, DEFAULT_TYPO_INTENSITY } from './typo'; export { scoreReplyWillingness } from './willing'; diff --git a/packages/service/src/account/assistant.ts b/packages/service/src/account/assistant.ts index 86723b2..4289ef4 100644 --- a/packages/service/src/account/assistant.ts +++ b/packages/service/src/account/assistant.ts @@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { basename, dirname, extname, join, resolve, sep } from 'node:path'; -import { reportUsage, type AgentLabEndpoint, type AgentLabModelRef, type AgentLabUsage } from '@weq/agentlab'; +import { reportUsage, pickMessageText, type AgentLabEndpoint, type AgentLabModelRef, type AgentLabUsage } from '@weq/agentlab'; import type { TokenUsageStore } from './agentlab_usage'; import type { ConversationStore, ConversationTurn } from './agentlab_conversation'; @@ -384,8 +384,8 @@ export class AssistantService { ], [], ); - const raw = data.choices?.[0]?.message?.content; - return cleanTitle(typeof raw === 'string' ? raw : ''); + const raw = pickMessageText(data.choices?.[0]?.message); + return cleanTitle(raw); } /** 核心多轮循环:返回最终文本。中途只 emit 过程 step,不 emit final。 */ @@ -424,7 +424,7 @@ export class AssistantService { const msg = data.choices?.[0]?.message; if (!msg) throw new Error('助手返回为空'); - const content = typeof msg.content === 'string' ? msg.content.trim() : ''; + const content = pickMessageText(msg); if (allowTools && msg.tool_calls?.length) { // 模型在调用工具前给出的思路 → 作为"思考"展示。 From 6fe81c31176fae8a904cdf4125f25c735a7a529c Mon Sep 17 00:00:00 2001 From: T4nzQ <2890888215@qq.com> Date: Wed, 1 Jul 2026 14:24:09 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=92=95=E5=92=95=E5=98=8E=E5=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agentlab/src/http.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/agentlab/src/http.ts b/packages/agentlab/src/http.ts index 5cf93fc..5c86391 100644 --- a/packages/agentlab/src/http.ts +++ b/packages/agentlab/src/http.ts @@ -346,9 +346,11 @@ async function postJson( } /** OpenAI 兼容消息里同时可能存在 content 和 reasoning_content(推理模型专用),取首个非空。 */ -export function pickMessageText(msg: { content?: string; reasoning_content?: string } | undefined): string { +export function pickMessageText(msg: { content?: unknown; reasoning_content?: unknown } | undefined): string { if (!msg) return ''; - return (msg.content || msg.reasoning_content || '').trim(); + const c = typeof msg.content === 'string' ? msg.content : ''; + const r = typeof msg.reasoning_content === 'string' ? msg.reasoning_content : ''; + return (c || r).trim(); } /** From 6dc56cbcb95f7de9d41874fffd40903f6551bb67 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Wed, 1 Jul 2026 20:00:48 +0800 Subject: [PATCH 3/7] Add appid for new version --- native/win32/x64/ninebird/account-list.js | 8 ++++++++ native/win32/x64/ninebird/qr-dbkey.js | 8 ++++++++ native/win32/x64/ninebird/quick-dbkey.js | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/native/win32/x64/ninebird/account-list.js b/native/win32/x64/ninebird/account-list.js index 83ed32c..49c4c0d 100644 --- a/native/win32/x64/ninebird/account-list.js +++ b/native/win32/x64/ninebird/account-list.js @@ -640,6 +640,14 @@ var appid_default = { "9.9.31-49599": { appid: 537355779, qua: "V1_WIN_NQ_9.9.31_49599_GW_B" + }, + "9.9.31-49738": { + appid: 537355830, + qua: "V1_WIN_NQ_9.9.31_49738_GW_B" + }, + "3.2.29-49738": { + appid: 537355867, + qua: "V1_LNX_NQ_3.2.29_49738_GW_B" } }; diff --git a/native/win32/x64/ninebird/qr-dbkey.js b/native/win32/x64/ninebird/qr-dbkey.js index 98d86a4..d986773 100644 --- a/native/win32/x64/ninebird/qr-dbkey.js +++ b/native/win32/x64/ninebird/qr-dbkey.js @@ -709,6 +709,14 @@ var appid_default = { "9.9.31-49599": { appid: 537355779, qua: "V1_WIN_NQ_9.9.31_49599_GW_B" + }, + "9.9.31-49738": { + appid: 537355830, + qua: "V1_WIN_NQ_9.9.31_49738_GW_B" + }, + "3.2.29-49738": { + appid: 537355867, + qua: "V1_LNX_NQ_3.2.29_49738_GW_B" } }; diff --git a/native/win32/x64/ninebird/quick-dbkey.js b/native/win32/x64/ninebird/quick-dbkey.js index 9ab24ea..d8f3d19 100644 --- a/native/win32/x64/ninebird/quick-dbkey.js +++ b/native/win32/x64/ninebird/quick-dbkey.js @@ -709,6 +709,14 @@ var appid_default = { "9.9.31-49599": { appid: 537355779, qua: "V1_WIN_NQ_9.9.31_49599_GW_B" + }, + "9.9.31-49738": { + appid: 537355830, + qua: "V1_WIN_NQ_9.9.31_49738_GW_B" + }, + "3.2.29-49738": { + appid: 537355867, + qua: "V1_LNX_NQ_3.2.29_49738_GW_B" } }; From c38053f8e59e2350786c0e3e8f0798079b38d810 Mon Sep 17 00:00:00 2001 From: T4nzQ <2890888215@qq.com> Date: Wed, 1 Jul 2026 22:37:09 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=E4=BF=AE=E4=BA=86=E4=B8=80=E5=8D=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/settings/AgentLabSection.tsx | 34 +++++++++++++------ .../src/renderer/src/views/AgentLabView.tsx | 6 +++- .../service/src/bootstrap/agentlab_config.ts | 15 +++++++- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/settings/AgentLabSection.tsx b/apps/desktop/src/renderer/src/components/settings/AgentLabSection.tsx index f477479..2b75d17 100644 --- a/apps/desktop/src/renderer/src/components/settings/AgentLabSection.tsx +++ b/apps/desktop/src/renderer/src/components/settings/AgentLabSection.tsx @@ -102,6 +102,16 @@ export function AgentLabSection(): ReactElement { setEditing(true); } + function mergeTemplateModels(currentModels: ModelForm[], vendor: string): ModelForm[] { + const entry = catalog.data?.find((item) => item.vendor === vendor); + if (!entry?.models.length) return currentModels; + const seen = new Set(currentModels.map((m) => m.id)); + const extra = entry.models + .filter((m) => !seen.has(m.id)) + .map((m) => ({ id: m.id, label: m.label ?? '', capabilities: m.capabilities as Capability[] })); + return extra.length > 0 ? [...currentModels, ...extra] : currentModels; + } + function applyVendor(vendor: string): void { const entry = catalog.data?.find((item) => item.vendor === vendor); setForm((current) => ({ @@ -109,21 +119,25 @@ export function AgentLabSection(): ReactElement { vendor, baseUrl: current.baseUrl.trim() || entry?.baseUrl || current.baseUrl, name: current.name.trim() || entry?.label?.replace(/(.*?)/g, '') || current.name, + models: mergeTemplateModels(current.models, vendor), })); } function importTemplateModels(): void { - const entry = catalog.data?.find((item) => item.vendor === form.vendor); - if (!entry?.models.length) return; - setForm((current) => { - const seen = new Set(current.models.map((m) => m.id)); - const extra = entry.models - .filter((m) => !seen.has(m.id)) - .map((m) => ({ id: m.id, label: m.label ?? '', capabilities: m.capabilities as Capability[] })); - return { ...current, models: [...current.models, ...extra] }; - }); + setForm((current) => ({ + ...current, + models: mergeTemplateModels(current.models, form.vendor), + })); } + /** 模板中有多少个模型尚未添加(用于按钮 badge)。 */ + const newModelCount = useMemo(() => { + const entry = catalog.data?.find((item) => item.vendor === form.vendor); + if (!entry?.models.length) return 0; + const seen = new Set(form.models.map((m) => m.id)); + return entry.models.filter((m) => !seen.has(m.id)).length; + }, [catalog.data, form.vendor, form.models]); + function addModel(): void { setForm((c) => ({ ...c, models: [...c.models, { id: '', label: '', capabilities: ['chat'] }] })); } @@ -320,7 +334,7 @@ export function AgentLabSection(): ReactElement {
可用模型
diff --git a/packages/service/src/bootstrap/agentlab_config.ts b/packages/service/src/bootstrap/agentlab_config.ts index 3281bf4..7f1c158 100644 --- a/packages/service/src/bootstrap/agentlab_config.ts +++ b/packages/service/src/bootstrap/agentlab_config.ts @@ -14,8 +14,21 @@ import type { UserConfigService } from './user_config'; export class AgentLabConfigService { constructor(private readonly userConfig: UserConfigService) {} + /** + * 用厂商模板自动补全缺失的模型(仅追加,不覆盖已有)。 + * 解决了用户早前保存的 provider 不包含 catalog 后续新增模型的问题。 + */ + private enrichProvider(p: AgentLabProviderConfig): AgentLabProviderConfig { + const entry = AGENTLAB_PROVIDER_CATALOG.find((c) => c.vendor === p.vendor); + if (!entry?.models?.length) return p; + const existingIds = new Set(p.models.map((m) => m.id)); + const extra = entry.models.filter((m) => !existingIds.has(m.id)); + if (extra.length === 0) return p; + return { ...p, models: [...p.models, ...extra] }; + } + listProviders(): AgentLabProviderConfig[] { - return this.userConfig.getSettings().agentLab.providers; + return this.userConfig.getSettings().agentLab.providers.map((p) => this.enrichProvider(p)); } getProvider(providerId: string): AgentLabProviderConfig | null { From d385b157fe05696b90e24eac31f706e8cc95c4ac Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Thu, 2 Jul 2026 00:27:27 +0800 Subject: [PATCH 5/7] =?UTF-8?q?Fix=20Qzone=20=E9=A3=8E=E6=8E=A7=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/service/src/account/web/index.ts | 2 +- packages/service/src/account/web/qzone.ts | 212 ++++++++++++++++++++-- packages/service/test/qzone.ts | 45 ++++- 3 files changed, 233 insertions(+), 26 deletions(-) diff --git a/packages/service/src/account/web/index.ts b/packages/service/src/account/web/index.ts index 724b20d..447970e 100644 --- a/packages/service/src/account/web/index.ts +++ b/packages/service/src/account/web/index.ts @@ -61,7 +61,7 @@ export { getGroupAlbumList } from './group_album'; export type { GroupAlbum } from './group_album'; export { getHonorList, HonorType } from './group_honor'; export type { HonorMember } from './group_honor'; -export { getQzoneMsgList, getQzoneFeeds, mapMsgList, mapFeeds, parseQzoneJson } from './qzone'; +export { getQzoneMsgList, getQzoneFeeds, mapMsgList, mapFeeds, parseQzoneJson, parseQzoneCallback } from './qzone'; export type { QzoneEmotion, QzoneMsgListResult, diff --git a/packages/service/src/account/web/qzone.ts b/packages/service/src/account/web/qzone.ts index c3dfb8e..16d0817 100644 --- a/packages/service/src/account/web/qzone.ts +++ b/packages/service/src/account/web/qzone.ts @@ -1,14 +1,16 @@ /** - * QQ 空间读接口 — 说说列表 (`taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6`) - * 与好友动态 (`ic2.qzone.qq.com/cgi-bin/feeds/feeds3_html_more`),都经 - * `h5.qzone.qq.com` 代理网关访问 —— 与群相册列表同一套 cookie/g_tk 通路。 + * QQ 空间读接口 — 说说列表 (`taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6`,经 + * `user.qzone.qq.com` 代理网关) 与好友动态 (`ic2.qzone.qq.com/cgi-bin/feeds/ + * feeds3_html_more`,经 `h5.qzone.qq.com` 代理网关) —— 与群相册列表同一套 + * cookie/g_tk 通路。两条走不同网关/目标域:说说走 user+taotao.qq.com 以避开 + * `-10000 使用人数过多` 风控(真机验证),动态仍走 h5+taotao.qzone。 * * Auth: cookie jar + `g_tk = bkn(p_skey || skey)`。`uin` 在 query 里裸传(不带 * 'o' 前缀),cookie 里的 uin 仍带 'o'(见 {@link cookieHeader})。 * - * 这两个 cgi 的响应是 JSONP 包裹(`_preloadCallback({...})`),不能直接走 - * {@link webRequestJson} 的严格 JSON.parse,所以用 {@link webRequestText} 取文本, - * 再用本文件内的 {@link parseQzoneJson} 容错切片解析。 + * 响应形态:说说列表用 `format=json` 回裸 JSON;好友动态是 JS 对象字面量(见 + * {@link parseQzoneCallback})。两者都走 {@link webRequestText} 取文本 —— 说说用 + * {@link parseQzoneJson} 容错切片,动态用非执行解析器,均不走严格 JSON.parse。 * * 读路径采用 throw-on-auth-failure 约定:传输失败、非零 `code`、或缺失数据数组 * (过期 cookie 产出的 body)都抛错,而 NOT 吞成空列表 —— 否则坏 cookie 会和 @@ -33,6 +35,174 @@ export function parseQzoneJson(text: string): T { return JSON.parse(s.slice(start, end + 1)) as T; } +/** + * 非执行地解析 qzone feeds 的 JS **对象字面量** body(不是 JSON)。 + * `feeds3_html_more` 返回 `_preloadCallback({ … })`,其中 `data` 用无引号键、 + * 单引号字符串、`\xNN` 转义、数组里还夹 `undefined` —— 本意是给浏览器回调 eval 的, + * 所以 {@link parseQzoneJson} 的 JSON.parse 会直接噎住。 + * + * 绝不 eval 远程内容(`vm` 沙箱不是安全边界,被篡改的响应能逃逸成 RCE)。这里用 + * 递归下降把字面量当**数据**解析:它只可能产出一个值,永远不会执行代码。 + * 只认对象/数组/字符串/数字/`true|false|null|undefined`;丢弃 `__proto__` 键防 + * 原型污染。数组尾部的 `undefined` 空洞由 {@link mapFeeds} 过滤。 + */ +function parseJsLiteral(src: string): unknown { + let i = 0; + const n = src.length; + const isWs = (c: string): boolean => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v'; + const skipWs = (): void => { + while (i < n && isWs(src[i]!)) i++; + }; + + function parseString(quote: string): string { + i++; // 开引号 + let out = ''; + while (i < n) { + const c = src[i]!; + if (c === '\\') { + const e = src[i + 1]; + if (e === 'x') { + out += String.fromCharCode(parseInt(src.slice(i + 2, i + 4), 16)); + i += 4; + continue; + } + if (e === 'u') { + out += String.fromCharCode(parseInt(src.slice(i + 2, i + 6), 16)); + i += 6; + continue; + } + const simple: Record = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0' }; + out += e !== undefined ? (simple[e] ?? e) : ''; // \/ → /, \' → ', 未知转义 → 原字符 + i += 2; + continue; + } + if (c === quote) { + i++; + return out; + } + out += c; + i++; + } + throw new Error('unterminated string in qzone feeds payload'); + } + + function parseKey(): string { + skipWs(); + const c = src[i]; + if (c === '"' || c === "'") return parseString(c); + let s = ''; + while (i < n && /[A-Za-z0-9_$]/.test(src[i]!)) { + s += src[i]; + i++; + } + if (!s) throw new Error('expected object key in qzone feeds payload'); + return s; + } + + function parseObject(): Record { + i++; // { + const obj: Record = {}; + skipWs(); + if (src[i] === '}') { + i++; + return obj; + } + for (;;) { + const key = parseKey(); + skipWs(); + if (src[i] !== ':') throw new Error('expected ":" in qzone feeds payload'); + i++; + const value = parseValue(); + if (key !== '__proto__') obj[key] = value; + skipWs(); + const ch = src[i]; + if (ch === ',') { + i++; + skipWs(); + if (src[i] === '}') { + i++; + return obj; + } + continue; + } + if (ch === '}') { + i++; + return obj; + } + throw new Error('expected "," or "}" in qzone feeds payload'); + } + } + + function parseArray(): unknown[] { + i++; // [ + const arr: unknown[] = []; + skipWs(); + if (src[i] === ']') { + i++; + return arr; + } + for (;;) { + arr.push(parseValue()); + skipWs(); + const ch = src[i]; + if (ch === ',') { + i++; + skipWs(); + if (src[i] === ']') { + i++; + return arr; + } + continue; + } + if (ch === ']') { + i++; + return arr; + } + throw new Error('expected "," or "]" in qzone feeds payload'); + } + } + + function parseValue(): unknown { + skipWs(); + const c = src[i]; + if (c === undefined) throw new Error('unexpected end of qzone feeds payload'); + if (c === '{') return parseObject(); + if (c === '[') return parseArray(); + if (c === '"' || c === "'") return parseString(c); + let token = ''; + while (i < n && !/[,}\]:\s]/.test(src[i]!)) { + token += src[i]; + i++; + } + if (token === 'true') return true; + if (token === 'false') return false; + if (token === 'null') return null; + if (token === 'undefined') return undefined; + if (token !== '') { + const num = Number(token); + if (!Number.isNaN(num)) return num; + } + throw new Error('unexpected token in qzone feeds payload: ' + token.slice(0, 20)); + } + + return parseValue(); +} + +/** + * 从 qzone feeds JSONP body 里取出回调实参那个对象,作为**数据**解析(永不执行, + * 见 {@link parseJsLiteral})。从第一个 `{`(回调实参)切起,解析一个平衡值; + * 尾部的 `);` 直接忽略。body 不是对象时抛错。 + */ +export function parseQzoneCallback(text: string): T { + const start = text.indexOf('{'); + if (start === -1) throw new Error('invalid feeds response from qzone api'); + const value = parseJsLiteral(text.slice(start)); + if (value === null || typeof value !== 'object') { + throw new Error('invalid feeds response from qzone api'); + } + return value as T; +} + // ─────────────── 说说列表 (说说 / emotion) — emotion_cgi_msglist_v6 ─────────────── interface RawPic { @@ -117,29 +287,29 @@ export async function getQzoneMsgList( ): Promise { const gtk = computeBkn(cred.pskey || cred.skey); - const url = `https://h5.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_msglist_v6?${new URLSearchParams( + // 关键路由:经 `user.qzone.qq.com` 代理网关访问目标域 `taotao.qq.com` + // (注意 NOT `taotao.qzone.qq.com`)。这一组合经真机抓包验证**不吃** + // `-10000 使用人数过多` 风控;而旧的 `h5.qzone.qq.com`+`taotao.qzone.qq.com` + // 那套会被 taotao 单独甩 -10000。`format=json` 直接回裸 JSON(不再 JSONP 包裹), + // parseQzoneJson 的容错切片照样能解。参数保持极简 —— 多余参数(loginUin/ + // callback/replynum…)对绕风控无益,去掉更贴近验证过的请求。 + const url = `https://user.qzone.qq.com/proxy/domain/taotao.qq.com/cgi-bin/emotion_cgi_msglist_v6?${new URLSearchParams( { uin: targetUin, - // 登录号(查询发起方)。跨号查别人空间时 QZone 靠它判权限,缺了会被风控 - // 甩 `-10000 使用人数过多`。`cred.uin` 是本账号裸 uin(不带 'o')。 - loginUin: cred.uin, ftype: '0', sort: '0', pos: String(pos), num: String(num), - replynum: '100', g_tk: String(gtk), - callback: '_preloadCallback', code_version: '1', - format: 'jsonp', - need_private_comment: '1', + format: 'json', }, ).toString()}`; const text = await webRequestText(url, { method: 'GET', cookie: cookieHeader(cred), - // QZone 风控会校验 referer 是 qzone 来源,缺了甩 `-10000 使用人数过多`。 + // Referer 指向目标空间;QZone 会校验 referer 是 qzone 来源。 headers: { Referer: `https://user.qzone.qq.com/${targetUin}` }, }); const data = parseQzoneJson(text); @@ -148,7 +318,7 @@ export async function getQzoneMsgList( throw new Error(`qzone msglist failed: code=${data.code} ${data.message ?? ''}`.trim()); } if (!Array.isArray(data.msglist)) { - throw new Error('无法获取空间说说列表(可能 cookie 失效或无权限)'); + throw new Error(`无法获取空间说说列表(响应结构异常): ${text.slice(0, 200)}`); } return mapMsgList(data); @@ -206,7 +376,8 @@ export interface QzoneFeedsResult { /** 纯转换:原始 feeds 响应 → 归一化好友动态列表。 */ export function mapFeeds(data: RawFeedsRet): QzoneFeedsResult { - const list = data.data?.data ?? []; + // 该 cgi 会在数组尾部塞 `undefined`/null 空洞 —— 过滤掉。 + const list = (data.data?.data ?? []).filter((f): f is RawFeedItem => !!f); return { feeds: list.map((f) => ({ uin: Number(f.uin ?? 0), @@ -265,13 +436,16 @@ export async function getQzoneFeeds( cookie: cookieHeader(cred), headers: { Referer: `https://user.qzone.qq.com/${selfUin}` }, }); - const data = parseQzoneJson(text); + // feeds3_html_more 的 body 是 JS 对象字面量而非 JSON —— 走非执行解析器 + // (见 parseQzoneCallback;永不执行),不能用 parseQzoneJson 的 JSON.parse。 + const data = parseQzoneCallback(text); if (typeof data.code === 'number' && data.code !== 0) { throw new Error(`qzone feeds failed: code=${data.code} ${data.message ?? ''}`.trim()); } if (!Array.isArray(data.data?.data)) { - throw new Error('无法获取空间好友动态(可能 cookie 失效或无权限)'); + // 带上响应头片段:cookie 失效 / 无权限 / 风控 各有不同 body,方便对症。 + throw new Error(`无法获取空间好友动态(响应结构异常): ${text.slice(0, 200)}`); } return mapFeeds(data); diff --git a/packages/service/test/qzone.ts b/packages/service/test/qzone.ts index 8a37890..0d3bd4a 100644 --- a/packages/service/test/qzone.ts +++ b/packages/service/test/qzone.ts @@ -6,14 +6,14 @@ * 在 ts 侧算。 * * 用法: pnpm tsx packages/service/test/qzone.ts [targetUin] - * targetUin 默认 1193368126(要拉说说的目标空间;需本账号有权限查看)。 + * targetUin 默认 3254005457(要拉说说的目标空间;需本账号有权限查看)。 */ import { loadNative } from '@weq/native'; import type { AccountSession } from '@weq/account'; import { WebQueryService } from '../src/account/web'; -const TARGET = process.argv[2] ?? '1193368126'; +const TARGET = process.argv[2] ?? '1404137127'; async function main(): Promise { const nt = loadNative().ntHelper; @@ -34,10 +34,43 @@ async function main(): Promise { const web = new WebQueryService(nt, { context: { uin } } as unknown as AccountSession, () => pid); - console.log(`\n[qzone] ===== 说说列表 (uin=${TARGET}, pos=0, num=20) =====`); - const msg = await web.getQzoneMsgList(TARGET, 0, 20); - console.log(`[qzone] 说说总数(total): ${msg.total} 本页拿到: ${msg.list.length} 条`); - console.dir(msg.list, { depth: null }); + console.log(`\n[qzone] ===== 说说列表翻页 (uin=${TARGET}, num=20 按 pos 递增) =====`); + const NUM = 20; + const MAX_PAGES = 10; // 防跑飞:最多翻 10 页 + const all: Awaited>['list'] = []; + let total = 0; + let pos = 0; + for (let page = 0; page < MAX_PAGES; page++) { + let msg: Awaited>; + try { + msg = await web.getQzoneMsgList(TARGET, pos, NUM); + } catch (e) { + // pos 翻过头时服务端回 code=0 且 msglist:null → 库里按结构异常抛错。 + // 对翻页而言这就是「没有更多了」,优雅停止即可。 + console.log(`[qzone] page=${page} pos=${pos} 拉取中止(通常=没有更多): ${(e as Error).message.slice(0, 80)}`); + break; + } + total = msg.total; + console.log(`[qzone] page=${page} pos=${pos} → 本页 ${msg.list.length} 条 (total=${total}, 累计 ${all.length + msg.list.length})`); + if (msg.list.length === 0) { + console.log('[qzone] 本页 0 条,停止翻页。'); + break; + } + all.push(...msg.list); + // 用「实际返回条数」推进游标,而不是 NUM —— 服务端每页可能少于 NUM。 + pos += msg.list.length; + if (all.length >= total) { + console.log('[qzone] 已拉满 total,停止翻页。'); + break; + } + // 翻页之间稍作停顿,降低触发风控概率。 + await new Promise((r) => setTimeout(r, 800)); + } + console.log(`[qzone] 翻页完成:累计拿到 ${all.length}/${total} 条说说`); + console.dir( + all.map((e) => ({ ...e, images: `<${e.images.length} imgs>`, content: e.content.slice(0, 30) })), + { depth: null }, + ); console.log('\n[qzone] ===== 好友动态 (本账号, pageNum=1, count=10) ====='); const feeds = await web.getQzoneFeeds(undefined, 1, 10); From b0cda86fbe6893a28342510a269f7ae9e30ae46f Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Thu, 2 Jul 2026 06:07:35 +0800 Subject: [PATCH 6/7] Devvvvv --- apps/desktop/src/main/context/app_context.ts | 17 +- apps/desktop/src/main/hitokoto.ts | 65 + apps/desktop/src/main/ipc/routers/account.ts | 134 + apps/desktop/src/main/mcp/tools.ts | 282 +- .../im-template/template/chatMainContent.tsx | 11 + .../desktop/src/renderer/src/styles/index.css | 276 + .../src/renderer/src/views/ChatHome.tsx | 245 + .../src/renderer/src/views/ExportView.tsx | 114 +- .../src/renderer/src/views/MainView.tsx | 6 +- .../src/views/agentlab/AssistantPanel.tsx | 14 + .../src/views/export/ExportLightbox.tsx | 38 +- .../src/renderer/src/views/export/types.ts | 18 +- packages/db/src/msg/c2c.ts | 36 + packages/db/src/msg/group.ts | 34 + packages/native/src/types.ts | 2 +- packages/service/src/account/assistant.ts | 45 +- packages/service/src/account/export/index.ts | 6 + .../src/account/export/message_source.ts | 62 + .../src/account/export/qzone_export.ts | 208 + .../src/account/export/task_manager.ts | 129 + packages/service/src/account/msg.ts | 19 + .../service/src/bootstrap/win32_detect.ts | 2 +- resources/assistant/report.css | 320 +- resources/hitokoto.json | 46452 ++++++++++++++++ 24 files changed, 48418 insertions(+), 117 deletions(-) create mode 100644 apps/desktop/src/main/hitokoto.ts create mode 100644 apps/desktop/src/renderer/src/views/ChatHome.tsx create mode 100644 packages/service/src/account/export/qzone_export.ts create mode 100644 resources/hitokoto.json diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index b7104fe..b0ced3f 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -26,6 +26,7 @@ import { createWin32Platform, isTencentFilesRoot, type Platform } from '@weq/pla import { startMcpServer, stopMcpServer } from '../mcp/server'; import { aiToolSpecs, runAiTool } from '../mcp/openai_tools'; import { getExternalMcpHub, disposeExternalMcp } from '../mcp/external'; +import { sampleHitokoto } from '../hitokoto'; import { accountConfigId, UserConfigService, @@ -529,6 +530,9 @@ export function initAppContext(): AppContext { // OIDB-backed video / file download URL resolver (needs the online QQ pid); // injected into the export manager for 视频 / 文件 媒体补全. const mediaUrl = new MediaUrlService(platform.native.ntHelper, session, resolveOnlinePid); + // QQ 空间 Web 查询(说说 / 相册)—— 也注入进导出管理器,供「好友空间导出」 + // 翻页拉说说。需在线 QQ 凭证;离线时 fetchMsgList 会抛错(前端已先拦截)。 + const webQuery = new WebQueryService(platform.native.ntHelper, session, resolveOnlinePid); // Shared instances also fed to the export manager's ChatLab deps (name / // role / profile resolution), so they're built before the services object. const groupInfo = new GroupInfoService(session); @@ -582,6 +586,8 @@ export function initAppContext(): AppContext { name.startsWith('mcp__') ? getExternalMcpHub().run(name, args) : runAiTool(name, args), // 配置变更/启动时把外部 MCP 配置同步给 Hub(连接惰性建立)。 syncExternalMcp: (raw) => getExternalMcpHub().configure(raw), + // 写报告时随机抽一批「一言」候选,供模型挑一句做主题大字(多元化)。 + sampleHitokoto, }), exportManager: new (await import('@weq/service')).ExportTaskManager( new MsgService(session), @@ -631,10 +637,12 @@ export function initAppContext(): AppContext { return uin ? { uid: '', uin, nick: '' } : null; }, }, + // 好友 QQ 空间说说导出:翻页拉取能力(需在线 QQ)。 + qzone: { fetchMsgList: (uin, pos, num) => webQuery.getQzoneMsgList(uin, pos, num) }, }, ), dbDecrypt: new DbDecryptService(session, platform), - webQuery: new WebQueryService(platform.native.ntHelper, session, resolveOnlinePid), + webQuery, groupAlbumMedia: new GroupAlbumMediaService(platform.native.ntHelper, session, resolveOnlinePid), }; // Scheduled export manager — fires saved templates through the export @@ -745,6 +753,8 @@ export function initAppContext(): AppContext { userConfig.cacheDir('media'), ); const mediaUrl = new MediaUrlService(platform.native.ntHelper, session, noPid); + // 静态账号无在线 QQ —— webQuery 用 noPid,「好友空间导出」会优雅失败(离线)。 + const webQuery = new WebQueryService(platform.native.ntHelper, session, noPid); const groupInfo = new GroupInfoService(session); const profile = new ProfileService(session); const fileSearch = new FileSearchService(session, platform); @@ -792,6 +802,8 @@ export function initAppContext(): AppContext { name.startsWith('mcp__') ? getExternalMcpHub().run(name, args) : runAiTool(name, args), // 配置变更/启动时把外部 MCP 配置同步给 Hub(连接惰性建立)。 syncExternalMcp: (raw) => getExternalMcpHub().configure(raw), + // 写报告时随机抽一批「一言」候选,供模型挑一句做主题大字(多元化)。 + sampleHitokoto, }), exportManager: new (await import('@weq/service')).ExportTaskManager( new MsgService(session), @@ -834,10 +846,11 @@ export function initAppContext(): AppContext { return uin ? { uid: '', uin, nick: '' } : null; }, }, + qzone: { fetchMsgList: (uin, pos, num) => webQuery.getQzoneMsgList(uin, pos, num) }, }, ), dbDecrypt: new DbDecryptService(session, platform), - webQuery: new WebQueryService(platform.native.ntHelper, session, noPid), + webQuery, groupAlbumMedia: new GroupAlbumMediaService(platform.native.ntHelper, session, noPid), }; diff --git a/apps/desktop/src/main/hitokoto.ts b/apps/desktop/src/main/hitokoto.ts new file mode 100644 index 0000000..295144d --- /dev/null +++ b/apps/desktop/src/main/hitokoto.ts @@ -0,0 +1,65 @@ +/** + * 一言(hitokoto)池 —— 供 WeQ 助手写报告时挑一句做「主题句大字」。 + * + * 数据是 `resources/hitokoto.json`(约 9000 条高质量句子,字段 hitokoto/from)。 + * 报告风格千篇一律的一大主因是标题僵化;给模型一批**随机**候选句,让它按报告主题/ + * 心情语义挑最贴合的一句渲染成封面级大字,既保证多元、又保证相关(LLM 挑句 = 比机械 + * 关键词匹配更聪明的「匹配」)。 + * + * 读盘一次后常驻内存;随机抽样在主进程做(此处允许 Math.random)。 + */ + +import { readFileSync } from 'node:fs'; +import { resolveResource } from './resource'; + +interface HitokotoEntry { + hitokoto?: string; + from?: string; +} + +/** 采样结果:句子 + 出处(出处可能缺失)。 */ +export interface HitokotoVerse { + text: string; + from: string; +} + +let cache: HitokotoEntry[] | null = null; + +function load(): HitokotoEntry[] { + if (cache) return cache; + try { + const path = resolveResource('hitokoto.json'); + const parsed = path ? JSON.parse(readFileSync(path, 'utf-8')) : []; + cache = Array.isArray(parsed) ? parsed : []; + } catch { + cache = []; + } + return cache; +} + +/** + * 随机抽 n 条一言(去重、去空、句长适中——太长的做不了大字标题)。 + * 资源缺失时返回空数组,调用方(系统提示)自动跳过该小节。 + */ +export function sampleHitokoto(n: number): HitokotoVerse[] { + const all = load(); + if (!all.length) return []; + const pool = all.filter((e) => { + const t = e.hitokoto?.trim(); + return t && t.length >= 4 && t.length <= 40; + }); + if (!pool.length) return []; + + const picked: HitokotoVerse[] = []; + const seen = new Set(); + const want = Math.min(n, pool.length); + let guard = 0; + while (picked.length < want && guard < want * 20) { + guard += 1; + const i = Math.floor(Math.random() * pool.length); + if (seen.has(i)) continue; + seen.add(i); + picked.push({ text: pool[i]!.hitokoto!.trim(), from: pool[i]!.from?.trim() || '' }); + } + return picked; +} diff --git a/apps/desktop/src/main/ipc/routers/account.ts b/apps/desktop/src/main/ipc/routers/account.ts index 17b8c8d..953ac70 100644 --- a/apps/desktop/src/main/ipc/routers/account.ts +++ b/apps/desktop/src/main/ipc/routers/account.ts @@ -17,6 +17,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import { basename, dirname, extname, join } from 'node:path'; import { getAppContext, dbEventBus, type AccountServices } from '../../context/app_context'; +import { sampleHitokoto } from '../../hitokoto'; import { procedure, router } from '../trpc'; import { assistantBus, type AssistantStreamEvent } from '../../mcp/assistant_bus'; import { @@ -68,6 +69,96 @@ function requireScheduler(): import('@weq/service').ExportScheduler { return ctx.scheduler; } +/** 会话类型判定(首页门面用;兼容字符串枚举与数字)。 */ +function chatKindOf(chatType: unknown): 'c2c' | 'group' | null { + const s = String(chatType).toUpperCase(); + if (s.includes('C2C') || s === '1') return 'c2c'; + if (s.includes('GROUP') || s === '2') return 'group'; + return null; +} + +/** + * 一条消息 → 纯文本,仅接受 text/at/face;出现任何媒体/卡片/引用等即返回 null + * (整条丢弃)。首页「虚拟聊天流」只滚动展示轻量文字气泡,媒体一律排除。 + */ +function plainTextLine( + elements: readonly { type?: string; data?: { textContent?: unknown; faceText?: unknown } }[], +): string | null { + const parts: string[] = []; + for (const el of elements ?? []) { + if (el.type === 'text' || el.type === 'at') { + parts.push(String(el.data?.textContent ?? '')); + } else if (el.type === 'face') { + const t = String(el.data?.faceText ?? '').trim(); + parts.push(t ? `[${t}]` : ''); + } else { + return null; // 图片/语音/视频/文件/卡片/引用… → 丢弃整条 + } + } + const text = parts.join('').replace(/\s+/g, ' ').trim(); + return text || null; +} + +/** Fisher–Yates 就地洗牌(主进程内允许 Math.random)。 */ +function shuffleInPlace(arr: T[]): void { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j]!, arr[i]!]; + } +} + +/** + * 首页门面:从**私聊**会话里抽一批「别人发来的」纯文本短句,带对方昵称/QQ 号 + * (前端据 QQ 号取头像),去重后洗牌返回。刻意加随机: + * - 会话先洗牌再取子集(不局限于最近几个); + * - 每个会话多读一些历史再随机挑(不总是最新那几条); + * 配合前端「每次进首页都重新请求」,做到每次打开都换一批。 + * 只留 text/face、按可读长度筛选、剔除链接、剔除自己发的。 + */ +async function sampleHomeChatLines( + limit: number, +): Promise> { + const svc = requireServices(); + const selfUin = (await svc.profile.getSelfProfile())?.uin ?? -1n; + const contacts = await svc.recentContacts.getRecentContact(200); + const c2c = contacts.filter((c) => chatKindOf(c.chatType) === 'c2c'); + shuffleInPlace(c2c); + const picked = c2c.slice(0, 24); + + const perConv = await Promise.all( + picked.map(async (c) => { + try { + // 多读一点历史,给随机挑选更大的样本空间(一次查询开销不大)。 + const rows = await svc.msgs.getC2cLatest(c.targetUid, 120); + return { c, rows }; + } catch { + return { c, rows: [] as Awaited> }; + } + }), + ); + + const seen = new Set(); + const pool: Array<{ text: string; uin: string; name: string }> = []; + for (const { c, rows } of perConv) { + const name = c.targetRemark || c.targetDisplayName || c.senderNick || String(c.targetUin); + const uin = c.targetUin ? String(c.targetUin) : ''; + for (const r of rows) { + if (r.senderUin === selfUin) continue; // 只保留别人发的 + const text = plainTextLine(r.elements as never); + if (!text) continue; + const len = [...text].length; + if (len < 3 || len > 28) continue; + if (/https?:\/\//i.test(text)) continue; + if (seen.has(text)) continue; + seen.add(text); + pool.push({ text, uin, name }); + } + } + + shuffleInPlace(pool); + return pool.slice(0, limit); +} + const agentLabModelRef = z.object({ providerId: z.string().min(1), model: z.string().min(1) }); const agentLabModels = z.object({ chat: agentLabModelRef, @@ -738,6 +829,16 @@ export const accountRouter = router({ return contacts.map(recentContactToWire); }), + /** 首页门面:随机一言若干(打字机轮播用;已按句长筛过)。 */ + sampleHitokoto: procedure + .input(z.object({ count: z.number().int().min(1).max(80).default(30) }).optional()) + .query(({ input }) => sampleHitokoto(input?.count ?? 30)), + + /** 首页门面:私聊纯文本短句池(装饰性「虚拟聊天流」用)。 */ + sampleChatLines: procedure + .input(z.object({ limit: z.number().int().min(1).max(160).default(80) }).optional()) + .query(({ input }) => sampleHomeChatLines(input?.limit ?? 80)), + /** Get unread message count for a conversation. */ getUnreadInfo: procedure .input(z.object({ chatType: z.number().int(), uid: z.string().min(1) })) @@ -1376,6 +1477,39 @@ export const accountRouter = router({ return requireServices().exportManager.startTask(input); }), + /** + * Start a friend-QZone (说说) export. Requires an online QQ (the web CGI needs + * this account's skey/pskey). `conv` carries the friend's uin. Media = 配图. + */ + startQzoneExport: procedure + .input(z.object({ + targetUin: z.string().min(1), + name: z.string().min(1), + format: z.enum(['json', 'txt']), + downloadMedia: z.boolean(), + range: z.object({ start: z.number().nullable(), end: z.number().nullable() }).optional(), + })) + .mutation(async ({ input }) => { + const services = requireServices(); + requireQqOnlineForAlbum(services); // 硬性要求:在线 QQ 实例 + return services.exportManager.startTask({ + qzone: true, + kind: 'c2c', + conv: input.targetUin, + name: input.name, + format: input.format, + total: 0, + media: { + exportMedia: input.downloadMedia, + completeMedia: false, + downloadVideo: false, + downloadFile: false, + transcribeVoice: false, + }, + ...(input.range ? { range: input.range } : {}), + }); + }), + /** * Force a one-shot rkey harvest from the online QQ for the open account — the * explicit "立即重新获取 rkey" before a media-completing export. Returns true diff --git a/apps/desktop/src/main/mcp/tools.ts b/apps/desktop/src/main/mcp/tools.ts index ed95019..a3afaec 100644 --- a/apps/desktop/src/main/mcp/tools.ts +++ b/apps/desktop/src/main/mcp/tools.ts @@ -20,6 +20,8 @@ import { groupMemberToWire, buddyToWire, userProfileToWire, + groupEssenceToWire, + groupBulletinToWire, } from '../ipc/serde'; /** @@ -79,6 +81,12 @@ function hhmm(sec: bigint | number): string { return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } +/** epoch 秒 → 本地「YYYY-MM-DD」(只到日,给建群时间等)。 */ +function fmtDate(sec: bigint | number): string { + const d = new Date(Number(sec) * 1000); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + /** * 解析「某天」为本地 [startSec, endSec) 半开窗口(秒),默认今天。 * date 形如 YYYY-MM-DD;非法时抛出可读错误。给「今日/某天」类工具单一事实源。 @@ -481,24 +489,187 @@ export const AI_TOOLS: AiTool[] = [ tool({ name: 'list_group_members', description: - '列出某个群的成员(群号、群名片 card、昵称 nick、uid、uin 等)。用来把群内的某个昵称解析成 uid,再定位 TA 在群里的发言。', + '列出某个群的成员名单(群号、群名片 card、昵称 nick、uid、uin、群等级 memberLevel、管理标记 adminFlag 等)。' + + '既能把群内的某个昵称解析成 uid(再定位 TA 的发言),也能出「群成员名单/等级排行」。' + + 'orderBy: default=默认顺序,level=按群等级从高到低(看群里的元老/等级排行)。支持 limit/offset 翻页。', input: z.object({ - group: z.string().min(1).describe('群号'), + group: z.string().min(1).describe('群号(可先用 find_contact 把群名解析成群号)'), + orderBy: z + .enum(['default', 'level']) + .default('default') + .describe('排序方式:default=默认顺序;level=按群等级从高到低'), limit: z.number().int().min(1).max(200).default(60).describe('返回条数上限'), offset: z.number().int().min(0).default(0).describe('分页偏移'), }), - run: async ({ group, limit, offset }) => { + run: async ({ group, orderBy, limit, offset }) => { let code: bigint; try { code = BigInt(group.trim()); } catch { throw new Error(`群号无效:${group}(应为纯数字群号,可先用 find_contact 解析)`); } - const members = await services().groupInfo.listMembersInGroup(code, limit, offset); + const members = + orderBy === 'level' + ? await services().groupInfo.listMembersByLevel(code, limit, offset) + : await services().groupInfo.listMembersInGroup(code, limit, offset); return members.map(groupMemberToWire); }, }), + tool({ + name: 'list_friends_by_intimacy', + description: + '按【亲密度】从高到低列出我的 QQ 好友排行榜。亲密度来自 QQ 本地资料(profile_info),0 表示未知/无数据,会排到最后。' + + '用来回答「我和谁最亲密」「亲密度最高的好友」「好友亲密度排行」。' + + '返回每位:rank 名次、nick 昵称、remark 备注、uin QQ号、uid、intimacy 亲密度分值。', + input: z.object({ + limit: z.number().int().min(1).max(200).default(30).describe('返回条数上限(取亲密度最高的若干位)'), + offset: z.number().int().min(0).default(0).describe('分页偏移(翻看排行后段)'), + }), + run: async ({ limit, offset }) => { + const friends = await services().profile.listFriendsByIntimacy(limit, offset); + return friends.map((f, i) => ({ + rank: offset + i + 1, + nick: f.nick, + remark: f.remark, + uin: f.uin, + uid: f.uid, + intimacy: f.intimacy, + })); + }, + }), + + tool({ + name: 'get_user_profile', + description: + '查看某个用户的详细资料卡(昵称、备注、QQ号、性别、年龄、生日、个性签名、亲密度、是否我的好友)。' + + '传入对方 uid(可用 find_contact 解析人名、或 list_group_members 从群里拿到 uid)。' + + '用来回答「XX 的生日/签名/性别是什么」「TA 是不是我好友」等。资料取自本地缓存,未缓存的字段可能为空。', + input: z.object({ + uid: z.string().min(1).describe('目标用户的 uid(可用 find_contact 把人名解析成 uid)'), + }), + run: async ({ uid }) => { + const p = await services().profile.getProfile(uid); + if (!p) { + return { + uid, + found: false, + hint: '没有该用户的缓存资料;确认 uid 是否正确(可用 find_contact 解析人名为 uid)。', + }; + } + const w = userProfileToWire(p); + return { + found: true, + uid: w.uid, + uin: w.uin, + nick: w.nick, + remark: w.remark, + gender: w.gender === 1 ? '男' : w.gender === 2 ? '女' : '未知', + ...(w.age ? { age: w.age } : {}), + ...(w.birthYear ? { birthday: `${w.birthYear}-${pad2(w.birthMonth)}-${pad2(w.birthDay)}` } : {}), + ...(w.signature ? { signature: w.signature } : {}), + intimacy: w.intimacy, + isFriend: w.isFriend, + }; + }, + }), + + tool({ + name: 'get_group_info', + description: + '查看某个群的资料详情(群名、群号、群主 uid、当前人数/人数上限、创建时间、群介绍、置顶公告、群标签)。' + + '传入群号(可用 find_contact 解析群名得到)。用来回答「这个群多少人」「群主是谁」「群什么时候建的」「群介绍/置顶」等。', + input: z.object({ + groupCode: z.string().min(1).describe('群号(纯数字,可用 find_contact 把群名解析成群号)'), + }), + run: async ({ groupCode }) => { + let gc: bigint; + try { + gc = BigInt(String(groupCode).trim()); + } catch { + throw new Error(`群号必须是纯数字:${groupCode}(先用 find_contact 把群名解析成群号)`); + } + const d = await services().groupInfo.getGroupDetail(gc); + if (!d) { + return { groupCode, found: false, hint: '找不到该群资料;确认群号是否正确(可用 find_contact 解析群名)。' }; + } + const w = groupDetailToWire(d); + return { + found: true, + groupCode: w.groupCode, + groupName: w.groupName, + ownerUid: w.ownerUid, + memberCount: w.memberCount, + maxMemberCount: w.maxMemberCount, + ...(w.createTime ? { created: fmtDate(w.createTime) } : {}), + ...(w.description ? { description: w.description } : {}), + ...(w.pinnedAnnounce ? { pinnedAnnounce: w.pinnedAnnounce } : {}), + ...(w.remark ? { remark: w.remark } : {}), + ...(w.labels ? { labels: w.labels } : {}), + }; + }, + }), + + tool({ + name: 'get_group_essence', + description: + '列出某个群的精华消息(被群管理设为「精华」的发言),较新在前。传入群号(可用 find_contact 解析群名得到)。' + + '用来回答「群里有哪些精华消息」「谁的发言被设成精华了」。' + + '返回每条:sender 原发言人、senderUin、operator 设精华的人、time 设置时间、msgSeq。', + input: z.object({ + groupCode: z.string().min(1).describe('群号'), + limit: z.number().int().min(1).max(100).default(30).describe('返回条数上限'), + }), + run: async ({ groupCode, limit }) => { + let gc: bigint; + try { + gc = BigInt(String(groupCode).trim()); + } catch { + throw new Error(`群号必须是纯数字:${groupCode}(先用 find_contact 把群名解析成群号)`); + } + const list = await services().groupInfo.getEssenceMessages(gc, limit, 0); + return list.map((e) => { + const w = groupEssenceToWire(e); + return { + sender: w.senderNick || w.senderUin, + senderUin: w.senderUin, + operator: w.operatorNick || w.operatorUin, + time: w.timestamp ? fmtTime(w.timestamp) : '', + msgSeq: w.msgSeq, + }; + }); + }, + }), + + tool({ + name: 'get_group_bulletins', + description: + '列出某个群的群公告(较新在前)。传入群号(可用 find_contact 解析群名得到)。' + + '用来回答「群公告说了什么」「最新群公告」「进群须知」等。返回每条:text 公告正文、time 发布时间、publisherUid 发布者。', + input: z.object({ + groupCode: z.string().min(1).describe('群号'), + limit: z.number().int().min(1).max(50).default(10).describe('返回条数上限'), + }), + run: async ({ groupCode, limit }) => { + let gc: bigint; + try { + gc = BigInt(String(groupCode).trim()); + } catch { + throw new Error(`群号必须是纯数字:${groupCode}(先用 find_contact 把群名解析成群号)`); + } + const list = await services().groupInfo.getGroupBulletins(gc, limit, 0); + return list.map((b) => { + const w = groupBulletinToWire(b); + const t = Number(w.msgTime); + return { + text: w.textContent, + time: t ? fmtTime(t) : '', + publisherUid: w.publisherUid, + }; + }); + }, + }), + tool({ name: 'list_user_groups', description: @@ -574,68 +745,69 @@ export const AI_TOOLS: AiTool[] = [ tool({ name: 'get_group_activity', description: - '获取某个群聊的活跃度统计(指定时间范围内的消息数、活跃成员排行、时段分布、每日趋势等),用于生成群聊活跃度报告。' + - '传入群号(可由 find_contact 解析群名得到)和时间范围(默认最近 30 天)。返回统计数据(JSON),适合用 write_report 生成 HTML 可视化报告。', + '获取某个群聊的活跃度全量统计——活跃成员排行(已解析成群名片/昵称)、24 时段分布、每日消息趋势、热词词云。' + + '**默认统计全部历史**(days=0);传 days>0 才只看最近 N 天。传入群号(可由 find_contact 解析群名得到)。' + + '内部走全量分页扫描(与「群聊分析」卡片同一套逻辑),不做 5000 条截断,故不会漏统计。' + + '返回统计数据(JSON),非常适合接着用 write_report 出一份带图表/排行/词云的 HTML 可视化报告。', input: z.object({ groupCode: z.string().min(1).describe('群号(纯数字,可用 find_contact 把群名解析成群号)'), - days: z.number().int().min(1).max(180).default(30).describe('统计最近 N 天(默认 30 天)'), + days: z + .number() + .int() + .min(0) + .max(3650) + .default(0) + .describe('统计最近 N 天;0=全部历史(默认,最不容易漏数据)'), + wordLimit: z + .number() + .int() + .min(0) + .max(300) + .default(60) + .describe('词云返回的热词数量;0=不算词云(省时)'), }), - run: async ({ groupCode, days }) => { + run: async ({ groupCode, days, wordLimit }) => { const svc = services(); - const now = Date.now(); - // group_msg_table 的 sendTime 是 unix **秒**(见 packages/db/src/msg/group.ts 列 40050), - // 故时间窗也换算成秒来比较;后面构造 Date 时再 ×1000 还原成毫秒。 - const startSec = Math.floor((now - days * 86400000) / 1000); - - // 通过 MsgService 获取群消息(getGroupLatest 最多 5000 条,足够覆盖大多数统计场景) - const msgs = await svc.msgs.getGroupLatest(groupCode, 5000); - const filtered = msgs.filter((m) => Number(m.sendTime) >= startSec); - - if (filtered.length === 0) { - return { - groupCode, - days, - totalMessages: 0, - activeDays: 0, - topSenders: [], - hourlyDistribution: {}, - daily: [], - hint: '该时间范围内无消息记录(或消息数超过 5000 条导致时间窗外的被截断)。', - }; + let gc: bigint; + try { + gc = BigInt(String(groupCode).trim()); + } catch { + throw new Error(`群号必须是纯数字:${groupCode}(先用 find_contact 把群名解析成群号)`); } - // 统计:总消息数、活跃天数、成员排行、时段分布、每日趋势 - const daily = new Map(); - const hourly: Record = {}; - for (let i = 0; i < 24; i++) hourly[i] = 0; - const senderCounts = new Map(); - - for (const msg of filtered) { - const d = new Date(Number(msg.sendTime) * 1000); - const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - daily.set(day, (daily.get(day) ?? 0) + 1); - hourly[d.getHours()] = (hourly[d.getHours()] ?? 0) + 1; - - const uid = msg.senderUid; - const prev = senderCounts.get(uid); - senderCounts.set(uid, { uid, count: (prev?.count ?? 0) + 1 }); + // 复刻「群聊分析」卡片:不限时间=全历史;days>0 时才下推 sendTime 时间窗(unix 秒)。 + let startTime: number | undefined; + let endTime: number | undefined; + if (days && days > 0) { + endTime = Math.floor(Date.now() / 1000); + startTime = endTime - days * 86400; } - const topSenders = [...senderCounts.values()] - .sort((a, b) => b.count - a.count) - .slice(0, 20) - .map((s) => ({ uid: s.uid, count: s.count })); + // 全部走 groupInfo 的全量分页统计(listBatch,逐 500 条扫到底),与卡片同源。 + const [ranking, hourlyDistribution, daily, wordCloud] = await Promise.all([ + svc.groupInfo.getGroupMessageRanking(gc, 20, startTime, endTime), + svc.groupInfo.getGroupActiveHours(gc, startTime, endTime), + svc.groupInfo.getGroupDailyActivity(gc, startTime, endTime), + wordLimit > 0 + ? svc.groupInfo.getGroupWordCloud(gc, wordLimit, startTime, endTime) + : Promise.resolve([]), + ]); + + const totalMessages = daily.reduce((sum, d) => sum + d.count, 0); return { groupCode, - days, - totalMessages: filtered.length, - activeDays: daily.size, - topSenders, - hourlyDistribution: hourly, - daily: [...daily.entries()] - .map(([date, count]) => ({ date, count })) - .sort((a, b) => a.date.localeCompare(b.date)), + range: days && days > 0 ? `最近 ${days} 天` : '全部历史', + totalMessages, + activeDays: daily.length, + // 排行已解析成群名片/昵称(displayName),报告里可直接展示,不必再自己查名字。 + topSenders: ranking.map((r) => ({ name: r.displayName, uid: r.uid, count: r.messageCount })), + hourlyDistribution, + daily, + wordCloud: wordCloud.map((w) => ({ word: w.word, count: w.count })), + ...(totalMessages === 0 + ? { hint: '该范围内没有消息记录:确认群号是否正确,或用 days=0 看全部历史。' } + : {}), }; }, }), diff --git a/apps/desktop/src/renderer/src/im-template/template/chatMainContent.tsx b/apps/desktop/src/renderer/src/im-template/template/chatMainContent.tsx index 867e2c2..c526049 100644 --- a/apps/desktop/src/renderer/src/im-template/template/chatMainContent.tsx +++ b/apps/desktop/src/renderer/src/im-template/template/chatMainContent.tsx @@ -32,6 +32,7 @@ export function ChatMainContent({ view, contactTab, relationGraphSlot, + chatHomeSlot, contactNotice, contactRequests, groupRequests, @@ -82,6 +83,12 @@ export function ChatMainContent({ contactTab?: ContactTab; /** App-provided content for the contacts main area (the relation graph). */ relationGraphSlot?: React.ReactNode; + /** + * App-provided landing shown in the messages view when no conversation is + * selected (the animated home facade). Falls back to ChatPane's built-in + * empty state when omitted. + */ + chatHomeSlot?: React.ReactNode; contactNotice: ContactNoticeView | null; contactRequests: ContactRequest[]; groupRequests: GroupJoinRequest[]; @@ -174,6 +181,10 @@ export function ChatMainContent({ ); } + if (!activeConversation && chatHomeSlot) { + return <>{chatHomeSlot}; + } + return ( ; + } + return ( + + {(name || '?').slice(0, 1)} + + ); +} + +/** ② 一言打字机(独立组件,隔离高频 setState)。 */ +function HitokotoTicker({ verses }: { verses: Verse[] }) { + const [display, setDisplay] = useState(''); + const [from, setFrom] = useState(''); + const [showFrom, setShowFrom] = useState(false); + + useEffect(() => { + if (verses.length === 0) return undefined; + let cancelled = false; + let timer: ReturnType; + let vi = 0; + + const typeVerse = (): void => { + if (cancelled) return; + const verse = verses[vi % verses.length]!; + const chars = [...verse.text]; + setFrom(verse.from); + setShowFrom(false); + let i = 0; + + const typeChar = (): void => { + if (cancelled) return; + i += 1; + setDisplay(chars.slice(0, i).join('')); + if (i < chars.length) { + timer = setTimeout(typeChar, 58 + Math.random() * 52); + } else { + setShowFrom(true); + timer = setTimeout(erase, 2400); + } + }; + + const erase = (): void => { + if (cancelled) return; + setShowFrom(false); + const eraseChar = (): void => { + if (cancelled) return; + i -= 1; + setDisplay(chars.slice(0, Math.max(0, i)).join('')); + if (i > 0) { + timer = setTimeout(eraseChar, 22); + } else { + vi += 1; + timer = setTimeout(typeVerse, 360); + } + }; + eraseChar(); + }; + + timer = setTimeout(typeChar, 280); + }; + + typeVerse(); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [verses]); + + return ( +
+

+ {display} + +

+

+ {from ? `—— ${from}` : ''} +

+
+ ); +} + +/** 时钟图标(见出し用的小装饰,暗示「过去」)。 */ +function ClockGlyph() { + return ( + + + + + ); +} + +/** 单条记忆:时间线节点 + 头像 + 昵称 + 一句旧话。 */ +function MemoryItem({ line }: { line: StreamLine }) { + return ( +
  • + + +
    + {line.name} +

    {line.text}

    +
    +
  • + ); +} + +/** + * ③ 记忆长廊(独立组件,零 setState):把旧消息排成一条时间线,纯 CSS `transform` + * 匀速上浮。轨道内容复制两份、位移 -50% 无缝循环;速度按条数比例(每条约 3.6s)保持 + * 恒定;悬停暂停,方便停下来读某一句。相比旧「聊天窗」既无高频重渲染(不卡),也没有 + * 「正在输入」气泡溢出被 mask 裁掉的问题。 + */ +function MemoryLane({ lines }: { lines: StreamLine[] }) { + // 取一段有限长度,够铺满两屏循环即可,避免 DOM 过大。 + const items = lines.slice(0, 22); + if (items.length === 0) return null; + + // 时长与条数成正比 → 视觉速度恒定,且不因条数少而转得飞快。 + const durationSec = Math.max(26, items.length * 3.6); + const trackStyle = { '--weq-lane-duration': `${durationSec}s` } as CSSProperties; + + return ( +
    +
    + + 那些他们说过的话 +
    +
    +
      + {items.map((line, i) => ( + + ))} + {/* 第二份副本,供无缝循环(对屏幕阅读器隐藏,故无需唯一语义 key) */} + {items.map((line, i) => ( + + ))} +
    +
    +
    + ); +} + +/** + * 记忆长廊暂时隐藏(保留全部实现,改天想要直接翻 true 即可复活)。隐藏时首页只留 + * ① 问候 + ② 一言,靠 .weq-chathome-inner 的 flex 居中自动回正、不留空档。 + */ +const SHOW_MEMORY_LANE = false; + +export function ChatHome({ nickname }: { nickname: string }) { + const hitokoto = trpc.account.sampleHitokoto.useQuery( + { count: 40 }, + { refetchOnMount: 'always', refetchOnWindowFocus: false }, + ); + const chatLines = trpc.account.sampleChatLines.useQuery( + { limit: 90 }, + // 隐藏时不必再拉取旧消息。 + { enabled: SHOW_MEMORY_LANE, refetchOnMount: 'always', refetchOnWindowFocus: false }, + ); + + const verses = (hitokoto.data ?? []) as Verse[]; + const lines = (chatLines.data ?? []) as StreamLine[]; + const name = nickname?.trim() || 'there'; + + return ( +
    +
    +
    + WeQ +

    + {greetWord()}, + {name} +

    +
    + + {verses.length > 0 && } + {SHOW_MEMORY_LANE && lines.length > 0 && } +
    +
    + ); +} diff --git a/apps/desktop/src/renderer/src/views/ExportView.tsx b/apps/desktop/src/renderer/src/views/ExportView.tsx index b16b55d..1f1dc3c 100644 --- a/apps/desktop/src/renderer/src/views/ExportView.tsx +++ b/apps/desktop/src/renderer/src/views/ExportView.tsx @@ -18,8 +18,8 @@ import { useEffect, useMemo, useState, type ReactElement, type ReactNode } from import { CalendarClock, DatabaseZap, - FileCode2, FlaskConical, + Globe, Images, MessagesSquare, Pause, @@ -41,6 +41,7 @@ import { FailureLightbox } from './export/FailureLightbox'; import { CHATLAB_FORMATS, FULL_FORMATS, + QZONE_FORMATS, chatKind, convAvatarUrl, fmtBytes, @@ -64,10 +65,10 @@ interface ModeDef { } const MODES: ModeDef[] = [ - { id: 'full', label: '完整消息格式', desc: 'JSON / JSONL / XLSX / CSV / TXT', icon: }, + { id: 'full', label: '完整消息格式', desc: 'JSON / JSONL / XLSX / CSV / TXT / HTML', icon: }, { id: 'decrypt', label: '解密数据库', desc: '导出原始 SQLite 供研究', icon: }, { id: 'chatlab', label: 'ChatLab 格式', desc: '供 AI 分析的结构化 JSON', icon: }, - { id: 'html', label: '导出为 HTML', desc: '单个会话的网页记录', icon: }, + { id: 'qzone', label: '好友QQ空间导出', desc: '导出好友空间说说(需在线 QQ)', icon: }, { id: 'scheduled', label: '定时导出任务', desc: '按计划自动导出', icon: }, { id: 'album', label: '群相册导出', desc: '批量下载群相册', icon: }, ]; @@ -111,9 +112,10 @@ export function ExportView(): ReactElement { /** Media-completion failure detail, when the user opens a task's failure list. */ const [failureView, setFailureView] = useState<{ name: string; failures: UiFailure[] } | null>(null); - // ChatLab only emits json/jsonl — clamp the chip when entering that mode. + // ChatLab only emits json/jsonl; 好友空间 only json/txt — clamp on mode entry. useEffect(() => { if (mode === 'chatlab' && format !== 'json' && format !== 'jsonl') setFormat('json'); + if (mode === 'qzone' && format !== 'json' && format !== 'txt') setFormat('json'); }, [mode, format]); // Live task progress: invalidate the list whenever the backend ticks. @@ -134,12 +136,19 @@ export function ExportView(): ReactElement { name: c.targetDisplayName || c.targetUid, avatarUrl: convAvatarUrl(kind, c.targetUid, c.targetUin), kind, + uin: c.targetUin, total: count, meta: `${fmtCount(count)} 条 · ${kind === 'group' ? '群聊' : '私聊'}`, }; }); }, [conversations.data]); + // 好友空间导出:只列私聊好友(排除群聊),且需有效 uin。 + const friendItems = useMemo( + () => convItems.filter((it) => it.kind === 'c2c' && it.uin && it.uin !== '0'), + [convItems], + ); + const groupItems = useMemo(() => { return ((groups.data ?? []) as GroupWire[]).map((g) => ({ id: g.groupCode, @@ -238,7 +247,7 @@ export function ExportView(): ReactElement { } if (convSelection.size === 0) return; setLightbox( - mode === 'scheduled' ? 'scheduled' : mode === 'chatlab' ? 'chatlab' : mode === 'html' ? 'html' : 'full', + mode === 'scheduled' ? 'scheduled' : mode === 'chatlab' ? 'chatlab' : mode === 'qzone' ? 'qzone' : 'full', ); } @@ -322,6 +331,59 @@ export function ExportView(): ReactElement { return true; } + /** + * Pre-flight for 好友空间导出: a live QQ instance must be logged in (the QZone + * web CGI needs this account's skey/pskey). Returns false to abort with a + * prompt to open QQ. + */ + async function preflightQqOnline(): Promise { + let online = false; + try { + online = (await client.account.getGroupAlbumAccessState.query()).qqOnline; + } catch (e) { + dialog.error('检查在线状态失败', e instanceof Error ? e.message : String(e)); + return false; + } + if (!online) { + await dialog.info( + '需要打开 QQ', + '导出好友 QQ 空间需要登录该账号的 QQ 客户端以获取访问凭证。请打开并登录 QQ 后重试。', + ); + return false; + } + return true; + } + + /** 好友空间导出:每个选中好友起一个说说导出任务(json/txt + 可选下载配图)。 */ + async function runQzoneExport(options: ExportOptions): Promise { + const targets = friendItems.filter((it) => convSelection.has(it.id)); + if (targets.length === 0) return; + const ok = await preflightQqOnline(); + if (!ok) return; + + const range = { start: options.range.start, end: options.range.end }; + setSubmitting(true); + try { + for (const t of targets) { + if (!t.uin) continue; + await client.account.startQzoneExport.mutate({ + targetUin: t.uin, + name: t.name, + format: format === 'txt' ? 'txt' : 'json', + downloadMedia: options.exportMedia, + range, + }); + } + setConvSelection(new Set()); + setLightbox(null); + refetchTasks(); + } catch (e) { + dialog.error('启动空间导出失败', e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + } + async function runFullExport( options: ExportOptions, opts: { chatlab?: boolean; format?: ExportFormat } = {}, @@ -466,15 +528,16 @@ export function ExportView(): ReactElement { async function onLightboxConfirm(result: LightboxResult): Promise { if (lightbox === 'full') { - void runFullExport(result.options); + // HTML is now one of the 完整消息 formats — pass the selected chip through. + void runFullExport(result.options, { format }); return; } if (lightbox === 'chatlab') { void runFullExport(result.options, { chatlab: true }); return; } - if (lightbox === 'html') { - void runFullExport(result.options, { format: 'html' }); + if (lightbox === 'qzone') { + void runQzoneExport(result.options); return; } if (lightbox === 'scheduled') { @@ -573,8 +636,12 @@ export function ExportView(): ReactElement { } const activeMode = MODES.find((m) => m.id === mode)!; - const isMultiConvMode = mode === 'full' || mode === 'chatlab' || mode === 'scheduled'; - const formatOptions = mode === 'chatlab' ? CHATLAB_FORMATS : FULL_FORMATS; + const isMultiConvMode = + mode === 'full' || mode === 'chatlab' || mode === 'scheduled' || mode === 'qzone'; + const formatOptions = + mode === 'chatlab' ? CHATLAB_FORMATS : mode === 'qzone' ? QZONE_FORMATS : FULL_FORMATS; + // 好友空间导出只列好友(排除群聊);其余多选模式用全部会话。 + const pickerItems = mode === 'qzone' ? friendItems : convItems; const primaryLabel = mode === 'scheduled' @@ -583,8 +650,8 @@ export function ExportView(): ReactElement { ? '导出相册' : mode === 'decrypt' ? '解密并导出' - : mode === 'html' - ? '导出 HTML' + : mode === 'qzone' + ? '导出空间' : mode === 'chatlab' ? '导出 ChatLab' : '导出'; @@ -603,8 +670,8 @@ export function ExportView(): ReactElement { return g ? `群相册 · ${g.name}` : '群相册'; } const n = convSelection.size; - const fmt = lightbox === 'html' ? 'HTML' : format.toUpperCase(); - return `${n} 个会话 · ${fmt}`; + const fmt = format.toUpperCase(); + return lightbox === 'qzone' ? `${n} 位好友 · ${fmt}` : `${n} 个会话 · ${fmt}`; })(); const lightboxHeadline = @@ -614,8 +681,8 @@ export function ExportView(): ReactElement { ? '导出群相册' : lightbox === 'chatlab' ? '导出 ChatLab 格式' - : lightbox === 'html' - ? '导出为 HTML 网页' + : lightbox === 'qzone' + ? '导出好友 QQ 空间' : '导出聊天记录'; return ( @@ -684,12 +751,13 @@ export function ExportView(): ReactElement { )} - ) : isMultiConvMode || mode === 'html' ? ( + ) : mode === 'full' || mode === 'chatlab' || mode === 'qzone' ? ( ) : mode === 'album' ? ( {mode === 'album' ? '选择一个群,下一步选择相册与时间范围' - : mode === 'html' - ? convSelection.size > 0 - ? `已选 ${convSelection.size} 个会话 · HTML · 每个会话导出一个文件夹` - : '选择会话后导出为网页聊天记录(每会话一个文件夹)' - : dbSelection.size > 0 - ? `已选 ${dbSelection.size} 个数据库 · ${fmtBytes(selectedDbBytes)}` - : '选择数据库后导出解密副本'} + : dbSelection.size > 0 + ? `已选 ${dbSelection.size} 个数据库 · ${fmtBytes(selectedDbBytes)}` + : '选择数据库后导出解密副本'} )}