thread-master/apps/web/src/data/live/m5Repos.ts

775 lines
27 KiB
TypeScript
Raw Normal View History

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
InspireChatMessage,
InspireElement,
InspireSession,
InspireSessionSummary,
ResearchHit,
ScoutHomeworkRecord,
ScoutPost,
ScoutRunBrief,
ScoutScanContext,
TrendItem,
TrendKind,
} 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,
2026-07-23 05:56:42 +00:00
learning_summary: raw.learning_summary != null ? String(raw.learning_summary) : undefined,
learning_version: raw.learning_version != null ? Number(raw.learning_version) : undefined,
learned_from_posts_count:
raw.learned_from_posts_count != null ? Number(raw.learned_from_posts_count) : undefined,
last_learned_at: raw.last_learned_at != null ? Number(raw.last_learned_at) : undefined,
2026-07-13 01:15:30 +00:00
};
}
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),
};
}
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);
},
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,
2026-07-28 06:33:51 +00:00
signal: opts.signal,
2026-07-13 01:15:30 +00:00
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[] = [];
2026-07-28 06:33:51 +00:00
// 中止時 reader.read() 會 reject這裡把它轉成一次 cancel避免連線留著繼續燒 AI 額度。
try {
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);
}
2026-07-13 01:15:30 +00:00
}
}
}
2026-07-28 06:33:51 +00:00
} finally {
void reader.cancel().catch(() => {});
2026-07-13 01:15:30 +00:00
}
if (streamError) {
throw new ApiError(streamError, 400001, 400);
}
if (!session) {
2026-07-28 06:33:51 +00:00
// 沒收到 done 事件才回頭走非串流版;已中止的請求不該再打一次 AI。
opts.signal?.throwIfAborted();
2026-07-13 01:15:30 +00:00
const fallback = await this.chat(opts);
return { session: fallback.session };
}
return { session, prompt, fingerprint, charCount, runeCount, sections };
},
};
}
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 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);
},
2026-08-03 05:52:02 +00:00
async promoteToOpportunity(postId) {
const raw = await apiRequest<Record<string, unknown>>(
`/api/v1/scout/posts/${encodeURIComponent(postId)}/promote`,
{ method: "POST", body: {} },
);
return {
opportunity_id: String(raw.opportunity_id ?? ""),
status: String(raw.status ?? ""),
intent_band: raw.intent_band != null ? String(raw.intent_band) : undefined,
intent_score: raw.intent_score != null ? Number(raw.intent_score) : undefined,
};
},
2026-07-13 01:15:30 +00:00
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",
});
},
};
}