/** * M5 live: inspire / research / media / scout */ import type { Brand, BrandProduct, InspirationIdea, Job, 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): 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): 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): InspireChatMessage { const draftRaw = raw.draft as Record | 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): 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)), 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): 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): 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): 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): 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 { 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): 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, 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, published_at: raw.published_at != null ? Number(raw.published_at) : undefined, }; } function mapScanJob(raw: Record): 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, }; } function mapHomework(raw: Record): 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) ?? {}), 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[] }>("/api/v1/inspire/trends"); return (data.list ?? []).map(mapTrend); }, async refreshTrends() { const data = await apiRequest<{ list: Record[] }>( "/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[] }>( "/api/v1/inspire/elements", ); return (data.list ?? []).map(mapElement); }, async saveElement(el) { const raw = await apiRequest>("/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>("/api/v1/inspire/session"); return mapSession(raw); }, async clearSession() { const raw = await apiRequest>("/api/v1/inspire/session/clear", { method: "POST", body: {}, }); return mapSession(raw); }, async listSessions() { const data = await apiRequest<{ list: Record[] }>( "/api/v1/inspire/sessions", ); return (data.list ?? []).map(mapSessionSummary); }, async createSession(title) { const raw = await apiRequest>("/api/v1/inspire/sessions", { method: "POST", body: title ? { title } : {}, }); return mapSession(raw); }, async activateSession(id) { const raw = await apiRequest>( `/api/v1/inspire/sessions/${encodeURIComponent(id)}/activate`, { method: "POST", body: {} }, ); return mapSession(raw); }, async deleteSession(id) { const raw = await apiRequest>( `/api/v1/inspire/sessions/${encodeURIComponent(id)}`, { method: "DELETE" }, ); return mapSession(raw); }, async chat(opts) { const data = await apiRequest<{ session: Record; messages: Record[]; }>("/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, 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 = { 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, 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; 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, 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[] }>( "/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 { 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[] }>("/api/v1/scout/brands"); return (data.list ?? []).map(mapBrand); }, async get(id) { try { const raw = await apiRequest>( `/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>("/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>("/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[] }>( `/api/v1/scout/products${q}`, ); return (data.list ?? []).map(mapProduct); }, async listAllProducts() { const data = await apiRequest<{ list: Record[] }>( "/api/v1/scout/products/all", ); return (data.list ?? []).map(mapProduct); }, async getProduct(id) { try { const raw = await apiRequest>( `/api/v1/scout/products/${encodeURIComponent(id)}`, ); return mapProduct(raw); } catch { return null; } }, async saveProduct(product) { const raw = await apiRequest>("/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>("/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>("/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) { const data = await apiRequest<{ job?: Record }>("/api/v1/scout/scan", { method: "POST", body: { brief: briefToBody(brief) }, }); const job = mapScanJob(data.job ?? {}); if (!job.id) throw new Error("海巡任務未建立"); return { job }; }, 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[] }>( `/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>( `/api/v1/scout/posts/${encodeURIComponent(postId)}/draft`, { method: "POST", body: { persona_id: personaId } }, ); return mapScoutPost(raw); }, async skipOutreach(postId) { const raw = await apiRequest>( `/api/v1/scout/posts/${encodeURIComponent(postId)}/skip`, { method: "POST", body: {} }, ); return mapScoutPost(raw); }, async markPublished(postId) { const raw = await apiRequest>( `/api/v1/scout/posts/${encodeURIComponent(postId)}/mark-published`, { method: "POST", body: {} }, ); return mapScoutPost(raw); }, async sendOutreach(opts) { const raw = await apiRequest>( `/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[] }>( "/api/v1/scout/homework", ); return (data.list ?? []).map(mapHomework); }, async saveHomework(record) { const raw = await apiRequest>("/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>( `/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", }); }, }; }