2026-07-13 01:15:30 +00:00
|
|
|
/**
|
|
|
|
|
* M5 live: inspire / research / media / scout
|
|
|
|
|
*/
|
|
|
|
|
import type {
|
|
|
|
|
Brand,
|
|
|
|
|
BrandProduct,
|
|
|
|
|
InspirationIdea,
|
2026-07-13 08:59:13 +00:00
|
|
|
Job,
|
2026-07-13 01:15:30 +00:00
|
|
|
InspireAngle,
|
|
|
|
|
InspireChatMessage,
|
|
|
|
|
InspireElement,
|
|
|
|
|
InspireSession,
|
|
|
|
|
InspireSessionSummary,
|
|
|
|
|
ResearchHit,
|
|
|
|
|
ScoutHomeworkRecord,
|
|
|
|
|
ScoutPost,
|
|
|
|
|
ScoutRunBrief,
|
|
|
|
|
ScoutScanContext,
|
|
|
|
|
ScoutTopic,
|
|
|
|
|
TrendItem,
|
|
|
|
|
TrendKind,
|
|
|
|
|
ViralSample,
|
|
|
|
|
} from "../../domain/types";
|
|
|
|
|
import type {
|
|
|
|
|
InspirationRepo,
|
|
|
|
|
MediaRepo,
|
|
|
|
|
ResearchRepo,
|
|
|
|
|
ScoutRepo,
|
|
|
|
|
} from "../repos";
|
|
|
|
|
import { messageForApiCode } from "../../lib/apiErrors";
|
|
|
|
|
import { ApiError, apiRequest, getApiBase, loadTokens } from "./http";
|
|
|
|
|
|
|
|
|
|
function mapTrend(raw: Record<string, unknown>): TrendItem {
|
|
|
|
|
return {
|
|
|
|
|
id: String(raw.id ?? ""),
|
|
|
|
|
kind: (raw.kind as TrendKind) || "threads_tag",
|
|
|
|
|
label: String(raw.label ?? ""),
|
|
|
|
|
summary: String(raw.summary ?? ""),
|
|
|
|
|
heat: Number(raw.heat ?? 0),
|
|
|
|
|
keywords: Array.isArray(raw.keywords) ? (raw.keywords as string[]) : [],
|
|
|
|
|
samples: Array.isArray(raw.samples) ? (raw.samples as string[]) : [],
|
|
|
|
|
source_label: String(raw.source_label ?? ""),
|
|
|
|
|
observed_at: Number(raw.observed_at ?? 0),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapElement(raw: Record<string, unknown>): InspireElement {
|
|
|
|
|
return {
|
|
|
|
|
id: String(raw.id ?? ""),
|
|
|
|
|
kind: (raw.kind as InspireElement["kind"]) || "snippet",
|
|
|
|
|
title: String(raw.title ?? ""),
|
|
|
|
|
body: String(raw.body ?? ""),
|
|
|
|
|
ref_id: raw.ref_id != null ? String(raw.ref_id) : null,
|
|
|
|
|
reusable: Boolean(raw.reusable),
|
|
|
|
|
created_at: Number(raw.created_at ?? 0),
|
|
|
|
|
updated_at: Number(raw.updated_at ?? 0),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapMessage(raw: Record<string, unknown>): InspireChatMessage {
|
|
|
|
|
const draftRaw = raw.draft as Record<string, unknown> | undefined;
|
|
|
|
|
return {
|
|
|
|
|
id: String(raw.id ?? ""),
|
|
|
|
|
role: (raw.role as InspireChatMessage["role"]) || "assistant",
|
|
|
|
|
text: String(raw.text ?? ""),
|
|
|
|
|
draft:
|
|
|
|
|
draftRaw && draftRaw.body != null
|
|
|
|
|
? {
|
|
|
|
|
title: draftRaw.title != null ? String(draftRaw.title) : undefined,
|
|
|
|
|
body: String(draftRaw.body),
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
created_at: Number(raw.created_at ?? 0),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapSession(raw: Record<string, unknown>): InspireSession {
|
|
|
|
|
const msgs = Array.isArray(raw.messages) ? raw.messages : [];
|
|
|
|
|
return {
|
|
|
|
|
id: String(raw.id ?? ""),
|
|
|
|
|
title: raw.title != null ? String(raw.title) : undefined,
|
|
|
|
|
messages: msgs.map((m) => mapMessage(m as Record<string, unknown>)),
|
|
|
|
|
pinned_element_ids: Array.isArray(raw.pinned_element_ids)
|
|
|
|
|
? (raw.pinned_element_ids as string[])
|
|
|
|
|
: [],
|
|
|
|
|
created_at: raw.created_at != null ? Number(raw.created_at) : undefined,
|
|
|
|
|
updated_at: Number(raw.updated_at ?? 0),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapSessionSummary(raw: Record<string, unknown>): InspireSessionSummary {
|
|
|
|
|
return {
|
|
|
|
|
id: String(raw.id ?? ""),
|
|
|
|
|
title: String(raw.title ?? "新對話"),
|
|
|
|
|
preview: String(raw.preview ?? ""),
|
|
|
|
|
message_count: Number(raw.message_count ?? 0),
|
|
|
|
|
updated_at: Number(raw.updated_at ?? 0),
|
|
|
|
|
created_at: Number(raw.created_at ?? 0),
|
|
|
|
|
active: Boolean(raw.active),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapBrand(raw: Record<string, unknown>): Brand {
|
|
|
|
|
return {
|
|
|
|
|
id: String(raw.id ?? ""),
|
|
|
|
|
display_name: String(raw.display_name ?? ""),
|
|
|
|
|
brief: String(raw.brief ?? ""),
|
|
|
|
|
target_audience: raw.target_audience != null ? String(raw.target_audience) : undefined,
|
|
|
|
|
goals: raw.goals != null ? String(raw.goals) : undefined,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapProduct(raw: Record<string, unknown>): BrandProduct {
|
|
|
|
|
return {
|
|
|
|
|
id: String(raw.id ?? ""),
|
|
|
|
|
brand_id: String(raw.brand_id ?? ""),
|
|
|
|
|
label: String(raw.label ?? ""),
|
|
|
|
|
product_context: String(raw.product_context ?? ""),
|
|
|
|
|
match_tags: Array.isArray(raw.match_tags) ? (raw.match_tags as string[]) : [],
|
|
|
|
|
pain_points: Array.isArray(raw.pain_points) ? (raw.pain_points as string[]) : [],
|
|
|
|
|
placement_url: raw.placement_url != null ? String(raw.placement_url) : undefined,
|
|
|
|
|
created_at: Number(raw.created_at ?? 0),
|
|
|
|
|
updated_at: Number(raw.updated_at ?? 0),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapBrief(raw: Record<string, unknown>): ScoutRunBrief {
|
|
|
|
|
return {
|
|
|
|
|
intent: String(raw.intent ?? ""),
|
|
|
|
|
mode: (raw.mode as ScoutRunBrief["mode"]) || "theme",
|
|
|
|
|
brand_id: raw.brand_id != null ? String(raw.brand_id) : null,
|
|
|
|
|
product_id: raw.product_id != null ? String(raw.product_id) : null,
|
|
|
|
|
product_label: raw.product_label != null ? String(raw.product_label) : undefined,
|
|
|
|
|
pains: Array.isArray(raw.pains) ? (raw.pains as string[]) : [],
|
|
|
|
|
tags: Array.isArray(raw.tags) ? (raw.tags as string[]) : [],
|
|
|
|
|
periphery: Array.isArray(raw.periphery) ? (raw.periphery as string[]) : [],
|
|
|
|
|
scan_terms: Array.isArray(raw.scan_terms) ? (raw.scan_terms as string[]) : [],
|
|
|
|
|
placement_note: raw.placement_note != null ? String(raw.placement_note) : undefined,
|
|
|
|
|
response_stance: raw.response_stance != null ? String(raw.response_stance) : undefined,
|
|
|
|
|
theme_key: raw.theme_key != null ? String(raw.theme_key) : undefined,
|
|
|
|
|
theme_label: raw.theme_label != null ? String(raw.theme_label) : undefined,
|
|
|
|
|
product_context: raw.product_context != null ? String(raw.product_context) : undefined,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function briefToBody(b: ScoutRunBrief): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
intent: b.intent,
|
|
|
|
|
mode: b.mode,
|
|
|
|
|
brand_id: b.brand_id || undefined,
|
|
|
|
|
product_id: b.product_id || undefined,
|
|
|
|
|
product_label: b.product_label,
|
|
|
|
|
pains: b.pains,
|
|
|
|
|
tags: b.tags,
|
|
|
|
|
periphery: b.periphery,
|
|
|
|
|
scan_terms: b.scan_terms,
|
|
|
|
|
placement_note: b.placement_note,
|
|
|
|
|
response_stance: b.response_stance,
|
|
|
|
|
theme_key: b.theme_key,
|
|
|
|
|
theme_label: b.theme_label,
|
|
|
|
|
product_context: b.product_context,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapScoutPost(raw: Record<string, unknown>): ScoutPost {
|
|
|
|
|
return {
|
|
|
|
|
id: String(raw.id ?? ""),
|
|
|
|
|
brand_id: raw.brand_id != null ? String(raw.brand_id) : null,
|
|
|
|
|
author: String(raw.author ?? ""),
|
|
|
|
|
text: String(raw.text ?? ""),
|
|
|
|
|
search_tag: String(raw.search_tag ?? ""),
|
|
|
|
|
opportunity: String(raw.opportunity ?? ""),
|
|
|
|
|
outreach_status: (raw.outreach_status as ScoutPost["outreach_status"]) || "new",
|
|
|
|
|
draft_text: raw.draft_text != null ? String(raw.draft_text) : undefined,
|
|
|
|
|
score: Number(raw.score ?? 0),
|
|
|
|
|
matched_product_id: raw.matched_product_id != null ? String(raw.matched_product_id) : null,
|
|
|
|
|
matched_product_label:
|
|
|
|
|
raw.matched_product_label != null ? String(raw.matched_product_label) : null,
|
|
|
|
|
match_reason: raw.match_reason != null ? String(raw.match_reason) : undefined,
|
|
|
|
|
scout_mode: raw.scout_mode as ScoutPost["scout_mode"],
|
|
|
|
|
intent_snippet: raw.intent_snippet != null ? String(raw.intent_snippet) : undefined,
|
|
|
|
|
theme_key: raw.theme_key != null ? String(raw.theme_key) : undefined,
|
|
|
|
|
theme_label: raw.theme_label != null ? String(raw.theme_label) : undefined,
|
2026-07-13 08:59:13 +00:00
|
|
|
scan_path: raw.scan_path != null ? String(raw.scan_path) : undefined,
|
|
|
|
|
classification: raw.classification != null ? String(raw.classification) : undefined,
|
|
|
|
|
permalink: raw.permalink != null ? String(raw.permalink) : undefined,
|
|
|
|
|
created_at: raw.created_at != null ? Number(raw.created_at) : undefined,
|
2026-07-15 15:23:59 +00:00
|
|
|
published_at: raw.published_at != null ? Number(raw.published_at) : undefined,
|
2026-07-13 08:59:13 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapScanJob(raw: Record<string, unknown>): Job {
|
|
|
|
|
return {
|
|
|
|
|
id: String(raw.id ?? ""),
|
|
|
|
|
template_type: String(raw.template_type ?? "scout_scan"),
|
|
|
|
|
status: (raw.status as Job["status"]) || "pending",
|
|
|
|
|
progress_summary: String(raw.progress_summary ?? ""),
|
|
|
|
|
progress_percent: Number(raw.progress_percent ?? 0),
|
|
|
|
|
error: raw.error != null ? String(raw.error) : undefined,
|
|
|
|
|
ref_id: raw.ref_id != null ? String(raw.ref_id) : undefined,
|
|
|
|
|
payload: raw.payload != null ? String(raw.payload) : undefined,
|
|
|
|
|
run_after: raw.run_after != null ? Number(raw.run_after) : undefined,
|
|
|
|
|
created_at: Number(raw.created_at ?? 0),
|
|
|
|
|
updated_at: Number(raw.updated_at ?? raw.created_at ?? 0),
|
|
|
|
|
completed_at: raw.completed_at != null ? Number(raw.completed_at) : undefined,
|
2026-07-13 01:15:30 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapHomework(raw: Record<string, unknown>): ScoutHomeworkRecord {
|
|
|
|
|
return {
|
|
|
|
|
theme_key: String(raw.theme_key ?? ""),
|
|
|
|
|
theme_label: String(raw.theme_label ?? ""),
|
|
|
|
|
purpose: (raw.purpose as ScoutHomeworkRecord["purpose"]) || "value",
|
|
|
|
|
brief: mapBrief((raw.brief as Record<string, unknown>) ?? {}),
|
|
|
|
|
created_at: Number(raw.created_at ?? 0),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const removed = (name: string) => async () => {
|
|
|
|
|
throw new Error(`${name} 已移除(現 UI 不支援)`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function createLiveInspiration(): InspirationRepo {
|
|
|
|
|
return {
|
|
|
|
|
async list() {
|
|
|
|
|
return [] as InspirationIdea[];
|
|
|
|
|
},
|
|
|
|
|
async listTrends() {
|
|
|
|
|
const data = await apiRequest<{ list: Record<string, unknown>[] }>("/api/v1/inspire/trends");
|
|
|
|
|
return (data.list ?? []).map(mapTrend);
|
|
|
|
|
},
|
|
|
|
|
async refreshTrends() {
|
|
|
|
|
const data = await apiRequest<{ list: Record<string, unknown>[] }>(
|
|
|
|
|
"/api/v1/inspire/trends/refresh",
|
|
|
|
|
{ method: "POST", body: {} },
|
|
|
|
|
);
|
|
|
|
|
return (data.list ?? []).map(mapTrend);
|
|
|
|
|
},
|
|
|
|
|
searchTrends: removed("searchTrends") as InspirationRepo["searchTrends"],
|
|
|
|
|
async listElements() {
|
|
|
|
|
const data = await apiRequest<{ list: Record<string, unknown>[] }>(
|
|
|
|
|
"/api/v1/inspire/elements",
|
|
|
|
|
);
|
|
|
|
|
return (data.list ?? []).map(mapElement);
|
|
|
|
|
},
|
|
|
|
|
async saveElement(el) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>("/api/v1/inspire/elements", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
id: el.id,
|
|
|
|
|
kind: el.kind,
|
|
|
|
|
title: el.title,
|
|
|
|
|
body: el.body,
|
|
|
|
|
ref_id: el.ref_id,
|
|
|
|
|
reusable: el.reusable,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return mapElement(raw);
|
|
|
|
|
},
|
|
|
|
|
async removeElement(id) {
|
|
|
|
|
await apiRequest(`/api/v1/inspire/elements/${encodeURIComponent(id)}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
async getSession() {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>("/api/v1/inspire/session");
|
|
|
|
|
return mapSession(raw);
|
|
|
|
|
},
|
|
|
|
|
async clearSession() {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>("/api/v1/inspire/session/clear", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {},
|
|
|
|
|
});
|
|
|
|
|
return mapSession(raw);
|
|
|
|
|
},
|
|
|
|
|
async listSessions() {
|
|
|
|
|
const data = await apiRequest<{ list: Record<string, unknown>[] }>(
|
|
|
|
|
"/api/v1/inspire/sessions",
|
|
|
|
|
);
|
|
|
|
|
return (data.list ?? []).map(mapSessionSummary);
|
|
|
|
|
},
|
|
|
|
|
async createSession(title) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>("/api/v1/inspire/sessions", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: title ? { title } : {},
|
|
|
|
|
});
|
|
|
|
|
return mapSession(raw);
|
|
|
|
|
},
|
|
|
|
|
async activateSession(id) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>(
|
|
|
|
|
`/api/v1/inspire/sessions/${encodeURIComponent(id)}/activate`,
|
|
|
|
|
{ method: "POST", body: {} },
|
|
|
|
|
);
|
|
|
|
|
return mapSession(raw);
|
|
|
|
|
},
|
|
|
|
|
async deleteSession(id) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>(
|
|
|
|
|
`/api/v1/inspire/sessions/${encodeURIComponent(id)}`,
|
|
|
|
|
{ method: "DELETE" },
|
|
|
|
|
);
|
|
|
|
|
return mapSession(raw);
|
|
|
|
|
},
|
|
|
|
|
async chat(opts) {
|
|
|
|
|
const data = await apiRequest<{
|
|
|
|
|
session: Record<string, unknown>;
|
|
|
|
|
messages: Record<string, unknown>[];
|
|
|
|
|
}>("/api/v1/inspire/chat", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
message: opts.message,
|
|
|
|
|
pinned_ids: opts.pinnedIds,
|
|
|
|
|
mode: opts.mode,
|
|
|
|
|
persona_id: opts.personaId || undefined,
|
|
|
|
|
session_id: opts.sessionId || undefined,
|
2026-07-20 06:33:14 +00:00
|
|
|
use_web: opts.useWeb || undefined,
|
2026-07-13 01:15:30 +00:00
|
|
|
material: opts.material || undefined,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const session = mapSession(data.session ?? {});
|
|
|
|
|
const messages = (data.messages ?? []).map(mapMessage);
|
|
|
|
|
return { session, messages };
|
|
|
|
|
},
|
|
|
|
|
async previewPrompt(opts) {
|
|
|
|
|
const data = await apiRequest<{
|
|
|
|
|
prompt: string;
|
|
|
|
|
mode: string;
|
|
|
|
|
sections?: string[];
|
|
|
|
|
blocks?: Array<{ title: string; body: string }>;
|
|
|
|
|
fingerprint?: string;
|
|
|
|
|
char_count?: number;
|
|
|
|
|
rune_count?: number;
|
|
|
|
|
pinned?: Array<{ id: string; kind: string; title: string; body: string }>;
|
|
|
|
|
note?: string;
|
|
|
|
|
}>("/api/v1/inspire/prompt-preview", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
message: opts.message,
|
|
|
|
|
pinned_ids: opts.pinnedIds,
|
|
|
|
|
mode: opts.mode,
|
|
|
|
|
persona_id: opts.personaId || undefined,
|
|
|
|
|
session_id: opts.sessionId || undefined,
|
|
|
|
|
material: opts.material || undefined,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
prompt: String(data.prompt ?? ""),
|
|
|
|
|
mode: String(data.mode ?? opts.mode),
|
|
|
|
|
sections: (data.sections ?? []).map(String),
|
|
|
|
|
blocks: (data.blocks ?? []).map((b) => ({
|
|
|
|
|
title: String(b.title ?? ""),
|
|
|
|
|
body: String(b.body ?? ""),
|
|
|
|
|
})),
|
|
|
|
|
fingerprint: String(data.fingerprint ?? ""),
|
|
|
|
|
charCount: Number(data.char_count ?? 0),
|
|
|
|
|
runeCount: Number(data.rune_count ?? 0),
|
|
|
|
|
pinned: (data.pinned ?? []).map((p) => ({
|
|
|
|
|
id: String(p.id ?? ""),
|
|
|
|
|
kind: String(p.kind ?? ""),
|
|
|
|
|
title: String(p.title ?? ""),
|
|
|
|
|
body: String(p.body ?? ""),
|
|
|
|
|
})),
|
|
|
|
|
note: data.note ? String(data.note) : undefined,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async chatStream(opts, onDelta) {
|
|
|
|
|
const tok = loadTokens();
|
|
|
|
|
const headers: Record<string, string> = {
|
|
|
|
|
Accept: "text/event-stream",
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
};
|
|
|
|
|
if (tok?.access_token) headers.Authorization = `Bearer ${tok.access_token}`;
|
|
|
|
|
const base = getApiBase();
|
|
|
|
|
const url = `${base}/api/v1/inspire/chat-stream`;
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers,
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
message: opts.message,
|
|
|
|
|
pinned_ids: opts.pinnedIds,
|
|
|
|
|
mode: opts.mode,
|
|
|
|
|
persona_id: opts.personaId || undefined,
|
|
|
|
|
session_id: opts.sessionId || undefined,
|
2026-07-20 06:33:14 +00:00
|
|
|
use_web: opts.useWeb || undefined,
|
2026-07-13 01:15:30 +00:00
|
|
|
material: opts.material || undefined,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok || !res.body) {
|
|
|
|
|
throw new ApiError(
|
|
|
|
|
messageForApiCode(res.status >= 500 ? 500000 : 400001, `stream HTTP ${res.status}`),
|
|
|
|
|
res.status,
|
|
|
|
|
res.status,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const reader = res.body.getReader();
|
|
|
|
|
const decoder = new TextDecoder();
|
|
|
|
|
let buf = "";
|
|
|
|
|
let session: InspireSession | null = null;
|
|
|
|
|
let streamError = "";
|
|
|
|
|
let prompt = "";
|
|
|
|
|
let fingerprint = "";
|
|
|
|
|
let charCount = 0;
|
|
|
|
|
let runeCount = 0;
|
|
|
|
|
let sections: string[] = [];
|
|
|
|
|
while (true) {
|
|
|
|
|
const { done, value } = await reader.read();
|
|
|
|
|
if (done) break;
|
|
|
|
|
buf += decoder.decode(value, { stream: true });
|
|
|
|
|
const parts = buf.split("\n\n");
|
|
|
|
|
buf = parts.pop() ?? "";
|
|
|
|
|
for (const block of parts) {
|
|
|
|
|
for (const line of block.split("\n")) {
|
|
|
|
|
if (!line.startsWith("data:")) continue;
|
|
|
|
|
const raw = line.slice(5).trim();
|
|
|
|
|
if (!raw || raw === "[DONE]") continue;
|
|
|
|
|
let ev: {
|
|
|
|
|
type?: string;
|
|
|
|
|
text?: string;
|
|
|
|
|
message?: string;
|
|
|
|
|
session?: Record<string, unknown>;
|
|
|
|
|
prompt?: string;
|
|
|
|
|
prompt_fingerprint?: string;
|
|
|
|
|
prompt_char_count?: number;
|
|
|
|
|
prompt_rune_count?: number;
|
|
|
|
|
prompt_sections?: string[];
|
|
|
|
|
};
|
|
|
|
|
try {
|
|
|
|
|
ev = JSON.parse(raw) as typeof ev;
|
|
|
|
|
} catch {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (ev.type === "delta" && ev.text) {
|
|
|
|
|
onDelta(ev.text);
|
|
|
|
|
} else if (ev.type === "error") {
|
|
|
|
|
streamError = ev.message || "stream error";
|
|
|
|
|
} else if (ev.type === "done" && ev.session) {
|
|
|
|
|
session = mapSession(ev.session);
|
|
|
|
|
prompt = String(ev.prompt ?? "");
|
|
|
|
|
fingerprint = String(ev.prompt_fingerprint ?? "");
|
|
|
|
|
charCount = Number(ev.prompt_char_count ?? 0);
|
|
|
|
|
runeCount = Number(ev.prompt_rune_count ?? 0);
|
|
|
|
|
sections = (ev.prompt_sections ?? []).map(String);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (streamError) {
|
|
|
|
|
throw new ApiError(streamError, 400001, 400);
|
|
|
|
|
}
|
|
|
|
|
if (!session) {
|
|
|
|
|
const fallback = await this.chat(opts);
|
|
|
|
|
return { session: fallback.session };
|
|
|
|
|
}
|
|
|
|
|
return { session, prompt, fingerprint, charCount, runeCount, sections };
|
|
|
|
|
},
|
|
|
|
|
sparkAngles: removed("sparkAngles") as () => Promise<InspireAngle[]>,
|
|
|
|
|
sparkAnglesForTopic: removed("sparkAnglesForTopic") as InspirationRepo["sparkAnglesForTopic"],
|
|
|
|
|
bookmarkTrend: removed("bookmarkTrend") as InspirationRepo["bookmarkTrend"],
|
|
|
|
|
saveIdea: removed("saveIdea") as InspirationRepo["saveIdea"],
|
|
|
|
|
generate: removed("generate") as InspirationRepo["generate"],
|
|
|
|
|
async listViral() {
|
|
|
|
|
return [] as ViralSample[];
|
|
|
|
|
},
|
|
|
|
|
sparkFromTrend: removed("sparkFromTrend") as InspirationRepo["sparkFromTrend"],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createLiveResearch(): ResearchRepo {
|
|
|
|
|
return {
|
|
|
|
|
async search(query) {
|
|
|
|
|
const data = await apiRequest<{ list: Record<string, unknown>[] }>(
|
|
|
|
|
"/api/v1/research/search",
|
|
|
|
|
{ method: "POST", body: { query } },
|
|
|
|
|
);
|
|
|
|
|
return (data.list ?? []).map(
|
|
|
|
|
(raw): ResearchHit => ({
|
|
|
|
|
id: String(raw.id ?? ""),
|
|
|
|
|
title: String(raw.title ?? ""),
|
|
|
|
|
snippet: String(raw.snippet ?? ""),
|
|
|
|
|
url: String(raw.url ?? ""),
|
|
|
|
|
summary: raw.summary != null ? String(raw.summary) : undefined,
|
|
|
|
|
learn_points: Array.isArray(raw.learn_points)
|
|
|
|
|
? (raw.learn_points as string[])
|
|
|
|
|
: undefined,
|
|
|
|
|
reply_hooks: Array.isArray(raw.reply_hooks) ? (raw.reply_hooks as string[]) : undefined,
|
|
|
|
|
source_label: raw.source_label != null ? String(raw.source_label) : undefined,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createLiveMedia(): MediaRepo {
|
|
|
|
|
return {
|
|
|
|
|
async generateImage(prompt) {
|
|
|
|
|
const data = await apiRequest<{ id: string; url: string; prompt: string }>(
|
|
|
|
|
"/api/v1/media/generate-image",
|
|
|
|
|
{ method: "POST", body: { prompt } },
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
id: String(data.id ?? ""),
|
|
|
|
|
url: String(data.url ?? ""),
|
|
|
|
|
prompt: String(data.prompt ?? prompt),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async attachLocal(files) {
|
|
|
|
|
const list = Array.from(files instanceof FileList ? Array.from(files) : files);
|
|
|
|
|
const out: { id: string; url: string; name?: string }[] = [];
|
|
|
|
|
for (const file of list) {
|
|
|
|
|
const content = await fileToDataURL(file);
|
|
|
|
|
const data = await apiRequest<{ url: string; key?: string }>("/api/v1/media/upload", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: { content, kind: "other" },
|
|
|
|
|
});
|
|
|
|
|
out.push({
|
|
|
|
|
id: data.key || `att_${Date.now()}`,
|
|
|
|
|
url: data.url,
|
|
|
|
|
name: file.name,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fileToDataURL(file: File): Promise<string> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
reader.onload = () => resolve(String(reader.result ?? ""));
|
|
|
|
|
reader.onerror = () => reject(new Error("read file failed"));
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createLiveScout(): ScoutRepo {
|
|
|
|
|
return {
|
|
|
|
|
async listBrands() {
|
|
|
|
|
const data = await apiRequest<{ list: Record<string, unknown>[] }>("/api/v1/scout/brands");
|
|
|
|
|
return (data.list ?? []).map(mapBrand);
|
|
|
|
|
},
|
|
|
|
|
async get(id) {
|
|
|
|
|
try {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>(
|
|
|
|
|
`/api/v1/scout/brands/${encodeURIComponent(id)}`,
|
|
|
|
|
);
|
|
|
|
|
return mapBrand(raw);
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
async getActiveBrandId() {
|
|
|
|
|
const data = await apiRequest<{ id: string }>("/api/v1/scout/brands-active");
|
|
|
|
|
return String(data.id ?? "");
|
|
|
|
|
},
|
|
|
|
|
async setActiveBrandId(id) {
|
|
|
|
|
await apiRequest("/api/v1/scout/brands-active", { method: "POST", body: { id } });
|
|
|
|
|
},
|
|
|
|
|
async createBrand(input) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/brands", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
display_name: input?.display_name || "未命名品牌",
|
|
|
|
|
brief: input?.brief || "",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return mapBrand(raw);
|
|
|
|
|
},
|
|
|
|
|
async saveBrand(brand) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/brands", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
id: brand.id,
|
|
|
|
|
display_name: brand.display_name,
|
|
|
|
|
brief: brand.brief,
|
|
|
|
|
target_audience: brand.target_audience,
|
|
|
|
|
goals: brand.goals,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return mapBrand(raw);
|
|
|
|
|
},
|
|
|
|
|
async removeBrand(id) {
|
|
|
|
|
await apiRequest(`/api/v1/scout/brands/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
|
|
|
},
|
|
|
|
|
async listProducts(brandId) {
|
|
|
|
|
const q = brandId ? `?brand_id=${encodeURIComponent(brandId)}` : "";
|
|
|
|
|
const data = await apiRequest<{ list: Record<string, unknown>[] }>(
|
|
|
|
|
`/api/v1/scout/products${q}`,
|
|
|
|
|
);
|
|
|
|
|
return (data.list ?? []).map(mapProduct);
|
|
|
|
|
},
|
|
|
|
|
async listAllProducts() {
|
|
|
|
|
const data = await apiRequest<{ list: Record<string, unknown>[] }>(
|
|
|
|
|
"/api/v1/scout/products/all",
|
|
|
|
|
);
|
|
|
|
|
return (data.list ?? []).map(mapProduct);
|
|
|
|
|
},
|
|
|
|
|
async getProduct(id) {
|
|
|
|
|
try {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>(
|
|
|
|
|
`/api/v1/scout/products/${encodeURIComponent(id)}`,
|
|
|
|
|
);
|
|
|
|
|
return mapProduct(raw);
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
async saveProduct(product) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/products", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
id: product.id,
|
|
|
|
|
brand_id: product.brand_id,
|
|
|
|
|
label: product.label,
|
|
|
|
|
product_context: product.product_context,
|
|
|
|
|
match_tags: product.match_tags,
|
|
|
|
|
pain_points: product.pain_points,
|
|
|
|
|
placement_url: product.placement_url,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return mapProduct(raw);
|
|
|
|
|
},
|
|
|
|
|
async removeProduct(id) {
|
|
|
|
|
await apiRequest(`/api/v1/scout/products/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
|
|
|
},
|
|
|
|
|
async importProductFromUrl(url) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/products/import", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: { url },
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
label: String(raw.label ?? ""),
|
|
|
|
|
product_context: String(raw.product_context ?? ""),
|
|
|
|
|
pain_points: Array.isArray(raw.pain_points) ? (raw.pain_points as string[]) : [],
|
|
|
|
|
match_tags: Array.isArray(raw.match_tags) ? (raw.match_tags as string[]) : [],
|
|
|
|
|
placement_url: String(raw.placement_url ?? url),
|
|
|
|
|
source_note: String(raw.source_note ?? ""),
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async listTopics() {
|
|
|
|
|
return [] as ScoutTopic[];
|
|
|
|
|
},
|
|
|
|
|
saveTopic: removed("saveTopic") as ScoutRepo["saveTopic"],
|
|
|
|
|
removeTopic: removed("removeTopic") as ScoutRepo["removeTopic"],
|
|
|
|
|
async prepareBrief(opts) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/brief", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
intent: opts.intent,
|
|
|
|
|
brand_id: opts.brandId,
|
|
|
|
|
product_id: opts.productId,
|
|
|
|
|
purpose: opts.purpose,
|
|
|
|
|
deep: opts.deep,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return mapBrief(raw);
|
|
|
|
|
},
|
|
|
|
|
async runScanFromBrief(brief) {
|
2026-07-13 08:59:13 +00:00
|
|
|
const data = await apiRequest<{ job?: Record<string, unknown> }>("/api/v1/scout/scan", {
|
2026-07-13 01:15:30 +00:00
|
|
|
method: "POST",
|
|
|
|
|
body: { brief: briefToBody(brief) },
|
|
|
|
|
});
|
2026-07-13 08:59:13 +00:00
|
|
|
const job = mapScanJob(data.job ?? {});
|
|
|
|
|
if (!job.id) throw new Error("海巡任務未建立");
|
|
|
|
|
return { job };
|
2026-07-13 01:15:30 +00:00
|
|
|
},
|
|
|
|
|
async getScanContext(brandId, productId) {
|
|
|
|
|
const brief = await this.prepareBrief({
|
|
|
|
|
intent: "legacy",
|
|
|
|
|
brandId,
|
|
|
|
|
productId,
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
brand_id: brandId,
|
|
|
|
|
product_ids: productId ? [productId] : [],
|
|
|
|
|
pains: brief.pains,
|
|
|
|
|
tags: brief.tags,
|
|
|
|
|
expand_terms: brief.periphery,
|
|
|
|
|
} satisfies ScoutScanContext;
|
|
|
|
|
},
|
|
|
|
|
async listPosts(brandId) {
|
|
|
|
|
const q =
|
|
|
|
|
brandId != null && brandId !== ""
|
|
|
|
|
? `?brand_id=${encodeURIComponent(brandId)}`
|
|
|
|
|
: "";
|
|
|
|
|
const data = await apiRequest<{ list: Record<string, unknown>[] }>(
|
|
|
|
|
`/api/v1/scout/posts${q}`,
|
|
|
|
|
);
|
|
|
|
|
return (data.list ?? []).map(mapScoutPost);
|
|
|
|
|
},
|
|
|
|
|
async runScan(brandId, productId, extraTerms) {
|
|
|
|
|
const brief = await this.prepareBrief({ intent: "scan", brandId, productId });
|
|
|
|
|
if (extraTerms?.length) {
|
|
|
|
|
brief.scan_terms = [...new Set([...brief.scan_terms, ...extraTerms])];
|
|
|
|
|
}
|
|
|
|
|
return this.runScanFromBrief(brief);
|
|
|
|
|
},
|
|
|
|
|
async draftOutreach(postId, personaId) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>(
|
|
|
|
|
`/api/v1/scout/posts/${encodeURIComponent(postId)}/draft`,
|
|
|
|
|
{ method: "POST", body: { persona_id: personaId } },
|
|
|
|
|
);
|
|
|
|
|
return mapScoutPost(raw);
|
|
|
|
|
},
|
|
|
|
|
async skipOutreach(postId) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>(
|
|
|
|
|
`/api/v1/scout/posts/${encodeURIComponent(postId)}/skip`,
|
|
|
|
|
{ method: "POST", body: {} },
|
|
|
|
|
);
|
|
|
|
|
return mapScoutPost(raw);
|
|
|
|
|
},
|
|
|
|
|
async markPublished(postId) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>(
|
|
|
|
|
`/api/v1/scout/posts/${encodeURIComponent(postId)}/mark-published`,
|
|
|
|
|
{ method: "POST", body: {} },
|
|
|
|
|
);
|
|
|
|
|
return mapScoutPost(raw);
|
|
|
|
|
},
|
|
|
|
|
async sendOutreach(opts) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>(
|
|
|
|
|
`/api/v1/scout/posts/${encodeURIComponent(opts.postId)}/send`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: { text: opts.text, account_id: opts.accountId },
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
return mapScoutPost(raw);
|
|
|
|
|
},
|
|
|
|
|
async removePost(postId) {
|
|
|
|
|
await apiRequest(`/api/v1/scout/posts/${encodeURIComponent(postId)}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
async removeTheme(themeKey) {
|
|
|
|
|
await apiRequest(`/api/v1/scout/themes/${encodeURIComponent(themeKey)}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
async listHomework() {
|
|
|
|
|
const data = await apiRequest<{ list: Record<string, unknown>[] }>(
|
|
|
|
|
"/api/v1/scout/homework",
|
|
|
|
|
);
|
|
|
|
|
return (data.list ?? []).map(mapHomework);
|
|
|
|
|
},
|
|
|
|
|
async saveHomework(record) {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/homework", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
theme_key: record.theme_key,
|
|
|
|
|
theme_label: record.theme_label,
|
|
|
|
|
purpose: record.purpose,
|
|
|
|
|
brief: briefToBody(record.brief),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return mapHomework(raw);
|
|
|
|
|
},
|
|
|
|
|
async getHomework(themeKey) {
|
|
|
|
|
try {
|
|
|
|
|
const raw = await apiRequest<Record<string, unknown>>(
|
|
|
|
|
`/api/v1/scout/homework/${encodeURIComponent(themeKey)}`,
|
|
|
|
|
);
|
|
|
|
|
return mapHomework(raw);
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
async removeHomework(themeKey) {
|
|
|
|
|
await apiRequest(`/api/v1/scout/homework/${encodeURIComponent(themeKey)}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|