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..84f09a7 100644 --- a/apps/desktop/src/main/ipc/routers/account.ts +++ b/apps/desktop/src/main/ipc/routers/account.ts @@ -17,8 +17,10 @@ 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 { groupChatBus, type GroupChatStreamEvent } from '../../mcp/agentlab_group_bus'; import { clientKeyExpiryMs, toRenderElements, @@ -68,6 +70,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, @@ -538,6 +630,115 @@ export const accountRouter = router({ return requireServices().agentLab.deletePersona(input.personaId); }), + // ── 克隆体群聊(M2 群骨架)───────────────────────────────────────────── + + createAgentLabGroup: procedure + .input(z.object({ name: z.string().min(1), personaIds: z.array(z.string().min(1)).min(1) })) + .mutation(({ input }) => { + return requireServices().agentLab.createGroup({ name: input.name, personaIds: input.personaIds }); + }), + + listAgentLabGroups: procedure.query(() => { + return requireServices().agentLab.listGroups(); + }), + + getAgentLabGroupDetail: procedure + .input(z.object({ groupId: z.string().min(1) })) + .query(({ input }) => { + return requireServices().agentLab.getGroupDetail(input.groupId); + }), + + renameAgentLabGroup: procedure + .input(z.object({ groupId: z.string().min(1), name: z.string().min(1) })) + .mutation(({ input }) => { + requireServices().agentLab.renameGroup(input.groupId, input.name); + return true; + }), + + deleteAgentLabGroup: procedure + .input(z.object({ groupId: z.string().min(1) })) + .mutation(({ input }) => { + requireServices().agentLab.deleteGroup(input.groupId); + return true; + }), + + addAgentLabGroupMember: procedure + .input(z.object({ groupId: z.string().min(1), personaId: z.string().min(1) })) + .mutation(({ input }) => { + requireServices().agentLab.addGroupMember(input.groupId, input.personaId); + return true; + }), + + removeAgentLabGroupMember: procedure + .input(z.object({ groupId: z.string().min(1), memberId: z.string().min(1) })) + .mutation(({ input }) => { + requireServices().agentLab.removeGroupMember(input.groupId, input.memberId); + return true; + }), + + getAgentLabGroupConversation: procedure + .input(z.object({ groupId: z.string().min(1) })) + .query(({ input }) => { + return requireServices().agentLab.getGroupMessages(input.groupId); + }), + + clearAgentLabGroupConversation: procedure + .input(z.object({ groupId: z.string().min(1) })) + .mutation(({ input }) => { + requireServices().agentLab.clearGroupMessages(input.groupId); + return true; + }), + + /** + * 启动一轮群聊(非阻塞)。立即返回 groupRunId;用户那条 + 每个克隆体的每条回复 + * 通过 `onGroupChatEvent` 逐条流式推送。镜像 chatWithAssistant 的范式。 + */ + sendAgentLabGroupMessage: procedure + .input( + z.object({ + groupId: z.string().min(1), + text: z.string().min(1), + mentions: z.array(z.string().min(1)).default([]), + }), + ) + .mutation(({ input }) => { + const svc = requireServices().agentLab; + const groupRunId = randomUUID(); + const groupId = input.groupId; + void svc + .sendGroupMessage({ groupId, text: input.text, mentions: input.mentions }, (message) => + groupChatBus.emit('event', { + groupRunId, + groupId, + kind: 'message', + message, + } satisfies GroupChatStreamEvent), + ) + .then(() => + groupChatBus.emit('event', { groupRunId, groupId, kind: 'done' } satisfies GroupChatStreamEvent), + ) + .catch((err: unknown) => + groupChatBus.emit('event', { + groupRunId, + groupId, + kind: 'error', + message: err instanceof Error ? err.message : String(err), + } satisfies GroupChatStreamEvent), + ); + return { groupRunId }; + }), + + /** 群聊过程流(每条群消息 / 收尾 / 出错)。 */ + onGroupChatEvent: procedure.subscription(() => { + return observable((emit) => { + const handler = (e: GroupChatStreamEvent): void => emit.next(e); + groupChatBus.on('event', handler); + return () => { + groupChatBus.off('event', handler); + }; + }); + }), + updateAgentLabPersona: procedure .input( z.object({ @@ -553,6 +754,14 @@ export const accountRouter = router({ }) .nullable() .optional(), + willing: z + .object({ + gatePrivate: z.boolean().optional(), + level: z.number().int().min(0).max(100).optional(), + mustReplyOnMention: z.boolean().optional(), + }) + .nullable() + .optional(), }), ) .mutation(({ input }) => { @@ -561,6 +770,7 @@ export const accountRouter = router({ customPrompt: input.customPrompt, voiceCloneEnabled: input.voiceCloneEnabled, voice: input.voice, + willing: input.willing, }); }), @@ -738,6 +948,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 +1596,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/agentlab_group_bus.ts b/apps/desktop/src/main/mcp/agentlab_group_bus.ts new file mode 100644 index 0000000..5a66713 --- /dev/null +++ b/apps/desktop/src/main/mcp/agentlab_group_bus.ts @@ -0,0 +1,20 @@ +/** + * In-process event bus for 克隆体群聊 的流式消息推送。 + * + * `sendGroupMessage` 启动一轮群聊(非阻塞),把每一条产出的群消息(用户那条 + + * 每个克隆体的每一条回复)通过这里广播;`onGroupChatEvent` subscription 桥接成 + * observable 推给渲染端。镜像 `assistant_bus.ts` 的 `assistantBus` 范式。 + * 事件带 `groupRunId` 供前端按当前轮过滤,`kind` 区分消息/收尾/出错。 + */ + +import { EventEmitter } from 'node:events'; +import type { AgentLabGroupMessage } from '@weq/agentlab'; + +export type GroupChatStreamEvent = + | { groupRunId: string; groupId: string; kind: 'message'; message: AgentLabGroupMessage } + | { groupRunId: string; groupId: string; kind: 'done' } + | { groupRunId: string; groupId: string; kind: 'error'; message: string }; + +export const groupChatBus = new EventEmitter(); +/** 放宽监听上限:理论上同一时刻只有一个订阅,保险起见。 */ +groupChatBus.setMaxListeners(50); 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/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 {
可用模型
+
群聊
+
+ {(groups.data ?? []).length === 0 ? ( +
还没有群聊。
+ ) : ( + (groups.data ?? []).map((g) => ( + + )) + )} +
+ + {cloneTasks.length > 0 ? (
{cloneTasks.map((t) => ( @@ -619,6 +677,17 @@ export function AgentLabView(): ReactElement {
) + ) : sel.kind === 'group' ? ( + setSel({ kind: 'home' })} + onDeleted={() => setSel({ kind: 'home' })} + /> ) : (
@@ -636,7 +705,11 @@ export function AgentLabView(): ReactElement { {activePersona?.name ?? '克隆体'} {activePersona - ? `${activePersona.models?.chat?.model ?? '旧版克隆,请重建'} · 样本 ${activePersona.corpusMessageCount} 条` + ? (() => { + const p = (providers.data ?? []).find((pr) => pr.id === activePersona.models?.chat?.providerId); + const modelText = activePersona.models?.chat?.model ?? '旧版克隆,请重建'; + return `${p ? `${p.name} · ` : ''}${modelText} · 样本 ${activePersona.corpusMessageCount} 条`; + })() : '加载中…'}
@@ -733,6 +806,14 @@ export function AgentLabView(): ReactElement { /> ) : null} + {groupOpen ? ( + setGroupOpen(false)} + onCreate={(args) => void onCreateGroup(args)} + /> + ) : null} + {viewingTask ? ( } onClose={() => setSettingsOpen(false)} diff --git a/apps/desktop/src/renderer/src/views/ChatHome.tsx b/apps/desktop/src/renderer/src/views/ChatHome.tsx new file mode 100644 index 0000000..8698dbe --- /dev/null +++ b/apps/desktop/src/renderer/src/views/ChatHome.tsx @@ -0,0 +1,245 @@ +/** + * 聊天页门面(无选中会话时的落地页)。三段式,纯装饰、无交互: + * + * ① 品牌 logo + 大字细体问候(按时间 Good morning/… + QQ 昵称) + * ② 一言打字机:从 hitokoto 池随机取句,逐字打出→停顿→逆序退回→下一句 + * ③ 记忆长廊:把私聊里「别人发来」的真实短句排成一条时间线,纯 CSS 匀速上浮(悬停 + * 暂停),像回忆浮现又淡去——真·旧消息就是最贴「回忆」主题的素材 + * + * 数据走只读 trpc(account.sampleHitokoto / account.sampleChatLines),每次进首页都 + * 重新请求(refetchOnMount: 'always')→ 后端每次重新洗牌 → 每次打开都换一批。 + * + * 性能:打字机每几十毫秒 setState,故拆成独立子组件隔离高频重渲染;记忆长廊则完全零 + * setState——滚动交给 CSS transform(GPU 合成,不触发布局),既不卡也不会有旧实现里 + * 「正在输入」气泡溢出被 mask 裁掉的问题。组件仅在空态挂载,选中会话即卸载,打字机定 + * 时器随之清理。 + */ + +import { useEffect, useState } from 'react'; +import type { CSSProperties } from 'react'; +import logoUrl from '@resources/brand/logo.png'; +import { trpc } from '../trpc/client'; + +interface Verse { + text: string; + from: string; +} + +interface StreamLine { + text: string; + uin: string; + name: string; +} + +/** QQ 号 → 头像 URL(与 MainView.senderAvatarSrc 同源)。 */ +function avatarUrl(uin: string): string | null { + return uin && uin !== '0' ? `https://thirdqq.qlogo.cn/g?b=sdk&s=0&nk=${uin}` : null; +} + +/** 按当前时刻给英文问候语。 */ +function greetWord(): string { + const h = new Date().getHours(); + if (h < 5) return 'Good night'; + if (h < 11) return 'Good morning'; + if (h < 13) return 'Hello'; + if (h < 18) return 'Good afternoon'; + if (h < 23) return 'Good evening'; + return 'Good night'; +} + +/** 小头像:有 QQ 号走 qlogo,取不到则用昵称首字兜底。 */ +function Avatar({ uin, name }: { uin: string; name: string }) { + const src = avatarUrl(uin); + if (src) { + 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)}` + : '选择数据库后导出解密副本'} )} +
    + {groupName} + {personaMembers.length} 个克隆体 + 我 · 共 {history.length} 条 +
    + +
    + + + +
    + + +
    + {history.length === 0 ? ( +
    + 这是一个群聊,有 {personaMembers.map((m) => m.displayName).join('、') || '(暂无克隆体)'}。 + 发一句话试试,或用 @ 点名某个克隆体。 +
    + ) : ( + history.map((m, index) => { + const meta = metaFor(m.senderId, m.senderKind, nameById.get(m.senderId) ?? '克隆体'); + return ( + + ); + }) + )} +
    + +
    + {mentionCandidates.length > 0 ? ( +
    + {mentionCandidates.map((m) => ( + + ))} +
    + ) : null} +