This commit is contained in:
王性驊 2026-07-28 06:33:51 +00:00
parent f2be5a436e
commit f46a5cf0a9
41 changed files with 572 additions and 3132 deletions

View File

@ -14,6 +14,8 @@ import { useRepos } from "../data/DataContext";
type AuthContextValue = { type AuthContextValue = {
member: Member | null; member: Member | null;
loading: boolean; loading: boolean;
/** 最近一次 me() 因暫時性原因失敗(斷網/後端重啟)。登入狀態未知,不是已登出。 */
unreachable: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
reload: () => Promise<void>; reload: () => Promise<void>;
@ -26,6 +28,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const repos = useRepos(); const repos = useRepos();
const [member, setMember] = useState<Member | null>(null); const [member, setMember] = useState<Member | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [unreachable, setUnreachable] = useState(false);
const reload = useCallback(async () => { const reload = useCallback(async () => {
// 已進過 app 後不要再把 loading=true否則 RequireAuth 會卸載整棵 /app // 已進過 app 後不要再把 loading=true否則 RequireAuth 會卸載整棵 /app
@ -33,6 +36,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try { try {
const me = await repos.auth.me(); const me = await repos.auth.me();
setMember(me); setMember(me);
setUnreachable(false);
} catch {
// me() 只在 401 回 null會走到這裡的都是連不上或 5xx。此時 session 很可能還有效,
// 保留目前的 member否則使用者會因為一次網路抖動就被踢回登入頁。
setUnreachable(true);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -46,6 +54,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
async (email: string, password: string) => { async (email: string, password: string) => {
const { member: m } = await repos.auth.login(email, password); const { member: m } = await repos.auth.login(email, password);
setMember(m); setMember(m);
setUnreachable(false);
}, },
[repos.auth], [repos.auth],
); );
@ -53,6 +62,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logout = useCallback(async () => { const logout = useCallback(async () => {
await repos.auth.logout(); await repos.auth.logout();
setMember(null); setMember(null);
setUnreachable(false);
}, [repos.auth]); }, [repos.auth]);
const updateProfile = useCallback( const updateProfile = useCallback(
@ -65,8 +75,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
); );
const value = useMemo( const value = useMemo(
() => ({ member, loading, login, logout, reload, updateProfile }), () => ({ member, loading, unreachable, login, logout, reload, updateProfile }),
[member, loading, login, logout, reload, updateProfile], [member, loading, unreachable, login, logout, reload, updateProfile],
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@ -3,7 +3,7 @@ import { useI18n } from "../i18n/I18nContext";
import { useAuth } from "./AuthContext"; import { useAuth } from "./AuthContext";
export function RequireAuth({ children }: { children: React.ReactNode }) { export function RequireAuth({ children }: { children: React.ReactNode }) {
const { member, loading } = useAuth(); const { member, loading, unreachable, reload } = useAuth();
const location = useLocation(); const location = useLocation();
const { t } = useI18n(); const { t } = useI18n();
@ -15,6 +15,19 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
); );
} }
// 連不上後端時登入狀態是未知的,不是已登出。踢回登入頁會讓使用者以為 session 掉了,
// 而且重新登入也一樣會失敗,所以這裡給重試而不是轉址。
if (!member && unreachable) {
return (
<div className="hb-login">
<p className="text-muted">{t("auth.unreachable")}</p>
<button type="button" className="hb-btn" onClick={() => void reload()}>
{t("common.retry")}
</button>
</div>
);
}
if (!member) { if (!member) {
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />; return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
} }

View File

@ -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<Workspace[]>([]);
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 (
<label className="hb-ws-switch" title={t("workspace.label")}>
<span className="hb-ws-switch__ico" aria-hidden>
<AppIcon name="brands" size={16} />
</span>
<span className="hb-ws-switch__label">{t("workspace.label")}</span>
<select
className="hb-ws-switch__select"
aria-label={t("workspace.label")}
value={currentId}
disabled={busy}
onChange={(e) => {
const id = e.target.value;
if (id === "__new__") {
const name = window.prompt(t("workspace.newPrompt"));
if (!name?.trim()) return;
setBusy(true);
void repos.growth
.createWorkspace(name.trim())
.then((w) => repos.growth.switchWorkspace(w.id))
.then((res) => {
setList(res.list);
setCurrentId(res.current_workspace_id);
})
.catch(() => undefined)
.finally(() => setBusy(false));
return;
}
if (id === currentId) return;
setBusy(true);
void repos.growth
.switchWorkspace(id)
.then((res) => {
setList(res.list);
setCurrentId(res.current_workspace_id);
})
.catch(() => undefined)
.finally(() => setBusy(false));
}}
>
{list.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
{w.is_default ? ` · ${t("workspace.default")}` : ""}
</option>
))}
<option value="__new__">{t("workspace.new")}</option>
</select>
</label>
);
}

View File

@ -37,6 +37,7 @@ export function PersonaWorkbench() {
const [sourceLabel, setSourceLabel] = useState(""); const [sourceLabel, setSourceLabel] = useState("");
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [loadError, setLoadError] = useState("");
const [previewPost, setPreviewPost] = useState(""); const [previewPost, setPreviewPost] = useState("");
const [previewReply, setPreviewReply] = useState(""); const [previewReply, setPreviewReply] = useState("");
const [previewTopic, setPreviewTopic] = useState(""); const [previewTopic, setPreviewTopic] = useState("");
@ -64,18 +65,24 @@ export function PersonaWorkbench() {
} }
async function load() { async function load() {
const [list, active, accList] = await Promise.all([ try {
repos.personas.list(), const [list, active, accList] = await Promise.all([
repos.personas.getActiveId(), repos.personas.list(),
repos.accounts.list(), repos.personas.getActiveId(),
]); repos.accounts.list(),
setPersonas(list); ]);
setAccounts(accList); setPersonas(list);
setActiveId(active); setAccounts(accList);
setSelectedId((cur) => { setActiveId(active);
if (cur && list.some((p) => p.id === cur)) return cur; setSelectedId((cur) => {
return active || list[0]?.id || ""; 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(() => { useEffect(() => {
@ -408,6 +415,11 @@ export function PersonaWorkbench() {
</div> </div>
</div> </div>
{loadError ? (
<p className="hb-banner-error" role="alert">
{loadError}
</p>
) : null}
{message ? ( {message ? (
<p className="hb-banner-ok" role="status"> <p className="hb-banner-ok" role="status">
{message} {message}

View File

@ -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<string, string>();
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("<html>502 Bad Gateway</html>", {
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<string, string>)?.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");
});
});

View File

@ -24,6 +24,11 @@ export class ApiError extends Error {
detail: unknown; detail: unknown;
/** 後端原始 message除錯用UI 請用 code → 語言包) */ /** 後端原始 message除錯用UI 請用 code → 語言包) */
rawMessage: string; rawMessage: string;
/**
* 401 refresh 5xx
* token
*/
sessionUnknown: boolean;
constructor(message: string, code: number, httpStatus: number, detail?: unknown, rawMessage?: string) { constructor(message: string, code: number, httpStatus: number, detail?: unknown, rawMessage?: string) {
super(message); super(message);
@ -32,6 +37,7 @@ export class ApiError extends Error {
this.httpStatus = httpStatus; this.httpStatus = httpStatus;
this.detail = detail; this.detail = detail;
this.rawMessage = rawMessage ?? message; 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)); localStorage.setItem(TOKEN_KEY, JSON.stringify(t));
} }
let refreshPromise: Promise<StoredTokens | null> | 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<StoredTokens | null> { let refreshPromise: Promise<RefreshResult> | null = null;
async function tryRefresh(): Promise<RefreshResult> {
const cur = loadTokens(); const cur = loadTokens();
if (!cur?.refresh_token) return null; if (!cur?.refresh_token) return { status: "rejected" };
if (!refreshPromise) { if (!refreshPromise) {
refreshPromise = (async () => { refreshPromise = (async (): Promise<RefreshResult> => {
try { try {
const res = await fetch(`${getApiBase()}/api/v1/auth/refresh`, { const res = await fetch(`${getApiBase()}/api/v1/auth/refresh`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: cur.refresh_token }), 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 }>; const env = (await res.json()) as Envelope<{ tokens: StoredTokens }>;
if (env.code !== SUCCESS || !env.data?.tokens) { if (env.code !== SUCCESS || !env.data?.tokens) {
saveTokens(null); saveTokens(null);
return null; return { status: "rejected" };
} }
const next = env.data.tokens; const next = env.data.tokens;
saveTokens(next); saveTokens(next);
return next; return { status: "renewed", tokens: next };
} catch { } catch {
// A temporary network failure is not proof that the refresh token is invalid. return { status: "unreachable" };
return null;
} finally { } finally {
refreshPromise = null; refreshPromise = null;
} }
@ -161,51 +179,63 @@ export async function apiRequest<T>(path: string, opts: RequestOpts = {}): Promi
} }
// retry once on 401 with refresh // retry once on 401 with refresh
let sessionUnknown = false;
if (res.status === 401 && auth && !opts.raw) { if (res.status === 401 && auth && !opts.raw) {
const refreshed = await tryRefresh(); const refreshed = await tryRefresh();
if (refreshed?.access_token) { if (refreshed.status === "renewed") {
headers.Authorization = `Bearer ${refreshed.access_token}`; headers.Authorization = `Bearer ${refreshed.tokens.access_token}`;
res = await fetch(url, { try {
method, res = await fetch(url, {
headers, method,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, 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<T>; let env: Envelope<T>;
try { try {
env = (await res.json()) as Envelope<T>; env = (await res.json()) as Envelope<T>;
} catch { } catch {
// go-zero 逾時常直接 503 非 envelope → 前端顯示「伺服器錯誤」;改成可理解的 AI 逾時 // go-zero 逾時常直接 503 非 envelope → 前端顯示「伺服器錯誤」;改成可理解的 AI 逾時
if (res.status === 503 || res.status === 504 || res.status === 408) { 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( throw tag(
messageForApiCode(res.status >= 500 ? 500000 : 400001, `invalid response (${res.status})`), new ApiError(
res.status, messageForApiCode(res.status >= 500 ? 500000 : 400001, `invalid response (${res.status})`),
res.status, res.status,
res.status,
),
); );
} }
if (env.code !== SUCCESS) { if (env.code !== SUCCESS) {
// gateway 逾時有時仍帶 envelope + 5xx // gateway 逾時有時仍帶 envelope + 5xx
if (res.status === 503 || res.status === 504 || env.code === 503001) { if (res.status === 503 || res.status === 504 || env.code === 503001) {
throw new ApiError( throw tag(
messageForApiCode(503001, env.message || "AI 回應逾時"), new ApiError(
503001, messageForApiCode(503001, env.message || "AI 回應逾時"),
res.status, 503001,
env.error, res.status,
env.message, env.error,
env.message,
),
); );
} }
// message 用目前語言包code 保留給 UI 再對一次語系 // message 用目前語言包code 保留給 UI 再對一次語系
throw new ApiError( throw tag(
messageForApiCode(env.code, env.message), new ApiError(messageForApiCode(env.code, env.message), env.code, res.status, env.error, env.message),
env.code,
res.status,
env.error,
env.message,
); );
} }
return env.data; return env.data;

View File

@ -6,7 +6,6 @@ import type {
BrandProduct, BrandProduct,
InspirationIdea, InspirationIdea,
Job, Job,
InspireAngle,
InspireChatMessage, InspireChatMessage,
InspireElement, InspireElement,
InspireSession, InspireSession,
@ -16,10 +15,8 @@ import type {
ScoutPost, ScoutPost,
ScoutRunBrief, ScoutRunBrief,
ScoutScanContext, ScoutScanContext,
ScoutTopic,
TrendItem, TrendItem,
TrendKind, TrendKind,
ViralSample,
} from "../../domain/types"; } from "../../domain/types";
import type { import type {
InspirationRepo, InspirationRepo,
@ -221,10 +218,6 @@ function mapHomework(raw: Record<string, unknown>): ScoutHomeworkRecord {
}; };
} }
const removed = (name: string) => async () => {
throw new Error(`${name} 已移除(現 UI 不支援)`);
};
export function createLiveInspiration(): InspirationRepo { export function createLiveInspiration(): InspirationRepo {
return { return {
async list() { async list() {
@ -241,7 +234,6 @@ export function createLiveInspiration(): InspirationRepo {
); );
return (data.list ?? []).map(mapTrend); return (data.list ?? []).map(mapTrend);
}, },
searchTrends: removed("searchTrends") as InspirationRepo["searchTrends"],
async listElements() { async listElements() {
const data = await apiRequest<{ list: Record<string, unknown>[] }>( const data = await apiRequest<{ list: Record<string, unknown>[] }>(
"/api/v1/inspire/elements", "/api/v1/inspire/elements",
@ -379,6 +371,7 @@ export function createLiveInspiration(): InspirationRepo {
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method: "POST",
headers, headers,
signal: opts.signal,
body: JSON.stringify({ body: JSON.stringify({
message: opts.message, message: opts.message,
pinned_ids: opts.pinnedIds, pinned_ids: opts.pinnedIds,
@ -406,66 +399,64 @@ export function createLiveInspiration(): InspirationRepo {
let charCount = 0; let charCount = 0;
let runeCount = 0; let runeCount = 0;
let sections: string[] = []; let sections: string[] = [];
while (true) { // 中止時 reader.read() 會 reject這裡把它轉成一次 cancel避免連線留著繼續燒 AI 額度。
const { done, value } = await reader.read(); try {
if (done) break; while (true) {
buf += decoder.decode(value, { stream: true }); const { done, value } = await reader.read();
const parts = buf.split("\n\n"); if (done) break;
buf = parts.pop() ?? ""; buf += decoder.decode(value, { stream: true });
for (const block of parts) { const parts = buf.split("\n\n");
for (const line of block.split("\n")) { buf = parts.pop() ?? "";
if (!line.startsWith("data:")) continue; for (const block of parts) {
const raw = line.slice(5).trim(); for (const line of block.split("\n")) {
if (!raw || raw === "[DONE]") continue; if (!line.startsWith("data:")) continue;
let ev: { const raw = line.slice(5).trim();
type?: string; if (!raw || raw === "[DONE]") continue;
text?: string; let ev: {
message?: string; type?: string;
session?: Record<string, unknown>; text?: string;
prompt?: string; message?: string;
prompt_fingerprint?: string; session?: Record<string, unknown>;
prompt_char_count?: number; prompt?: string;
prompt_rune_count?: number; prompt_fingerprint?: string;
prompt_sections?: string[]; prompt_char_count?: number;
}; prompt_rune_count?: number;
try { prompt_sections?: string[];
ev = JSON.parse(raw) as typeof ev; };
} catch { try {
continue; ev = JSON.parse(raw) as typeof ev;
} } catch {
if (ev.type === "delta" && ev.text) { continue;
onDelta(ev.text); }
} else if (ev.type === "error") { if (ev.type === "delta" && ev.text) {
streamError = ev.message || "stream error"; onDelta(ev.text);
} else if (ev.type === "done" && ev.session) { } else if (ev.type === "error") {
session = mapSession(ev.session); streamError = ev.message || "stream error";
prompt = String(ev.prompt ?? ""); } else if (ev.type === "done" && ev.session) {
fingerprint = String(ev.prompt_fingerprint ?? ""); session = mapSession(ev.session);
charCount = Number(ev.prompt_char_count ?? 0); prompt = String(ev.prompt ?? "");
runeCount = Number(ev.prompt_rune_count ?? 0); fingerprint = String(ev.prompt_fingerprint ?? "");
sections = (ev.prompt_sections ?? []).map(String); 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) { if (streamError) {
throw new ApiError(streamError, 400001, 400); throw new ApiError(streamError, 400001, 400);
} }
if (!session) { if (!session) {
// 沒收到 done 事件才回頭走非串流版;已中止的請求不該再打一次 AI。
opts.signal?.throwIfAborted();
const fallback = await this.chat(opts); const fallback = await this.chat(opts);
return { session: fallback.session }; return { session: fallback.session };
} }
return { session, prompt, fingerprint, charCount, runeCount, sections }; return { session, prompt, fingerprint, charCount, runeCount, sections };
}, },
sparkAngles: removed("sparkAngles") as () => Promise<InspireAngle[]>,
sparkAnglesForTopic: removed("sparkAnglesForTopic") as InspirationRepo["sparkAnglesForTopic"],
bookmarkTrend: removed("bookmarkTrend") as InspirationRepo["bookmarkTrend"],
saveIdea: removed("saveIdea") as InspirationRepo["saveIdea"],
generate: removed("generate") as InspirationRepo["generate"],
async listViral() {
return [] as ViralSample[];
},
sparkFromTrend: removed("sparkFromTrend") as InspirationRepo["sparkFromTrend"],
}; };
} }
@ -640,11 +631,6 @@ export function createLiveScout(): ScoutRepo {
source_note: String(raw.source_note ?? ""), 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) { async prepareBrief(opts) {
const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/brief", { const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/brief", {
method: "POST", method: "POST",

View File

@ -202,10 +202,11 @@ function createLiveAuth(): AuthRepo {
const raw = await apiRequest<Record<string, unknown>>("/api/v1/auth/me"); const raw = await apiRequest<Record<string, unknown>>("/api/v1/auth/me");
return normalizeMember(raw); return normalizeMember(raw);
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.httpStatus === 401) { if (error instanceof ApiError && error.httpStatus === 401 && !error.sessionUnknown) {
saveTokens(null); saveTokens(null);
return null;
} }
return null; throw error;
} }
}, },
async updateProfile(patch: MemberProfilePatch) { async updateProfile(patch: MemberProfilePatch) {
@ -849,11 +850,9 @@ function createLiveUsage(): UsageRepo {
const p = await this.getMemberPrefs(); const p = await this.getMemberPrefs();
return p.plan_id; return p.plan_id;
}, },
async setPlanId() { async getMemberPrefs(uid) {
/* admin only via setMemberPrefs */ const q = uid ? `?uid=${encodeURIComponent(uid)}` : "";
}, const raw = await apiRequest<Record<string, unknown>>(`/api/v1/usage/prefs${q}`);
async getMemberPrefs() {
const raw = await apiRequest<Record<string, unknown>>("/api/v1/usage/prefs");
const id = String(raw.plan_id ?? "free").toLowerCase().trim(); const id = String(raw.plan_id ?? "free").toLowerCase().trim();
return { return {
plan_id: (id in PLANS ? id : "free") as PlanId, plan_id: (id in PLANS ? id : "free") as PlanId,

View File

@ -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", () => { describe("BYOK usage mapping", () => {
it("maps BYOK counts separately and fills absent meters with zero", () => { it("maps BYOK counts separately and fills absent meters with zero", () => {
const mapped = mapByokMeterBlock(PLANS.free, { const mapped = mapByokMeterBlock(PLANS.free, {

View File

@ -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);
},
};
}

View File

@ -6,7 +6,6 @@ import type {
DataSource, DataSource,
ExternalThreadTarget, ExternalThreadTarget,
InspirationIdea, InspirationIdea,
InspireAngle,
InspireChatMessage, InspireChatMessage,
InspireElement, InspireElement,
InspireSession, InspireSession,
@ -27,14 +26,12 @@ import type {
ScoutPost, ScoutPost,
ScoutRunBrief, ScoutRunBrief,
ScoutScanContext, ScoutScanContext,
ScoutTopic,
ThreadPlay, ThreadPlay,
ThreadsAccount, ThreadsAccount,
TokenPair, TokenPair,
TrendItem, TrendItem,
TrendKind, TrendKind,
ViralAnalysis, ViralAnalysis,
ViralSample,
} from "../domain/types"; } from "../domain/types";
import type { AdminUserListPage, MemberAdminView } from "../lib/tenantUsers"; import type { AdminUserListPage, MemberAdminView } from "../lib/tenantUsers";
import type { PlanPurchase } from "../lib/planPurchase"; import type { PlanPurchase } from "../lib/planPurchase";
@ -77,6 +74,11 @@ export type MemberProfilePatch = {
export type AuthRepo = { export type AuthRepo = {
login(email: string, password: string): Promise<{ tokens: TokenPair; member: Member }>; login(email: string, password: string): Promise<{ tokens: TokenPair; member: Member }>;
logout(): Promise<void>; logout(): Promise<void>;
/**
* null token 401
* 5xx throw null
* 使
*/
me(): Promise<Member | null>; me(): Promise<Member | null>;
/** 更新自己的會員資料mock 寫 session */ /** 更新自己的會員資料mock 寫 session */
updateProfile(patch: MemberProfilePatch): Promise<Member>; updateProfile(patch: MemberProfilePatch): Promise<Member>;
@ -342,7 +344,6 @@ export type InspirationRepo = {
/** 預設靈感榜Threads 熱標(提示用) */ /** 預設靈感榜Threads 熱標(提示用) */
listTrends(kind?: TrendKind | "all"): Promise<TrendItem[]>; listTrends(kind?: TrendKind | "all"): Promise<TrendItem[]>;
refreshTrends(kind?: TrendKind | "all"): Promise<TrendItem[]>; refreshTrends(kind?: TrendKind | "all"): Promise<TrendItem[]>;
searchTrends(query: string): Promise<TrendItem[]>;
/** 元素庫 */ /** 元素庫 */
listElements(): Promise<InspireElement[]>; listElements(): Promise<InspireElement[]>;
saveElement(el: InspireElement): Promise<InspireElement>; saveElement(el: InspireElement): Promise<InspireElement>;
@ -376,6 +377,7 @@ export type InspirationRepo = {
}): Promise<{ session: InspireSession; messages: InspireChatMessage[] }>; }): Promise<{ session: InspireSession; messages: InspireChatMessage[] }>;
/** /**
* onDelta session * onDelta session
* reject `AbortError``isAbortError()`
*/ */
chatStream( chatStream(
opts: { opts: {
@ -386,6 +388,8 @@ export type InspirationRepo = {
sessionId?: string; sessionId?: string;
useWeb?: boolean; useWeb?: boolean;
material?: string; material?: string;
/** 使用者按停止或元件卸載時中止;不帶則跑到結束。 */
signal?: AbortSignal;
}, },
onDelta: (chunk: string) => void, onDelta: (chunk: string) => void,
): Promise<{ ): Promise<{
@ -419,26 +423,6 @@ export type InspirationRepo = {
pinned: Array<{ id: string; kind: string; title: string; body: string }>; pinned: Array<{ id: string; kind: string; title: string; body: string }>;
note?: string; note?: string;
}>; }>;
/** @deprecated 舊角度流;保留相容 */
sparkAngles(
trendId: string,
opts?: { personaId?: string | null; brandId?: string | null },
): Promise<InspireAngle[]>;
/** @deprecated */
sparkAnglesForTopic(
label: string,
opts?: { personaId?: string | null; brandId?: string | null; samples?: string[] },
): Promise<{ trend: TrendItem; angles: InspireAngle[] }>;
bookmarkTrend(trendId: string): Promise<InspirationIdea>;
saveIdea(idea: InspirationIdea): Promise<InspirationIdea>;
generate(topic: string, personaId?: string): Promise<InspirationIdea>;
listViral(): Promise<ViralSample[]>;
/** @deprecated */
sparkFromTrend(
trendId: string,
personaId?: string,
opts?: { save?: boolean },
): Promise<InspirationIdea>;
}; };
export type ResearchRepo = { export type ResearchRepo = {
@ -521,9 +505,6 @@ export type ScoutRepo = {
placement_url: string; placement_url: string;
source_note: string; source_note: string;
}>; }>;
listTopics(brandId?: string): Promise<ScoutTopic[]>;
saveTopic(topic: ScoutTopic): Promise<ScoutTopic>;
removeTopic(id: string): Promise<void>;
/** /**
* + brief * + brief
* = theme * = theme
@ -577,11 +558,11 @@ export type ScoutRepo = {
}; };
export type UsageRepo = { export type UsageRepo = {
/** 目前登入者(或指定 uid本月摘要 + 明細 */ /** 目前登入者本月摘要 + 明細 */
getSummary(monthKey?: string, uid?: string): Promise<UsageMonthSummary>; getSummary(monthKey?: string): Promise<UsageMonthSummary>;
listEvents(limit?: number, uid?: string): Promise<UsageEvent[]>; listEvents(limit?: number): Promise<UsageEvent[]>;
getPlanId(): Promise<PlanId>; getPlanId(): Promise<PlanId>;
setPlanId(id: PlanId): Promise<void>; /** 讀方案無限uid 需管理員權限,省略則為自己 */
getMemberPrefs(uid?: string): Promise<UsageMemberPrefs>; getMemberPrefs(uid?: string): Promise<UsageMemberPrefs>;
/** 管理員:設某人方案/無限 */ /** 管理員:設某人方案/無限 */
setMemberPrefs(uid: string, patch: Partial<UsageMemberPrefs>): Promise<UsageMemberPrefs>; setMemberPrefs(uid: string, patch: Partial<UsageMemberPrefs>): Promise<UsageMemberPrefs>;

View File

@ -509,15 +509,6 @@ export type MentionItem = {
created_at: number; created_at: number;
}; };
export type ViralSample = {
id: string;
author: string;
text: string;
like_count: number;
reply_count: number;
topic?: string;
};
export type ViralAnalysis = { export type ViralAnalysis = {
hooks: string; hooks: string;
structure: string; structure: string;
@ -553,37 +544,6 @@ export type ResearchHit = {
*/ */
export type ScoutResearchTier = "core" | "adjacent" | "broad"; 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;
/** 學習重點35 條子彈 */
learn_points?: string[];
/** 回帖可直接借的鉤子13 句) */
reply_hooks?: string[];
url: string;
source_label?: string;
/** 從此頁抽出可海巡的詞 */
keywords?: string[];
/** 與主題貼近度分層 */
tier?: ScoutResearchTier;
/** 與產品的關係 */
relation?: ScoutKnowledgeRelation;
};
export type InspirationIdea = { export type InspirationIdea = {
id: string; id: string;
title: string; title: string;
@ -594,12 +554,6 @@ export type InspirationIdea = {
trend_id?: string | null; trend_id?: string | null;
}; };
/** 靈感第二步:可選的開場角度(舊流程相容) */
export type InspireAngle = {
id: string;
hook: string;
};
/** 靈感元素庫:可重用產文材料 */ /** 靈感元素庫:可重用產文材料 */
export type InspireElementKind = "persona" | "role" | "brand" | "snippet" | "trend"; export type InspireElementKind = "persona" | "role" | "brand" | "snippet" | "trend";
@ -729,15 +683,6 @@ export type BrandProduct = {
updated_at: number; 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"; export type ScoutOutreachStatus = "new" | "drafted" | "queued" | "published" | "skipped";
/** /**
@ -809,15 +754,8 @@ export type ScoutRunBrief = {
placement_note?: string; placement_note?: string;
/** B回應姿態 */ /** B回應姿態 */
response_stance?: string; response_stance?: string;
/**
*
* research
*/
research_notes?: ScoutResearchNote[];
/** 產品 context 全文(展示用) */ /** 產品 context 全文(展示用) */
product_context?: string; product_context?: string;
/** 對方常講的話match_tags 詳列) */
match_tags_detail?: string[];
/** 分組/持久化用(與命中 theme_key 對齊) */ /** 分組/持久化用(與命中 theme_key 對齊) */
theme_key?: string; theme_key?: string;
theme_label?: string; theme_label?: string;

View File

@ -0,0 +1,7 @@
/**
* 使
* fetch DOMException("AbortError") name Error
*/
export function isAbortError(e: unknown): boolean {
return e instanceof Error && e.name === "AbortError";
}

View File

@ -41,6 +41,7 @@ export const zhTW: MessageDict = {
"common.save": "儲存", "common.save": "儲存",
"common.cancel": "取消", "common.cancel": "取消",
"common.loading": "載入中…", "common.loading": "載入中…",
"common.retry": "重試",
"common.back": "返回", "common.back": "返回",
"common.delete": "刪除", "common.delete": "刪除",
"common.edit": "編輯", "common.edit": "編輯",
@ -70,6 +71,7 @@ export const zhTW: MessageDict = {
"api.err.400004": "驗證碼無效或已過期", "api.err.400004": "驗證碼無效或已過期",
"api.err.400020": "不能停權自己的帳號", "api.err.400020": "不能停權自己的帳號",
"api.err.400021": "不能移除或停權最後一位管理員", "api.err.400021": "不能移除或停權最後一位管理員",
"auth.unreachable": "連不上伺服器,無法確認登入狀態。請檢查網路後重試。",
"api.err.401001": "請先登入", "api.err.401001": "請先登入",
"api.err.401002": "登入已失效,請重新登入", "api.err.401002": "登入已失效,請重新登入",
"api.err.401003": "找不到會員資料,請重新登入", "api.err.401003": "找不到會員資料,請重新登入",
@ -152,6 +154,7 @@ export const zhTW: MessageDict = {
"verify.after": "驗證完成後即可使用今日、創作、海巡等功能。", "verify.after": "驗證完成後即可使用今日、創作、海巡等功能。",
"settings.title": "設定", "settings.title": "設定",
"settings.loadFail": "載入設定失敗,請重新整理再試",
"settings.localeCurrency": "語言與幣別", "settings.localeCurrency": "語言與幣別",
"settings.locale": "介面語言", "settings.locale": "介面語言",
"settings.currency": "顯示幣別", "settings.currency": "顯示幣別",
@ -360,6 +363,7 @@ export const zhTW: MessageDict = {
"crew.connecting": "連線中…", "crew.connecting": "連線中…",
"crew.tokenRenewHint": "Token 由背景任務自動延長(約第 30 天),可在「任務」查看;無需手動刷新。", "crew.tokenRenewHint": "Token 由背景任務自動延長(約第 30 天),可在「任務」查看;無需手動刷新。",
"crew.empty": "尚無帳號", "crew.empty": "尚無帳號",
"crew.loadFail": "載入帳號失敗",
"crew.unusable": "不可用", "crew.unusable": "不可用",
"crew.expires": "到期 {time}", "crew.expires": "到期 {time}",
"crew.lastRefresh": "上次延長 {time}", "crew.lastRefresh": "上次延長 {time}",
@ -479,6 +483,7 @@ export const zhTW: MessageDict = {
"outbox.status.cancelled": "已取消", "outbox.status.cancelled": "已取消",
"outbox.detail.missingId": "缺少 id", "outbox.detail.missingId": "缺少 id",
"outbox.detail.notFound": "找不到發送", "outbox.detail.notFound": "找不到發送",
"outbox.detail.loadFail": "載入發送內容失敗",
"outbox.detail.loading": "載入中…", "outbox.detail.loading": "載入中…",
"outbox.detail.sendingHint": "正在發到 Threads通常 1030 秒),頁面會自動更新…", "outbox.detail.sendingHint": "正在發到 Threads通常 1030 秒),頁面會自動更新…",
"outbox.detail.doneHint": "已成功發到 Threads。", "outbox.detail.doneHint": "已成功發到 Threads。",
@ -679,13 +684,9 @@ export const zhTW: MessageDict = {
"wizard.topic.topicPh": "這串文想聊什麼?", "wizard.topic.topicPh": "這串文想聊什麼?",
"wizard.topic.aiView": "AI 視角", "wizard.topic.aiView": "AI 視角",
"wizard.topic.personaNotReady": "人設未就緒", "wizard.topic.personaNotReady": "人設未就緒",
"wizard.topic.generating": "生成中…",
"wizard.topic.aiSuggest": "AI 幫我想",
"wizard.topic.quickFill": "快速填入", "wizard.topic.quickFill": "快速填入",
"wizard.topic.sampleTitle": "週末咖啡話題", "wizard.topic.sampleTitle": "週末咖啡話題",
"wizard.topic.sampleTopic": "週末想找間不踩雷的咖啡店,插座要多、能坐久。", "wizard.topic.sampleTopic": "週末想找間不踩雷的咖啡店,插座要多、能坐久。",
"wizard.topic.aiTitle": "AI 主題",
"wizard.topic.personaOpen": "{name}開場",
"wizard.crew.title": "2. 出場", "wizard.crew.title": "2. 出場",
"wizard.crew.lead": "主帳", "wizard.crew.lead": "主帳",
"wizard.crew.noUsable": "尚無可用帳號", "wizard.crew.noUsable": "尚無可用帳號",
@ -803,6 +804,7 @@ export const zhTW: MessageDict = {
"jobs.detail": "詳情", "jobs.detail": "詳情",
"jobs.loadMore": "載入更多(還有 {n}", "jobs.loadMore": "載入更多(還有 {n}",
"jobs.notFound": "找不到任務", "jobs.notFound": "找不到任務",
"jobs.detailLoadFail": "載入任務失敗",
"jobs.progress": "進度 {n}%", "jobs.progress": "進度 {n}%",
"jobs.updated": "更新 {time}", "jobs.updated": "更新 {time}",
"jobs.backList": "返回列表", "jobs.backList": "返回列表",
@ -1098,21 +1100,8 @@ export const zhTW: MessageDict = {
"scout.sending": "發送中…", "scout.sending": "發送中…",
"scout.needAccountBefore": "請先到", "scout.needAccountBefore": "請先到",
"scout.needAccountAfter": "連線可用帳號。", "scout.needAccountAfter": "連線可用帳號。",
"scout.knowledge": "周邊知識",
"scout.knowledgeWithLabel": "周邊知識 · {label}",
"scout.knowledgeLearn": "周邊知識 · 可學",
"scout.collapseKnowledge": "收合知識",
"scout.expandLearn": "展開學習 · {n} 則",
"scout.expandKnowledge": "展開知識",
"scout.deleteKnowledgeRun": "刪除此批知識與命中",
"scout.loadingKnowledge": "正在整理周邊知識…", "scout.loadingKnowledge": "正在整理周邊知識…",
"scout.notesCount": "{n} 則",
"scout.noKnowledge": "尚無周邊知識",
"scout.product": "產品", "scout.product": "產品",
"scout.painsSolved": "能解的痛",
"scout.focus": "焦點",
"scout.all": "全部",
"scout.noWebSummary": "尚無網頁摘要",
"scout.queue": "命中紀錄 · {n}", "scout.queue": "命中紀錄 · {n}",
"scout.valueQueue": "痛點/產品接話 · {n}", "scout.valueQueue": "痛點/產品接話 · {n}",
"scout.activityQueue": "活躍短回 · {n}", "scout.activityQueue": "活躍短回 · {n}",
@ -1120,9 +1109,6 @@ export const zhTW: MessageDict = {
"scout.collapseQueue": "收合佇列", "scout.collapseQueue": "收合佇列",
"scout.expandQueue": "展開佇列", "scout.expandQueue": "展開佇列",
"scout.noOtherPending": "沒有其他待回", "scout.noOtherPending": "沒有其他待回",
"scout.learnPoints": "學習重點",
"scout.replyHooks": "回帖可借",
"scout.badgeCore": "最貼主題",
"scout.unnamedRun": "未命名批次", "scout.unnamedRun": "未命名批次",
"scout.thisRun": "此批次", "scout.thisRun": "此批次",
"scout.confirmDeleteRun": "刪除海巡批次「{label}」?\\n會一併刪除這批命中與周邊知識無法復原。", "scout.confirmDeleteRun": "刪除海巡批次「{label}」?\\n會一併刪除這批命中與周邊知識無法復原。",
@ -1165,17 +1151,6 @@ export const zhTW: MessageDict = {
"scout.stanceActivity": "短回 · 養活躍", "scout.stanceActivity": "短回 · 養活躍",
"scout.stanceProduct": "共感 · 可輕帶產品", "scout.stanceProduct": "共感 · 可輕帶產品",
"scout.stanceRelation": "接話 · 建關係", "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.title": "品牌",
"brands.railAria": "品牌列表", "brands.railAria": "品牌列表",
@ -1397,6 +1372,8 @@ export const zhTW: MessageDict = {
"inspire.useDraft": "用這則寫", "inspire.useDraft": "用這則寫",
"inspire.openPlay": "開串場", "inspire.openPlay": "開串場",
"inspire.thinking": "思考中…", "inspire.thinking": "思考中…",
"inspire.stop": "停止產生",
"inspire.stopped": "已停止產生",
"inspire.pinnedAria": "本輪參考(給 AI 看)", "inspire.pinnedAria": "本輪參考(給 AI 看)",
"inspire.pinned": "本輪參考", "inspire.pinned": "本輪參考",
"inspire.pinnedCount": "· {n}", "inspire.pinnedCount": "· {n}",
@ -1569,6 +1546,7 @@ export const zhTW: MessageDict = {
"persona.jobRunning": "背景分析中… 完成後會自動更新(也可到「任務」查看進度)", "persona.jobRunning": "背景分析中… 完成後會自動更新(也可到「任務」查看進度)",
"persona.jobDone": "背景分析完成 · 已寫入指紋/範本", "persona.jobDone": "背景分析完成 · 已寫入指紋/範本",
"persona.jobFailed": "背景分析失敗,請到任務頁查看錯誤或重試", "persona.jobFailed": "背景分析失敗,請到任務頁查看錯誤或重試",
"persona.loadFail": "載入人設失敗",
"persona.openJob": "開啟任務詳情", "persona.openJob": "開啟任務詳情",
"persona.setDefaultMsg": "「{name}」已設為預設", "persona.setDefaultMsg": "「{name}」已設為預設",
"persona.confirmDelete": "確定刪除人設「{name}」?", "persona.confirmDelete": "確定刪除人設「{name}」?",
@ -1724,7 +1702,6 @@ export const zhTW: MessageDict = {
"persona.previewReplySample": "大安那間還行但人很多", "persona.previewReplySample": "大安那間還行但人很多",
"inspire.playTitle": "靈感串場", "inspire.playTitle": "靈感串場",
"wizard.topic.fallbackTopic": "生活小題",
"play.err.needLead": "請選擇主帳號", "play.err.needLead": "請選擇主帳號",
"play.err.needRoot": "請至少有一則主貼", "play.err.needRoot": "請至少有一則主貼",
@ -1793,6 +1770,7 @@ export const en: MessageDict = {
"common.save": "Save", "common.save": "Save",
"common.cancel": "Cancel", "common.cancel": "Cancel",
"common.loading": "Loading…", "common.loading": "Loading…",
"common.retry": "Retry",
"common.back": "Back", "common.back": "Back",
"common.delete": "Delete", "common.delete": "Delete",
"common.edit": "Edit", "common.edit": "Edit",
@ -1821,6 +1799,7 @@ export const en: MessageDict = {
"api.err.400004": "Invalid or expired verification code", "api.err.400004": "Invalid or expired verification code",
"api.err.400020": "You cannot suspend your own account", "api.err.400020": "You cannot suspend your own account",
"api.err.400021": "Cannot demote or suspend the last active admin", "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.401001": "Please sign in",
"api.err.401002": "Session expired. Please sign in again.", "api.err.401002": "Session expired. Please sign in again.",
"api.err.401003": "Account not found. 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.", "verify.after": "After verification you can use Today, Studio, Patrol, and more.",
"settings.title": "Settings", "settings.title": "Settings",
"settings.loadFail": "Could not load settings. Refresh to try again.",
"settings.localeCurrency": "Language & currency", "settings.localeCurrency": "Language & currency",
"settings.locale": "Interface language", "settings.locale": "Interface language",
"settings.currency": "Display currency", "settings.currency": "Display currency",
@ -2111,6 +2091,7 @@ export const en: MessageDict = {
"crew.connecting": "Connecting…", "crew.connecting": "Connecting…",
"crew.tokenRenewHint": "Tokens auto-renew via background jobs (~day 30). Check Jobs; no manual refresh.", "crew.tokenRenewHint": "Tokens auto-renew via background jobs (~day 30). Check Jobs; no manual refresh.",
"crew.empty": "No accounts yet", "crew.empty": "No accounts yet",
"crew.loadFail": "Could not load accounts",
"crew.unusable": "Unavailable", "crew.unusable": "Unavailable",
"crew.expires": "Expires {time}", "crew.expires": "Expires {time}",
"crew.lastRefresh": "Last extended {time}", "crew.lastRefresh": "Last extended {time}",
@ -2230,6 +2211,7 @@ export const en: MessageDict = {
"outbox.status.cancelled": "Cancelled", "outbox.status.cancelled": "Cancelled",
"outbox.detail.missingId": "Missing id", "outbox.detail.missingId": "Missing id",
"outbox.detail.notFound": "Outbox item not found", "outbox.detail.notFound": "Outbox item not found",
"outbox.detail.loadFail": "Could not load this outbox item",
"outbox.detail.loading": "Loading…", "outbox.detail.loading": "Loading…",
"outbox.detail.sendingHint": "Publishing to Threads (often 1030s). This page updates automatically…", "outbox.detail.sendingHint": "Publishing to Threads (often 1030s). This page updates automatically…",
"outbox.detail.doneHint": "Published to Threads successfully.", "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.topicPh": "What should this thread discuss?",
"wizard.topic.aiView": "AI persona view", "wizard.topic.aiView": "AI persona view",
"wizard.topic.personaNotReady": "Persona not ready", "wizard.topic.personaNotReady": "Persona not ready",
"wizard.topic.generating": "Generating…",
"wizard.topic.aiSuggest": "AI suggest",
"wizard.topic.quickFill": "Quick fill", "wizard.topic.quickFill": "Quick fill",
"wizard.topic.sampleTitle": "Weekend coffee chat", "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.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.title": "2. Cast",
"wizard.crew.lead": "Lead", "wizard.crew.lead": "Lead",
"wizard.crew.noUsable": "No usable accounts", "wizard.crew.noUsable": "No usable accounts",
@ -2554,6 +2532,7 @@ export const en: MessageDict = {
"jobs.detail": "Details", "jobs.detail": "Details",
"jobs.loadMore": "Load more ({n} more)", "jobs.loadMore": "Load more ({n} more)",
"jobs.notFound": "Job not found", "jobs.notFound": "Job not found",
"jobs.detailLoadFail": "Could not load this job",
"jobs.progress": "Progress {n}%", "jobs.progress": "Progress {n}%",
"jobs.updated": "Updated {time}", "jobs.updated": "Updated {time}",
"jobs.backList": "Back to list", "jobs.backList": "Back to list",
@ -2849,21 +2828,8 @@ export const en: MessageDict = {
"scout.sending": "Sending…", "scout.sending": "Sending…",
"scout.needAccountBefore": "Connect a usable account under", "scout.needAccountBefore": "Connect a usable account under",
"scout.needAccountAfter": "first.", "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.loadingKnowledge": "Preparing related knowledge…",
"scout.notesCount": "{n} notes",
"scout.noKnowledge": "No related knowledge yet",
"scout.product": "Product", "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.queue": "Matches · {n}",
"scout.valueQueue": "Value replies · {n}", "scout.valueQueue": "Value replies · {n}",
"scout.activityQueue": "Activity short replies · {n}", "scout.activityQueue": "Activity short replies · {n}",
@ -2871,9 +2837,6 @@ export const en: MessageDict = {
"scout.collapseQueue": "Collapse queue", "scout.collapseQueue": "Collapse queue",
"scout.expandQueue": "Expand queue", "scout.expandQueue": "Expand queue",
"scout.noOtherPending": "No other pending", "scout.noOtherPending": "No other pending",
"scout.learnPoints": "Key takeaways",
"scout.replyHooks": "Reply hooks",
"scout.badgeCore": "Closest to topic",
"scout.unnamedRun": "Unnamed batch", "scout.unnamedRun": "Unnamed batch",
"scout.thisRun": "this batch", "scout.thisRun": "this batch",
"scout.confirmDeleteRun": "Delete patrol batch “{label}”?\\nHits and related knowledge will be removed. This cannot be undone.", "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.stanceActivity": "Short reply · activity",
"scout.stanceProduct": "Empathy · soft product", "scout.stanceProduct": "Empathy · soft product",
"scout.stanceRelation": "Engage · build rapport", "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.title": "Brands",
"brands.railAria": "Brand list", "brands.railAria": "Brand list",
@ -3148,6 +3100,8 @@ export const en: MessageDict = {
"inspire.useDraft": "Use this draft", "inspire.useDraft": "Use this draft",
"inspire.openPlay": "Open play", "inspire.openPlay": "Open play",
"inspire.thinking": "Thinking…", "inspire.thinking": "Thinking…",
"inspire.stop": "Stop generating",
"inspire.stopped": "Generation stopped",
"inspire.pinnedAria": "This-round references (for the AI)", "inspire.pinnedAria": "This-round references (for the AI)",
"inspire.pinned": "References", "inspire.pinned": "References",
"inspire.pinnedCount": "· {n}", "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.jobRunning": "Analyzing in background… will refresh when ready (see Jobs for progress)",
"persona.jobDone": "Background analysis finished · fingerprint saved", "persona.jobDone": "Background analysis finished · fingerprint saved",
"persona.jobFailed": "Background analysis failed — check Jobs for the error or retry", "persona.jobFailed": "Background analysis failed — check Jobs for the error or retry",
"persona.loadFail": "Could not load personas",
"persona.openJob": "Open job details", "persona.openJob": "Open job details",
"persona.setDefaultMsg": "“{name}” set as default", "persona.setDefaultMsg": "“{name}” set as default",
"persona.confirmDelete": "Delete persona “{name}”?", "persona.confirmDelete": "Delete persona “{name}”?",
@ -3475,7 +3430,6 @@ export const en: MessageDict = {
"persona.previewReplySample": "The one in Daan is fine but crowded", "persona.previewReplySample": "The one in Daan is fine but crowded",
"inspire.playTitle": "Inspired thread", "inspire.playTitle": "Inspired thread",
"wizard.topic.fallbackTopic": "Everyday topic",
"play.err.needLead": "Pick a lead account", "play.err.needLead": "Pick a lead account",
"play.err.needRoot": "Add at least one root post", "play.err.needRoot": "Add at least one root post",

View File

@ -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<string, { direct: number; total: number }> {
const children = new Map<string, string[]>();
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<string, number>();
function totalOf(uid: string, stack: Set<string>): 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<string, { direct: number; total: number }>();
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<string, TenantUserRecord>,
cache: Map<string, number>,
stack: Set<string>,
): 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<string, { direct: number; total: number }>,
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<string, InviteMemberBrief> {
const counts = countDownlines(users);
const byUid = new Map(users.map((u) => [u.uid, u]));
const depthCache = new Map<string, number>();
const map = new Map<string, InviteMemberBrief>();
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 keyUI 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<string | null, InviteMemberBrief[]>();
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<string>();
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"));
}

View File

@ -1,20 +0,0 @@
/**
* Backend uid is int64 from 1_000_000 (78 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));
}

View File

@ -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<void> {
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 || "23 短段",
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 ||
"先痛點、再經驗、最後輕帶觀點,避免一句廣告。",
};
}

View File

@ -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<GeneratedImage> {
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,
};
}

View File

@ -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<InspireAngle[]> {
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,
};
}

View File

@ -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<InspireChatMessage[]> {
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,
};
}

View File

@ -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<ProductUrlDraft> {
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();
// 粗分類用路徑關鍵字推痛點tagsmock 啟發式)
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: "已填入,可再改",
};
}

View File

@ -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<ScoutResearchNote, "learn_points" | "summary">): 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<ScoutResearchNote, "reply_hooks">): string[] {
return (n.reply_hooks || []).filter(Boolean).slice(0, 3);
}
/**
* mock + + + + URL
* core adjacent
*/
export async function mockWebResearch(query: string): Promise<ResearchHit[]> {
await mockDelay(650);
const q = query.trim() || "主題";
const slug = q.replace(/\s+/g, "-").slice(0, 32) || "topic";
const pages: Array<Omit<ResearchHit, "id">> = [
{
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<ScoutResearchNote[]> {
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<ScoutResearchTier, ScoutResearchNote[]> = {
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] }));
}

View File

@ -1,242 +0,0 @@
import type { BrandProduct, ScoutRunBrief, ScoutScanContext } from "../domain/types";
/** 簡單相關詞表:有命中才擴,避免假圖譜 */
const RELATED: Record<string, string[]> = {
: ["香精過敏", "刺鼻", "低敏洗劑"],
: ["換季刺癢", "成分表", "溫和"],
: ["無香", "刺鼻", "過敏"],
: ["無香", "香精"],
: ["洗淨力", "殘留", "寶寶衣物"],
: ["嬰幼兒", "溫和", "洗不乾淨"],
: ["不限時", "筆電", "久坐"],
: ["第三空間", "安靜", "插座"],
: ["插座", "工作座位"],
: ["換季", "無香", "刺癢"],
: ["敏感肌", "換季", "成分"],
: ["咖啡廳", "插座", "久坐"],
: ["去哪", "人少", "不限時"],
};
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<string>();
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}」,你們會先問什麼?`,
];
}

View File

@ -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<string[]> {
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") ? "24 個短段落,段間空行" : "一段講完再補一句問句",
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<Record<StyleDimKey, StyleDimension>> = {
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 或 01 個"),
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<Persona> {
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: "23 短段,段間空行",
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(),
};
}

View File

@ -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<ExternalThreadTarget> {
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<ExternalThreadTarget, "url"> | string): string {
const url = typeof target === "string" ? target : target.url;
return normalizeThreadUrl(url);
}

View File

@ -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<TrendItem, "id" | "observed_at" | "heat">[] = [
{
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<TrendItem[]> {
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<TrendItem[]> {
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}`;
}

View File

@ -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<ViralAnalysis> {
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<string> {
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");
}

View File

@ -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;
// 0100
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}`;
}

View File

@ -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("—");
});
});

View File

@ -92,8 +92,11 @@ export function formatRelativeFromNow(
/** 過去時間通知列表用剛剛N 分前) */ /** 過去時間通知列表用剛剛N 分前) */
export function formatTimeAgo(nano: number | null | undefined, now = nowUnixNano()): string { export function formatTimeAgo(nano: number | null | undefined, now = nowUnixNano()): string {
if (nano == null || nano <= 0) return "—"; // 這裡是唯一漏掉正規化的時間格式化函式:後端若回秒或毫秒,未換算的數值會遠小於 now
const diffMs = Math.floor((now - nano) / 1_000_000); // 算出來就變成「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"); if (diffMs < 45_000) return t("time.justNow");
const min = Math.floor(diffMs / 60_000); const min = Math.floor(diffMs / 60_000);
if (min < 60) return t("time.minAgo", { n: min }); if (min < 60) return t("time.minAgo", { n: min });

View File

@ -92,12 +92,19 @@ export function CrewPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const load = useCallback(async () => { 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 list = await repos.growth.listAccountHealth().catch(() => [] as AccountHealth[]);
const map: Record<string, AccountHealth> = {}; const map: Record<string, AccountHealth> = {};
for (const h of list) map[h.threads_account_id] = h; for (const h of list) map[h.threads_account_id] = h;
setOpHealth(map); setOpHealth(map);
}, [repos.accounts, repos.growth]); }, [repos.accounts, repos.growth, t]);
const pagedAccounts = useMemo( const pagedAccounts = useMemo(
() => pageSlice(accounts, accPage, ACCOUNTS_PAGE), () => pageSlice(accounts, accPage, ACCOUNTS_PAGE),
@ -224,7 +231,7 @@ export function CrewPage() {
) : null} ) : null}
{accounts.length === 0 ? ( {accounts.length === 0 ? (
<EmptyState title={t("crew.empty")} /> error ? null : <EmptyState title={t("crew.empty")} />
) : ( ) : (
<> <>
<ul className="hb-compact-list"> <ul className="hb-compact-list">

View File

@ -32,39 +32,64 @@ export function JobDetailPage() {
const [job, setJob] = useState<Job | null>(null); const [job, setJob] = useState<Job | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
const displayedJobStatus = job?.status; const displayedJobStatus = job?.status;
const isActive =
displayedJobStatus === "pending" ||
displayedJobStatus === "queued" ||
displayedJobStatus === "running";
const needsPayload = displayedJobStatus === "succeeded" && !job?.payload;
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
const fromLive = liveJobs.find((j) => j.id === id); const fromLive = liveJobs.find((j) => j.id === id);
if (fromLive) { if (fromLive) {
setJob(fromLive); setJob(fromLive);
setLoading(false);
setLoadFailed(false);
return; return;
} }
void repos.jobs.get(id).then(setJob); void repos.jobs
}, [id, repos.jobs, tick, liveJobs, revision]); .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 一次拿 payloadresult_text // 進行中:額外直接 get避免 list 延遲
useEffect(() => { useEffect(() => {
if (!id || !job) return; if (!id || !isActive) return;
const active = // 只看 isActive不看整個 job否則每次輪詢寫回新物件都會重建 interval計時器一直被歸零。
job.status === "pending" || job.status === "queued" || job.status === "running"; const timer = window.setInterval(() => {
if (active) { void repos.jobs.get(id).then(
const timer = window.setInterval(() => { (j) => {
void repos.jobs.get(id).then((j) => {
if (j) setJob(j); if (j) setJob(j);
}); },
}, 1500); () => {},
return () => window.clearInterval(timer); );
} }, 1500);
// succeeded 但 list 可能沒 payload → 補 get return () => window.clearInterval(timer);
if (job.status === "succeeded" && !job.payload) { }, [id, isActive, repos.jobs]);
void repos.jobs.get(id).then((j) => {
// succeeded 但 list 可能沒 payload → 補 get
useEffect(() => {
if (!id || !needsPayload) return;
void repos.jobs.get(id).then(
(j) => {
if (j) setJob(j); if (j) setJob(j);
}); },
} () => {},
}, [id, job?.status, job?.payload, repos.jobs]); // eslint-disable-line react-hooks/exhaustive-deps );
}, [id, needsPayload, repos.jobs]);
useEffect(() => { useEffect(() => {
if (!id || !displayedJobStatus) return; if (!id || !displayedJobStatus) return;
@ -138,6 +163,23 @@ export function JobDetailPage() {
} }
} }
if (!job && loading) {
return <EmptyState title={t("common.loading")} />;
}
if (!job && loadFailed) {
return (
<>
<p className="hb-banner-error" role="alert">
{error || t("jobs.detailLoadFail")}
</p>
<Link to="/app/jobs" className="hb-btn hb-btn--ghost">
{t("common.back")}
</Link>
</>
);
}
if (!job) { if (!job) {
return ( return (
<EmptyState <EmptyState

View File

@ -24,29 +24,39 @@ export function OutboxDetailPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!id) return; if (!id) return;
try { try {
const [b, acc] = await Promise.all([repos.outbox.get(id), repos.accounts.list()]); const [b, acc] = await Promise.all([repos.outbox.get(id), repos.accounts.list()]);
setBundle(b); setBundle(b);
setAccounts(acc); setAccounts(acc);
setLoadFailed(false);
setError("");
} catch (e) {
// 載入失敗與「這篇不存在」是兩件事,分開記,否則畫面會謊稱找不到。
setLoadFailed(true);
setError(e instanceof Error ? e.message : t("outbox.detail.loadFail"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [id, repos.outbox, repos.accounts]); }, [id, repos.outbox, repos.accounts, t]);
useEffect(() => { useEffect(() => {
void load(); void load();
}, [load, tick]); }, [load, tick]);
// 發文中自動輪詢worker 可能 1030s 才完成,不輪詢會一直卡舊狀態/舊錯誤 // 發文中自動輪詢worker 可能 1030s 才完成,不輪詢會一直卡舊狀態/舊錯誤。
// 只看 inFlight 布林:依賴整個 bundle 的話,每次輪詢寫回新物件都會重建 interval。
const inFlight = isInFlightBundle(bundle);
useEffect(() => { useEffect(() => {
if (!isInFlightBundle(bundle)) return; if (!inFlight) return;
const id = window.setInterval(() => { const timer = window.setInterval(() => {
void load(); void load();
}, 2000); }, 2000);
return () => window.clearInterval(id); return () => window.clearInterval(timer);
}, [bundle, load]); }, [inFlight, load]);
async function run(action: () => Promise<OutboxBundle>) { async function run(action: () => Promise<OutboxBundle>) {
setBusy(true); setBusy(true);
@ -79,12 +89,28 @@ export function OutboxDetailPage() {
if (!id) return <EmptyState title={t("outbox.detail.missingId")} />; if (!id) return <EmptyState title={t("outbox.detail.missingId")} />;
if (loading && !bundle) return <EmptyState title={t("outbox.detail.loading")} />; if (loading && !bundle) return <EmptyState title={t("outbox.detail.loading")} />;
if (!bundle && loadFailed) {
return (
<>
<p className="hb-banner-error" role="alert">
{error || t("outbox.detail.loadFail")}
</p>
<div className="hb-wizard-actions">
<Button type="button" onClick={() => void load()}>
{t("common.retry")}
</Button>
<Link to="/app/outbox" className="hb-btn hb-btn--ghost">
{t("common.back")}
</Link>
</div>
</>
);
}
if (!bundle) return <EmptyState title={t("outbox.detail.notFound")} />; if (!bundle) return <EmptyState title={t("outbox.detail.notFound")} />;
const nameOf = (accountId: string) => const nameOf = (accountId: string) =>
accounts.find((a) => a.id === accountId)?.display_name || accountId; 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"); const allPublished = bundle.steps.length > 0 && bundle.steps.every((s) => s.status === "published");
return ( return (

View File

@ -10,16 +10,8 @@ import type {
ScoutHomeworkRecord, ScoutHomeworkRecord,
ScoutPost, ScoutPost,
ScoutPurpose, ScoutPurpose,
ScoutResearchNote,
ScoutResearchTier,
ScoutRunBrief, ScoutRunBrief,
} from "../domain/types"; } from "../domain/types";
import {
groupNotesByTier,
noteLearnPoints,
noteReplyHooks,
formatResearchLink,
} from "../lib/mockResearch";
import { newId } from "../lib/id"; import { newId } from "../lib/id";
import { allowHttpUrl } from "../lib/externalUrl"; import { allowHttpUrl } from "../lib/externalUrl";
import { bumpScoutTodayDone, loadScoutToday, saveScoutToday } from "../lib/scoutToday"; 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))}`; 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 (
<li className={`hb-scout-research__card${compact ? " is-preview" : ""}`}>
<div className="hb-scout-research__head">
<strong>{note.title}</strong>
{badge ? <Badge tone="success">{badge}</Badge> : null}
</div>
{relationLabel ? (
<span className="hb-scout-research__relation">{relationLabel}</span>
) : null}
{showPoints.length > 0 ? (
<div className="hb-scout-research__block">
<p className="hb-scout-research__block-label">{t("scout.learnPoints")}</p>
<ul className="hb-scout-research__points">
{showPoints.map((pt) => (
<li key={pt}>{pt}</li>
))}
</ul>
</div>
) : note.summary ? (
<p
className={`hb-scout-research__summary${compact ? " hb-scout-research__summary--clamp" : ""}`}
>
{note.summary}
</p>
) : null}
{showHooks.length > 0 ? (
<div className="hb-scout-research__block">
<p className="hb-scout-research__block-label">{t("scout.replyHooks")}</p>
<ul className="hb-scout-research__hooks">
{showHooks.map((h) => (
<li key={h}>{h}</li>
))}
</ul>
</div>
) : null}
{safeUrl ? (
<a
className="hb-scout-research__link"
href={safeUrl}
target="_blank"
rel="noreferrer"
title={safeUrl}
>
<span className="hb-scout-research__link-label">{link.label}</span>
<span className="hb-scout-research__link-host">{link.host}</span>
</a>
) : null}
</li>
);
}
/** /**
* + + * + +
*/ */
@ -221,11 +143,8 @@ export function ScoutPage() {
const [currentId, setCurrentId] = useState<string | null>(null); const [currentId, setCurrentId] = useState<string | null>(null);
const [draftText, setDraftText] = useState(""); const [draftText, setDraftText] = useState("");
const [report, setReport] = useState<ScoutRunBrief | null>(null);
const [reportOpen, setReportOpen] = useState(false);
const [valueQueuePage, setValueQueuePage] = useState(1); const [valueQueuePage, setValueQueuePage] = useState(1);
const [activityQueuePage, setActivityQueuePage] = useState(1); const [activityQueuePage, setActivityQueuePage] = useState(1);
const [researchTier, setResearchTier] = useState<"all" | ScoutResearchTier>("all");
const [homeworkList, setHomeworkList] = useState<ScoutHomeworkRecord[]>([]); const [homeworkList, setHomeworkList] = useState<ScoutHomeworkRecord[]>([]);
/** 目前檢視的海巡批次(每次按開始 = 一筆) */ /** 目前檢視的海巡批次(每次按開始 = 一筆) */
const [activeRunKey, setActiveRunKey] = useState<string | null>(null); const [activeRunKey, setActiveRunKey] = useState<string | null>(null);
@ -431,15 +350,6 @@ export function ScoutPage() {
setActiveRunKey(key); setActiveRunKey(key);
setValueQueuePage(1); setValueQueuePage(1);
setActivityQueuePage(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 const pending = posts
.filter((p) => postRunKey(p) === key && isPending(p)) .filter((p) => postRunKey(p) === key && isPending(p))
.sort((a, b) => (b.score || 0) - (a.score || 0)); .sort((a, b) => (b.score || 0) - (a.score || 0));
@ -462,7 +372,6 @@ export function ScoutPage() {
setHomeworkList(hw); setHomeworkList(hw);
if (activeRunKey === key) { if (activeRunKey === key) {
setActiveRunKey(null); setActiveRunKey(null);
setReport(null);
setCurrentId(null); setCurrentId(null);
} else if (currentId && !list.some((p) => p.id === currentId)) { } else if (currentId && !list.some((p) => p.id === currentId)) {
pickNext(currentId, list, activeRunKey); pickNext(currentId, list, activeRunKey);
@ -964,175 +873,6 @@ export function ScoutPage() {
)} )}
</Card> </Card>
{/* ④ 周邊知識 · 綁目前海巡批次 */}
{report?.mode !== "activity" &&
report?.theme_key === activeRunKey &&
report.research_notes?.length ? (
<Card
title={
report?.theme_label
? t("scout.knowledgeWithLabel", { label: report.theme_label })
: t("scout.knowledgeLearn")
}
>
<div className="hb-scout-learn">
<div className="hb-scout-learn__toolbar">
<Button
type="button"
variant={report?.research_notes?.length && !reportOpen ? "primary" : "ghost"}
onClick={() => setReportOpen((v) => !v)}
disabled={!report}
>
{reportOpen
? t("scout.collapseKnowledge")
: report?.research_notes?.length
? t("scout.expandLearn", { n: report.research_notes.length })
: t("scout.expandKnowledge")}
</Button>
{report?.theme_key ? (
<Button
type="button"
variant="ghost"
disabled={Boolean(busy)}
onClick={() => void removeRun(report.theme_key!)}
>
{t("scout.deleteKnowledgeRun")}
</Button>
) : null}
</div>
{!reportOpen || !report ? (
report ? (
report.research_notes && report.research_notes.length > 0 ? (
<>
<div className="hb-scout-learn-banner is-ready">
<p style={{ fontSize: "0.9rem", fontWeight: 600 }}>
{t("scout.notesCount", { n: report.research_notes.length })}
{report.product_label ? ` · ${report.product_label}` : ""}
</p>
</div>
<ul className="hb-scout-research hb-scout-research--preview">
{report.research_notes
.filter((n) => (n.tier || "core") === "core")
.slice(0, 2)
.map((n) => (
<KnowledgeNoteCard key={n.id} note={n} badge={t("scout.badgeCore")} compact />
))}
</ul>
</>
) : (
<p className="hb-scout-learn__intro">{t("scout.noKnowledge")}</p>
)
) : null
) : (
<>
{report.product_label ? (
<div className="hb-scout-context">
<p className="hb-field__label">{t("scout.product")}</p>
<p style={{ fontWeight: 600, margin: 0 }}>{report.product_label}</p>
{report.product_context ? (
<p style={{ margin: 0, fontSize: "0.9rem", lineHeight: 1.55 }}>
{report.product_context}
</p>
) : null}
</div>
) : null}
{report.pains.length > 0 ? (
<div className="hb-scout-context">
<p className="hb-field__label">
{report.product_label ? t("scout.painsSolved") : t("scout.focus")}
</p>
<div className="hb-chip-row">
{report.pains.map((p) => (
<span key={p} className="hb-chip is-static">
{p}
</span>
))}
</div>
</div>
) : null}
{report.research_notes && report.research_notes.length > 0 ? (
<div className="hb-scout-context hb-scout-learn">
<div className="hb-scout-research-tier__head">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("scout.knowledge")}
</p>
<Badge tone="brand">{report.research_notes.length}</Badge>
</div>
<div className="hb-tabs hb-tabs--sm hb-scout-learn__tabs" role="tablist">
<button
type="button"
className={`hb-tab ${researchTier === "all" ? "is-active" : ""}`}
onClick={() => setResearchTier("all")}
>
{t("scout.all")}
</button>
{(["core", "adjacent", "broad"] as ScoutResearchTier[]).map((tier) => {
const n = report.research_notes!.filter(
(x) => (x.tier || "adjacent") === tier,
).length;
if (!n) return null;
return (
<button
key={tier}
type="button"
className={`hb-tab ${researchTier === tier ? "is-active" : ""}`}
onClick={() => setResearchTier(tier)}
title={t(`scout.tier.${tier}Hint`)}
>
{t(`scout.tier.${tier}`)}
<span className="hb-tab__count"> {n}</span>
</button>
);
})}
</div>
<div className="hb-scout-learn__tiers">
{(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 : (
<div key={group.tier} className="hb-scout-research-tier">
{researchTier === "all" ? (
<div className="hb-scout-research-tier__head">
<Badge
tone={
group.tier === "core"
? "success"
: group.tier === "adjacent"
? "brand"
: "neutral"
}
>
{t(`scout.tier.${group.tier}`)}
</Badge>
</div>
) : null}
<ul className="hb-scout-research">
{group.notes.map((n) => (
<KnowledgeNoteCard key={n.id} note={n} />
))}
</ul>
</div>
),
)}
</div>
</div>
) : (
<p className="hb-scout-learn__intro">{t("scout.noWebSummary")}</p>
)}
</>
)}
</div>
</Card>
) : null}
{/* ⑤ 不同回覆策略不能混排,避免將短回誤當成痛點接話。 */} {/* ⑤ 不同回覆策略不能混排,避免將短回誤當成痛點接話。 */}
<MatchQueue <MatchQueue

View File

@ -46,8 +46,16 @@ export function SettingsPage() {
/** 進頁:一支 GetAi 同時拿設定 + 模型清單 + 上次選定 model */ /** 進頁:一支 GetAi 同時拿設定 + 模型清單 + 上次選定 model */
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
const a = await repos.settings.getAi(); let a: AiSettings;
const p = await repos.settings.getPlacement(); 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 provider = normalizeProvider(a.provider);
const list = a.models?.length const list = a.models?.length
? a.models ? a.models
@ -69,11 +77,9 @@ export function SettingsPage() {
models: list, models: list,
}); });
setPlacement(p); 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 */ /** 換 provider同一支 getAi(?provider=) 帶回 models + selected_model */
async function onProviderChange(nextId: string) { async function onProviderChange(nextId: string) {

View File

@ -106,6 +106,7 @@ export function UsagePage() {
} }
}, [repos.usage, repos.growth, t]); }, [repos.usage, repos.growth, t]);
/** 失敗會 throw呼叫端的存檔流程要能知道刷新沒成功不能自己吞掉。 */
async function loadTenant(query = applied) { async function loadTenant(query = applied) {
if (!isAdmin) return; if (!isAdmin) return;
setTenant( setTenant(
@ -133,7 +134,11 @@ export function UsagePage() {
}, [loadMine, tick]); }, [loadMine, tick]);
useEffect(() => { 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, isAdmin, repos.usage, tick, applied]); }, [tab, isAdmin, repos.usage, tick, applied]);

View File

@ -12,6 +12,7 @@ import type {
InspireSessionSummary, InspireSessionSummary,
TrendItem, TrendItem,
} from "../../domain/types"; } from "../../domain/types";
import { isAbortError } from "../../lib/abort";
import { newId } from "../../lib/id"; import { newId } from "../../lib/id";
import { saveComposeDraftBody } from "../../lib/composeBridge"; import { saveComposeDraftBody } from "../../lib/composeBridge";
import { nowUnixNano } from "../../lib/time"; import { nowUnixNano } from "../../lib/time";
@ -72,6 +73,8 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
const [error, setError] = useState(""); const [error, setError] = useState("");
/** stream 中的助手暫存字(尚未 done */ /** stream 中的助手暫存字(尚未 done */
const [streamingText, setStreamingText] = useState(""); const [streamingText, setStreamingText] = useState("");
/** 進行中的串流;按停止或離開頁面時用它中止,避免背景繼續燒 AI 額度 */
const streamAbort = useRef<AbortController | null>(null);
const [useWeb, setUseWeb] = useState(false); const [useWeb, setUseWeb] = useState(false);
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [newKind, setNewKind] = useState<InspireElementKind>("role"); const [newKind, setNewKind] = useState<InspireElementKind>("role");
@ -164,6 +167,11 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [session?.messages.length, busy, streamingText]); }, [session?.messages.length, busy, streamingText]);
// 離開頁面時關掉還開著的串流,否則 SSE 連線與後端的 AI 呼叫都會繼續跑到結束。
useEffect(() => {
return () => streamAbort.current?.abort();
}, []);
// 從今日等入口:?topic=xxx → 預填輸入框(只一次) // 從今日等入口:?topic=xxx → 預填輸入框(只一次)
useEffect(() => { useEffect(() => {
if (topicSeeded.current) return; if (topicSeeded.current) return;
@ -298,6 +306,18 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
return [...ids].sort().join(","); 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 { function resolveSendPayload(mode: "chat" | "generate"): { message: string; mode: "chat" | "generate" } | null {
const text = input.trim(); const text = input.trim();
@ -333,6 +353,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
], ],
}); });
} }
const signal = beginStream();
try { try {
const result = await repos.inspiration.chatStream( const result = await repos.inspiration.chatStream(
{ {
@ -341,6 +362,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
mode: "generate", mode: "generate",
personaId, personaId,
sessionId: session?.id, sessionId: session?.id,
signal,
}, },
(chunk) => { (chunk) => {
setStreamingText((prev) => prev + chunk); setStreamingText((prev) => prev + chunk);
@ -376,7 +398,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
refresh(); refresh();
} catch (e) { } catch (e) {
setStreamingText(""); 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 { try {
setSession(await repos.inspiration.getSession()); setSession(await repos.inspiration.getSession());
} catch { } catch {
@ -417,6 +439,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
}); });
} }
setInput(""); setInput("");
const signal = beginStream();
try { try {
const result = await repos.inspiration.chatStream( const result = await repos.inspiration.chatStream(
{ {
@ -426,6 +449,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
personaId, personaId,
sessionId: session?.id, sessionId: session?.id,
useWeb, useWeb,
signal,
}, },
(chunk) => { (chunk) => {
setStreamingText((prev) => prev + chunk); setStreamingText((prev) => prev + chunk);
@ -489,7 +513,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
refresh(); refresh();
} catch (e) { } catch (e) {
setStreamingText(""); 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 若後端沒寫入 // 失敗時重拉 session 去掉樂觀 user bubble 若後端沒寫入
try { try {
setSession(await repos.inspiration.getSession()); setSession(await repos.inspiration.getSession());
@ -990,6 +1014,13 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
</div> </div>
</div> </div>
) : null} ) : null}
{busy === "chat" || busy === "generate" ? (
<div className="hb-inspire-stop">
<Button type="button" variant="ghost" onClick={stopStream}>
{t("inspire.stop")}
</Button>
</div>
) : null}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>

View File

@ -3,7 +3,6 @@ import { Button, Card, Input, Select, Textarea } from "../../../components/ui";
import { useRepos } from "../../../data/DataContext"; import { useRepos } from "../../../data/DataContext";
import type { Persona } from "../../../domain/types"; import type { Persona } from "../../../domain/types";
import { useI18n } from "../../../i18n/I18nContext"; import { useI18n } from "../../../i18n/I18nContext";
import { mockDelay, mockGenerateTopic } from "../../../lib/mockAi";
import { isPersonaReady, personaOptionLabel } from "../../../lib/personaPrompt"; import { isPersonaReady, personaOptionLabel } from "../../../lib/personaPrompt";
import type { WizardDraft } from "../wizardState"; import type { WizardDraft } from "../wizardState";
@ -17,7 +16,6 @@ export function TopicStep({ draft, onChange }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [personas, setPersonas] = useState<Persona[]>([]); const [personas, setPersonas] = useState<Persona[]>([]);
const [personaId, setPersonaId] = useState(""); const [personaId, setPersonaId] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
@ -28,29 +26,6 @@ export function TopicStep({ draft, onChange }: Props) {
})(); })();
}, [repos.personas]); }, [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)); const ready = isPersonaReady(personas.find((p) => p.id === personaId));
return ( return (
@ -90,9 +65,6 @@ export function TopicStep({ draft, onChange }: Props) {
</p> </p>
) : null} ) : null}
<div className="hb-wizard-actions"> <div className="hb-wizard-actions">
<Button type="button" variant="ghost" onClick={() => void aiTopic()} disabled={busy || !ready}>
{busy ? t("wizard.topic.generating") : t("wizard.topic.aiSuggest")}
</Button>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"

View File

@ -23,6 +23,8 @@ export function PainKeywordsPage() {
const data = await apiRequest<NonNullable<typeof result>>("/api/v1/public/tools/pain-keywords", { const data = await apiRequest<NonNullable<typeof result>>("/api/v1/public/tools/pain-keywords", {
method: "POST", method: "POST",
body: { product_brief: brief, audience }, body: { product_brief: brief, audience },
// 免登入端點:不要把會員 JWT 送出去,也不要在 401 時觸發 refresh。
auth: false,
}); });
setResult(data); setResult(data);
} catch (e) { } catch (e) {

View File

@ -24,6 +24,8 @@ export function StyleQuizPage() {
const data = await apiRequest<NonNullable<typeof result>>("/api/v1/public/tools/style-quiz", { const data = await apiRequest<NonNullable<typeof result>>("/api/v1/public/tools/style-quiz", {
method: "POST", method: "POST",
body: { samples }, body: { samples },
// 免登入端點:不要把會員 JWT 送出去,也不要在 401 時觸發 refresh。
auth: false,
}); });
setResult(data); setResult(data);
} catch (e) { } catch (e) {

View File

@ -895,6 +895,12 @@ svg {
vertical-align: text-bottom; vertical-align: text-bottom;
} }
.hb-inspire-stop {
display: flex;
justify-content: center;
padding: 4px 0 8px;
}
/* —— 輕量 markdown聊天泡泡逐行換行不擠成一團 —— */ /* —— 輕量 markdown聊天泡泡逐行換行不擠成一團 —— */
.hb-md { .hb-md {
display: flex; display: flex;
@ -1778,279 +1784,6 @@ svg {
background: var(--hb-brand-soft); background: var(--hb-brand-soft);
} }
/* 探查:周邊知識 chips */
.hb-scout-context {
display: flex;
flex-direction: column;
gap: var(--hb-gap-stack);
padding: var(--hb-space-5);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface-muted);
margin: 0;
min-width: 0;
}
.hb-scout-context > .hb-field__label,
.hb-scout-context > p {
margin: 0;
}
/* 展開學習:整塊面板 */
.hb-scout-learn {
display: flex;
flex-direction: column;
gap: var(--hb-gap-block);
margin: 0;
min-width: 0;
}
.hb-scout-learn__toolbar {
display: flex;
flex-wrap: wrap;
gap: var(--hb-gap-inline);
margin: 0;
padding: 0;
}
.hb-scout-learn__intro {
margin: 0;
font-size: 0.85rem;
line-height: 1.55;
color: var(--hb-muted);
}
.hb-scout-learn__tabs {
margin: 0 !important;
width: 100%;
}
.hb-scout-learn__tiers {
display: flex;
flex-direction: column;
gap: var(--hb-gap-section);
margin: 0;
min-width: 0;
}
.hb-scout-research {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--hb-gap-stack);
}
.hb-scout-research__card {
display: flex;
flex-direction: column;
gap: var(--hb-gap-stack);
padding: var(--hb-space-5);
border: 1px solid var(--hb-line);
border-radius: var(--hb-radius-lg);
background: var(--hb-surface-solid, var(--hb-surface));
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
min-width: 0;
}
.hb-scout-research__relation {
align-self: flex-start;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--hb-muted);
padding: 0.2rem 0.55rem;
border-radius: var(--hb-radius-pill);
border: 1px solid var(--hb-line);
background: var(--hb-surface-muted);
margin: 0;
}
.hb-scout-research__block {
display: flex;
flex-direction: column;
gap: var(--hb-gap-tight);
margin: 0;
padding: 0.65rem 0.75rem;
border-radius: var(--hb-radius);
background: color-mix(in srgb, var(--hb-surface-muted) 80%, transparent);
border: 1px solid color-mix(in srgb, var(--hb-line) 70%, transparent);
}
.hb-scout-research__block-label {
margin: 0;
font-size: 0.72rem;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: none;
color: var(--hb-muted);
}
.hb-scout-research__points,
.hb-scout-research__hooks {
margin: 0;
padding: 0 0 0 1.1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.hb-scout-research__points li {
font-size: 0.85rem;
line-height: 1.55;
color: var(--hb-ink-secondary, var(--hb-text));
padding-block: 0.05rem;
}
.hb-scout-research__hooks li {
font-size: 0.82rem;
line-height: 1.5;
color: color-mix(in srgb, var(--hb-brand-deep) 80%, var(--hb-ink));
list-style-type: "↳ ";
padding-left: 0.15rem;
padding-block: 0.1rem;
}
.hb-scout-research__head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--hb-gap-inline);
margin: 0;
}
.hb-scout-research__head strong {
font-size: 0.95rem;
line-height: 1.4;
flex: 1;
min-width: 10rem;
letter-spacing: -0.01em;
}
.hb-scout-research__summary {
margin: 0;
font-size: 0.85rem;
line-height: 1.6;
color: var(--hb-ink-secondary, var(--hb-text));
white-space: pre-wrap;
}
.hb-scout-research__url {
font-size: 0.78rem;
word-break: break-all;
color: var(--hb-brand-deep);
text-decoration: underline;
text-underline-offset: 0.15em;
}
.hb-scout-research__url:hover {
opacity: 0.9;
}
/* 漂亮來源連結:站名 + host不甩整串 URL */
.hb-scout-research__link {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.5rem;
max-width: 100%;
margin-top: 0.15rem;
padding: 0.45rem 0.8rem;
border-radius: var(--hb-radius-pill);
border: 1px solid color-mix(in srgb, var(--hb-brand) 35%, var(--hb-line));
background: color-mix(in srgb, var(--hb-brand-soft, var(--hb-surface-muted)) 55%, var(--hb-surface));
color: var(--hb-brand-deep);
text-decoration: none;
font-size: 0.8rem;
line-height: 1.3;
align-self: flex-start;
}
.hb-scout-research__link:hover {
border-color: var(--hb-brand);
background: color-mix(in srgb, var(--hb-brand-soft, var(--hb-surface-muted)) 75%, var(--hb-surface));
}
.hb-scout-research__link-label {
font-weight: 650;
}
.hb-scout-research__link-host {
color: var(--hb-muted);
font-size: 0.75rem;
}
.hb-scout-research-tier {
display: flex;
flex-direction: column;
gap: var(--hb-gap-stack);
margin: 0;
padding: 0;
min-width: 0;
}
.hb-scout-research-tier__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--hb-gap-inline);
margin: 0;
padding-bottom: var(--hb-space-2);
border-bottom: 1px solid color-mix(in srgb, var(--hb-line) 85%, transparent);
}
.hb-scout-research-tier__hint {
margin: 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--hb-muted);
}
/* 周邊知識:可學橫幅 + 收合預覽 */
.hb-scout-learn-banner {
display: flex;
flex-direction: column;
gap: var(--hb-gap-tight);
padding: var(--hb-space-4) var(--hb-space-5);
border-radius: var(--hb-radius-lg);
border: 1px solid var(--hb-line);
background: var(--hb-surface-muted);
margin: 0;
}
.hb-scout-learn-banner p {
margin: 0;
}
.hb-scout-learn-banner.is-ready {
border-color: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-line));
background: color-mix(in srgb, var(--hb-brand-soft, var(--hb-surface-muted)) 65%, var(--hb-surface));
}
.hb-scout-learn-banner.is-loading {
border-style: dashed;
}
.hb-scout-research--preview {
gap: var(--hb-gap-stack);
}
.hb-scout-research__card.is-preview {
background: var(--hb-surface-solid, var(--hb-surface));
gap: var(--hb-gap-inline);
padding: var(--hb-space-4);
}
.hb-scout-research__summary--clamp {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.hb-tab__count { .hb-tab__count {
opacity: 0.75; opacity: 0.75;