import { prisma } from "@/lib/db"; import { getOrCreateSettings } from "@/lib/user-settings"; import { analyzeTopicIntent } from "@/lib/ai/analyze-topic"; import { parseProviderApiKeys } from "@/lib/ai/keys"; import { assertJobNotCancelled } from "@/lib/jobs/cancel"; import { updateJobProgress } from "@/lib/jobs/progress-server"; import type { JobProgressDetail } from "@/lib/jobs/types"; import { discoverSimilarAccounts } from "@/lib/services/discover-accounts"; import type { ResearchMap, SearchIntent } from "@/lib/types/research"; import { isPlacementGoal } from "@/lib/types/topic-goal"; import { normalizeUsername, parseSelectedTags } from "@/lib/types/research"; import { reconcileSelectedTags } from "@/lib/services/preserve-selected-tags"; import { pickDefaultSelectedTags } from "@/lib/services/scan-tasks"; import { getActiveAccount, refreshSession } from "@/lib/threads-browser"; import { resolvePersona } from "@/lib/db"; import { getActiveAccountProfile } from "@/lib/account-context"; async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { let timer: ReturnType | undefined; try { return await Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label}逾時`)), timeoutMs); }), ]); } finally { if (timer) clearTimeout(timer); } } function enrichTagsWithDiscoveredAccounts(map: ResearchMap): ResearchMap { const accounts = map.similarAccounts ?? []; if (accounts.length === 0) return map; const existing = new Set( map.suggestedTags.map((t) => normalizeUsername(t.tag).toLowerCase()) ); const accountTags = accounts .slice(0, 6) .filter((a) => !existing.has(a.username.toLowerCase())) .map((a) => ({ tag: `@${a.username}`, reason: (a.reason || "網路搜尋找到的同領域帳號").slice(0, 80), searchIntent: "經驗" as SearchIntent, searchType: "帳號" as const, })); if (accountTags.length === 0) return map; return { ...map, suggestedTags: [...map.suggestedTags, ...accountTags].slice(0, 18), }; } export async function analyzeTopic( topicId: string, briefOverride?: string | null, options?: { jobId?: string; onProgress?: (message: string) => void | Promise; } ): Promise { const jobId = options?.jobId; const report = async (detail: JobProgressDetail) => { await options?.onProgress?.(detail.summary); if (jobId) await updateJobProgress(jobId, detail); }; const topic = await prisma.topic.findUnique({ where: { id: topicId } }); if (!topic) throw new Error("找不到主題"); const brief = briefOverride !== undefined ? briefOverride?.trim() || null : topic.brief; if (briefOverride !== undefined) { await prisma.topic.update({ where: { id: topicId }, data: { brief }, }); } const settings = await getOrCreateSettings(); const activeAccount = await getActiveAccountProfile(); const apiKeys = parseProviderApiKeys(settings.providerApiKeys); const provider = settings.researchAiProvider ?? settings.aiProvider; const model = settings.researchAiModel ?? settings.aiModel; console.log(`[analyze-topic] start topic=${topicId} provider=${provider} model=${model}`); await report({ summary: "AI 分析研究地圖中…", phase: "ai", tasks: [{ id: "ai", label: "AI 研究地圖", status: "running" }], }); await assertJobNotCancelled(jobId); const baseMap = await analyzeTopicIntent({ label: topic.label, query: topic.query, brief, productContext: topic.productContext, topicGoal: topic.topicGoal, persona: resolvePersona(settings, activeAccount), aiProvider: settings.researchAiProvider ?? settings.aiProvider, aiModel: settings.researchAiModel ?? settings.aiModel, apiKeys, }); await assertJobNotCancelled(jobId); const placementMode = isPlacementGoal(topic.topicGoal); let similarAccounts: ResearchMap["similarAccounts"] = []; if (placementMode) { await report({ summary: "AI 研究地圖完成(置入模式略過相似帳號)", phase: "ai", tasks: [{ id: "ai", label: "AI 研究地圖", status: "done" }], }); } else { await report({ summary: "AI 研究地圖完成,搜尋相似帳號中…", phase: "accounts", tasks: [ { id: "ai", label: "AI 研究地圖", status: "done" }, { id: "accounts", label: "搜尋相似帳號", status: "running" }, ], }); await assertJobNotCancelled(jobId); const account = await getActiveAccount().catch(() => null); let storageState: string | undefined; if (account) { const refreshed = await withTimeout(refreshSession(account), 20_000, "Threads Session 更新") .catch(() => null); if (refreshed?.valid) storageState = refreshed.storageState; } similarAccounts = await withTimeout( discoverSimilarAccounts({ label: topic.label, query: topic.query, brief, productContext: topic.productContext, pillars: baseMap.pillars, suggestedTags: baseMap.suggestedTags.map((t) => t.tag), exclusions: baseMap.exclusions, storageState, limit: 8, aiProvider: provider, aiModel: model, apiKeys, }), 90_000, "相似帳號搜尋" ).catch(async (error) => { console.warn(`[analyze-topic] similar account search skipped: ${String(error)}`); await report({ summary: "研究地圖完成(相似帳號搜尋逾時,已略過)", phase: "accounts", tasks: [ { id: "ai", label: "AI 研究地圖", status: "done" }, { id: "accounts", label: "搜尋相似帳號(已略過)", status: "done" }, ], }); return []; }); await assertJobNotCancelled(jobId); console.log( `[analyze-topic] discovered accounts=${similarAccounts.length} topic=${topicId}` ); await report({ summary: `AI 研究地圖完成 · 找到 ${similarAccounts.length} 個相似帳號`, phase: "accounts", tasks: [ { id: "ai", label: "AI 研究地圖", status: "done" }, { id: "accounts", label: "搜尋相似帳號", status: "done", found: similarAccounts.length }, ], }); } let researchMap: ResearchMap = { ...baseMap, similarAccounts }; if (placementMode) { researchMap = { ...researchMap, similarAccounts: [], suggestedTags: researchMap.suggestedTags.filter( (t) => t.searchType !== "帳號" && !t.tag.startsWith("@") ), }; } else { researchMap = enrichTagsWithDiscoveredAccounts(researchMap); } const existingSelected = parseSelectedTags(topic.selectedTags); const reconciled = reconcileSelectedTags(existingSelected, researchMap, { label: topic.label, query: topic.query, brief, }); researchMap = reconciled.researchMap; const defaults = pickDefaultSelectedTags(researchMap, topic.topicGoal); const finalSelected = existingSelected.length > 0 ? [...new Set([...reconciled.selectedTags, ...defaults])].slice( 0, Math.max(6, Math.min(8, researchMap.suggestedTags.length)) ) : defaults; await assertJobNotCancelled(jobId); await prisma.topic.update({ where: { id: topicId }, data: { researchMap: JSON.stringify(researchMap), selectedTags: JSON.stringify(finalSelected), }, }); console.log( `[analyze-topic] done topic=${topicId} tags=${researchMap.suggestedTags.length} accounts=${similarAccounts.length}` ); return researchMap; }