diff --git a/apps/web/src/auth/AuthContext.tsx b/apps/web/src/auth/AuthContext.tsx index 4190a42..bf177ed 100644 --- a/apps/web/src/auth/AuthContext.tsx +++ b/apps/web/src/auth/AuthContext.tsx @@ -14,6 +14,8 @@ import { useRepos } from "../data/DataContext"; type AuthContextValue = { member: Member | null; loading: boolean; + /** 最近一次 me() 因暫時性原因失敗(斷網/後端重啟)。登入狀態未知,不是已登出。 */ + unreachable: boolean; login: (email: string, password: string) => Promise; logout: () => Promise; reload: () => Promise; @@ -26,6 +28,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const repos = useRepos(); const [member, setMember] = useState(null); const [loading, setLoading] = useState(true); + const [unreachable, setUnreachable] = useState(false); const reload = useCallback(async () => { // 已進過 app 後不要再把 loading=true:否則 RequireAuth 會卸載整棵 /app, @@ -33,6 +36,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { const me = await repos.auth.me(); setMember(me); + setUnreachable(false); + } catch { + // me() 只在 401 回 null,會走到這裡的都是連不上或 5xx。此時 session 很可能還有效, + // 保留目前的 member,否則使用者會因為一次網路抖動就被踢回登入頁。 + setUnreachable(true); } finally { setLoading(false); } @@ -46,6 +54,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { async (email: string, password: string) => { const { member: m } = await repos.auth.login(email, password); setMember(m); + setUnreachable(false); }, [repos.auth], ); @@ -53,6 +62,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const logout = useCallback(async () => { await repos.auth.logout(); setMember(null); + setUnreachable(false); }, [repos.auth]); const updateProfile = useCallback( @@ -65,8 +75,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const value = useMemo( - () => ({ member, loading, login, logout, reload, updateProfile }), - [member, loading, login, logout, reload, updateProfile], + () => ({ member, loading, unreachable, login, logout, reload, updateProfile }), + [member, loading, unreachable, login, logout, reload, updateProfile], ); return {children}; diff --git a/apps/web/src/auth/RequireAuth.tsx b/apps/web/src/auth/RequireAuth.tsx index 96defa6..ef3cc6f 100644 --- a/apps/web/src/auth/RequireAuth.tsx +++ b/apps/web/src/auth/RequireAuth.tsx @@ -3,7 +3,7 @@ import { useI18n } from "../i18n/I18nContext"; import { useAuth } from "./AuthContext"; export function RequireAuth({ children }: { children: React.ReactNode }) { - const { member, loading } = useAuth(); + const { member, loading, unreachable, reload } = useAuth(); const location = useLocation(); const { t } = useI18n(); @@ -15,6 +15,19 @@ export function RequireAuth({ children }: { children: React.ReactNode }) { ); } + // 連不上後端時登入狀態是未知的,不是已登出。踢回登入頁會讓使用者以為 session 掉了, + // 而且重新登入也一樣會失敗,所以這裡給重試而不是轉址。 + if (!member && unreachable) { + return ( +
+

{t("auth.unreachable")}

+ +
+ ); + } + if (!member) { return ; } diff --git a/apps/web/src/components/layout/WorkspaceSwitcher.tsx b/apps/web/src/components/layout/WorkspaceSwitcher.tsx deleted file mode 100644 index ab802fa..0000000 --- a/apps/web/src/components/layout/WorkspaceSwitcher.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { useI18n } from "../../i18n/I18nContext"; -import { useRepos } from "../../data/DataContext"; -import type { Workspace } from "../../domain/types"; -import { AppIcon } from "../ui/AppIcons"; - -/** 頂欄 Workspace 切換(growth-loop P1) */ -export function WorkspaceSwitcher() { - const { t } = useI18n(); - const repos = useRepos(); - const [list, setList] = useState([]); - const [currentId, setCurrentId] = useState(""); - const [busy, setBusy] = useState(false); - - const load = useCallback(async () => { - try { - const res = await repos.growth.listWorkspaces(); - setList(res.list); - setCurrentId(res.current_workspace_id || res.list[0]?.id || ""); - } catch { - /* ignore until auth ready */ - } - }, [repos.growth]); - - useEffect(() => { - void load(); - }, [load]); - - if (list.length === 0) return null; - - return ( - - ); -} diff --git a/apps/web/src/components/persona/PersonaWorkbench.tsx b/apps/web/src/components/persona/PersonaWorkbench.tsx index b0d7f4f..f0f4f2e 100644 --- a/apps/web/src/components/persona/PersonaWorkbench.tsx +++ b/apps/web/src/components/persona/PersonaWorkbench.tsx @@ -37,6 +37,7 @@ export function PersonaWorkbench() { const [sourceLabel, setSourceLabel] = useState(""); const [busy, setBusy] = useState(""); const [message, setMessage] = useState(""); + const [loadError, setLoadError] = useState(""); const [previewPost, setPreviewPost] = useState(""); const [previewReply, setPreviewReply] = useState(""); const [previewTopic, setPreviewTopic] = useState(""); @@ -64,18 +65,24 @@ export function PersonaWorkbench() { } async function load() { - const [list, active, accList] = await Promise.all([ - repos.personas.list(), - repos.personas.getActiveId(), - repos.accounts.list(), - ]); - setPersonas(list); - setAccounts(accList); - setActiveId(active); - setSelectedId((cur) => { - if (cur && list.some((p) => p.id === cur)) return cur; - return active || list[0]?.id || ""; - }); + try { + const [list, active, accList] = await Promise.all([ + repos.personas.list(), + repos.personas.getActiveId(), + repos.accounts.list(), + ]); + setPersonas(list); + setAccounts(accList); + setActiveId(active); + setSelectedId((cur) => { + if (cur && list.some((p) => p.id === cur)) return cur; + return active || list[0]?.id || ""; + }); + setLoadError(""); + } catch (e) { + // 這裡不能借用 message:它同時被 job 進度用,下一次輪詢就會把錯誤蓋掉。 + setLoadError(e instanceof Error ? e.message : t("persona.loadFail")); + } } useEffect(() => { @@ -408,6 +415,11 @@ export function PersonaWorkbench() { + {loadError ? ( +

+ {loadError} +

+ ) : null} {message ? (

{message} diff --git a/apps/web/src/data/live/authSession.test.ts b/apps/web/src/data/live/authSession.test.ts new file mode 100644 index 0000000..7aa50fa --- /dev/null +++ b/apps/web/src/data/live/authSession.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createLiveRepos } from "./repos"; +import { loadTokens, saveTokens } from "./http"; + +function installLocalStorage() { + const store = new Map(); + const shim: Storage = { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + removeItem: (key) => void store.delete(key), + setItem: (key, value) => void store.set(key, String(value)), + }; + Object.defineProperty(globalThis, "localStorage", { + value: shim, + configurable: true, + writable: true, + }); +} + +// The auth bootstrap treats a null result from me() as "signed out" and sends the user to the +// login page. Returning null for a network blip therefore logged people out mid-session, and +// signing in again would not have worked either because the backend was the thing that was down. +describe("auth.me session handling", () => { + const originalFetch = globalThis.fetch; + const tokens = { access_token: "access-1", refresh_token: "refresh-1" }; + + beforeEach(() => { + installLocalStorage(); + saveTokens(tokens); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns null and clears tokens when the backend rejects the session", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ code: 401002, message: "invalid token", data: null, error: null }), { + status: 401, + headers: { "Content-Type": "application/json" }, + })) as typeof fetch; + + await expect(createLiveRepos().auth.me()).resolves.toBeNull(); + expect(loadTokens()).toBeNull(); + }); + + it("throws and keeps tokens when the network is down", async () => { + globalThis.fetch = (async () => { + throw new TypeError("Failed to fetch"); + }) as typeof fetch; + + await expect(createLiveRepos().auth.me()).rejects.toThrow(); + expect(loadTokens()?.access_token).toBe("access-1"); + }); + + it("throws and keeps tokens when the backend is restarting", async () => { + globalThis.fetch = (async () => + new Response("502 Bad Gateway", { + status: 502, + headers: { "Content-Type": "text/html" }, + })) as typeof fetch; + + await expect(createLiveRepos().auth.me()).rejects.toThrow(); + expect(loadTokens()?.refresh_token).toBe("refresh-1"); + }); + + it("keeps tokens when the access token expired but the refresh call could not reach the server", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/auth/refresh")) throw new TypeError("Failed to fetch"); + return new Response(JSON.stringify({ code: 401002, message: "expired", data: null, error: null }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + + await expect(createLiveRepos().auth.me()).rejects.toThrow(); + expect(loadTokens()?.refresh_token).toBe("refresh-1"); + }); + + it("clears tokens when the refresh endpoint says the refresh token is invalid", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => + String(input).endsWith("/auth/refresh") + ? new Response(JSON.stringify({ code: 401002, message: "invalid refresh", data: null, error: null }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + : new Response(JSON.stringify({ code: 401002, message: "expired", data: null, error: null }), { + status: 401, + headers: { "Content-Type": "application/json" }, + })) as typeof fetch; + + await expect(createLiveRepos().auth.me()).resolves.toBeNull(); + expect(loadTokens()).toBeNull(); + }); + + it("retries the original request with the renewed token", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + seen.push(`${url}:${(init?.headers as Record)?.Authorization ?? ""}`); + if (url.endsWith("/auth/refresh")) { + return new Response( + JSON.stringify({ + code: 102000, + message: "ok", + data: { tokens: { access_token: "access-2", refresh_token: "refresh-2" } }, + error: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (seen.length === 1) { + return new Response(JSON.stringify({ code: 401002, message: "expired", data: null, error: null }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response( + JSON.stringify({ code: 102000, message: "ok", data: { uid: 7, email: "a@b.c" }, error: null }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as typeof fetch; + + await expect(createLiveRepos().auth.me()).resolves.not.toBeNull(); + expect(seen[2]).toContain("Bearer access-2"); + expect(loadTokens()?.access_token).toBe("access-2"); + }); +}); diff --git a/apps/web/src/data/live/http.ts b/apps/web/src/data/live/http.ts index 48dea9d..1ea256c 100644 --- a/apps/web/src/data/live/http.ts +++ b/apps/web/src/data/live/http.ts @@ -24,6 +24,11 @@ export class ApiError extends Error { detail: unknown; /** 後端原始 message(除錯用;UI 請用 code → 語言包) */ rawMessage: string; + /** + * 401 但 refresh 沒拿到結論(連不上/5xx)。代表登入狀態未知,不等於已登出, + * 呼叫端不應該清 token 或把人踢回登入頁。 + */ + sessionUnknown: boolean; constructor(message: string, code: number, httpStatus: number, detail?: unknown, rawMessage?: string) { super(message); @@ -32,6 +37,7 @@ export class ApiError extends Error { this.httpStatus = httpStatus; this.detail = detail; this.rawMessage = rawMessage ?? message; + this.sessionUnknown = false; } } @@ -75,30 +81,42 @@ export function saveTokens(t: StoredTokens | null): void { localStorage.setItem(TOKEN_KEY, JSON.stringify(t)); } -let refreshPromise: Promise | null = null; +/** + * refresh 的三種結果要分開,因為它們對呼叫端的意義完全不同: + * - renewed:拿到新 token,重送原請求。 + * - rejected:後端明確說 refresh token 無效 → 真的登出,清 token。 + * - unreachable:連不上或後端掛了 → 登入狀態未知,token 要留著等下次重試。 + */ +type RefreshResult = + | { status: "renewed"; tokens: StoredTokens } + | { status: "rejected" } + | { status: "unreachable" }; -async function tryRefresh(): Promise { +let refreshPromise: Promise | null = null; + +async function tryRefresh(): Promise { const cur = loadTokens(); - if (!cur?.refresh_token) return null; + if (!cur?.refresh_token) return { status: "rejected" }; if (!refreshPromise) { - refreshPromise = (async () => { + refreshPromise = (async (): Promise => { try { const res = await fetch(`${getApiBase()}/api/v1/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: cur.refresh_token }), }); + // 5xx 是後端狀況,不是「這張 refresh token 壞了」。 + if (res.status >= 500) return { status: "unreachable" }; const env = (await res.json()) as Envelope<{ tokens: StoredTokens }>; if (env.code !== SUCCESS || !env.data?.tokens) { saveTokens(null); - return null; + return { status: "rejected" }; } const next = env.data.tokens; saveTokens(next); - return next; + return { status: "renewed", tokens: next }; } catch { - // A temporary network failure is not proof that the refresh token is invalid. - return null; + return { status: "unreachable" }; } finally { refreshPromise = null; } @@ -161,51 +179,63 @@ export async function apiRequest(path: string, opts: RequestOpts = {}): Promi } // retry once on 401 with refresh + let sessionUnknown = false; if (res.status === 401 && auth && !opts.raw) { const refreshed = await tryRefresh(); - if (refreshed?.access_token) { - headers.Authorization = `Bearer ${refreshed.access_token}`; - res = await fetch(url, { - method, - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - }); + if (refreshed.status === "renewed") { + headers.Authorization = `Bearer ${refreshed.tokens.access_token}`; + try { + res = await fetch(url, { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + } catch (e) { + throw new ApiError(messageForApiCode(0), 0, 0, e); + } + } else { + sessionUnknown = refreshed.status === "unreachable"; } } + const tag = (e: ApiError): ApiError => { + e.sessionUnknown = sessionUnknown; + return e; + }; + let env: Envelope; try { env = (await res.json()) as Envelope; } catch { // go-zero 逾時常直接 503 非 envelope → 前端顯示「伺服器錯誤」;改成可理解的 AI 逾時 if (res.status === 503 || res.status === 504 || res.status === 408) { - throw new ApiError(messageForApiCode(503001, "AI 回應逾時,請縮短內容後再試"), 503001, res.status); + throw tag(new ApiError(messageForApiCode(503001, "AI 回應逾時,請縮短內容後再試"), 503001, res.status)); } - throw new ApiError( - messageForApiCode(res.status >= 500 ? 500000 : 400001, `invalid response (${res.status})`), - res.status, - res.status, + throw tag( + new ApiError( + messageForApiCode(res.status >= 500 ? 500000 : 400001, `invalid response (${res.status})`), + res.status, + res.status, + ), ); } if (env.code !== SUCCESS) { // gateway 逾時有時仍帶 envelope + 5xx if (res.status === 503 || res.status === 504 || env.code === 503001) { - throw new ApiError( - messageForApiCode(503001, env.message || "AI 回應逾時"), - 503001, - res.status, - env.error, - env.message, + throw tag( + new ApiError( + messageForApiCode(503001, env.message || "AI 回應逾時"), + 503001, + res.status, + env.error, + env.message, + ), ); } // message 用目前語言包;code 保留給 UI 再對一次語系 - throw new ApiError( - messageForApiCode(env.code, env.message), - env.code, - res.status, - env.error, - env.message, + throw tag( + new ApiError(messageForApiCode(env.code, env.message), env.code, res.status, env.error, env.message), ); } return env.data; diff --git a/apps/web/src/data/live/m5Repos.ts b/apps/web/src/data/live/m5Repos.ts index bbd14a9..4bcebc6 100644 --- a/apps/web/src/data/live/m5Repos.ts +++ b/apps/web/src/data/live/m5Repos.ts @@ -6,7 +6,6 @@ import type { BrandProduct, InspirationIdea, Job, - InspireAngle, InspireChatMessage, InspireElement, InspireSession, @@ -16,10 +15,8 @@ import type { ScoutPost, ScoutRunBrief, ScoutScanContext, - ScoutTopic, TrendItem, TrendKind, - ViralSample, } from "../../domain/types"; import type { InspirationRepo, @@ -221,10 +218,6 @@ function mapHomework(raw: Record): ScoutHomeworkRecord { }; } -const removed = (name: string) => async () => { - throw new Error(`${name} 已移除(現 UI 不支援)`); -}; - export function createLiveInspiration(): InspirationRepo { return { async list() { @@ -241,7 +234,6 @@ export function createLiveInspiration(): InspirationRepo { ); return (data.list ?? []).map(mapTrend); }, - searchTrends: removed("searchTrends") as InspirationRepo["searchTrends"], async listElements() { const data = await apiRequest<{ list: Record[] }>( "/api/v1/inspire/elements", @@ -379,6 +371,7 @@ export function createLiveInspiration(): InspirationRepo { const res = await fetch(url, { method: "POST", headers, + signal: opts.signal, body: JSON.stringify({ message: opts.message, pinned_ids: opts.pinnedIds, @@ -406,66 +399,64 @@ export function createLiveInspiration(): InspirationRepo { 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); + // 中止時 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; + 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 }; }, - 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"], }; } @@ -640,11 +631,6 @@ export function createLiveScout(): ScoutRepo { 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", diff --git a/apps/web/src/data/live/repos.ts b/apps/web/src/data/live/repos.ts index c36cf7c..1910ec0 100644 --- a/apps/web/src/data/live/repos.ts +++ b/apps/web/src/data/live/repos.ts @@ -202,10 +202,11 @@ function createLiveAuth(): AuthRepo { const raw = await apiRequest>("/api/v1/auth/me"); return normalizeMember(raw); } catch (error) { - if (error instanceof ApiError && error.httpStatus === 401) { + if (error instanceof ApiError && error.httpStatus === 401 && !error.sessionUnknown) { saveTokens(null); + return null; } - return null; + throw error; } }, async updateProfile(patch: MemberProfilePatch) { @@ -849,11 +850,9 @@ function createLiveUsage(): UsageRepo { const p = await this.getMemberPrefs(); return p.plan_id; }, - async setPlanId() { - /* admin only via setMemberPrefs */ - }, - async getMemberPrefs() { - const raw = await apiRequest>("/api/v1/usage/prefs"); + async getMemberPrefs(uid) { + const q = uid ? `?uid=${encodeURIComponent(uid)}` : ""; + const raw = await apiRequest>(`/api/v1/usage/prefs${q}`); const id = String(raw.plan_id ?? "free").toLowerCase().trim(); return { plan_id: (id in PLANS ? id : "free") as PlanId, diff --git a/apps/web/src/data/live/usageMapping.test.ts b/apps/web/src/data/live/usageMapping.test.ts index 652fb68..a9f629b 100644 --- a/apps/web/src/data/live/usageMapping.test.ts +++ b/apps/web/src/data/live/usageMapping.test.ts @@ -61,6 +61,32 @@ describe("billing API mapping", () => { }); }); +describe("member prefs", () => { + // The admin console reads the plan of the member it is editing. This used to drop the uid and + // return the admin's own plan, so a Free member could be shown as Pro/unlimited. + it("sends the requested uid so admins do not see their own plan", async () => { + const originalFetch = globalThis.fetch; + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + requested.push(String(input)); + return new Response( + JSON.stringify({ code: 102000, message: "ok", data: { plan_id: "starter", unlimited: false }, error: null }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as typeof fetch; + try { + const usage = createLiveRepos().usage; + await usage.getMemberPrefs("4242"); + await usage.getMemberPrefs(); + } finally { + globalThis.fetch = originalFetch; + } + + expect(requested[0]).toBe("/api/v1/usage/prefs?uid=4242"); + expect(requested[1]).toBe("/api/v1/usage/prefs"); + }); +}); + describe("BYOK usage mapping", () => { it("maps BYOK counts separately and fills absent meters with zero", () => { const mapped = mapByokMeterBlock(PLANS.free, { diff --git a/apps/web/src/data/mock/inviteRepo.ts b/apps/web/src/data/mock/inviteRepo.ts deleted file mode 100644 index 015d345..0000000 --- a/apps/web/src/data/mock/inviteRepo.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * 邀請網絡 local stub(後端 M6 前暫用 localStorage 島民表)。 - */ -import { - adminMoveInviteMember, - applyInviteCode, - buildInviteForest, - getMyInviteNetwork, - listInviteParentCandidates, -} from "../../lib/inviteNetwork"; -import type { InviteRepo } from "../repos"; -import { mockDelay } from "../../lib/mockAi"; -import { findUserByUid, toMember } from "../../lib/tenantUsers"; -import { getSession, setSession } from "./store"; - -function memberFromSession() { - const s = getSession(); - if (!s?.member) return null; - const live = findUserByUid(s.member.uid); - if (live) return toMember(live); - return s.member; -} - -export function createMockInviteRepo(): InviteRepo { - return { - async getMyNetwork() { - await mockDelay(100); - const me = memberFromSession(); - if (!me) throw new Error("invite.err.notLoggedIn"); - return getMyInviteNetwork(me.uid); - }, - async applyInviteCode(code) { - await mockDelay(180); - const me = memberFromSession(); - if (!me) throw new Error("invite.err.notLoggedIn"); - const network = applyInviteCode(me.uid, code); - // 同步 session.member.parent_uid - const live = findUserByUid(me.uid); - const sess = getSession(); - if (live && sess) { - setSession({ ...sess, member: toMember(live) }); - } - return network; - }, - async getTree() { - await mockDelay(120); - const me = memberFromSession(); - if (!me?.roles.includes("admin")) throw new Error("invite.err.needAdmin"); - return buildInviteForest(); - }, - async moveMember(uid, newParentUid) { - await mockDelay(200); - const me = memberFromSession(); - if (!me?.roles.includes("admin")) throw new Error("invite.err.needAdmin"); - return adminMoveInviteMember(true, uid, newParentUid); - }, - async listParentCandidates(excludeSubtreeRootUid) { - await mockDelay(80); - const me = memberFromSession(); - if (!me?.roles.includes("admin")) throw new Error("invite.err.needAdmin"); - return listInviteParentCandidates(excludeSubtreeRootUid); - }, - }; -} diff --git a/apps/web/src/data/repos.ts b/apps/web/src/data/repos.ts index b9b5ef2..a800e0b 100644 --- a/apps/web/src/data/repos.ts +++ b/apps/web/src/data/repos.ts @@ -6,7 +6,6 @@ import type { DataSource, ExternalThreadTarget, InspirationIdea, - InspireAngle, InspireChatMessage, InspireElement, InspireSession, @@ -27,14 +26,12 @@ import type { ScoutPost, ScoutRunBrief, ScoutScanContext, - ScoutTopic, ThreadPlay, ThreadsAccount, TokenPair, TrendItem, TrendKind, ViralAnalysis, - ViralSample, } from "../domain/types"; import type { AdminUserListPage, MemberAdminView } from "../lib/tenantUsers"; import type { PlanPurchase } from "../lib/planPurchase"; @@ -77,6 +74,11 @@ export type MemberProfilePatch = { export type AuthRepo = { login(email: string, password: string): Promise<{ tokens: TokenPair; member: Member }>; logout(): Promise; + /** + * null 代表「確定未登入」(沒 token 或後端回 401)。 + * 暫時性失敗(斷網、後端重啟、5xx)必須 throw,不可回 null, + * 否則呼叫端會把使用者當成登出而踢回登入頁。 + */ me(): Promise; /** 更新自己的會員資料(mock 寫 session) */ updateProfile(patch: MemberProfilePatch): Promise; @@ -342,7 +344,6 @@ export type InspirationRepo = { /** 預設靈感榜:Threads 熱標(提示用) */ listTrends(kind?: TrendKind | "all"): Promise; refreshTrends(kind?: TrendKind | "all"): Promise; - searchTrends(query: string): Promise; /** 元素庫 */ listElements(): Promise; saveElement(el: InspireElement): Promise; @@ -376,6 +377,7 @@ export type InspirationRepo = { }): Promise<{ session: InspireSession; messages: InspireChatMessage[] }>; /** * 串流聊天/產文。onDelta 每收到一段正文就回呼;結束回完整 session。 + * 中止時 reject `AbortError`(`isAbortError()` 可判斷),呼叫端不需當成失敗顯示。 */ chatStream( opts: { @@ -386,6 +388,8 @@ export type InspirationRepo = { sessionId?: string; useWeb?: boolean; material?: string; + /** 使用者按停止或元件卸載時中止;不帶則跑到結束。 */ + signal?: AbortSignal; }, onDelta: (chunk: string) => void, ): Promise<{ @@ -419,26 +423,6 @@ export type InspirationRepo = { pinned: Array<{ id: string; kind: string; title: string; body: string }>; note?: string; }>; - /** @deprecated 舊角度流;保留相容 */ - sparkAngles( - trendId: string, - opts?: { personaId?: string | null; brandId?: string | null }, - ): Promise; - /** @deprecated */ - sparkAnglesForTopic( - label: string, - opts?: { personaId?: string | null; brandId?: string | null; samples?: string[] }, - ): Promise<{ trend: TrendItem; angles: InspireAngle[] }>; - bookmarkTrend(trendId: string): Promise; - saveIdea(idea: InspirationIdea): Promise; - generate(topic: string, personaId?: string): Promise; - listViral(): Promise; - /** @deprecated */ - sparkFromTrend( - trendId: string, - personaId?: string, - opts?: { save?: boolean }, - ): Promise; }; export type ResearchRepo = { @@ -521,9 +505,6 @@ export type ScoutRepo = { placement_url: string; source_note: string; }>; - listTopics(brandId?: string): Promise; - saveTopic(topic: ScoutTopic): Promise; - removeTopic(id: string): Promise; /** * 意圖 + 可選產品 → 可審知識 brief(痛點/周邊/掃描詞) * 無產品 = theme 模式 @@ -577,11 +558,11 @@ export type ScoutRepo = { }; export type UsageRepo = { - /** 目前登入者(或指定 uid)本月摘要 + 明細 */ - getSummary(monthKey?: string, uid?: string): Promise; - listEvents(limit?: number, uid?: string): Promise; + /** 目前登入者本月摘要 + 明細 */ + getSummary(monthKey?: string): Promise; + listEvents(limit?: number): Promise; getPlanId(): Promise; - setPlanId(id: PlanId): Promise; + /** 讀方案/無限;uid 需管理員權限,省略則為自己 */ getMemberPrefs(uid?: string): Promise; /** 管理員:設某人方案/無限 */ setMemberPrefs(uid: string, patch: Partial): Promise; diff --git a/apps/web/src/domain/types.ts b/apps/web/src/domain/types.ts index 4f43710..355f3e5 100644 --- a/apps/web/src/domain/types.ts +++ b/apps/web/src/domain/types.ts @@ -509,15 +509,6 @@ export type MentionItem = { created_at: number; }; -export type ViralSample = { - id: string; - author: string; - text: string; - like_count: number; - reply_count: number; - topic?: string; -}; - export type ViralAnalysis = { hooks: string; structure: string; @@ -553,37 +544,6 @@ export type ResearchHit = { */ export type ScoutResearchTier = "core" | "adjacent" | "broad"; -/** 知識節點與產品/痛點的關係(圖譜邊) */ -export type ScoutKnowledgeRelation = - | "solves_pain" - | "nearby_scene" - | "myth" - | "contrast" - | "background"; - -/** - * 海巡/產品知識圖譜節點: - * 學習重點 + 來源網址 + 可選回帖鉤子 - */ -export type ScoutResearchNote = { - id: string; - title: string; - /** 網頁內容摘要(次要;主讀 learn_points) */ - summary: string; - /** 學習重點:3~5 條子彈 */ - learn_points?: string[]; - /** 回帖可直接借的鉤子(1~3 句) */ - reply_hooks?: string[]; - url: string; - source_label?: string; - /** 從此頁抽出可海巡的詞 */ - keywords?: string[]; - /** 與主題貼近度分層 */ - tier?: ScoutResearchTier; - /** 與產品的關係 */ - relation?: ScoutKnowledgeRelation; -}; - export type InspirationIdea = { id: string; title: string; @@ -594,12 +554,6 @@ export type InspirationIdea = { trend_id?: string | null; }; -/** 靈感第二步:可選的開場角度(舊流程相容) */ -export type InspireAngle = { - id: string; - hook: string; -}; - /** 靈感元素庫:可重用產文材料 */ export type InspireElementKind = "persona" | "role" | "brand" | "snippet" | "trend"; @@ -729,15 +683,6 @@ export type BrandProduct = { updated_at: number; }; -export type ScoutTopic = { - id: string; - brand_id: string; - name: string; - keywords: string[]; - /** 此主題預設主推的產品 */ - preferred_product_id?: string | null; -}; - export type ScoutOutreachStatus = "new" | "drafted" | "queued" | "published" | "skipped"; /** @@ -809,15 +754,8 @@ export type ScoutRunBrief = { placement_note?: string; /** B:回應姿態 */ response_stance?: string; - /** - * 做功課:擴充知識(網頁摘要+網址) - * 對齊舊海巡 research 詳細度 - */ - research_notes?: ScoutResearchNote[]; /** 產品 context 全文(展示用) */ product_context?: string; - /** 對方常講的話(match_tags 詳列) */ - match_tags_detail?: string[]; /** 分組/持久化用(與命中 theme_key 對齊) */ theme_key?: string; theme_label?: string; diff --git a/apps/web/src/lib/abort.ts b/apps/web/src/lib/abort.ts new file mode 100644 index 0000000..336e3ac --- /dev/null +++ b/apps/web/src/lib/abort.ts @@ -0,0 +1,7 @@ +/** + * 使用者自己按停止、或元件卸載造成的中止,不是錯誤,不該跳紅字。 + * fetch 中止在不同瀏覽器分別丟 DOMException("AbortError") 或帶 name 的 Error,這裡一起認。 + */ +export function isAbortError(e: unknown): boolean { + return e instanceof Error && e.name === "AbortError"; +} diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts index 8c9ec04..97dd859 100644 --- a/apps/web/src/lib/i18n/messages.ts +++ b/apps/web/src/lib/i18n/messages.ts @@ -41,6 +41,7 @@ export const zhTW: MessageDict = { "common.save": "儲存", "common.cancel": "取消", "common.loading": "載入中…", + "common.retry": "重試", "common.back": "返回", "common.delete": "刪除", "common.edit": "編輯", @@ -70,6 +71,7 @@ export const zhTW: MessageDict = { "api.err.400004": "驗證碼無效或已過期", "api.err.400020": "不能停權自己的帳號", "api.err.400021": "不能移除或停權最後一位管理員", + "auth.unreachable": "連不上伺服器,無法確認登入狀態。請檢查網路後重試。", "api.err.401001": "請先登入", "api.err.401002": "登入已失效,請重新登入", "api.err.401003": "找不到會員資料,請重新登入", @@ -152,6 +154,7 @@ export const zhTW: MessageDict = { "verify.after": "驗證完成後即可使用今日、創作、海巡等功能。", "settings.title": "設定", + "settings.loadFail": "載入設定失敗,請重新整理再試", "settings.localeCurrency": "語言與幣別", "settings.locale": "介面語言", "settings.currency": "顯示幣別", @@ -360,6 +363,7 @@ export const zhTW: MessageDict = { "crew.connecting": "連線中…", "crew.tokenRenewHint": "Token 由背景任務自動延長(約第 30 天),可在「任務」查看;無需手動刷新。", "crew.empty": "尚無帳號", + "crew.loadFail": "載入帳號失敗", "crew.unusable": "不可用", "crew.expires": "到期 {time}", "crew.lastRefresh": "上次延長 {time}", @@ -479,6 +483,7 @@ export const zhTW: MessageDict = { "outbox.status.cancelled": "已取消", "outbox.detail.missingId": "缺少 id", "outbox.detail.notFound": "找不到發送", + "outbox.detail.loadFail": "載入發送內容失敗", "outbox.detail.loading": "載入中…", "outbox.detail.sendingHint": "正在發到 Threads(通常 10~30 秒),頁面會自動更新…", "outbox.detail.doneHint": "已成功發到 Threads。", @@ -679,13 +684,9 @@ export const zhTW: MessageDict = { "wizard.topic.topicPh": "這串文想聊什麼?", "wizard.topic.aiView": "AI 視角", "wizard.topic.personaNotReady": "人設未就緒", - "wizard.topic.generating": "生成中…", - "wizard.topic.aiSuggest": "AI 幫我想", "wizard.topic.quickFill": "快速填入", "wizard.topic.sampleTitle": "週末咖啡話題", "wizard.topic.sampleTopic": "週末想找間不踩雷的咖啡店,插座要多、能坐久。", - "wizard.topic.aiTitle": "AI 主題", - "wizard.topic.personaOpen": "{name}開場", "wizard.crew.title": "2. 出場", "wizard.crew.lead": "主帳", "wizard.crew.noUsable": "尚無可用帳號", @@ -803,6 +804,7 @@ export const zhTW: MessageDict = { "jobs.detail": "詳情", "jobs.loadMore": "載入更多(還有 {n})", "jobs.notFound": "找不到任務", + "jobs.detailLoadFail": "載入任務失敗", "jobs.progress": "進度 {n}%", "jobs.updated": "更新 {time}", "jobs.backList": "返回列表", @@ -1098,21 +1100,8 @@ export const zhTW: MessageDict = { "scout.sending": "發送中…", "scout.needAccountBefore": "請先到", "scout.needAccountAfter": "連線可用帳號。", - "scout.knowledge": "周邊知識", - "scout.knowledgeWithLabel": "周邊知識 · {label}", - "scout.knowledgeLearn": "周邊知識 · 可學", - "scout.collapseKnowledge": "收合知識", - "scout.expandLearn": "展開學習 · {n} 則", - "scout.expandKnowledge": "展開知識", - "scout.deleteKnowledgeRun": "刪除此批知識與命中", "scout.loadingKnowledge": "正在整理周邊知識…", - "scout.notesCount": "{n} 則", - "scout.noKnowledge": "尚無周邊知識", "scout.product": "產品", - "scout.painsSolved": "能解的痛", - "scout.focus": "焦點", - "scout.all": "全部", - "scout.noWebSummary": "尚無網頁摘要", "scout.queue": "命中紀錄 · {n}", "scout.valueQueue": "痛點/產品接話 · {n}", "scout.activityQueue": "活躍短回 · {n}", @@ -1120,9 +1109,6 @@ export const zhTW: MessageDict = { "scout.collapseQueue": "收合佇列", "scout.expandQueue": "展開佇列", "scout.noOtherPending": "沒有其他待回", - "scout.learnPoints": "學習重點", - "scout.replyHooks": "回帖可借", - "scout.badgeCore": "最貼主題", "scout.unnamedRun": "未命名批次", "scout.thisRun": "此批次", "scout.confirmDeleteRun": "刪除海巡批次「{label}」?\\n會一併刪除這批命中與周邊知識,無法復原。", @@ -1165,17 +1151,6 @@ export const zhTW: MessageDict = { "scout.stanceActivity": "短回 · 養活躍", "scout.stanceProduct": "共感 · 可輕帶產品", "scout.stanceRelation": "接話 · 建關係", - "scout.tier.core": "最貼主題", - "scout.tier.coreHint": "直接對準痛點/關鍵語,優先讀", - "scout.tier.adjacent": "相關周邊", - "scout.tier.adjacentHint": "鄰近情境,擴搜尋面", - "scout.tier.broad": "最廣泛", - "scout.tier.broadHint": "背景與對照,選讀即可", - "scout.relation.solves_pain": "對準痛點", - "scout.relation.nearby_scene": "鄰近場景", - "scout.relation.myth": "迷思澄清", - "scout.relation.contrast": "對照選購", - "scout.relation.background": "背景脈絡", "brands.title": "品牌", "brands.railAria": "品牌列表", @@ -1397,6 +1372,8 @@ export const zhTW: MessageDict = { "inspire.useDraft": "用這則寫", "inspire.openPlay": "開串場", "inspire.thinking": "思考中…", + "inspire.stop": "停止產生", + "inspire.stopped": "已停止產生", "inspire.pinnedAria": "本輪參考(給 AI 看)", "inspire.pinned": "本輪參考", "inspire.pinnedCount": "· {n}", @@ -1569,6 +1546,7 @@ export const zhTW: MessageDict = { "persona.jobRunning": "背景分析中… 完成後會自動更新(也可到「任務」查看進度)", "persona.jobDone": "背景分析完成 · 已寫入指紋/範本", "persona.jobFailed": "背景分析失敗,請到任務頁查看錯誤或重試", + "persona.loadFail": "載入人設失敗", "persona.openJob": "開啟任務詳情", "persona.setDefaultMsg": "「{name}」已設為預設", "persona.confirmDelete": "確定刪除人設「{name}」?", @@ -1724,7 +1702,6 @@ export const zhTW: MessageDict = { "persona.previewReplySample": "大安那間還行但人很多", "inspire.playTitle": "靈感串場", - "wizard.topic.fallbackTopic": "生活小題", "play.err.needLead": "請選擇主帳號", "play.err.needRoot": "請至少有一則主貼", @@ -1793,6 +1770,7 @@ export const en: MessageDict = { "common.save": "Save", "common.cancel": "Cancel", "common.loading": "Loading…", + "common.retry": "Retry", "common.back": "Back", "common.delete": "Delete", "common.edit": "Edit", @@ -1821,6 +1799,7 @@ export const en: MessageDict = { "api.err.400004": "Invalid or expired verification code", "api.err.400020": "You cannot suspend your own account", "api.err.400021": "Cannot demote or suspend the last active admin", + "auth.unreachable": "Can't reach the server, so your sign-in state is unknown. Check your connection and retry.", "api.err.401001": "Please sign in", "api.err.401002": "Session expired. Please sign in again.", "api.err.401003": "Account not found. Please sign in again.", @@ -1902,6 +1881,7 @@ export const en: MessageDict = { "verify.after": "After verification you can use Today, Studio, Patrol, and more.", "settings.title": "Settings", + "settings.loadFail": "Could not load settings. Refresh to try again.", "settings.localeCurrency": "Language & currency", "settings.locale": "Interface language", "settings.currency": "Display currency", @@ -2111,6 +2091,7 @@ export const en: MessageDict = { "crew.connecting": "Connecting…", "crew.tokenRenewHint": "Tokens auto-renew via background jobs (~day 30). Check Jobs; no manual refresh.", "crew.empty": "No accounts yet", + "crew.loadFail": "Could not load accounts", "crew.unusable": "Unavailable", "crew.expires": "Expires {time}", "crew.lastRefresh": "Last extended {time}", @@ -2230,6 +2211,7 @@ export const en: MessageDict = { "outbox.status.cancelled": "Cancelled", "outbox.detail.missingId": "Missing id", "outbox.detail.notFound": "Outbox item not found", + "outbox.detail.loadFail": "Could not load this outbox item", "outbox.detail.loading": "Loading…", "outbox.detail.sendingHint": "Publishing to Threads (often 10–30s). This page updates automatically…", "outbox.detail.doneHint": "Published to Threads successfully.", @@ -2430,13 +2412,9 @@ export const en: MessageDict = { "wizard.topic.topicPh": "What should this thread discuss?", "wizard.topic.aiView": "AI persona view", "wizard.topic.personaNotReady": "Persona not ready", - "wizard.topic.generating": "Generating…", - "wizard.topic.aiSuggest": "AI suggest", "wizard.topic.quickFill": "Quick fill", "wizard.topic.sampleTitle": "Weekend coffee chat", "wizard.topic.sampleTopic": "Looking for a reliable café this weekend — lots of outlets, stay-all-day friendly.", - "wizard.topic.aiTitle": "AI topic", - "wizard.topic.personaOpen": "{name} opens", "wizard.crew.title": "2. Cast", "wizard.crew.lead": "Lead", "wizard.crew.noUsable": "No usable accounts", @@ -2554,6 +2532,7 @@ export const en: MessageDict = { "jobs.detail": "Details", "jobs.loadMore": "Load more ({n} more)", "jobs.notFound": "Job not found", + "jobs.detailLoadFail": "Could not load this job", "jobs.progress": "Progress {n}%", "jobs.updated": "Updated {time}", "jobs.backList": "Back to list", @@ -2849,21 +2828,8 @@ export const en: MessageDict = { "scout.sending": "Sending…", "scout.needAccountBefore": "Connect a usable account under", "scout.needAccountAfter": "first.", - "scout.knowledge": "Related knowledge", - "scout.knowledgeWithLabel": "Related · {label}", - "scout.knowledgeLearn": "Related · to learn", - "scout.collapseKnowledge": "Collapse knowledge", - "scout.expandLearn": "Expand · {n} notes", - "scout.expandKnowledge": "Expand knowledge", - "scout.deleteKnowledgeRun": "Delete this batch's knowledge & hits", "scout.loadingKnowledge": "Preparing related knowledge…", - "scout.notesCount": "{n} notes", - "scout.noKnowledge": "No related knowledge yet", "scout.product": "Product", - "scout.painsSolved": "Pains we solve", - "scout.focus": "Focus", - "scout.all": "All", - "scout.noWebSummary": "No web summaries yet", "scout.queue": "Matches · {n}", "scout.valueQueue": "Value replies · {n}", "scout.activityQueue": "Activity short replies · {n}", @@ -2871,9 +2837,6 @@ export const en: MessageDict = { "scout.collapseQueue": "Collapse queue", "scout.expandQueue": "Expand queue", "scout.noOtherPending": "No other pending", - "scout.learnPoints": "Key takeaways", - "scout.replyHooks": "Reply hooks", - "scout.badgeCore": "Closest to topic", "scout.unnamedRun": "Unnamed batch", "scout.thisRun": "this batch", "scout.confirmDeleteRun": "Delete patrol batch “{label}”?\\nHits and related knowledge will be removed. This cannot be undone.", @@ -2916,17 +2879,6 @@ export const en: MessageDict = { "scout.stanceActivity": "Short reply · activity", "scout.stanceProduct": "Empathy · soft product", "scout.stanceRelation": "Engage · build rapport", - "scout.tier.core": "Closest", - "scout.tier.coreHint": "Directly on the pain/keywords — read first", - "scout.tier.adjacent": "Adjacent", - "scout.tier.adjacentHint": "Nearby context, broaden search", - "scout.tier.broad": "Broad", - "scout.tier.broadHint": "Background & contrast — optional", - "scout.relation.solves_pain": "Hits the pain", - "scout.relation.nearby_scene": "Nearby scene", - "scout.relation.myth": "Myth-busting", - "scout.relation.contrast": "Contrast shopping", - "scout.relation.background": "Background", "brands.title": "Brands", "brands.railAria": "Brand list", @@ -3148,6 +3100,8 @@ export const en: MessageDict = { "inspire.useDraft": "Use this draft", "inspire.openPlay": "Open play", "inspire.thinking": "Thinking…", + "inspire.stop": "Stop generating", + "inspire.stopped": "Generation stopped", "inspire.pinnedAria": "This-round references (for the AI)", "inspire.pinned": "References", "inspire.pinnedCount": "· {n}", @@ -3320,6 +3274,7 @@ export const en: MessageDict = { "persona.jobRunning": "Analyzing in background… will refresh when ready (see Jobs for progress)", "persona.jobDone": "Background analysis finished · fingerprint saved", "persona.jobFailed": "Background analysis failed — check Jobs for the error or retry", + "persona.loadFail": "Could not load personas", "persona.openJob": "Open job details", "persona.setDefaultMsg": "“{name}” set as default", "persona.confirmDelete": "Delete persona “{name}”?", @@ -3475,7 +3430,6 @@ export const en: MessageDict = { "persona.previewReplySample": "The one in Da’an is fine but crowded", "inspire.playTitle": "Inspired thread", - "wizard.topic.fallbackTopic": "Everyday topic", "play.err.needLead": "Pick a lead account", "play.err.needRoot": "Add at least one root post", diff --git a/apps/web/src/lib/inviteNetwork.ts b/apps/web/src/lib/inviteNetwork.ts deleted file mode 100644 index 2747604..0000000 --- a/apps/web/src/lib/inviteNetwork.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * 邀請制網絡:樹建構、上下線查詢、管理員移動(防環)。 - * 資料來源:TenantUserRecord.invite_code / parent_uid。 - */ -import type { - InviteMemberBrief, - InviteTreeNode, - MemberStatus, - MyInviteNetwork, - Role, -} from "../domain/types"; -import type { TenantUserRecord } from "./tenantUsers"; -import { loadTenantUsers, upsertTenantUser } from "./tenantUsers"; - -function normalizeStatus(s: unknown): MemberStatus { - return s === "suspended" ? "suspended" : "active"; -} - -/** 計算每人直屬/全部子孫人數 */ -function countDownlines(users: TenantUserRecord[]): Map { - const children = new Map(); - for (const u of users) { - const p = u.parent_uid?.trim() || ""; - if (!p) continue; - const list = children.get(p) || []; - list.push(u.uid); - children.set(p, list); - } - - const memo = new Map(); - function totalOf(uid: string, stack: Set): number { - if (memo.has(uid)) return memo.get(uid)!; - if (stack.has(uid)) return 0; // 防壞資料環 - stack.add(uid); - const kids = children.get(uid) || []; - let n = kids.length; - for (const k of kids) n += totalOf(k, stack); - stack.delete(uid); - memo.set(uid, n); - return n; - } - - const out = new Map(); - for (const u of users) { - out.set(u.uid, { - direct: (children.get(u.uid) || []).length, - total: totalOf(u.uid, new Set()), - }); - } - return out; -} - -function depthOf( - uid: string, - byUid: Map, - cache: Map, - stack: Set, -): number { - if (cache.has(uid)) return cache.get(uid)!; - if (stack.has(uid)) return 0; - const u = byUid.get(uid); - if (!u?.parent_uid) { - cache.set(uid, 0); - return 0; - } - stack.add(uid); - const d = 1 + depthOf(u.parent_uid, byUid, cache, stack); - stack.delete(uid); - cache.set(uid, d); - return d; -} - -export function toInviteBrief( - u: TenantUserRecord, - counts: Map, - depth: number, -): InviteMemberBrief { - const c = counts.get(u.uid) || { direct: 0, total: 0 }; - return { - uid: u.uid, - email: u.email, - display_name: u.display_name, - invite_code: (u.invite_code || "").trim() || "—", - parent_uid: u.parent_uid?.trim() || null, - roles: u.roles as Role[], - status: normalizeStatus(u.status), - downline_count: c.direct, - total_downline_count: c.total, - depth, - joined_at: u.created_at, - avatar_url: u.avatar_url?.trim() || null, - }; -} - -export function buildInviteBriefs(users: TenantUserRecord[]): Map { - const counts = countDownlines(users); - const byUid = new Map(users.map((u) => [u.uid, u])); - const depthCache = new Map(); - const map = new Map(); - for (const u of users) { - const depth = depthOf(u.uid, byUid, depthCache, new Set()); - map.set(u.uid, toInviteBrief(u, counts, depth)); - } - return map; -} - -/** - * 穩定錯誤碼(i18n key)。UI 用 t(err.message) 顯示雙語。 - * 與 play.err.* 同一風格。 - */ -export const INVITE_ERR = { - notFound: "invite.err.notFound", - needAdmin: "invite.err.needAdmin", - memberNotFound: "invite.err.memberNotFound", - selfParent: "invite.err.selfParent", - parentNotFound: "invite.err.parentNotFound", - cycle: "invite.err.cycle", - /** 已有邀請人,不可再補填 */ - alreadyBound: "invite.err.alreadyBound", - /** 邀請碼空白/格式 */ - codeRequired: "invite.err.codeRequired", - /** 邀請碼不存在 */ - codeNotFound: "invite.err.codeNotFound", - /** 不能填自己的碼 */ - codeSelf: "invite.err.codeSelf", -} as const; - -/** - * 會員自行補填邀請碼(僅當尚無邀請人)。 - * 成功後 parent_uid 指向持有該碼的會員。 - */ -export function applyInviteCode(uid: string, rawCode: string): MyInviteNetwork { - const code = (rawCode || "").trim().toUpperCase(); - if (!code) throw new Error(INVITE_ERR.codeRequired); - - const users = loadTenantUsers(); - const me = users.find((u) => u.uid === uid); - if (!me) throw new Error(INVITE_ERR.notFound); - - if ((me.parent_uid || "").trim()) { - throw new Error(INVITE_ERR.alreadyBound); - } - - const inviter = users.find( - (u) => (u.invite_code || "").trim().toUpperCase() === code, - ); - if (!inviter) throw new Error(INVITE_ERR.codeNotFound); - if (inviter.uid === uid) throw new Error(INVITE_ERR.codeSelf); - - // 理論上 me 無 parent,不會成環;仍擋「邀請人是自己的子孫」 - if (isDescendantOf(uid, inviter.uid, users)) { - throw new Error(INVITE_ERR.cycle); - } - - upsertTenantUser({ - ...me, - parent_uid: inviter.uid, - }); - return getMyInviteNetwork(uid); -} - -/** 一般會員:自己的邀請碼 + 上線 + 直屬下線 */ -export function getMyInviteNetwork(uid: string): MyInviteNetwork { - const users = loadTenantUsers(); - const briefs = buildInviteBriefs(users); - const me = briefs.get(uid); - if (!me) throw new Error(INVITE_ERR.notFound); - - const upline = me.parent_uid ? briefs.get(me.parent_uid) || null : null; - const downlines = users - .filter((u) => (u.parent_uid || "") === uid) - .map((u) => briefs.get(u.uid)!) - .filter(Boolean) - .sort((a, b) => b.joined_at - a.joined_at); - - return { - me, - upline, - downlines, - total_downline_count: me.total_downline_count, - }; -} - -/** 管理員:森林(多根) */ -export function buildInviteForest(users?: TenantUserRecord[]): InviteTreeNode[] { - const list = users ?? loadTenantUsers(); - const briefs = buildInviteBriefs(list); - const byParent = new Map(); - - for (const u of list) { - const brief = briefs.get(u.uid)!; - const p = brief.parent_uid; - // 上線不存在時當根,避免孤兒懸空 - const parentKey = - p && briefs.has(p) ? p : null; - const arr = byParent.get(parentKey) || []; - arr.push(brief); - byParent.set(parentKey, arr); - } - - function build(uid: string | null): InviteTreeNode[] { - const kids = byParent.get(uid) || []; - kids.sort((a, b) => a.display_name.localeCompare(b.display_name, "zh-Hant")); - return kids.map((k) => ({ - ...k, - children: build(k.uid), - })); - } - - return build(null); -} - -/** 是否為 ancestor 的子孫(含自己) */ -export function isDescendantOf( - ancestorUid: string, - maybeDescendantUid: string, - users: TenantUserRecord[], -): boolean { - if (ancestorUid === maybeDescendantUid) return true; - const byUid = new Map(users.map((u) => [u.uid, u])); - let cur = byUid.get(maybeDescendantUid); - const seen = new Set(); - while (cur?.parent_uid) { - if (seen.has(cur.uid)) break; - seen.add(cur.uid); - if (cur.parent_uid === ancestorUid) return true; - cur = byUid.get(cur.parent_uid); - } - return false; -} - -/** - * 管理員移動節點:將 uid 掛到 newParentUid 下(null = 升為根)。 - * 不可掛到自己或自己的子孫底下。 - */ -export function adminMoveInviteMember( - actorIsAdmin: boolean, - uid: string, - newParentUid: string | null, -): InviteMemberBrief { - if (!actorIsAdmin) throw new Error(INVITE_ERR.needAdmin); - const users = loadTenantUsers(); - const target = users.find((u) => u.uid === uid); - if (!target) throw new Error(INVITE_ERR.memberNotFound); - - const parent = newParentUid?.trim() || null; - if (parent === uid) throw new Error(INVITE_ERR.selfParent); - if (parent) { - const p = users.find((u) => u.uid === parent); - if (!p) throw new Error(INVITE_ERR.parentNotFound); - if (isDescendantOf(uid, parent, users)) { - throw new Error(INVITE_ERR.cycle); - } - } - - const current = target.parent_uid?.trim() || null; - if (current === parent) { - const briefs = buildInviteBriefs(users); - return briefs.get(uid)!; - } - - const next = upsertTenantUser({ - ...target, - parent_uid: parent, - }); - const briefs = buildInviteBriefs(loadTenantUsers()); - return briefs.get(next.uid)!; -} - -/** 扁平候選上線列表(移動選單用) */ -export function listInviteParentCandidates( - excludeSubtreeRootUid?: string, -): InviteMemberBrief[] { - const users = loadTenantUsers(); - const briefs = buildInviteBriefs(users); - return users - .filter((u) => { - if (!excludeSubtreeRootUid) return true; - return !isDescendantOf(excludeSubtreeRootUid, u.uid, users); - }) - .map((u) => briefs.get(u.uid)!) - .filter(Boolean) - .sort((a, b) => a.display_name.localeCompare(b.display_name, "zh-Hant")); -} diff --git a/apps/web/src/lib/memberUid.ts b/apps/web/src/lib/memberUid.ts deleted file mode 100644 index 36ebd0f..0000000 --- a/apps/web/src/lib/memberUid.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Backend uid is int64 from 1_000_000 (7–8 numeric digits). - * Display always pad to 8 characters: 01000000, 01000001, … - */ -export function formatMemberUid(uid: string | number | null | undefined): string { - if (uid === null || uid === undefined || uid === "") return ""; - const n = typeof uid === "number" ? uid : Number(uid); - if (!Number.isFinite(n) || n < 0) return String(uid); - return String(Math.trunc(n)).padStart(8, "0"); -} - -/** Parse display or raw uid string back to API path/body form (no leading zeros required). */ -export function parseMemberUid(raw: string): string { - const s = raw.trim(); - if (!s) return ""; - // keep as decimal string without leading zeros for JSON number path - const n = Number(s); - if (!Number.isFinite(n)) return s; - return String(Math.trunc(n)); -} diff --git a/apps/web/src/lib/mockAi.ts b/apps/web/src/lib/mockAi.ts deleted file mode 100644 index bdb5bae..0000000 --- a/apps/web/src/lib/mockAi.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { Persona } from "../domain/types"; -import { buildPersonaPromptBlock, isPersonaReady, toneOf } from "./personaPrompt"; - -/** Tiny delay so mock AI buttons feel async */ -export function mockDelay(ms = 450): Promise { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - -export function voiceLabel(persona?: Persona | null): string { - return persona?.name || "預設語氣"; -} - -function clip(s: string, n: number): string { - const t = s.trim(); - return t.length > n ? `${t.slice(0, n)}…` : t; -} - -function fingerprintBits(persona?: Persona | null) { - const d = persona?.style?.draft; - return { - tone: toneOf(persona), - hooks: d?.hooks || "先丟痛點再問", - fingerprint: d?.languageFingerprint || "口語短句", - rhythm: d?.rhythm || "2~3 短段", - examples: d?.examples || "", - avoid: d?.avoid || persona?.guard?.avoid?.join("、") || "硬廣", - cta: d?.ctaStyle || "輕輕問一句", - audience: d?.audience || "會求經驗的人", - }; -} - -/** 產出時附帶使用的人設 block(除錯/預覽用) */ -export function explainPersonaUsage(persona?: Persona | null, mode: "post" | "reply" | "outreach" | "inspire" = "post") { - return { - ready: isPersonaReady(persona), - name: persona?.name || "(未選)", - block: buildPersonaPromptBlock(persona, mode), - }; -} - -export function mockGenerateTopic(seed: string, persona?: Persona | null): string { - const b = fingerprintBits(persona); - const base = seed.trim() || "日常小發現"; - return [ - `(視角:${persona?.name || "預設"} · ${b.tone})`, - `${b.hooks}:最近卡在「${base}」。`, - `想聽 ${b.audience} 的真實經驗——不是規格文。`, - `用字偏向:${b.fingerprint}`, - ].join("\n"); -} - -export function mockGenerateRoot(topic: string, persona?: Persona | null): string { - const b = fingerprintBits(persona); - const t = topic.trim() || "一個大家會想回的小題目"; - const body = [ - clip(t, 120), - "", - `我自己目前卡在「有沒有實際用過、會不會踩雷」。`, - b.examples ? `(像我會說的:${clip(b.examples, 48)})` : "", - b.cta, - ] - .filter(Boolean) - .join("\n"); - return `(${b.tone} · 避開:${clip(b.avoid, 24)})\n${body}`; -} - -export function mockGenerateReply(opts: { - context: string; - speakerName: string; - /** 有才套用語氣;沒有就中性口吻 */ - persona?: Persona | null; - /** 有才輕帶品牌視角;沒有就不提 */ - brandName?: string; - brandBrief?: string; - isLead?: boolean; -}): string { - const { context, speakerName, persona, brandName, brandBrief, isLead } = opts; - const hasPersona = Boolean(persona); - const b = fingerprintBits(persona); - const clipCtx = clip(context, 48) || "前面那則"; - const head = hasPersona ? `(${speakerName} · ${b.tone})` : `(${speakerName})`; - const brandLine = - brandName && brandBrief - ? `補充一點 ${brandName} 相關經驗:${clip(brandBrief, 48)}` - : brandName - ? `補充一點和 ${brandName} 有關的實際用法。` - : ""; - - if (isLead) { - return [ - head, - `懂你說的「${clipCtx}」。`, - brandLine || "想再問一句:你實際怎麼選的?", - hasPersona ? `(避開 ${clip(b.avoid, 24)})` : "", - ] - .filter(Boolean) - .join("\n"); - } - return [ - head, - brandLine || `我這邊經驗是:${clipCtx} 真的有差。`, - brandLine ? `對「${clipCtx}」這點,務實做法通常是先對情境再對規格。` : "如果在意使用情境,我可以再補細節。", - hasPersona ? `用字:${b.fingerprint}` : "", - ] - .filter(Boolean) - .join("\n"); -} - -export function mockGenerateOwnPostReply(opts: { - postText: string; - replyText?: string; - persona?: Persona | null; -}): string { - const b = fingerprintBits(opts.persona); - const target = clip(opts.replyText || opts.postText, 40); - return [ - `(作者本人 · ${b.tone})`, - `懂你說的「${target}」。`, - `我自己是先抓痛點再對規格,通常看使用情境而不是只看規格表。`, - `你比較在意哪一點?`, - opts.persona?.guard?.banAiTone ? "(已關 AI 腔/客服腔)" : "", - ] - .filter(Boolean) - .join("\n"); -} - -export function mockGenerateScoutDraft(opts: { - postText: string; - brandName: string; - brandBrief?: string; - targetAudience?: string; - productLabel?: string; - productContext?: string; - painHint?: string; - placementUrl?: string; - persona?: Persona | null; - /** theme = 接話;product = 可輕帶解法;activity = 短回養帳號 */ - mode?: "product" | "theme" | "activity"; -}): string { - const b = fingerprintBits(opts.persona); - const clipPost = clip(opts.postText, 36); - const mode = opts.mode || (opts.productLabel ? "product" : "theme"); - - if (mode === "activity") { - return [ - `同感「${clipPost}」這段 🙌`, - `我也常這樣,後來比較會先停一下再決定。`, - `你現在比較偏哪一種狀態?`, - ].join("\n"); - } - - if (mode === "theme") { - return [ - `看到你聊「${clipPost}」——`, - `我之前也卡過類似的點,後來比較有感的是先釐清自己在意什麼。`, - `你現在最卡的是哪一段?可以再多講一點(真想聽,不是套公式)。`, - opts.persona?.guard?.banAiTone ? "" : "", - ] - .filter(Boolean) - .join("\n"); - } - - const lines = [ - `看到你提到「${clipPost}」很有感。`, - ]; - if (opts.painHint) { - lines.push(`這類「${clip(opts.painHint, 28)}」的情況,身邊也常聽到。`); - } - if (opts.productContext) { - lines.push(clip(opts.productContext, 120)); - } else if (opts.productLabel) { - lines.push(`我自己後來會先從「${opts.productLabel}」這類情境想解法,不一定一步到位。`); - } else { - lines.push(`整理經驗時也常遇到類似情況——可以分享一個比較務實的切入點(非業配)。`); - } - if (opts.placementUrl) { - lines.push(opts.placementUrl); - } - lines.push(`你比較在意哪一點?`); - if (b.tone) { - // light fingerprint without fake meta banner - } - return lines.join("\n"); -} - -export function mockGenerateInspiration( - topic: string, - persona?: Persona | null, -): { - title: string; - hook: string; - angle: string; -} { - const b = fingerprintBits(persona); - const t = topic.trim() || "生活小題"; - return { - title: `${t} · ${persona?.name || "日常視角"}`, - hook: `${b.hooks}(主題:${t};對 ${b.audience})`, - angle: - persona?.style?.dimensions?.d4Topics?.summary || - persona?.brief || - "先痛點、再經驗、最後輕帶觀點,避免一句廣告。", - }; -} diff --git a/apps/web/src/lib/mockImage.ts b/apps/web/src/lib/mockImage.ts deleted file mode 100644 index e525681..0000000 --- a/apps/web/src/lib/mockImage.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { mockDelay } from "./mockAi"; -import { newId } from "./id"; - -export type GeneratedImage = { - id: string; - url: string; - prompt: string; -}; - -/** mock 產圖:用 dicebear 當可顯示縮圖 */ -export async function mockGenerateImage(prompt: string): Promise { - await mockDelay(700); - const p = prompt.trim() || "threads post visual"; - const seed = encodeURIComponent(p.slice(0, 48) || "harbor"); - return { - id: newId("img"), - url: `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=20b49c,69d4c5,ff7b73,b6f3e4,ffd5dc`, - prompt: p, - }; -} diff --git a/apps/web/src/lib/mockInspireAngles.ts b/apps/web/src/lib/mockInspireAngles.ts deleted file mode 100644 index e2a671d..0000000 --- a/apps/web/src/lib/mockInspireAngles.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { Brand, InspireAngle, Persona, TrendItem } from "../domain/types"; -import { newId } from "./id"; -import { mockDelay } from "./mockAi"; -import { isPersonaReady, toneOf } from "./personaPrompt"; - -function clip(s: string, n: number): string { - const t = s.trim(); - return t.length > n ? `${t.slice(0, n)}…` : t; -} - -/** - * 針對一則熱點產 3 個開場角度。 - * 人設/品牌選填:有且可用才餵進語氣/品牌視角。 - */ -export async function mockGenerateInspireAngles(opts: { - trend: TrendItem; - persona?: Persona | null; - brand?: Brand | null; -}): Promise { - await mockDelay(650); - const { trend } = opts; - const label = trend.label.replace(/^#/, ""); - const sample = trend.samples[0] || trend.summary; - const persona = opts.persona && isPersonaReady(opts.persona) ? opts.persona : null; - const brand = opts.brand || null; - const tone = persona ? toneOf(persona) : ""; - const brandBit = brand?.brief - ? clip(brand.brief, 36) - : brand?.display_name - ? brand.display_name - : ""; - - const a1 = persona - ? `(${tone})看到大家都在聊 ${trend.label}——${clip(sample, 40)} 你實際怎麼處理?` - : `${trend.label} 最近很吵:${clip(sample, 42)} 有人也是嗎?`; - - const a2 = brandBit - ? `講 ${label} 時大家常忽略一點:${brandBit}。我自己的經驗是…` - : `先別急著結論 ${label}。我比較想聽「真正踩過雷」的人怎麼說。`; - - const a3 = - trend.keywords[0] != null - ? `一句話:${trend.keywords[0]} 到底卡在哪?留言區求不業配的真實答案。` - : `如果只能給 ${label} 一個實用建議,你會說什麼?`; - - return [ - { id: newId("ang"), hook: a1 }, - { id: newId("ang"), hook: a2 }, - { id: newId("ang"), hook: a3 }, - ]; -} - -/** 搜尋關鍵字當臨時熱點,走同一套角度流 */ -export function trendFromQuery(query: string): TrendItem { - const q = query.trim() || "熱門"; - const now = Date.now() * 1_000_000; - return { - id: newId("trend"), - kind: "threads_tag", - label: q.startsWith("#") ? q : `#${q.replace(/\s+/g, "")}`, - summary: `與「${q}」相關的 Threads 討論`, - heat: 70, - keywords: [q], - samples: [`最近一直刷到「${q}」,求經驗`], - source_label: "搜尋", - observed_at: now, - }; -} diff --git a/apps/web/src/lib/mockInspireChat.ts b/apps/web/src/lib/mockInspireChat.ts deleted file mode 100644 index 6cae6e0..0000000 --- a/apps/web/src/lib/mockInspireChat.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { - Brand, - InspireChatMessage, - InspireElement, - Persona, -} from "../domain/types"; -import { newId } from "./id"; -import { mockDelay } from "./mockAi"; -import { isPersonaReady } from "./personaPrompt"; -import { nowUnixNano } from "./time"; - -export type ResolvedInspireContext = { - roles: string[]; - snippets: string[]; - trends: string[]; - persona?: Persona | null; - brand?: Brand | null; - labels: string[]; -}; - -export function resolveInspireContext( - elements: InspireElement[], - opts?: { - personas?: Persona[]; - brands?: Brand[]; - }, -): ResolvedInspireContext { - const personas = opts?.personas || []; - const brands = opts?.brands || []; - const roles: string[] = []; - const snippets: string[] = []; - const trends: string[] = []; - const labels: string[] = []; - let persona: Persona | null = null; - let brand: Brand | null = null; - - for (const el of elements) { - labels.push(el.title); - if (el.kind === "role") { - roles.push(el.body || el.title); - } else if (el.kind === "snippet") { - snippets.push(el.body || el.title); - } else if (el.kind === "trend") { - trends.push(el.body || el.title); - } else if (el.kind === "persona" && el.ref_id) { - const p = personas.find((x) => x.id === el.ref_id); - if (p && isPersonaReady(p)) persona = p; - else if (el.body) snippets.push(el.body); - } else if (el.kind === "brand" && el.ref_id) { - const b = brands.find((x) => x.id === el.ref_id); - if (b) brand = b; - else if (el.body) snippets.push(el.body); - } else if (el.body) { - snippets.push(el.body); - } - } - - return { roles, snippets, trends, persona, brand, labels }; -} - -/** mock 產文:依素材重寫即可,不 fortify 固定開頭 */ -function buildDraftBody(userText: string, ctx: ResolvedInspireContext): string { - const topic = - userText.trim() || - ctx.trends[0] || - ctx.brand?.display_name || - "日常小事"; - const lines: string[] = []; - - if (ctx.trends.length) { - lines.push(`最近大家在聊 ${ctx.trends[0]!.replace(/^主題:/, "").slice(0, 40)},`); - } - - const brandBit = ctx.brand - ? `(想到 ${ctx.brand.display_name}${ctx.brand.brief ? `:${ctx.brand.brief.slice(0, 40)}` : ""})` - : ""; - - if (/改短|短一點|精簡/.test(userText)) { - lines.push(`${topic.slice(0, 60)}——有人也卡在這嗎?`); - } else if (/更口語|隨便|碎念/.test(userText)) { - lines.push(`所以 ${topic.slice(0, 50)} 這件事,我真的想問你們怎麼處理的。`); - } else { - if (!ctx.trends.length) { - lines.push(`${topic.slice(0, 80)}${brandBit}`); - } else if (brandBit) { - lines.push(brandBit); - } - lines.push("我自己目前比較在意「實際用起來」而不是包裝怎麼寫。"); - } - - const wantAsk = - ctx.snippets.some((s) => /問句|留言/.test(s)) || - ctx.roles.some((r) => /鉤子|問句/.test(r)); - if (wantAsk) { - lines.push("你們最近有類似經驗嗎?"); - } - - let body = lines.filter(Boolean).join("\n"); - if (ctx.snippets.some((s) => /短貼|180|280/.test(s)) && body.length > 200) { - body = body.slice(0, 180) + "…"; - } - return body.trim(); -} - -function assistantNote(ctx: ResolvedInspireContext, mode: "chat" | "generate"): string { - if (mode === "chat") { - if (ctx.labels.length) { - return `好。這輪會帶上:${ctx.labels.slice(0, 4).join("、")}${ctx.labels.length > 4 ? "…" : ""}。你可以直接說想寫什麼,或按「產文」。`; - } - return "可以。想寫什麼主題?也可以先從右側套用角色/人設/片段,或點下方夯什麼。"; - } - if (ctx.labels.length) { - return `已依 ${ctx.labels.slice(0, 3).join("、")} 寫一則草稿(可再改):`; - } - return "這是一則草稿(尚未套用特別元素,可在右側 pin 後再產):"; -} - -export async function mockInspireChat(opts: { - userMessage: string; - mode: "chat" | "generate"; - elements: InspireElement[]; - personas?: Persona[]; - brands?: Brand[]; -}): Promise { - await mockDelay(opts.mode === "generate" ? 650 : 380); - const now = nowUnixNano(); - const ctx = resolveInspireContext(opts.elements, { - personas: opts.personas, - brands: opts.brands, - }); - const userText = opts.userMessage.trim(); - - const out: InspireChatMessage[] = []; - if (userText) { - out.push({ - id: newId("im"), - role: "user", - text: userText, - created_at: now, - }); - } - - if (opts.mode === "generate") { - const body = buildDraftBody(userText || "寫一則可發的 Threads", ctx); - out.push({ - id: newId("im"), - role: "assistant", - text: assistantNote(ctx, "generate"), - draft: { - title: (ctx.trends[0] || userText || "靈感草稿").slice(0, 32), - body, - }, - created_at: now + 1, - }); - } else { - out.push({ - id: newId("im"), - role: "assistant", - text: assistantNote(ctx, "chat"), - created_at: now + 1, - }); - } - - return out; -} - -export function emptyInspireSession(): import("../domain/types").InspireSession { - const now = nowUnixNano(); - return { - id: newId("isess"), - messages: [ - { - id: newId("im"), - role: "assistant", - text: "說你想寫什麼。右側可套用角色、人設、品牌、片段;下方是最近 Threads 夯什麼。", - created_at: now, - }, - ], - pinned_element_ids: [], - updated_at: now, - }; -} diff --git a/apps/web/src/lib/mockProductImport.ts b/apps/web/src/lib/mockProductImport.ts deleted file mode 100644 index 5ac32de..0000000 --- a/apps/web/src/lib/mockProductImport.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { mockDelay } from "./mockAi"; - -/** 從商品連結「抓回來」後要填進表單的草稿(尚未存檔) */ -export type ProductUrlDraft = { - label: string; - product_context: string; - pain_points: string[]; - match_tags: string[]; - placement_url: string; - /** mock 說明:live 會是後端爬頁 */ - source_note: string; -}; - -function slugToWords(slug: string): string[] { - return slug - .split(/[-_+/]+/) - .map((s) => decodeURIComponent(s).trim()) - .filter((s) => s.length > 1 && !/^\d+$/.test(s) && !/^(www|com|tw|html|php|p|product|products|item|shop)$/i.test(s)); -} - -function titleCaseWords(words: string[]): string { - return words - .map((w) => { - if (/[\u4e00-\u9fff]/.test(w)) return w; - return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); - }) - .join(" "); -} - -/** 依 URL 路徑/主機推估產品語意(mock;真抓頁屬 Phase C 後端) */ -export async function mockImportProductFromUrl(rawUrl: string): Promise { - await mockDelay(900); - const trimmed = rawUrl.trim(); - if (!trimmed) throw new Error("請貼上商品或官網連結"); - - let url: URL; - try { - url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); - } catch { - throw new Error("連結格式不正確"); - } - - const host = url.hostname.replace(/^www\./, ""); - const pathParts = url.pathname.split("/").filter(Boolean); - const lastSlug = pathParts[pathParts.length - 1] || host.split(".")[0] || "product"; - const words = slugToWords(lastSlug.replace(/\.(html?|php)$/i, "")); - const fromQuery = - url.searchParams.get("name") || - url.searchParams.get("title") || - url.searchParams.get("q") || - ""; - const labelBase = - (fromQuery && fromQuery.trim()) || - (words.length ? titleCaseWords(words.slice(0, 6)) : "") || - host.split(".")[0] || - "未命名商品"; - - const blob = `${labelBase} ${words.join(" ")} ${host}`.toLowerCase(); - - // 粗分類:用路徑關鍵字推痛點/tags(mock 啟發式) - let pain_points: string[] = []; - let match_tags: string[] = [...words.slice(0, 5)]; - let angle = "依商品頁摘要整理的賣點與使用情境(請再人工改準)。"; - - if (/敏感|無香|香精|抗敏|unscent|sensitive|hypo/i.test(blob)) { - pain_points = ["香精/氣味太重受不了", "皮膚或頭皮容易刺癢", "找不到真的溫和選項"]; - match_tags = [...new Set([...match_tags, "無香", "敏感肌", "溫和", "香精"])]; - angle = "主打低刺激/無香體驗;適合先講使用感受,再輕帶規格與通路。"; - } else if (/咖啡|cafe|coffee|插座|座位|第三空間/i.test(blob)) { - pain_points = ["平日下午沒地方久坐", "店裡沒插座或趕客", "人多坐不久"]; - match_tags = [...new Set([...match_tags, "咖啡廳", "插座", "筆電", "不限時"])]; - angle = "強調座位與工作友善,分享實際待店經驗優於硬推商品。"; - } else if (/寶寶|嬰|幼兒|baby|kids/i.test(blob)) { - pain_points = ["怕洗劑太刺激", "怕洗不乾淨", "不知道怎麼選成分"]; - match_tags = [...new Set([...match_tags, "寶寶", "溫和", "嬰幼兒"])]; - angle = "親子情境:務實講洗淨與溫和的取捨,避免恐嚇式行銷。"; - } else if (/洗|沐|護|detergent|shampoo|soap|skincare|保養/i.test(blob)) { - pain_points = ["不知道適不適合自己膚況", "行銷話術看不懂", "用完反而不適"]; - match_tags = [...new Set([...match_tags, "選品", "成分", "使用心得"])]; - angle = "先對齊使用情境與頻率,再對規格;語氣務實不硬廣。"; - } else { - pain_points = [`在找「${labelBase}」相關解法或真實心得`, "被行銷文搞混、想聽實際用過的人講"]; - if (!match_tags.length) match_tags = [labelBase, "推薦", "心得"]; - angle = `從「${labelBase}」商品頁推測的使用情境;請依真實賣點改寫。`; - } - - const product_context = [`【${labelBase}】`, angle].join("\n"); - - return { - label: labelBase.slice(0, 48), - product_context, - pain_points, - match_tags: match_tags.map((t) => t.slice(0, 24)).filter(Boolean).slice(0, 12), - placement_url: url.toString(), - source_note: "已填入,可再改", - }; -} diff --git a/apps/web/src/lib/mockResearch.ts b/apps/web/src/lib/mockResearch.ts deleted file mode 100644 index a83a24a..0000000 --- a/apps/web/src/lib/mockResearch.ts +++ /dev/null @@ -1,323 +0,0 @@ -import type { - ResearchHit, - ScoutKnowledgeRelation, - ScoutResearchNote, - ScoutResearchTier, -} from "../domain/types"; -import { mockDelay } from "./mockAi"; -import { newId } from "./id"; - -const SOURCES = [ - { host: "www.commonhealth.com.tw", label: "康健雜誌" }, - { host: "www.edh.tw", label: "早安健康" }, - { host: "heho.com.tw", label: "Heho健康" }, - { host: "www.dcard.tw", label: "Dcard" }, - { host: "www.ptt.cc", label: "PTT" }, - { host: "medium.com", label: "Medium" }, -]; - -export const RESEARCH_TIER_META: Record< - ScoutResearchTier, - { label: string; hint: string; order: number } -> = { - core: { label: "最貼主題", hint: "直接對準痛點/關鍵語,優先讀", order: 0 }, - adjacent: { label: "相關周邊", hint: "鄰近情境,擴搜尋面", order: 1 }, - broad: { label: "最廣泛", hint: "背景與對照,選讀即可", order: 2 }, -}; - -export const KNOWLEDGE_RELATION_META: Record< - ScoutKnowledgeRelation, - { label: string } -> = { - solves_pain: { label: "對準痛點" }, - nearby_scene: { label: "鄰近場景" }, - myth: { label: "迷思澄清" }, - contrast: { label: "對照選購" }, - background: { label: "背景脈絡" }, -}; - -function pageUrl(host: string, slug: string): string { - return `https://${host}/article/${encodeURIComponent(slug)}`; -} - -/** 漂亮顯示用:來源站名 + 精簡 host */ -export function formatResearchLink(url: string, sourceLabel?: string): { - label: string; - host: string; -} { - let host = ""; - try { - host = new URL(url).hostname.replace(/^www\./, ""); - } catch { - host = url.replace(/^https?:\/\//, "").split("/")[0] || ""; - } - return { - label: sourceLabel?.trim() || host || "來源", - host, - }; -} - -/** - * 取得可顯示的學習重點(優先 learn_points;舊資料從 summary 拆) - */ -export function noteLearnPoints(n: Pick): string[] { - if (n.learn_points && n.learn_points.length > 0) { - return n.learn_points.slice(0, 5); - } - const raw = (n.summary || "").trim(); - if (!raw) return []; - const byNum = raw - .split(/(?=\d))|(?=\d\.)|(?=;)/) - .map((s) => s.replace(/^\d+[).]\s*/, "").replace(/^;\s*/, "").trim()) - .filter((s) => s.length >= 8 && s.length <= 80); - if (byNum.length >= 2) return byNum.slice(0, 4); - const bySentence = raw - .split(/[。!?\n]+/) - .map((s) => s.trim()) - .filter((s) => s.length >= 8); - return bySentence.slice(0, 3); -} - -export function noteReplyHooks(n: Pick): string[] { - return (n.reply_hooks || []).filter(Boolean).slice(0, 3); -} - -/** - * mock 上網研究:標題 + 學習重點 + 回帖鉤子 + 摘要 + URL - * 前幾則 core,後則 adjacent - */ -export async function mockWebResearch(query: string): Promise { - await mockDelay(650); - const q = query.trim() || "主題"; - const slug = q.replace(/\s+/g, "-").slice(0, 32) || "topic"; - - const pages: Array> = [ - { - title: `${q}:常見迷思與實際差異`, - snippet: `多數人討論「${q}」時會忽略使用情境,只看行銷字。`, - learn_points: [ - `「溫和/天然」要對應使用頻率與膚況,不是萬能標籤`, - `別只看成分表第一行,香精、防腐與殘留感也會刺`, - `先寫自己的痛點句,再對規格,比問「哪個最好」準`, - ], - reply_hooks: [ - `先確認是刺鼻、刺癢還是洗不乾淨,三種解法不太一樣`, - `無香也不等於一定不刺激,可以一起對成分表看`, - ], - summary: [ - `本篇整理「${q}」在公開討論中的三個常見誤區:`, - `把「溫和/天然」當萬能標籤;只比成分表第一行;聽完業配再決策。`, - `建議先寫下痛點句(刺鼻、刺癢、洗不乾淨),再回頭對規格。`, - ].join(""), - url: pageUrl(SOURCES[0]!.host, `${slug}-myths`), - source_label: SOURCES[0]!.label, - tier: "core", - }, - { - title: `專家觀點:如何評估 ${q}`, - snippet: `可從安全性、長期成本、真實回饋三維度評估。`, - learn_points: [ - `優先問:刺激來源能不能排除、有沒有可驗證使用情境`, - `敏感或長時間接觸:先收集失敗案例,不要只看成功廣告`, - `可操作三問:什麼會變糟?多久改善?有無生活替代?`, - ], - reply_hooks: [ - `你試過之後是「當下就刺」還是「用幾天後才不舒服」?`, - `若目標是敏感肌,失敗案例通常比業配文更有參考價值`, - ], - summary: [ - `科普向文章:評估「${q}」時優先看刺激來源與使用情境。`, - `問句可直接當 Threads 接話鉤子。`, - ].join(""), - url: pageUrl(SOURCES[1]!.host, `${slug}-howto`), - source_label: SOURCES[1]!.label, - tier: "core", - }, - { - title: `論壇實測串:${q} 用過的人怎麼說`, - snippet: `公開討論裡「實際用過」的留言互動通常高於純規格文。`, - learn_points: [ - `高頻抱怨:一用就刺鼻/刺癢、洗完有殘留、假無香`, - `正向經驗多來自小範圍試用、對照香精欄、接受磨合期`, - `海巡關鍵語:真的無香嗎、有人也刺痛嗎、求非業配`, - ], - reply_hooks: [ - `也有人一用就刺鼻,你是碰到味道還是接觸後刺癢?`, - `論壇裡「求非業配」串通常比較敢講失敗經驗`, - ], - summary: [ - `彙整論壇約 40 則心得:抱怨集中在刺鼻、殘留、假無香。`, - `回覆時先共感具體症狀,再輕提解法。`, - ].join(""), - url: pageUrl(SOURCES[3]!.host, `f-mood-${slug}-reviews`), - source_label: SOURCES[3]!.label, - tier: "adjacent", - }, - { - title: `${q} 對照表與選購清單(摘要)`, - snippet: `把規格拆成痛點對照,比堆疊功能點更容易說服人。`, - learn_points: [ - `拆成:刺激源、使用步驟、價格帶、適合誰/不適合誰`, - `適合「已明確知道自己的雷」的人,不適合亂槍打鳥`, - `對方痛點講清楚前,先不要急著丟產品連結`, - ], - reply_hooks: [ - `你比較在意「完全無味」還是「接觸後不刺」?兩個規格不一樣`, - `若你已經知道自己的雷,對照表會比推薦清單好用`, - ], - summary: [ - `清單文把「${q}」拆成刺激源、步驟、價格、適合對象。`, - `置入啟發:先確認痛點再決定要不要帶產品。`, - ].join(""), - url: pageUrl(SOURCES[2]!.host, `${slug}-checklist`), - source_label: SOURCES[2]!.label, - tier: "adjacent", - }, - ]; - - return pages.map((p) => ({ ...p, id: newId("hit") })); -} - -export function formatResearchInsert(hits: ResearchHit[]): string { - if (!hits.length) return ""; - return ( - "\n\n——\n(補充資料)\n" + - hits - .map((h, i) => { - const points = h.learn_points?.length - ? h.learn_points.map((p) => `· ${p}`).join("\n") - : h.summary || h.snippet; - const link = formatResearchLink(h.url, h.source_label); - return `${i + 1}. ${h.title}\n${points}\n(${link.label} · ${link.host})`; - }) - .join("\n\n") - ); -} - -function relationForTier(tier: ScoutResearchTier, title: string): ScoutKnowledgeRelation { - if (title.includes("迷思")) return "myth"; - if (title.includes("對照") || title.includes("選購")) return "contrast"; - if (tier === "core") return "solves_pain"; - if (tier === "adjacent") return "nearby_scene"; - return "background"; -} - -/** ResearchHit → 海巡功課用的知識節點 */ -export function researchHitsToScoutNotes(hits: ResearchHit[]): ScoutResearchNote[] { - return hits.map((h) => { - const tier = h.tier || "core"; - const summary = (h.summary || h.snippet || "").trim(); - const learn_points = - h.learn_points && h.learn_points.length > 0 - ? h.learn_points - : noteLearnPoints({ summary, learn_points: undefined }); - return { - id: h.id, - title: h.title, - summary, - learn_points, - reply_hooks: h.reply_hooks?.length ? h.reply_hooks : undefined, - url: h.url, - source_label: h.source_label, - keywords: extractKeywordsFromNote(h.title, h.summary || h.snippet), - tier, - relation: relationForTier(tier, h.title), - }; - }); -} - -function extractKeywordsFromNote(title: string, body: string): string[] { - const blob = `${title} ${body}`; - const candidates = [ - "無香", - "敏感肌", - "刺鼻", - "刺癢", - "香精", - "成分表", - "溫和", - "實測", - "非業配", - "插座", - "第三空間", - "久坐", - ]; - const found = candidates.filter((k) => blob.includes(k)); - const head = title.split(/[::]/)[0]?.trim(); - if (head && head.length >= 2 && head.length <= 16 && !found.includes(head)) { - found.unshift(head.slice(0, 12)); - } - return [...new Set(found)].slice(0, 5); -} - -/** - * 周邊詞 → 分層延伸頁 - * 前半 adjacent,後半 broad - */ -export async function mockExpandKnowledgePages( - terms: string[], - contextLabel?: string, -): Promise { - await mockDelay(350); - const take = terms.filter(Boolean).slice(0, 6); - return take.map((term, i) => { - const src = SOURCES[(i + 2) % SOURCES.length]!; - const ctx = contextLabel ? `(對齊 ${contextLabel})` : ""; - const tier: ScoutResearchTier = i < 2 ? "adjacent" : "broad"; - if (tier === "broad") { - return { - id: newId("ek"), - title: `背景:${term} 的更大討論脈絡${ctx}`, - summary: `較廣角閱讀:「${term}」在生活/消費文化裡如何被框定。`, - learn_points: [ - `「${term}」常被包進更大的生活/消費敘事,不只是單點規格`, - `用途是聽懂對方從哪個場景開講,不是立刻給選品答案`, - `語氣很散時:先接背景一句,再收斂到具體痛點`, - ], - reply_hooks: [ - `聽起來你是從「${term}」這條線在煩,我先對一下場景`, - ], - url: pageUrl(src.host, `expand-${term.slice(0, 16)}-${i + 1}`), - source_label: src.label, - keywords: [term], - tier, - relation: "background", - }; - } - return { - id: newId("ek"), - title: `周邊:${term} 為什麼常被一起談${ctx}`, - summary: `針對關鍵語「${term}」的延伸摘要。`, - learn_points: [ - `社群常把「${term}」和相鄰痛點綁在一起講`, - `對方未必搜產品名,而是用症狀/場景說話`, - `可帶走:用對方的詞接話 → 先釐清情境 → 再談規格`, - ], - reply_hooks: [ - `很多人提到「${term}」時,其實在講隔壁的痛,你是哪一種?`, - ], - url: pageUrl(src.host, `expand-${term.slice(0, 16)}-${i + 1}`), - source_label: src.label, - keywords: [term], - tier, - relation: "nearby_scene", - }; - }); -} - -export function groupNotesByTier( - notes: ScoutResearchNote[], -): { tier: ScoutResearchTier; notes: ScoutResearchNote[] }[] { - const buckets: Record = { - core: [], - adjacent: [], - broad: [], - }; - for (const n of notes) { - const t = n.tier || "adjacent"; - buckets[t].push(n); - } - return (["core", "adjacent", "broad"] as ScoutResearchTier[]) - .filter((t) => buckets[t].length > 0) - .map((tier) => ({ tier, notes: buckets[tier] })); -} diff --git a/apps/web/src/lib/mockScoutExpand.ts b/apps/web/src/lib/mockScoutExpand.ts deleted file mode 100644 index 8dfb040..0000000 --- a/apps/web/src/lib/mockScoutExpand.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { BrandProduct, ScoutRunBrief, ScoutScanContext } from "../domain/types"; - -/** 簡單相關詞表:有命中才擴,避免假圖譜 */ -const RELATED: Record = { - 無香: ["香精過敏", "刺鼻", "低敏洗劑"], - 敏感肌: ["換季刺癢", "成分表", "溫和"], - 香精: ["無香", "刺鼻", "過敏"], - 刺鼻: ["無香", "香精"], - 洗衣精: ["洗淨力", "殘留", "寶寶衣物"], - 寶寶: ["嬰幼兒", "溫和", "洗不乾淨"], - 插座: ["不限時", "筆電", "久坐"], - 咖啡廳: ["第三空間", "安靜", "插座"], - 筆電: ["插座", "工作座位"], - 頭皮: ["換季", "無香", "刺癢"], - 刺癢: ["敏感肌", "換季", "成分"], - 第三空間: ["咖啡廳", "插座", "久坐"], - 週末: ["去哪", "人少", "不限時"], -}; - -const STOP = new Set([ - "的", - "了", - "在", - "是", - "我", - "有", - "和", - "就", - "不", - "人", - "都", - "一", - "這", - "次", - "想", - "找", - "海", - "巡", - "主題", - "關於", - "可以", - "什麼", - "為", - "或", - "與", - "到", - "會", - "被", - "讓", -]); - -function expandFromSeeds(seeds: string[]): string[] { - const out: string[] = []; - const seen = new Set(seeds.map((s) => s.toLowerCase())); - for (const s of seeds) { - const key = Object.keys(RELATED).find((k) => s.includes(k) || k.includes(s)); - if (!key) continue; - for (const r of RELATED[key] || []) { - if (seen.has(r.toLowerCase())) continue; - seen.add(r.toLowerCase()); - out.push(r); - if (out.length >= 6) return out; - } - } - return out; -} - -/** 從意圖粗抽關鍵詞(mock,非 NLP) */ -export function extractIntentTerms(intent: string): string[] { - const raw = intent - .replace(/[,。!?、;:\s\n\r#@「」『』()()[\]{}]/g, " ") - .split(/\s+/) - .map((s) => s.trim()) - .filter((s) => s.length >= 2 && !STOP.has(s)); - // 也抓 RELATED 鍵是否出現在全文 - const hits: string[] = []; - for (const k of Object.keys(RELATED)) { - if (intent.includes(k) && !hits.includes(k)) hits.push(k); - } - const merged = [...hits, ...raw]; - const seen = new Set(); - const out: string[] = []; - for (const t of merged) { - const key = t.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - out.push(t); - if (out.length >= 8) break; - } - return out; -} - -export function buildScoutScanContext( - brandId: string, - products: BrandProduct[], -): ScoutScanContext { - const pains = [...new Set(products.flatMap((p) => p.pain_points).filter(Boolean))]; - const tags = [...new Set(products.flatMap((p) => p.match_tags).filter(Boolean))]; - const seeds = [...pains, ...tags, ...products.map((p) => p.label)]; - const expand_terms = expandFromSeeds(seeds); - return { - brand_id: brandId, - product_ids: products.map((p) => p.id), - pains: pains.slice(0, 8), - tags: tags.slice(0, 12), - expand_terms, - }; -} - -/** - * 從意圖 + 可選產品組 brief。 - * purpose=activity → 關鍵字活躍;否則有 product → product,否則 theme。 - */ -export function prepareScoutBrief(opts: { - intent: string; - brandId?: string | null; - product?: BrandProduct | null; - purpose?: "value" | "activity"; -}): ScoutRunBrief { - const intent = opts.intent.trim(); - if (!intent) throw new Error("請先寫這次想找什麼/關鍵字"); - - const intentTerms = extractIntentTerms(intent); - const product = opts.product || null; - const purpose = opts.purpose || "value"; - - // 活躍度:只吃關鍵字,輕量、不強制功課感 - if (purpose === "activity") { - const focus = intentTerms.length ? intentTerms : [intent.slice(0, 20)]; - const periphery = expandFromSeeds(focus).slice(0, 4); - const scan_terms = [...new Set([...focus, ...periphery])].slice(0, 10); - const theme_label = `活躍 · ${intent.slice(0, 28)}${intent.length > 28 ? "…" : ""}`; - const theme_key = ["activity", "", intent.slice(0, 48)].join("|"); - return { - intent, - mode: "activity", - brand_id: opts.brandId || null, - product_id: null, - product_label: null, - pains: focus.slice(0, 4), - tags: focus.slice(0, 8), - periphery, - scan_terms, - theme_key, - theme_label, - response_stance: - "短回、自然、有溫度;目的是活躍與互動率,不硬銷、不長文說教。可接一句同感或小問題。", - }; - } - - if (product) { - const pains = [...(product.pain_points || [])].filter(Boolean); - const tags = [...(product.match_tags || [])].filter(Boolean); - // 意圖與產品語境對齊:意圖詞若與痛/標籤重疊優先 - const aligned = intentTerms.filter( - (t) => - pains.some((p) => p.includes(t) || t.includes(p)) || - tags.some((g) => g.includes(t) || t.includes(g)) || - (product.label && (product.label.includes(t) || t.includes(product.label))), - ); - const periphery = expandFromSeeds([...pains, ...tags, ...intentTerms, product.label]); - const scan_terms = [ - ...new Set([ - ...aligned, - ...pains.slice(0, 4), - ...tags.slice(0, 4), - ...intentTerms.slice(0, 3), - ...periphery.slice(0, 3), - ]), - ].slice(0, 12); - const theme_label = product.label; - const theme_key = ["product", product.id, intent.slice(0, 48)].join("|"); - - return { - intent, - mode: "product", - brand_id: opts.brandId || product.brand_id, - product_id: product.id, - product_label: product.label, - pains: pains.slice(0, 8), - tags: tags.slice(0, 10), - periphery, - scan_terms, - theme_key, - theme_label, - placement_note: - product.product_context?.trim() || - `共感對方痛點後,輕帶「${product.label}」使用情境(勿硬銷)。`, - }; - } - - // 主題模式 - const focus = intentTerms.length ? intentTerms : [intent.slice(0, 16)]; - const periphery = expandFromSeeds(focus); - const scan_terms = [...new Set([...focus, ...periphery])].slice(0, 12); - const theme_label = intent.slice(0, 36) + (intent.length > 36 ? "…" : ""); - const theme_key = ["theme", "", intent.slice(0, 48)].join("|"); - - return { - intent, - mode: "theme", - brand_id: opts.brandId || null, - product_id: null, - product_label: null, - pains: focus.slice(0, 4), // 主題焦點展示 - tags: focus.slice(0, 8), - periphery, - scan_terms, - theme_key, - theme_label, - response_stance: "接話、分享經驗、可留問句;不硬銷、不提產品連結。", - }; -} - -/** mock 命中正文模板 */ -export function mockHitTextsForTerm( - term: string, - mode: "product" | "theme" | "activity", - productLabel?: string | null, -): string[] { - if (mode === "product") { - return [ - `有人也被「${term}」搞到很煩嗎?求真正用過的經驗,不要業配腔。`, - `最近一直卡在${term},試了幾個都不對…有人有務實建議嗎?`, - productLabel - ? `想問有沒有比較溫和的解法(想到${term}),不一定要貴的。` - : `關於「${term}」想聽真實心得。`, - ]; - } - if (mode === "activity") { - return [ - `隨便聊聊:「${term}」你們最近有感嗎?`, - `路過刷到「${term}」,想聽一句真心話就好。`, - `今天就想跟「${term}」相關的人講兩句,有人也在嗎?`, - ]; - } - return [ - `最近在想「${term}」這件事,有人也有興趣聊聊嗎?`, - `有人在跟「${term}」相關的坑嗎?想聽故事。`, - `週末如果聊「${term}」,你們會先問什麼?`, - ]; -} diff --git a/apps/web/src/lib/mockStyleAnalyze.ts b/apps/web/src/lib/mockStyleAnalyze.ts deleted file mode 100644 index 67477d2..0000000 --- a/apps/web/src/lib/mockStyleAnalyze.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { Persona, PersonaDraftFields, StyleDimKey, StyleDimension } from "../domain/types"; -import { mockDelay } from "./mockAi"; -import { - emptyDraftFields, - emptyGuard, - normalizePersona, - serializeDraftText, -} from "./personaPrompt"; -import { nowUnixNano } from "./time"; - -export function splitSamples(raw: string): string[] { - return raw - .split(/\n\s*---\s*\n|\n{3,}/) - .map((s) => s.trim()) - .filter((s) => s.length >= 10) - .slice(0, 12); -} - -function pickEvidence(samples: string[], max = 2): string[] { - return samples - .map((s) => (s.length > 36 ? `${s.slice(0, 36)}…` : s)) - .slice(0, max); -} - -function inferTone(blob: string): string { - if (/哈哈|笑死|哭|靠|真的假的/.test(blob)) return "輕鬆吐槽、情緒外露"; - if (/建議|成分|數據|對比|規格/.test(blob)) return "務實、有依據、少廢話"; - if (/懂|抱抱|辛苦|没关系|沒關係|一起/.test(blob)) return "共感、溫柔、像朋友"; - return "口語親近、不端著"; -} - -function inferHooks(blob: string): string { - if (/?|\?/.test(blob)) return "用真心疑問開場,邀請別人補經驗"; - if (/有人|大家|求/.test(blob)) return "先丟痛點再求推坑/經驗"; - return "先講自己卡關的情境,再拋問題"; -} - -export type AnalyzeSource = - | { kind: "manual"; label?: string } - | { kind: "benchmark"; username: string }; - -/** - * Mock:模擬爬取公開 Threads 貼文(接真後改 Playwright / API)。 - */ -export async function mockScrapePublicPosts(username: string): Promise { - const handle = username.replace(/^@/, "").trim().toLowerCase(); - if (!handle || handle.length < 2) { - throw new Error("請輸入有效的 Threads username(不含網址)"); - } - if (/[\s/]/.test(handle)) { - throw new Error("username 請只填帳號,例如 ultralab_tw"); - } - - // 模擬網路延遲(爬公開頁) - await mockDelay(900); - - // 依 username 長出可辨識差異的假樣本,讓分析結果不像隨機 - const isFun = /fun|meme|laugh|吐|梗/.test(handle); - const isPro = /lab|pro|tech|care|skin|official/.test(handle); - - if (isFun) { - return [ - `不是我不想研究,是 @${handle} 看規格表看到想睡…有人也這樣嗎?`, - `講真的上次踩雷之後我都先問「實際用起來」再下單,規格文先放旁邊。`, - `笑死剛看到有人寫「無痛入手」,結果後來留言區全是後悔文。`, - `你們週末都在幹嘛,我還在跟自己的購物車吵架。`, - ]; - } - if (isPro) { - return [ - `若你在意耐用,我會先看使用情境再對規格;同樣標示差很多。`, - `建議把需求拆成:頻率、膚況/環境、預算三欄,再對產品會比較準。`, - `成分表先找會刺鼻或致敏的那幾欄;有疑慮再問實際使用者。`, - `對比兩款時不要只看行銷字,看「你會每天碰到的那一段流程」。`, - ]; - } - return [ - `有時候真的會卡在「不知道問誰」——後來我改成先寫自己的使用情境。`, - `懂那種越研究越焦慮的感覺。我通常先問有沒有實際用過、會不會踩雷。`, - `大家如果有推的,最好附一句為什麼,比空推一個名字有用。`, - `我自己目前是先抓痛點再對規格,不是一開始就掃整張規格表。`, - `你們呢?有類似經驗也可以講,我想整理給之後的自己。`, - ]; -} - -function analyzeFromSamples( - persona: Persona, - samples: string[], - source: AnalyzeSource, -): Persona { - if (samples.length < 2) { - throw new Error("樣本不足(至少 2 則公開貼文/參考段)"); - } - - const blob = samples.join("\n"); - const evidence = pickEvidence(samples); - const tone = inferTone(blob); - const hooks = inferHooks(blob); - const base = normalizePersona(persona); - const label = - source.kind === "benchmark" - ? `@${source.username.replace(/^@/, "")}` - : source.label?.trim() || "手動貼文"; - - const draft: PersonaDraftFields = { - ...emptyDraftFields(), - identity: base.style.draft.identity || base.name || "生活觀察者", - tone, - audience: base.style.draft.audience || "同溫層、會在 Threads 求經驗的人", - hooks, - languageFingerprint: /有時候|後來|其實|講真的/.test(blob) - ? "常用「有時候/後來/其實」當轉折" - : "短句、口語、少成語", - rhythm: blob.includes("\n") ? "2~4 個短段落,段間空行" : "一段講完再補一句問句", - punctuation: /…|\.\.\./.test(blob) ? "愛用省略號與問號" : "逗號與問號為主,少驚嘆", - contentPatterns: "情境 → 感受或觀察 → 輕問一句", - knowledgeTranslation: "先講使用情境,再講重點,不丟術語牆", - ctaStyle: "自然邀留言(「你們呢」「有人也…嗎」),不命令", - examples: samples[0].slice(0, 80), - avoid: base.style.draft.avoid || "硬銷、條列教學、客服腔、假裝中立實則業配", - }; - - const dim = (summary: string, ev = evidence): StyleDimension => ({ summary, evidence: ev }); - - const dimensions: Partial> = { - d1Tone: dim(tone), - d2Structure: dim("開場情境 → 中段補充 → 結尾提問或輕 CTA"), - d3Interaction: dim("接住對方情緒後再給觀點;回覆用提問延續對話"), - d4Topics: dim(base.brief || `生活經驗(樣本:${label})`, evidence.slice(0, 1)), - d5Rhythm: dim(draft.rhythm), - d6Visual: dim(draft.punctuation + ";少 emoji 或 0~1 個"), - d7Conversion: dim(draft.ctaStyle), - d8Risk: dim(draft.avoid, []), - }; - - const draftText = serializeDraftText(draft); - const avoidList = draft.avoid - .split(/[、,,]/) - .map((s) => s.trim()) - .filter(Boolean); - - return { - ...base, - brief: base.brief || `${tone};對「${draft.audience}」說話`, - status: "ready", - voice: draft.tone, - notes: base.brief, - style: { - dimensions, - draft, - draftText, - source: source.kind === "benchmark" ? "benchmark" : "manual", - benchmarkUsername: source.kind === "benchmark" ? source.username.replace(/^@/, "") : undefined, - sourceLabel: source.kind === "manual" ? label : undefined, - sampleCount: samples.length, - samplePreviews: samples.slice(0, 5).map((s) => (s.length > 100 ? `${s.slice(0, 100)}…` : s)), - analyzedAt: nowUnixNano(), - }, - guard: { - ...emptyGuard(), - ...base.guard, - avoid: avoidList.length ? avoidList : emptyGuard().avoid, - banAiTone: true, - maxChars: base.guard?.maxChars || 280, - }, - }; -} - -/** - * 從貼上文字分析(手動樣本)。 - */ -export async function mockAnalyzePersonaFromText( - persona: Persona, - rawText: string, - sourceLabel?: string, -): Promise { - await mockDelay(500); - const samples = splitSamples(rawText); - if (samples.length < 2) { - throw new Error("請至少貼 2 段參考文字(可用 --- 分隔),每段至少約 10 字"); - } - return analyzeFromSamples(persona, samples, { kind: "manual", label: sourceLabel }); -} - -/** - * 從公開帳號爬貼文再分析(mock 爬取)。 - */ -export async function mockAnalyzePersonaFromAccount( - persona: Persona, - username: string, -): Promise<{ persona: Persona; posts: string[] }> { - const handle = username.replace(/^@/, "").trim(); - const posts = await mockScrapePublicPosts(handle); - // 分析階段 - await mockDelay(600); - const next = analyzeFromSamples(persona, posts, { kind: "benchmark", username: handle }); - return { persona: next, posts }; -} - -export function buildSeedReadyStyle(opts: { - identity: string; - tone: string; - audience: string; - examples: string; - avoid?: string; -}): Persona["style"] { - const draft: PersonaDraftFields = { - identity: opts.identity, - tone: opts.tone, - audience: opts.audience, - hooks: "先丟真實卡關,再問一句", - languageFingerprint: "口語、短句、偶爾「其實/有時候」", - rhythm: "2~3 短段,段間空行", - punctuation: "問號收尾,少驚嘆", - contentPatterns: "痛點 → 自己經驗 → 輕問", - knowledgeTranslation: "用生活例子講,不丟規格表", - ctaStyle: "「有人也這樣嗎」", - examples: opts.examples, - avoid: opts.avoid || "硬廣、說教、AI 腔", - }; - const dim = (summary: string): StyleDimension => ({ summary, evidence: [opts.examples.slice(0, 40)] }); - return { - dimensions: { - d1Tone: dim(opts.tone), - d2Structure: dim("情境開場 → 經驗 → 提問"), - d3Interaction: dim("先接話再補觀點"), - d4Topics: dim("生活選品與真實經驗"), - d5Rhythm: dim(draft.rhythm), - d6Visual: dim("換行分段,少 emoji"), - d7Conversion: dim(draft.ctaStyle), - d8Risk: dim(draft.avoid), - }, - draft, - draftText: serializeDraftText(draft), - source: "seed", - sampleCount: 3, - analyzedAt: nowUnixNano(), - }; -} diff --git a/apps/web/src/lib/mockThreadLink.ts b/apps/web/src/lib/mockThreadLink.ts deleted file mode 100644 index 78b64ee..0000000 --- a/apps/web/src/lib/mockThreadLink.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { mockDelay } from "./mockAi"; -import { nowUnixNano } from "./time"; -import type { ExternalThreadTarget } from "../domain/types"; - -/** 正規化 Threads permalink(比對方案用) */ -export function normalizeThreadUrl(raw: string): string { - const trimmed = (raw || "").trim(); - if (!trimmed) return ""; - try { - const withProto = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; - const u = new URL(withProto); - const host = u.hostname.replace(/^www\./i, "").toLowerCase(); - // threads.com / threads.net 等 - let path = u.pathname.replace(/\/+$/, "") || ""; - return `https://${host}${path}`; - } catch { - return trimmed.split("?")[0].split("#")[0].replace(/\/+$/, ""); - } -} - -/** - * 從連結拆 shortcode / 作者。 - * 支援: - * - https://www.threads.net/@user/post/CODE - * - https://www.threads.com/@user/post/CODE - * - https://www.threads.net/t/CODE (較少見) - */ -export function parseThreadsPermalink(raw: string): { - url: string; - shortcode?: string; - author_username?: string; -} | null { - const input = (raw || "").trim(); - if (!input) return null; - - let href = input; - if (!/^https?:\/\//i.test(href)) href = `https://${href}`; - - let u: URL; - try { - u = new URL(href); - } catch { - return null; - } - - const host = u.hostname.replace(/^www\./i, "").toLowerCase(); - if (!host.includes("threads.")) { - // 仍允許貼完整 URL 當目標,但提示非標準 - if (!/threads/i.test(input)) return null; - } - - const path = u.pathname; - const postMatch = path.match(/\/@([^/]+)\/post\/([^/?#]+)/i); - if (postMatch) { - const author = postMatch[1]!; - const code = postMatch[2]!; - const url = normalizeThreadUrl(`https://www.threads.net/@${author}/post/${code}`); - return { url, shortcode: code, author_username: author }; - } - - const tMatch = path.match(/\/t\/([^/?#]+)/i); - if (tMatch) { - const code = tMatch[1]!; - const url = normalizeThreadUrl(`https://www.threads.net/t/${code}`); - return { url, shortcode: code }; - } - - // 任意 threads 連結:至少當目標 URL - if (host.includes("threads.")) { - return { url: normalizeThreadUrl(href) }; - } - - return null; -} - -const MOCK_SNIPPETS = [ - "最近有人推這套流程嗎?我試了兩週還在卡關…", - "真心問:週末還有哪間不擠、有插座的店?", - "敏感肌用這款會刺痛嗎?求真實心得不要業配腔。", - "第一次自己發串,回覆要怎麼接才不像機器人?", - "有人用過這招嗎?留言區好像很吵。", -]; - -function mockPreviewForCode(code: string, author?: string): string { - let h = 0; - const s = `${code}|${author || ""}`; - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; - const base = MOCK_SNIPPETS[h % MOCK_SNIPPETS.length]!; - return author ? `@${author}:${base}` : base; -} - -/** - * mock 解析 Threads 連結 → 可掛互回的目標。 - * live 應改後端 resolve media id + 正文。 - */ -export async function mockResolveThreadLink(rawUrl: string): Promise { - await mockDelay(450); - const parsed = parseThreadsPermalink(rawUrl); - if (!parsed) { - throw new Error("請貼有效的 Threads 連結(例如 https://www.threads.net/@user/post/…)"); - } - const shortcode = parsed.shortcode || "unknown"; - return { - url: parsed.url, - raw_url: rawUrl.trim(), - shortcode, - author_username: parsed.author_username, - text_preview: mockPreviewForCode(shortcode, parsed.author_username), - // mock 假 numeric id,方便之後對齊 API - media_id: `mock_${shortcode.replace(/[^a-zA-Z0-9]/g, "").slice(0, 12) || "post"}`, - resolved_at: nowUnixNano(), - }; -} - -export function externalTargetKey(target: Pick | string): string { - const url = typeof target === "string" ? target : target.url; - return normalizeThreadUrl(url); -} diff --git a/apps/web/src/lib/mockTrends.ts b/apps/web/src/lib/mockTrends.ts deleted file mode 100644 index 33f1d31..0000000 --- a/apps/web/src/lib/mockTrends.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Persona, TrendItem, TrendKind } from "../domain/types"; -import { newId } from "./id"; -import { mockDelay, mockGenerateInspiration } from "./mockAi"; -import { nowUnixNano } from "./time"; - -const EXTRA_POOL: Omit[] = [ - { - kind: "threads_tag", - label: "#辦公室零食", - summary: "午休話題回溫;「同事搶食」與「健康零食」兩極討論。", - keywords: ["零食", "辦公室", "午休"], - samples: ["抽屜裡永遠少一包,有人也是嗎?"], - source_label: "Threads 熱門標籤", - }, - { - kind: "threads_tag", - label: "#早起失敗", - summary: "作息自我吐槽串;適合輕鬆人設接「前一晚到底在幹嘛」。", - keywords: ["早起", "賴床", "作息"], - samples: ["鬧鐘響了七次還是起不來,求物理方法。"], - source_label: "Threads 熱門標籤", - }, - { - kind: "web_keyword", - label: "低敏洗面乳", - summary: "成分表關鍵字搜索上升;真實「用完刺癢」留言比評測更有熱度。", - keywords: ["洗面乳", "低敏", "成分"], - samples: ["標榜低敏還是刺,有人也遇過嗎?"], - source_label: "網搜熱門關鍵字", - }, - { - kind: "web_keyword", - label: "筆電咖啡廳 台北", - summary: "在地搜尋熱;「不限時 + 插座密度」是決策關鍵字。", - keywords: ["咖啡廳", "筆電", "台北", "不限時"], - samples: ["文山區能坐下午的店還有嗎?"], - source_label: "網搜熱門關鍵字", - }, - { - kind: "news", - label: "週末賽事/轉播話題", - summary: "運動時事帶流量;非粉也可用「不懂但陪看」角度切入生活感。", - keywords: ["賽事", "週末", "陪看"], - samples: ["不懂規則但被氣氛感染,你們會看嗎?"], - source_label: "最近熱門時事", - }, - { - kind: "news", - label: "暑期/開學倒數", - summary: "季節節點;家長與學生族群討論度上升,可做輕量生活開問。", - keywords: ["暑假", "開學", "作息"], - samples: ["作息還沒調回來,只剩三天怎麼救?"], - source_label: "最近熱門時事", - }, -]; - -function jitterHeat(heat: number): number { - const delta = Math.floor(Math.random() * 17) - 8; - return Math.max(35, Math.min(99, heat + delta)); -} - -function pickSamples(samples: string[]): string[] { - if (samples.length <= 1) return samples; - const shuffled = [...samples].sort(() => Math.random() - 0.5); - return shuffled.slice(0, Math.min(2, shuffled.length)); -} - -/** 刷新:抖動熱度、更新時間;有機會換入池中新標 */ -export async function mockRefreshTrends( - current: TrendItem[], - kind: TrendKind | "all" = "all", -): Promise { - await mockDelay(700); - const now = nowUnixNano(); - let next = current.map((t) => ({ - ...t, - heat: jitterHeat(t.heat), - samples: pickSamples(t.samples.length ? t.samples : ["(刷新後暫無新片段)"]), - observed_at: now - Math.floor(Math.random() * 4) * 3_600_000_000_000, - })); - - if (Math.random() > 0.35) { - const used = new Set(next.map((t) => t.label)); - const candidates = EXTRA_POOL.filter((p) => !used.has(p.label)); - const pool = - kind === "all" ? candidates : candidates.filter((p) => p.kind === kind); - if (pool.length) { - const pick = pool[Math.floor(Math.random() * pool.length)]!; - const injected: TrendItem = { - ...pick, - id: newId("trend"), - heat: 55 + Math.floor(Math.random() * 40), - observed_at: now, - }; - // drop lowest heat of same kind filter scope - const sortable = kind === "all" ? next : next.filter((t) => t.kind === kind); - const drop = [...sortable].sort((a, b) => a.heat - b.heat)[0]; - if (drop) next = next.filter((t) => t.id !== drop.id); - next = [injected, ...next]; - } - } - - return next.sort((a, b) => b.heat - a.heat); -} - -/** 上網搜:回傳與 query 相關的「熱關鍵字 / 時事」假資料 */ -export async function mockSearchTrends(query: string): Promise { - await mockDelay(800); - const q = query.trim() || "熱門"; - const now = nowUnixNano(); - const results: TrendItem[] = [ - { - id: newId("trend"), - kind: "web_keyword", - label: `${q} 是什麼`, - summary: `網搜「${q}」相關:解釋向與清單文並存;真實經驗文互動通常高於純定義。`, - heat: 70 + Math.floor(Math.random() * 20), - keywords: [q, `${q} 推薦`, `${q} 心得`], - samples: [`最近一直刷到「${q}」,有人能用一句話解釋嗎?`], - source_label: "網搜熱門關鍵字", - observed_at: now, - }, - { - id: newId("trend"), - kind: "web_keyword", - label: `${q} 踩雷`, - summary: `負向關鍵字組合熱度偏高;適合「避雷條件」開問,比直接推品安全。`, - heat: 60 + Math.floor(Math.random() * 25), - keywords: [q, "踩雷", "注意"], - samples: [`買 ${q} 前一定要看什麼?求過來人。`], - source_label: "網搜熱門關鍵字", - observed_at: now - 2_000_000_000_000, - }, - { - id: newId("trend"), - kind: "news", - label: `近日與「${q}」相關討論`, - summary: `時事/社群交叉討論:可從生活感受切入,再輕帶你的專業視角。`, - heat: 55 + Math.floor(Math.random() * 30), - keywords: [q, "近況", "討論"], - samples: [`大家都在聊 ${q},我卻還停在…`], - source_label: "最近熱門時事", - observed_at: now - 5_000_000_000_000, - }, - { - id: newId("trend"), - kind: "threads_tag", - label: `#${q.replace(/\s+/g, "")}`, - summary: `Threads 上與「${q}」相近標籤正在累積回覆;開場宜具體情境。`, - heat: 65 + Math.floor(Math.random() * 28), - keywords: [q], - samples: [`標了 #${q.replace(/\s+/g, "")} 求真實經驗,別業配。`], - source_label: "Threads 熱門標籤", - observed_at: now - 1_000_000_000_000, - }, - ]; - return results.sort((a, b) => b.heat - a.heat); -} - -export function sparkCopyFromTrend( - trend: TrendItem, - persona?: Persona | null, -): { title: string; hook: string; angle: string } { - const base = mockGenerateInspiration(trend.label.replace(/^#/, ""), persona); - const sample = trend.samples[0] || trend.summary; - return { - title: `${trend.label} · 切入`, - hook: - trend.kind === "threads_tag" - ? `順著 ${trend.label} 的熱度:用一句貼近「${sample.slice(0, 28)}…」的開問,比空泛跟風標籤好回。` - : trend.kind === "news" - ? `時事「${trend.label}」→ 落到日常:${base.hook}` - : `搜尋熱度在「${trend.label}」:${base.hook}`, - angle: [ - trend.summary, - `關鍵字:${trend.keywords.slice(0, 4).join("、")}`, - base.angle, - ].join(" "), - }; -} - -export function trendKindLabel(kind: TrendKind): string { - switch (kind) { - case "threads_tag": - return "Threads"; - case "web_keyword": - return "網搜"; - case "news": - return "時事"; - } -} - -/** 帶進寫一則的正文:乾淨、無 meta 尾巴 */ -export function seedTextFromTrend(trend: TrendItem, sampleIndex = 0): string { - const sample = trend.samples[sampleIndex] || trend.samples[0] || ""; - if (sample) return sample; - return `${trend.label}\n\n${trend.summary}`; -} - -/** 列表次行:短摘要 */ -export function trendListMeta(trend: TrendItem): string { - const short = - trend.summary.length > 42 ? `${trend.summary.slice(0, 42)}…` : trend.summary; - return `${trendKindLabel(trend.kind)} · ${trend.heat} · ${short}`; -} diff --git a/apps/web/src/lib/mockViral.ts b/apps/web/src/lib/mockViral.ts deleted file mode 100644 index a574d6c..0000000 --- a/apps/web/src/lib/mockViral.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Persona, ViralAnalysis } from "../domain/types"; -import { mockDelay } from "./mockAi"; -import { toneOf } from "./personaPrompt"; - -export async function mockAnalyzeViral(text: string): Promise { - await mockDelay(600); - const t = text.trim() || "(空文)"; - const hasQ = /?|\?/.test(t); - const hasPain = /卡|煩|雷|痛|敏感|不會|怎麼/.test(t); - return { - hooks: hasQ - ? "用真心疑問收尾,降低回覆門檻" - : hasPain - ? "先丟具體痛點,讀者有代入感" - : "開場用生活情境,不像廣告", - structure: "情境/痛點 → 自己經驗一句 → 邀請補充(或輕 CTA)", - emotion: hasPain ? "共感 + 一點焦慮釋放" : "好奇/認同", - copyable: "可複製:短段落、一個明確條件、結尾問句;勿整段抄原文", - risks: "勿保證效果、勿硬廣、勿嘲諷留言者", - summary: `這則之所以有互動潛力:${hasPain ? "痛點具體" : "情境清楚"}${hasQ ? " + 好回的問題" : ""}。改寫時保留結構,換成你的經驗與人設語氣。`, - }; -} - -export async function mockMimicPost(sourceText: string, persona?: Persona | null): Promise { - await mockDelay(550); - const tone = toneOf(persona); - const clip = sourceText.trim().slice(0, 80) || "某個生活卡關"; - const name = persona?.name || "預設"; - return [ - `(仿寫 · ${name} · ${tone})`, - `最近也卡在類似情況:${clip}${sourceText.length > 80 ? "…" : ""}`, - ``, - `我自己目前是先抓「使用情境」再對規格,比較不會越研究越焦慮。`, - `有人也這樣嗎?歡迎補一句你的實際經驗。`, - ].join("\n"); -} - -export function formatViralAnalysis(a: ViralAnalysis): string { - return [ - `【為什麼可能爆】${a.summary}`, - `【鉤子】${a.hooks}`, - `【結構】${a.structure}`, - `【情緒】${a.emotion}`, - `【可複製】${a.copyable}`, - `【風險】${a.risks}`, - ].join("\n"); -} diff --git a/apps/web/src/lib/productMatch.ts b/apps/web/src/lib/productMatch.ts deleted file mode 100644 index c2532a8..0000000 --- a/apps/web/src/lib/productMatch.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { BrandProduct } from "../domain/types"; - -/** 掃描關鍵字池:產品痛點 + tags(可多產品合併) */ -export function buildScanKeywordPool( - products: BrandProduct | BrandProduct[] | null | undefined, -): string[] { - const list = !products ? [] : Array.isArray(products) ? products : [products]; - const pool = list.flatMap((product) => [ - ...(product.match_tags || []), - ...(product.pain_points || []), - product.label || "", - ]) - .map((k) => k.trim()) - .filter(Boolean); - return [...new Set(pool)]; -} - -export function scoreProductForPost( - product: BrandProduct, - searchTag: string, - postText: string, -): number { - const tags = [...product.match_tags, ...product.pain_points, product.label].filter(Boolean); - const hay = `${searchTag} ${postText}`.toLowerCase(); - let hits = 0; - for (const tag of tags) { - const t = tag.toLowerCase(); - if (t && hay.includes(t)) hits += 1; - } - if (!hits) return 0; - // 0–100 - return Math.min(99, 40 + hits * 18); -} - -export function resolveProductForPost(opts: { - products: BrandProduct[]; - preferredProductId?: string | null; - searchTag: string; - postText: string; -}): { product: BrandProduct | null; score: number } { - const { products, preferredProductId, searchTag, postText } = opts; - if (!products.length) return { product: null, score: 0 }; - - let best: BrandProduct | null = null; - let bestScore = 0; - for (const p of products) { - const s = scoreProductForPost(p, searchTag, postText); - if (s > bestScore) { - bestScore = s; - best = p; - } - } - if (best && bestScore >= 40) return { product: best, score: bestScore }; - - if (preferredProductId) { - const pref = products.find((p) => p.id === preferredProductId); - if (pref) return { product: pref, score: Math.max(bestScore, 35) }; - } - return { product: products[0] || null, score: bestScore || 30 }; -} - -export function opportunityLine(product: BrandProduct | null, searchTag: string): string { - if (!product) return `關鍵字「${searchTag}」命中;尚未對上產品線`; - const pain = product.pain_points[0] || product.match_tags[0] || product.label; - return `對上「${product.label}」· 痛點/語境:${pain}`; -} diff --git a/apps/web/src/lib/time.test.ts b/apps/web/src/lib/time.test.ts new file mode 100644 index 0000000..598ffd2 --- /dev/null +++ b/apps/web/src/lib/time.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { formatTimeAgo, normalizeUnixNano } from "./time"; + +const NOW_NANO = 1_800_000_000_000_000_000; // 2027-01-15T08:00:00Z, in nanoseconds + +describe("normalizeUnixNano", () => { + it("accepts seconds, milliseconds, and nanoseconds", () => { + expect(normalizeUnixNano(1_800_000_000)).toBe(NOW_NANO); + expect(normalizeUnixNano(1_800_000_000_000)).toBe(NOW_NANO); + expect(normalizeUnixNano(NOW_NANO)).toBe(NOW_NANO); + }); + + it("treats blank and nullish input as absent", () => { + expect(normalizeUnixNano(null)).toBeNull(); + expect(normalizeUnixNano(undefined)).toBeNull(); + expect(normalizeUnixNano("")).toBeNull(); + expect(normalizeUnixNano(" ")).toBeNull(); + }); +}); + +// Every other formatter normalized its input, so a seconds-precision timestamp from the backend +// showed up here as decades in the past rather than minutes. +describe("formatTimeAgo", () => { + it("gives the same answer whichever precision the timestamp arrives in", () => { + const tenMinutesAgoNano = NOW_NANO - 10 * 60 * 1_000_000_000; + const expected = formatTimeAgo(tenMinutesAgoNano, NOW_NANO); + + expect(formatTimeAgo(tenMinutesAgoNano / 1_000_000, NOW_NANO)).toBe(expected); + expect(formatTimeAgo(tenMinutesAgoNano / 1_000_000_000, NOW_NANO)).toBe(expected); + expect(expected).not.toBe("—"); + }); + + it("still reports very recent timestamps as just now", () => { + expect(formatTimeAgo(NOW_NANO - 1_000_000_000, NOW_NANO)).toBe( + formatTimeAgo(NOW_NANO, NOW_NANO), + ); + }); + + it("returns a placeholder for missing timestamps", () => { + expect(formatTimeAgo(null, NOW_NANO)).toBe("—"); + expect(formatTimeAgo(0, NOW_NANO)).toBe("—"); + }); +}); diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts index f1b692e..ad1a258 100644 --- a/apps/web/src/lib/time.ts +++ b/apps/web/src/lib/time.ts @@ -92,8 +92,11 @@ export function formatRelativeFromNow( /** 過去時間:通知列表用(剛剛/N 分前) */ export function formatTimeAgo(nano: number | null | undefined, now = nowUnixNano()): string { - if (nano == null || nano <= 0) return "—"; - const diffMs = Math.floor((now - nano) / 1_000_000); + // 這裡是唯一漏掉正規化的時間格式化函式:後端若回秒或毫秒,未換算的數值會遠小於 now, + // 算出來就變成「56 年前」。 + const n = normalizeUnixNano(nano ?? null); + if (n == null || n <= 0) return "—"; + const diffMs = Math.floor((now - n) / 1_000_000); if (diffMs < 45_000) return t("time.justNow"); const min = Math.floor(diffMs / 60_000); if (min < 60) return t("time.minAgo", { n: min }); diff --git a/apps/web/src/pages/CrewPage.tsx b/apps/web/src/pages/CrewPage.tsx index 96c075c..96d6ec8 100644 --- a/apps/web/src/pages/CrewPage.tsx +++ b/apps/web/src/pages/CrewPage.tsx @@ -92,12 +92,19 @@ export function CrewPage() { const [error, setError] = useState(""); const load = useCallback(async () => { - setAccounts(await repos.accounts.list()); + try { + // 帳號列表失敗時若只留空陣列,畫面會顯示「尚未連接帳號」,跟真的沒帳號分不出來。 + setAccounts(await repos.accounts.list()); + setError(""); + } catch (e) { + setError(e instanceof Error ? e.message : t("crew.loadFail")); + return; + } const list = await repos.growth.listAccountHealth().catch(() => [] as AccountHealth[]); const map: Record = {}; for (const h of list) map[h.threads_account_id] = h; setOpHealth(map); - }, [repos.accounts, repos.growth]); + }, [repos.accounts, repos.growth, t]); const pagedAccounts = useMemo( () => pageSlice(accounts, accPage, ACCOUNTS_PAGE), @@ -224,7 +231,7 @@ export function CrewPage() { ) : null} {accounts.length === 0 ? ( - + error ? null : ) : ( <>

    diff --git a/apps/web/src/pages/JobDetailPage.tsx b/apps/web/src/pages/JobDetailPage.tsx index 4759100..0be3811 100644 --- a/apps/web/src/pages/JobDetailPage.tsx +++ b/apps/web/src/pages/JobDetailPage.tsx @@ -32,39 +32,64 @@ export function JobDetailPage() { const [job, setJob] = useState(null); const [deleting, setDeleting] = useState(false); const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const [loadFailed, setLoadFailed] = useState(false); const [applying, setApplying] = useState(false); const displayedJobStatus = job?.status; + const isActive = + displayedJobStatus === "pending" || + displayedJobStatus === "queued" || + displayedJobStatus === "running"; + const needsPayload = displayedJobStatus === "succeeded" && !job?.payload; useEffect(() => { if (!id) return; const fromLive = liveJobs.find((j) => j.id === id); if (fromLive) { setJob(fromLive); + setLoading(false); + setLoadFailed(false); return; } - void repos.jobs.get(id).then(setJob); - }, [id, repos.jobs, tick, liveJobs, revision]); + void repos.jobs + .get(id) + .then((j) => { + setJob(j); + setLoadFailed(false); + }) + .catch((e: unknown) => { + // 讀不到跟「這個任務不存在」是兩件事,分開記才不會謊稱找不到。 + setLoadFailed(true); + setError(formatApiError(e, "jobs.detailLoadFail")); + }) + .finally(() => setLoading(false)); + }, [id, repos.jobs, tick, liveJobs, revision, formatApiError]); - // 進行中:額外直接 get,避免 list 延遲;終態也 get 一次拿 payload(result_text) + // 進行中:額外直接 get,避免 list 延遲 useEffect(() => { - if (!id || !job) return; - const active = - job.status === "pending" || job.status === "queued" || job.status === "running"; - if (active) { - const timer = window.setInterval(() => { - void repos.jobs.get(id).then((j) => { + if (!id || !isActive) return; + // 只看 isActive,不看整個 job:否則每次輪詢寫回新物件都會重建 interval,計時器一直被歸零。 + const timer = window.setInterval(() => { + void repos.jobs.get(id).then( + (j) => { if (j) setJob(j); - }); - }, 1500); - return () => window.clearInterval(timer); - } - // succeeded 但 list 可能沒 payload → 補 get - if (job.status === "succeeded" && !job.payload) { - void repos.jobs.get(id).then((j) => { + }, + () => {}, + ); + }, 1500); + return () => window.clearInterval(timer); + }, [id, isActive, repos.jobs]); + + // succeeded 但 list 可能沒 payload → 補 get + useEffect(() => { + if (!id || !needsPayload) return; + void repos.jobs.get(id).then( + (j) => { if (j) setJob(j); - }); - } - }, [id, job?.status, job?.payload, repos.jobs]); // eslint-disable-line react-hooks/exhaustive-deps + }, + () => {}, + ); + }, [id, needsPayload, repos.jobs]); useEffect(() => { if (!id || !displayedJobStatus) return; @@ -138,6 +163,23 @@ export function JobDetailPage() { } } + if (!job && loading) { + return ; + } + + if (!job && loadFailed) { + return ( + <> +

    + {error || t("jobs.detailLoadFail")} +

    + + {t("common.back")} + + + ); + } + if (!job) { return ( { if (!id) return; try { const [b, acc] = await Promise.all([repos.outbox.get(id), repos.accounts.list()]); setBundle(b); setAccounts(acc); + setLoadFailed(false); + setError(""); + } catch (e) { + // 載入失敗與「這篇不存在」是兩件事,分開記,否則畫面會謊稱找不到。 + setLoadFailed(true); + setError(e instanceof Error ? e.message : t("outbox.detail.loadFail")); } finally { setLoading(false); } - }, [id, repos.outbox, repos.accounts]); + }, [id, repos.outbox, repos.accounts, t]); useEffect(() => { void load(); }, [load, tick]); - // 發文中自動輪詢:worker 可能 10~30s 才完成,不輪詢會一直卡舊狀態/舊錯誤 + // 發文中自動輪詢:worker 可能 10~30s 才完成,不輪詢會一直卡舊狀態/舊錯誤。 + // 只看 inFlight 布林:依賴整個 bundle 的話,每次輪詢寫回新物件都會重建 interval。 + const inFlight = isInFlightBundle(bundle); useEffect(() => { - if (!isInFlightBundle(bundle)) return; - const id = window.setInterval(() => { + if (!inFlight) return; + const timer = window.setInterval(() => { void load(); }, 2000); - return () => window.clearInterval(id); - }, [bundle, load]); + return () => window.clearInterval(timer); + }, [inFlight, load]); async function run(action: () => Promise) { setBusy(true); @@ -79,12 +89,28 @@ export function OutboxDetailPage() { if (!id) return ; if (loading && !bundle) return ; + if (!bundle && loadFailed) { + return ( + <> +

    + {error || t("outbox.detail.loadFail")} +

    +
    + + + {t("common.back")} + +
    + + ); + } if (!bundle) return ; const nameOf = (accountId: string) => accounts.find((a) => a.id === accountId)?.display_name || accountId; - const inFlight = isInFlightBundle(bundle); const allPublished = bundle.steps.length > 0 && bundle.steps.every((s) => s.status === "published"); return ( diff --git a/apps/web/src/pages/ScoutPage.tsx b/apps/web/src/pages/ScoutPage.tsx index 90f176d..4673e8d 100644 --- a/apps/web/src/pages/ScoutPage.tsx +++ b/apps/web/src/pages/ScoutPage.tsx @@ -10,16 +10,8 @@ import type { ScoutHomeworkRecord, ScoutPost, ScoutPurpose, - ScoutResearchNote, - ScoutResearchTier, ScoutRunBrief, } from "../domain/types"; -import { - groupNotesByTier, - noteLearnPoints, - noteReplyHooks, - formatResearchLink, -} from "../lib/mockResearch"; import { newId } from "../lib/id"; import { allowHttpUrl } from "../lib/externalUrl"; import { bumpScoutTodayDone, loadScoutToday, saveScoutToday } from "../lib/scoutToday"; @@ -128,76 +120,6 @@ function shortRunLabel(label: string, max = 20): string { return `${t.slice(0, Math.max(1, max - 1))}…`; } -/** 知識圖譜節點卡:學習重點 + 回帖鉤子 + 來源 */ -function KnowledgeNoteCard({ - note, - badge, - compact, -}: { - note: ScoutResearchNote; - badge?: string; - compact?: boolean; -}) { - const { t } = useI18n(); - const link = formatResearchLink(note.url, note.source_label); - const points = noteLearnPoints(note); - const hooks = noteReplyHooks(note); - const relationLabel = note.relation ? t(`scout.relation.${note.relation}`) : null; - const showPoints = compact ? points.slice(0, 3) : points; - const showHooks = compact ? hooks.slice(0, 1) : hooks; - const safeUrl = allowHttpUrl(note.url); - - return ( -
  • -
    - {note.title} - {badge ? {badge} : null} -
    - {relationLabel ? ( - {relationLabel} - ) : null} - {showPoints.length > 0 ? ( -
    -

    {t("scout.learnPoints")}

    -
      - {showPoints.map((pt) => ( -
    • {pt}
    • - ))} -
    -
    - ) : note.summary ? ( -

    - {note.summary} -

    - ) : null} - {showHooks.length > 0 ? ( -
    -

    {t("scout.replyHooks")}

    -
      - {showHooks.map((h) => ( -
    • 「{h}」
    • - ))} -
    -
    - ) : null} - {safeUrl ? ( - - {link.label} - {link.host} - - ) : null} -
  • - ); -} - /** * 海巡:今日目標 + 現在這一則 + 收合功課/佇列 */ @@ -221,11 +143,8 @@ export function ScoutPage() { const [currentId, setCurrentId] = useState(null); const [draftText, setDraftText] = useState(""); - const [report, setReport] = useState(null); - const [reportOpen, setReportOpen] = useState(false); const [valueQueuePage, setValueQueuePage] = useState(1); const [activityQueuePage, setActivityQueuePage] = useState(1); - const [researchTier, setResearchTier] = useState<"all" | ScoutResearchTier>("all"); const [homeworkList, setHomeworkList] = useState([]); /** 目前檢視的海巡批次(每次按開始 = 一筆) */ const [activeRunKey, setActiveRunKey] = useState(null); @@ -431,15 +350,6 @@ export function ScoutPage() { setActiveRunKey(key); setValueQueuePage(1); setActivityQueuePage(1); - const hw = homeworkList.find((h) => h.theme_key === key); - if (hw) { - setReport(hw.brief); - setResearchTier( - hw.brief.research_notes?.some((n) => (n.tier || "core") === "core") ? "core" : "all", - ); - } else { - setReport(null); - } const pending = posts .filter((p) => postRunKey(p) === key && isPending(p)) .sort((a, b) => (b.score || 0) - (a.score || 0)); @@ -462,7 +372,6 @@ export function ScoutPage() { setHomeworkList(hw); if (activeRunKey === key) { setActiveRunKey(null); - setReport(null); setCurrentId(null); } else if (currentId && !list.some((p) => p.id === currentId)) { pickNext(currentId, list, activeRunKey); @@ -964,175 +873,6 @@ export function ScoutPage() { )} - {/* ④ 周邊知識 · 綁目前海巡批次 */} - {report?.mode !== "activity" && - report?.theme_key === activeRunKey && - report.research_notes?.length ? ( - -
    -
    - - {report?.theme_key ? ( - - ) : null} -
    - - {!reportOpen || !report ? ( - report ? ( - report.research_notes && report.research_notes.length > 0 ? ( - <> -
    -

    - {t("scout.notesCount", { n: report.research_notes.length })} - {report.product_label ? ` · ${report.product_label}` : ""} -

    -
    -
      - {report.research_notes - .filter((n) => (n.tier || "core") === "core") - .slice(0, 2) - .map((n) => ( - - ))} -
    - - ) : ( -

    {t("scout.noKnowledge")}

    - ) - ) : null - ) : ( - <> - {report.product_label ? ( -
    -

    {t("scout.product")}

    -

    {report.product_label}

    - {report.product_context ? ( -

    - {report.product_context} -

    - ) : null} -
    - ) : null} - {report.pains.length > 0 ? ( -
    -

    - {report.product_label ? t("scout.painsSolved") : t("scout.focus")} -

    -
    - {report.pains.map((p) => ( - - {p} - - ))} -
    -
    - ) : null} - {report.research_notes && report.research_notes.length > 0 ? ( -
    -
    -

    - {t("scout.knowledge")} -

    - {report.research_notes.length} -
    -
    - - {(["core", "adjacent", "broad"] as ScoutResearchTier[]).map((tier) => { - const n = report.research_notes!.filter( - (x) => (x.tier || "adjacent") === tier, - ).length; - if (!n) return null; - return ( - - ); - })} -
    -
    - {(researchTier === "all" - ? groupNotesByTier(report.research_notes) - : [ - { - tier: researchTier, - notes: report.research_notes.filter( - (x) => (x.tier || "adjacent") === researchTier, - ), - }, - ] - ).map((group) => - group.notes.length === 0 ? null : ( -
    - {researchTier === "all" ? ( -
    - - {t(`scout.tier.${group.tier}`)} - -
    - ) : null} -
      - {group.notes.map((n) => ( - - ))} -
    -
    - ), - )} -
    -
    - ) : ( -

    {t("scout.noWebSummary")}

    - )} - - )} -
    -
    - ) : null} {/* ⑤ 不同回覆策略不能混排,避免將短回誤當成痛點接話。 */} { void (async () => { - const a = await repos.settings.getAi(); - const p = await repos.settings.getPlacement(); + let a: AiSettings; + let p: PlacementSettings; + try { + a = await repos.settings.getAi(); + p = await repos.settings.getPlacement(); + } catch (e) { + // ai 留 null → 整個設定表單不渲染,沒有訊息的話畫面會是一片空白。 + setError(e instanceof Error ? e.message : t("settings.loadFail")); + return; + } const provider = normalizeProvider(a.provider); const list = a.models?.length ? a.models @@ -69,11 +77,9 @@ export function SettingsPage() { models: list, }); setPlacement(p); - if (a.models_error) { - setError(a.models_error); - } + setError(a.models_error || ""); })(); - }, [repos.settings]); + }, [repos.settings, t]); /** 換 provider:同一支 getAi(?provider=) 帶回 models + selected_model */ async function onProviderChange(nextId: string) { diff --git a/apps/web/src/pages/UsagePage.tsx b/apps/web/src/pages/UsagePage.tsx index 374b122..cc5bb07 100644 --- a/apps/web/src/pages/UsagePage.tsx +++ b/apps/web/src/pages/UsagePage.tsx @@ -106,6 +106,7 @@ export function UsagePage() { } }, [repos.usage, repos.growth, t]); + /** 失敗會 throw:呼叫端的存檔流程要能知道刷新沒成功,不能自己吞掉。 */ async function loadTenant(query = applied) { if (!isAdmin) return; setTenant( @@ -133,7 +134,11 @@ export function UsagePage() { }, [loadMine, tick]); useEffect(() => { - if (tab === "tenant" && isAdmin) void loadTenant(applied); + if (tab !== "tenant" || !isAdmin) return; + void loadTenant(applied).then( + () => setError(""), + (e: unknown) => setError(e instanceof Error ? e.message : t("usage.fail")), + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, isAdmin, repos.usage, tick, applied]); diff --git a/apps/web/src/pages/studio/InspirePanel.tsx b/apps/web/src/pages/studio/InspirePanel.tsx index 42fe953..99bec9e 100644 --- a/apps/web/src/pages/studio/InspirePanel.tsx +++ b/apps/web/src/pages/studio/InspirePanel.tsx @@ -12,6 +12,7 @@ import type { InspireSessionSummary, TrendItem, } from "../../domain/types"; +import { isAbortError } from "../../lib/abort"; import { newId } from "../../lib/id"; import { saveComposeDraftBody } from "../../lib/composeBridge"; import { nowUnixNano } from "../../lib/time"; @@ -72,6 +73,8 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) { const [error, setError] = useState(""); /** stream 中的助手暫存字(尚未 done) */ const [streamingText, setStreamingText] = useState(""); + /** 進行中的串流;按停止或離開頁面時用它中止,避免背景繼續燒 AI 額度 */ + const streamAbort = useRef(null); const [useWeb, setUseWeb] = useState(false); const [showAdd, setShowAdd] = useState(false); const [newKind, setNewKind] = useState("role"); @@ -164,6 +167,11 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [session?.messages.length, busy, streamingText]); + // 離開頁面時關掉還開著的串流,否則 SSE 連線與後端的 AI 呼叫都會繼續跑到結束。 + useEffect(() => { + return () => streamAbort.current?.abort(); + }, []); + // 從今日等入口:?topic=xxx → 預填輸入框(只一次) useEffect(() => { if (topicSeeded.current) return; @@ -298,6 +306,18 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) { return [...ids].sort().join(","); } + /** 換上新的 controller,順手中止前一輪沒收乾淨的串流。 */ + function beginStream(): AbortSignal { + streamAbort.current?.abort(); + const controller = new AbortController(); + streamAbort.current = controller; + return controller.signal; + } + + function stopStream() { + streamAbort.current?.abort(); + } + /** 聊天:必須有輸入。 */ function resolveSendPayload(mode: "chat" | "generate"): { message: string; mode: "chat" | "generate" } | null { const text = input.trim(); @@ -333,6 +353,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) { ], }); } + const signal = beginStream(); try { const result = await repos.inspiration.chatStream( { @@ -341,6 +362,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) { mode: "generate", personaId, sessionId: session?.id, + signal, }, (chunk) => { setStreamingText((prev) => prev + chunk); @@ -376,7 +398,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) { refresh(); } catch (e) { setStreamingText(""); - setMessage(e instanceof Error ? e.message : t("inspire.fail")); + setMessage(isAbortError(e) ? t("inspire.stopped") : e instanceof Error ? e.message : t("inspire.fail")); try { setSession(await repos.inspiration.getSession()); } catch { @@ -417,6 +439,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) { }); } setInput(""); + const signal = beginStream(); try { const result = await repos.inspiration.chatStream( { @@ -426,6 +449,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) { personaId, sessionId: session?.id, useWeb, + signal, }, (chunk) => { setStreamingText((prev) => prev + chunk); @@ -489,7 +513,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) { refresh(); } catch (e) { setStreamingText(""); - setMessage(e instanceof Error ? e.message : t("inspire.fail")); + setMessage(isAbortError(e) ? t("inspire.stopped") : e instanceof Error ? e.message : t("inspire.fail")); // 失敗時重拉 session 去掉樂觀 user bubble 若後端沒寫入 try { setSession(await repos.inspiration.getSession()); @@ -990,6 +1014,13 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) { ) : null} + {busy === "chat" || busy === "generate" ? ( +
    + +
    + ) : null}
    diff --git a/apps/web/src/pages/studio/steps/TopicStep.tsx b/apps/web/src/pages/studio/steps/TopicStep.tsx index 47896c7..c13434e 100644 --- a/apps/web/src/pages/studio/steps/TopicStep.tsx +++ b/apps/web/src/pages/studio/steps/TopicStep.tsx @@ -3,7 +3,6 @@ import { Button, Card, Input, Select, Textarea } from "../../../components/ui"; import { useRepos } from "../../../data/DataContext"; import type { Persona } from "../../../domain/types"; import { useI18n } from "../../../i18n/I18nContext"; -import { mockDelay, mockGenerateTopic } from "../../../lib/mockAi"; import { isPersonaReady, personaOptionLabel } from "../../../lib/personaPrompt"; import type { WizardDraft } from "../wizardState"; @@ -17,7 +16,6 @@ export function TopicStep({ draft, onChange }: Props) { const { t } = useI18n(); const [personas, setPersonas] = useState([]); const [personaId, setPersonaId] = useState(""); - const [busy, setBusy] = useState(false); useEffect(() => { void (async () => { @@ -28,29 +26,6 @@ export function TopicStep({ draft, onChange }: Props) { })(); }, [repos.personas]); - async function aiTopic() { - const persona = personas.find((p) => p.id === personaId); - if (!isPersonaReady(persona)) { - return; - } - setBusy(true); - try { - await mockDelay(); - const topic = mockGenerateTopic( - draft.topic || draft.title || t("wizard.topic.fallbackTopic"), - persona, - ); - onChange({ - topic, - title: - draft.title || - (persona ? t("wizard.topic.personaOpen", { name: persona.name }) : t("wizard.topic.aiTitle")), - }); - } finally { - setBusy(false); - } - } - const ready = isPersonaReady(personas.find((p) => p.id === personaId)); return ( @@ -90,9 +65,6 @@ export function TopicStep({ draft, onChange }: Props) {

    ) : null}
    -