228 lines
7.5 KiB
TypeScript
228 lines
7.5 KiB
TypeScript
|
|
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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
|
|
try {
|
||
|
|
return await Promise.race([
|
||
|
|
promise,
|
||
|
|
new Promise<T>((_, 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<void>;
|
||
|
|
}
|
||
|
|
): Promise<ResearchMap> {
|
||
|
|
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;
|
||
|
|
}
|