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

784 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* M5 live: inspire / research / media / scout
*/
import type {
Brand,
BrandProduct,
InspirationIdea,
Job,
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,
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,
};
}
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[]) : [],
provider_capability_terms: Array.isArray(raw.provider_capability_terms)
? (raw.provider_capability_terms as string[])
: [],
provider_exclude_terms: Array.isArray(raw.provider_exclude_terms)
? (raw.provider_exclude_terms 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,
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,
posted_at: raw.posted_at != null ? Number(raw.posted_at) : 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<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,
};
}
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,
use_web: opts.useWeb || 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<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,
signal: opts.signal,
body: JSON.stringify({
message: opts.message,
pinned_ids: opts.pinnedIds,
mode: opts.mode,
persona_id: opts.personaId || undefined,
session_id: opts.sessionId || undefined,
use_web: opts.useWeb || 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[] = [];
// 中止時 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);
}
}
}
}
} finally {
void reader.cancel().catch(() => {});
}
if (streamError) {
throw new ApiError(streamError, 400001, 400);
}
if (!session) {
// 沒收到 done 事件才回頭走非串流版;已中止的請求不該再打一次 AI。
opts.signal?.throwIfAborted();
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,
provider_capability_terms: product.provider_capability_terms,
provider_exclude_terms: product.provider_exclude_terms,
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) {
const data = await apiRequest<{ job?: Record<string, unknown> }>("/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<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 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,
};
},
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",
});
},
};
}