fix bugs
This commit is contained in:
parent
f2be5a436e
commit
f46a5cf0a9
|
|
@ -14,6 +14,8 @@ import { useRepos } from "../data/DataContext";
|
|||
type AuthContextValue = {
|
||||
member: Member | null;
|
||||
loading: boolean;
|
||||
/** 最近一次 me() 因暫時性原因失敗(斷網/後端重啟)。登入狀態未知,不是已登出。 */
|
||||
unreachable: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
reload: () => Promise<void>;
|
||||
|
|
@ -26,6 +28,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const repos = useRepos();
|
||||
const [member, setMember] = useState<Member | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
// 已進過 app 後不要再把 loading=true:否則 RequireAuth 會卸載整棵 /app,
|
||||
|
|
@ -33,6 +36,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
try {
|
||||
const me = await repos.auth.me();
|
||||
setMember(me);
|
||||
setUnreachable(false);
|
||||
} catch {
|
||||
// me() 只在 401 回 null,會走到這裡的都是連不上或 5xx。此時 session 很可能還有效,
|
||||
// 保留目前的 member,否則使用者會因為一次網路抖動就被踢回登入頁。
|
||||
setUnreachable(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -46,6 +54,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
async (email: string, password: string) => {
|
||||
const { member: m } = await repos.auth.login(email, password);
|
||||
setMember(m);
|
||||
setUnreachable(false);
|
||||
},
|
||||
[repos.auth],
|
||||
);
|
||||
|
|
@ -53,6 +62,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const logout = useCallback(async () => {
|
||||
await repos.auth.logout();
|
||||
setMember(null);
|
||||
setUnreachable(false);
|
||||
}, [repos.auth]);
|
||||
|
||||
const updateProfile = useCallback(
|
||||
|
|
@ -65,8 +75,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ member, loading, login, logout, reload, updateProfile }),
|
||||
[member, loading, login, logout, reload, updateProfile],
|
||||
() => ({ member, loading, unreachable, login, logout, reload, updateProfile }),
|
||||
[member, loading, unreachable, login, logout, reload, updateProfile],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useI18n } from "../i18n/I18nContext";
|
|||
import { useAuth } from "./AuthContext";
|
||||
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { member, loading } = useAuth();
|
||||
const { member, loading, unreachable, reload } = useAuth();
|
||||
const location = useLocation();
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -15,6 +15,19 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
// 連不上後端時登入狀態是未知的,不是已登出。踢回登入頁會讓使用者以為 session 掉了,
|
||||
// 而且重新登入也一樣會失敗,所以這裡給重試而不是轉址。
|
||||
if (!member && unreachable) {
|
||||
return (
|
||||
<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) {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ export function PersonaWorkbench() {
|
|||
const [sourceLabel, setSourceLabel] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [previewPost, setPreviewPost] = useState("");
|
||||
const [previewReply, setPreviewReply] = useState("");
|
||||
const [previewTopic, setPreviewTopic] = useState("");
|
||||
|
|
@ -64,6 +65,7 @@ export function PersonaWorkbench() {
|
|||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [list, active, accList] = await Promise.all([
|
||||
repos.personas.list(),
|
||||
repos.personas.getActiveId(),
|
||||
|
|
@ -76,6 +78,11 @@ export function PersonaWorkbench() {
|
|||
if (cur && list.some((p) => p.id === cur)) return cur;
|
||||
return active || list[0]?.id || "";
|
||||
});
|
||||
setLoadError("");
|
||||
} catch (e) {
|
||||
// 這裡不能借用 message:它同時被 job 進度用,下一次輪詢就會把錯誤蓋掉。
|
||||
setLoadError(e instanceof Error ? e.message : t("persona.loadFail"));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -408,6 +415,11 @@ export function PersonaWorkbench() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{loadError ? (
|
||||
<p className="hb-banner-error" role="alert">
|
||||
{loadError}
|
||||
</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="hb-banner-ok" role="status">
|
||||
{message}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -24,6 +24,11 @@ export class ApiError extends Error {
|
|||
detail: unknown;
|
||||
/** 後端原始 message(除錯用;UI 請用 code → 語言包) */
|
||||
rawMessage: string;
|
||||
/**
|
||||
* 401 但 refresh 沒拿到結論(連不上/5xx)。代表登入狀態未知,不等於已登出,
|
||||
* 呼叫端不應該清 token 或把人踢回登入頁。
|
||||
*/
|
||||
sessionUnknown: boolean;
|
||||
|
||||
constructor(message: string, code: number, httpStatus: number, detail?: unknown, rawMessage?: string) {
|
||||
super(message);
|
||||
|
|
@ -32,6 +37,7 @@ export class ApiError extends Error {
|
|||
this.httpStatus = httpStatus;
|
||||
this.detail = detail;
|
||||
this.rawMessage = rawMessage ?? message;
|
||||
this.sessionUnknown = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,30 +81,42 @@ export function saveTokens(t: StoredTokens | null): void {
|
|||
localStorage.setItem(TOKEN_KEY, JSON.stringify(t));
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<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();
|
||||
if (!cur?.refresh_token) return null;
|
||||
if (!cur?.refresh_token) return { status: "rejected" };
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
refreshPromise = (async (): Promise<RefreshResult> => {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/v1/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: cur.refresh_token }),
|
||||
});
|
||||
// 5xx 是後端狀況,不是「這張 refresh token 壞了」。
|
||||
if (res.status >= 500) return { status: "unreachable" };
|
||||
const env = (await res.json()) as Envelope<{ tokens: StoredTokens }>;
|
||||
if (env.code !== SUCCESS || !env.data?.tokens) {
|
||||
saveTokens(null);
|
||||
return null;
|
||||
return { status: "rejected" };
|
||||
}
|
||||
const next = env.data.tokens;
|
||||
saveTokens(next);
|
||||
return next;
|
||||
return { status: "renewed", tokens: next };
|
||||
} catch {
|
||||
// A temporary network failure is not proof that the refresh token is invalid.
|
||||
return null;
|
||||
return { status: "unreachable" };
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
|
|
@ -161,51 +179,63 @@ export async function apiRequest<T>(path: string, opts: RequestOpts = {}): Promi
|
|||
}
|
||||
|
||||
// retry once on 401 with refresh
|
||||
let sessionUnknown = false;
|
||||
if (res.status === 401 && auth && !opts.raw) {
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed?.access_token) {
|
||||
headers.Authorization = `Bearer ${refreshed.access_token}`;
|
||||
if (refreshed.status === "renewed") {
|
||||
headers.Authorization = `Bearer ${refreshed.tokens.access_token}`;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new ApiError(messageForApiCode(0), 0, 0, e);
|
||||
}
|
||||
} else {
|
||||
sessionUnknown = refreshed.status === "unreachable";
|
||||
}
|
||||
}
|
||||
|
||||
const tag = (e: ApiError): ApiError => {
|
||||
e.sessionUnknown = sessionUnknown;
|
||||
return e;
|
||||
};
|
||||
|
||||
let env: Envelope<T>;
|
||||
try {
|
||||
env = (await res.json()) as Envelope<T>;
|
||||
} catch {
|
||||
// go-zero 逾時常直接 503 非 envelope → 前端顯示「伺服器錯誤」;改成可理解的 AI 逾時
|
||||
if (res.status === 503 || res.status === 504 || res.status === 408) {
|
||||
throw new ApiError(messageForApiCode(503001, "AI 回應逾時,請縮短內容後再試"), 503001, res.status);
|
||||
throw tag(new ApiError(messageForApiCode(503001, "AI 回應逾時,請縮短內容後再試"), 503001, res.status));
|
||||
}
|
||||
throw new ApiError(
|
||||
throw tag(
|
||||
new ApiError(
|
||||
messageForApiCode(res.status >= 500 ? 500000 : 400001, `invalid response (${res.status})`),
|
||||
res.status,
|
||||
res.status,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (env.code !== SUCCESS) {
|
||||
// gateway 逾時有時仍帶 envelope + 5xx
|
||||
if (res.status === 503 || res.status === 504 || env.code === 503001) {
|
||||
throw new ApiError(
|
||||
throw tag(
|
||||
new ApiError(
|
||||
messageForApiCode(503001, env.message || "AI 回應逾時"),
|
||||
503001,
|
||||
res.status,
|
||||
env.error,
|
||||
env.message,
|
||||
),
|
||||
);
|
||||
}
|
||||
// message 用目前語言包;code 保留給 UI 再對一次語系
|
||||
throw new ApiError(
|
||||
messageForApiCode(env.code, env.message),
|
||||
env.code,
|
||||
res.status,
|
||||
env.error,
|
||||
env.message,
|
||||
throw tag(
|
||||
new ApiError(messageForApiCode(env.code, env.message), env.code, res.status, env.error, env.message),
|
||||
);
|
||||
}
|
||||
return env.data;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type {
|
|||
BrandProduct,
|
||||
InspirationIdea,
|
||||
Job,
|
||||
InspireAngle,
|
||||
InspireChatMessage,
|
||||
InspireElement,
|
||||
InspireSession,
|
||||
|
|
@ -16,10 +15,8 @@ import type {
|
|||
ScoutPost,
|
||||
ScoutRunBrief,
|
||||
ScoutScanContext,
|
||||
ScoutTopic,
|
||||
TrendItem,
|
||||
TrendKind,
|
||||
ViralSample,
|
||||
} from "../../domain/types";
|
||||
import type {
|
||||
InspirationRepo,
|
||||
|
|
@ -221,10 +218,6 @@ function mapHomework(raw: Record<string, unknown>): ScoutHomeworkRecord {
|
|||
};
|
||||
}
|
||||
|
||||
const removed = (name: string) => async () => {
|
||||
throw new Error(`${name} 已移除(現 UI 不支援)`);
|
||||
};
|
||||
|
||||
export function createLiveInspiration(): InspirationRepo {
|
||||
return {
|
||||
async list() {
|
||||
|
|
@ -241,7 +234,6 @@ export function createLiveInspiration(): InspirationRepo {
|
|||
);
|
||||
return (data.list ?? []).map(mapTrend);
|
||||
},
|
||||
searchTrends: removed("searchTrends") as InspirationRepo["searchTrends"],
|
||||
async listElements() {
|
||||
const data = await apiRequest<{ list: Record<string, unknown>[] }>(
|
||||
"/api/v1/inspire/elements",
|
||||
|
|
@ -379,6 +371,7 @@ export function createLiveInspiration(): InspirationRepo {
|
|||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal: opts.signal,
|
||||
body: JSON.stringify({
|
||||
message: opts.message,
|
||||
pinned_ids: opts.pinnedIds,
|
||||
|
|
@ -406,6 +399,8 @@ export function createLiveInspiration(): InspirationRepo {
|
|||
let charCount = 0;
|
||||
let runeCount = 0;
|
||||
let sections: string[] = [];
|
||||
// 中止時 reader.read() 會 reject,這裡把它轉成一次 cancel,避免連線留著繼續燒 AI 額度。
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
|
@ -448,24 +443,20 @@ export function createLiveInspiration(): InspirationRepo {
|
|||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
void reader.cancel().catch(() => {});
|
||||
}
|
||||
if (streamError) {
|
||||
throw new ApiError(streamError, 400001, 400);
|
||||
}
|
||||
if (!session) {
|
||||
// 沒收到 done 事件才回頭走非串流版;已中止的請求不該再打一次 AI。
|
||||
opts.signal?.throwIfAborted();
|
||||
const fallback = await this.chat(opts);
|
||||
return { session: fallback.session };
|
||||
}
|
||||
return { session, prompt, fingerprint, charCount, runeCount, sections };
|
||||
},
|
||||
sparkAngles: removed("sparkAngles") as () => Promise<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 ?? ""),
|
||||
};
|
||||
},
|
||||
async listTopics() {
|
||||
return [] as ScoutTopic[];
|
||||
},
|
||||
saveTopic: removed("saveTopic") as ScoutRepo["saveTopic"],
|
||||
removeTopic: removed("removeTopic") as ScoutRepo["removeTopic"],
|
||||
async prepareBrief(opts) {
|
||||
const raw = await apiRequest<Record<string, unknown>>("/api/v1/scout/brief", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -202,11 +202,12 @@ function createLiveAuth(): AuthRepo {
|
|||
const raw = await apiRequest<Record<string, unknown>>("/api/v1/auth/me");
|
||||
return normalizeMember(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.httpStatus === 401) {
|
||||
if (error instanceof ApiError && error.httpStatus === 401 && !error.sessionUnknown) {
|
||||
saveTokens(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async updateProfile(patch: MemberProfilePatch) {
|
||||
// undefined = 省略(不改);null = 清除(avatar_url 送 "",後端 *string 可區分)
|
||||
|
|
@ -849,11 +850,9 @@ function createLiveUsage(): UsageRepo {
|
|||
const p = await this.getMemberPrefs();
|
||||
return p.plan_id;
|
||||
},
|
||||
async setPlanId() {
|
||||
/* admin only via setMemberPrefs */
|
||||
},
|
||||
async getMemberPrefs() {
|
||||
const raw = await apiRequest<Record<string, unknown>>("/api/v1/usage/prefs");
|
||||
async getMemberPrefs(uid) {
|
||||
const q = uid ? `?uid=${encodeURIComponent(uid)}` : "";
|
||||
const raw = await apiRequest<Record<string, unknown>>(`/api/v1/usage/prefs${q}`);
|
||||
const id = String(raw.plan_id ?? "free").toLowerCase().trim();
|
||||
return {
|
||||
plan_id: (id in PLANS ? id : "free") as PlanId,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,32 @@ describe("billing API mapping", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("member prefs", () => {
|
||||
// The admin console reads the plan of the member it is editing. This used to drop the uid and
|
||||
// return the admin's own plan, so a Free member could be shown as Pro/unlimited.
|
||||
it("sends the requested uid so admins do not see their own plan", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requested: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
requested.push(String(input));
|
||||
return new Response(
|
||||
JSON.stringify({ code: 102000, message: "ok", data: { plan_id: "starter", unlimited: false }, error: null }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const usage = createLiveRepos().usage;
|
||||
await usage.getMemberPrefs("4242");
|
||||
await usage.getMemberPrefs();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
expect(requested[0]).toBe("/api/v1/usage/prefs?uid=4242");
|
||||
expect(requested[1]).toBe("/api/v1/usage/prefs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BYOK usage mapping", () => {
|
||||
it("maps BYOK counts separately and fills absent meters with zero", () => {
|
||||
const mapped = mapByokMeterBlock(PLANS.free, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import type {
|
|||
DataSource,
|
||||
ExternalThreadTarget,
|
||||
InspirationIdea,
|
||||
InspireAngle,
|
||||
InspireChatMessage,
|
||||
InspireElement,
|
||||
InspireSession,
|
||||
|
|
@ -27,14 +26,12 @@ import type {
|
|||
ScoutPost,
|
||||
ScoutRunBrief,
|
||||
ScoutScanContext,
|
||||
ScoutTopic,
|
||||
ThreadPlay,
|
||||
ThreadsAccount,
|
||||
TokenPair,
|
||||
TrendItem,
|
||||
TrendKind,
|
||||
ViralAnalysis,
|
||||
ViralSample,
|
||||
} from "../domain/types";
|
||||
import type { AdminUserListPage, MemberAdminView } from "../lib/tenantUsers";
|
||||
import type { PlanPurchase } from "../lib/planPurchase";
|
||||
|
|
@ -77,6 +74,11 @@ export type MemberProfilePatch = {
|
|||
export type AuthRepo = {
|
||||
login(email: string, password: string): Promise<{ tokens: TokenPair; member: Member }>;
|
||||
logout(): Promise<void>;
|
||||
/**
|
||||
* null 代表「確定未登入」(沒 token 或後端回 401)。
|
||||
* 暫時性失敗(斷網、後端重啟、5xx)必須 throw,不可回 null,
|
||||
* 否則呼叫端會把使用者當成登出而踢回登入頁。
|
||||
*/
|
||||
me(): Promise<Member | null>;
|
||||
/** 更新自己的會員資料(mock 寫 session) */
|
||||
updateProfile(patch: MemberProfilePatch): Promise<Member>;
|
||||
|
|
@ -342,7 +344,6 @@ export type InspirationRepo = {
|
|||
/** 預設靈感榜:Threads 熱標(提示用) */
|
||||
listTrends(kind?: TrendKind | "all"): Promise<TrendItem[]>;
|
||||
refreshTrends(kind?: TrendKind | "all"): Promise<TrendItem[]>;
|
||||
searchTrends(query: string): Promise<TrendItem[]>;
|
||||
/** 元素庫 */
|
||||
listElements(): Promise<InspireElement[]>;
|
||||
saveElement(el: InspireElement): Promise<InspireElement>;
|
||||
|
|
@ -376,6 +377,7 @@ export type InspirationRepo = {
|
|||
}): Promise<{ session: InspireSession; messages: InspireChatMessage[] }>;
|
||||
/**
|
||||
* 串流聊天/產文。onDelta 每收到一段正文就回呼;結束回完整 session。
|
||||
* 中止時 reject `AbortError`(`isAbortError()` 可判斷),呼叫端不需當成失敗顯示。
|
||||
*/
|
||||
chatStream(
|
||||
opts: {
|
||||
|
|
@ -386,6 +388,8 @@ export type InspirationRepo = {
|
|||
sessionId?: string;
|
||||
useWeb?: boolean;
|
||||
material?: string;
|
||||
/** 使用者按停止或元件卸載時中止;不帶則跑到結束。 */
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
onDelta: (chunk: string) => void,
|
||||
): Promise<{
|
||||
|
|
@ -419,26 +423,6 @@ export type InspirationRepo = {
|
|||
pinned: Array<{ id: string; kind: string; title: string; body: string }>;
|
||||
note?: string;
|
||||
}>;
|
||||
/** @deprecated 舊角度流;保留相容 */
|
||||
sparkAngles(
|
||||
trendId: string,
|
||||
opts?: { personaId?: string | null; brandId?: string | null },
|
||||
): Promise<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 = {
|
||||
|
|
@ -521,9 +505,6 @@ export type ScoutRepo = {
|
|||
placement_url: string;
|
||||
source_note: string;
|
||||
}>;
|
||||
listTopics(brandId?: string): Promise<ScoutTopic[]>;
|
||||
saveTopic(topic: ScoutTopic): Promise<ScoutTopic>;
|
||||
removeTopic(id: string): Promise<void>;
|
||||
/**
|
||||
* 意圖 + 可選產品 → 可審知識 brief(痛點/周邊/掃描詞)
|
||||
* 無產品 = theme 模式
|
||||
|
|
@ -577,11 +558,11 @@ export type ScoutRepo = {
|
|||
};
|
||||
|
||||
export type UsageRepo = {
|
||||
/** 目前登入者(或指定 uid)本月摘要 + 明細 */
|
||||
getSummary(monthKey?: string, uid?: string): Promise<UsageMonthSummary>;
|
||||
listEvents(limit?: number, uid?: string): Promise<UsageEvent[]>;
|
||||
/** 目前登入者本月摘要 + 明細 */
|
||||
getSummary(monthKey?: string): Promise<UsageMonthSummary>;
|
||||
listEvents(limit?: number): Promise<UsageEvent[]>;
|
||||
getPlanId(): Promise<PlanId>;
|
||||
setPlanId(id: PlanId): Promise<void>;
|
||||
/** 讀方案/無限;uid 需管理員權限,省略則為自己 */
|
||||
getMemberPrefs(uid?: string): Promise<UsageMemberPrefs>;
|
||||
/** 管理員:設某人方案/無限 */
|
||||
setMemberPrefs(uid: string, patch: Partial<UsageMemberPrefs>): Promise<UsageMemberPrefs>;
|
||||
|
|
|
|||
|
|
@ -509,15 +509,6 @@ export type MentionItem = {
|
|||
created_at: number;
|
||||
};
|
||||
|
||||
export type ViralSample = {
|
||||
id: string;
|
||||
author: string;
|
||||
text: string;
|
||||
like_count: number;
|
||||
reply_count: number;
|
||||
topic?: string;
|
||||
};
|
||||
|
||||
export type ViralAnalysis = {
|
||||
hooks: string;
|
||||
structure: string;
|
||||
|
|
@ -553,37 +544,6 @@ export type ResearchHit = {
|
|||
*/
|
||||
export type ScoutResearchTier = "core" | "adjacent" | "broad";
|
||||
|
||||
/** 知識節點與產品/痛點的關係(圖譜邊) */
|
||||
export type ScoutKnowledgeRelation =
|
||||
| "solves_pain"
|
||||
| "nearby_scene"
|
||||
| "myth"
|
||||
| "contrast"
|
||||
| "background";
|
||||
|
||||
/**
|
||||
* 海巡/產品知識圖譜節點:
|
||||
* 學習重點 + 來源網址 + 可選回帖鉤子
|
||||
*/
|
||||
export type ScoutResearchNote = {
|
||||
id: string;
|
||||
title: string;
|
||||
/** 網頁內容摘要(次要;主讀 learn_points) */
|
||||
summary: string;
|
||||
/** 學習重點:3~5 條子彈 */
|
||||
learn_points?: string[];
|
||||
/** 回帖可直接借的鉤子(1~3 句) */
|
||||
reply_hooks?: string[];
|
||||
url: string;
|
||||
source_label?: string;
|
||||
/** 從此頁抽出可海巡的詞 */
|
||||
keywords?: string[];
|
||||
/** 與主題貼近度分層 */
|
||||
tier?: ScoutResearchTier;
|
||||
/** 與產品的關係 */
|
||||
relation?: ScoutKnowledgeRelation;
|
||||
};
|
||||
|
||||
export type InspirationIdea = {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -594,12 +554,6 @@ export type InspirationIdea = {
|
|||
trend_id?: string | null;
|
||||
};
|
||||
|
||||
/** 靈感第二步:可選的開場角度(舊流程相容) */
|
||||
export type InspireAngle = {
|
||||
id: string;
|
||||
hook: string;
|
||||
};
|
||||
|
||||
/** 靈感元素庫:可重用產文材料 */
|
||||
export type InspireElementKind = "persona" | "role" | "brand" | "snippet" | "trend";
|
||||
|
||||
|
|
@ -729,15 +683,6 @@ export type BrandProduct = {
|
|||
updated_at: number;
|
||||
};
|
||||
|
||||
export type ScoutTopic = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
keywords: string[];
|
||||
/** 此主題預設主推的產品 */
|
||||
preferred_product_id?: string | null;
|
||||
};
|
||||
|
||||
export type ScoutOutreachStatus = "new" | "drafted" | "queued" | "published" | "skipped";
|
||||
|
||||
/**
|
||||
|
|
@ -809,15 +754,8 @@ export type ScoutRunBrief = {
|
|||
placement_note?: string;
|
||||
/** B:回應姿態 */
|
||||
response_stance?: string;
|
||||
/**
|
||||
* 做功課:擴充知識(網頁摘要+網址)
|
||||
* 對齊舊海巡 research 詳細度
|
||||
*/
|
||||
research_notes?: ScoutResearchNote[];
|
||||
/** 產品 context 全文(展示用) */
|
||||
product_context?: string;
|
||||
/** 對方常講的話(match_tags 詳列) */
|
||||
match_tags_detail?: string[];
|
||||
/** 分組/持久化用(與命中 theme_key 對齊) */
|
||||
theme_key?: string;
|
||||
theme_label?: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* 使用者自己按停止、或元件卸載造成的中止,不是錯誤,不該跳紅字。
|
||||
* fetch 中止在不同瀏覽器分別丟 DOMException("AbortError") 或帶 name 的 Error,這裡一起認。
|
||||
*/
|
||||
export function isAbortError(e: unknown): boolean {
|
||||
return e instanceof Error && e.name === "AbortError";
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ export const zhTW: MessageDict = {
|
|||
"common.save": "儲存",
|
||||
"common.cancel": "取消",
|
||||
"common.loading": "載入中…",
|
||||
"common.retry": "重試",
|
||||
"common.back": "返回",
|
||||
"common.delete": "刪除",
|
||||
"common.edit": "編輯",
|
||||
|
|
@ -70,6 +71,7 @@ export const zhTW: MessageDict = {
|
|||
"api.err.400004": "驗證碼無效或已過期",
|
||||
"api.err.400020": "不能停權自己的帳號",
|
||||
"api.err.400021": "不能移除或停權最後一位管理員",
|
||||
"auth.unreachable": "連不上伺服器,無法確認登入狀態。請檢查網路後重試。",
|
||||
"api.err.401001": "請先登入",
|
||||
"api.err.401002": "登入已失效,請重新登入",
|
||||
"api.err.401003": "找不到會員資料,請重新登入",
|
||||
|
|
@ -152,6 +154,7 @@ export const zhTW: MessageDict = {
|
|||
"verify.after": "驗證完成後即可使用今日、創作、海巡等功能。",
|
||||
|
||||
"settings.title": "設定",
|
||||
"settings.loadFail": "載入設定失敗,請重新整理再試",
|
||||
"settings.localeCurrency": "語言與幣別",
|
||||
"settings.locale": "介面語言",
|
||||
"settings.currency": "顯示幣別",
|
||||
|
|
@ -360,6 +363,7 @@ export const zhTW: MessageDict = {
|
|||
"crew.connecting": "連線中…",
|
||||
"crew.tokenRenewHint": "Token 由背景任務自動延長(約第 30 天),可在「任務」查看;無需手動刷新。",
|
||||
"crew.empty": "尚無帳號",
|
||||
"crew.loadFail": "載入帳號失敗",
|
||||
"crew.unusable": "不可用",
|
||||
"crew.expires": "到期 {time}",
|
||||
"crew.lastRefresh": "上次延長 {time}",
|
||||
|
|
@ -479,6 +483,7 @@ export const zhTW: MessageDict = {
|
|||
"outbox.status.cancelled": "已取消",
|
||||
"outbox.detail.missingId": "缺少 id",
|
||||
"outbox.detail.notFound": "找不到發送",
|
||||
"outbox.detail.loadFail": "載入發送內容失敗",
|
||||
"outbox.detail.loading": "載入中…",
|
||||
"outbox.detail.sendingHint": "正在發到 Threads(通常 10~30 秒),頁面會自動更新…",
|
||||
"outbox.detail.doneHint": "已成功發到 Threads。",
|
||||
|
|
@ -679,13 +684,9 @@ export const zhTW: MessageDict = {
|
|||
"wizard.topic.topicPh": "這串文想聊什麼?",
|
||||
"wizard.topic.aiView": "AI 視角",
|
||||
"wizard.topic.personaNotReady": "人設未就緒",
|
||||
"wizard.topic.generating": "生成中…",
|
||||
"wizard.topic.aiSuggest": "AI 幫我想",
|
||||
"wizard.topic.quickFill": "快速填入",
|
||||
"wizard.topic.sampleTitle": "週末咖啡話題",
|
||||
"wizard.topic.sampleTopic": "週末想找間不踩雷的咖啡店,插座要多、能坐久。",
|
||||
"wizard.topic.aiTitle": "AI 主題",
|
||||
"wizard.topic.personaOpen": "{name}開場",
|
||||
"wizard.crew.title": "2. 出場",
|
||||
"wizard.crew.lead": "主帳",
|
||||
"wizard.crew.noUsable": "尚無可用帳號",
|
||||
|
|
@ -803,6 +804,7 @@ export const zhTW: MessageDict = {
|
|||
"jobs.detail": "詳情",
|
||||
"jobs.loadMore": "載入更多(還有 {n})",
|
||||
"jobs.notFound": "找不到任務",
|
||||
"jobs.detailLoadFail": "載入任務失敗",
|
||||
"jobs.progress": "進度 {n}%",
|
||||
"jobs.updated": "更新 {time}",
|
||||
"jobs.backList": "返回列表",
|
||||
|
|
@ -1098,21 +1100,8 @@ export const zhTW: MessageDict = {
|
|||
"scout.sending": "發送中…",
|
||||
"scout.needAccountBefore": "請先到",
|
||||
"scout.needAccountAfter": "連線可用帳號。",
|
||||
"scout.knowledge": "周邊知識",
|
||||
"scout.knowledgeWithLabel": "周邊知識 · {label}",
|
||||
"scout.knowledgeLearn": "周邊知識 · 可學",
|
||||
"scout.collapseKnowledge": "收合知識",
|
||||
"scout.expandLearn": "展開學習 · {n} 則",
|
||||
"scout.expandKnowledge": "展開知識",
|
||||
"scout.deleteKnowledgeRun": "刪除此批知識與命中",
|
||||
"scout.loadingKnowledge": "正在整理周邊知識…",
|
||||
"scout.notesCount": "{n} 則",
|
||||
"scout.noKnowledge": "尚無周邊知識",
|
||||
"scout.product": "產品",
|
||||
"scout.painsSolved": "能解的痛",
|
||||
"scout.focus": "焦點",
|
||||
"scout.all": "全部",
|
||||
"scout.noWebSummary": "尚無網頁摘要",
|
||||
"scout.queue": "命中紀錄 · {n}",
|
||||
"scout.valueQueue": "痛點/產品接話 · {n}",
|
||||
"scout.activityQueue": "活躍短回 · {n}",
|
||||
|
|
@ -1120,9 +1109,6 @@ export const zhTW: MessageDict = {
|
|||
"scout.collapseQueue": "收合佇列",
|
||||
"scout.expandQueue": "展開佇列",
|
||||
"scout.noOtherPending": "沒有其他待回",
|
||||
"scout.learnPoints": "學習重點",
|
||||
"scout.replyHooks": "回帖可借",
|
||||
"scout.badgeCore": "最貼主題",
|
||||
"scout.unnamedRun": "未命名批次",
|
||||
"scout.thisRun": "此批次",
|
||||
"scout.confirmDeleteRun": "刪除海巡批次「{label}」?\\n會一併刪除這批命中與周邊知識,無法復原。",
|
||||
|
|
@ -1165,17 +1151,6 @@ export const zhTW: MessageDict = {
|
|||
"scout.stanceActivity": "短回 · 養活躍",
|
||||
"scout.stanceProduct": "共感 · 可輕帶產品",
|
||||
"scout.stanceRelation": "接話 · 建關係",
|
||||
"scout.tier.core": "最貼主題",
|
||||
"scout.tier.coreHint": "直接對準痛點/關鍵語,優先讀",
|
||||
"scout.tier.adjacent": "相關周邊",
|
||||
"scout.tier.adjacentHint": "鄰近情境,擴搜尋面",
|
||||
"scout.tier.broad": "最廣泛",
|
||||
"scout.tier.broadHint": "背景與對照,選讀即可",
|
||||
"scout.relation.solves_pain": "對準痛點",
|
||||
"scout.relation.nearby_scene": "鄰近場景",
|
||||
"scout.relation.myth": "迷思澄清",
|
||||
"scout.relation.contrast": "對照選購",
|
||||
"scout.relation.background": "背景脈絡",
|
||||
|
||||
"brands.title": "品牌",
|
||||
"brands.railAria": "品牌列表",
|
||||
|
|
@ -1397,6 +1372,8 @@ export const zhTW: MessageDict = {
|
|||
"inspire.useDraft": "用這則寫",
|
||||
"inspire.openPlay": "開串場",
|
||||
"inspire.thinking": "思考中…",
|
||||
"inspire.stop": "停止產生",
|
||||
"inspire.stopped": "已停止產生",
|
||||
"inspire.pinnedAria": "本輪參考(給 AI 看)",
|
||||
"inspire.pinned": "本輪參考",
|
||||
"inspire.pinnedCount": "· {n}",
|
||||
|
|
@ -1569,6 +1546,7 @@ export const zhTW: MessageDict = {
|
|||
"persona.jobRunning": "背景分析中… 完成後會自動更新(也可到「任務」查看進度)",
|
||||
"persona.jobDone": "背景分析完成 · 已寫入指紋/範本",
|
||||
"persona.jobFailed": "背景分析失敗,請到任務頁查看錯誤或重試",
|
||||
"persona.loadFail": "載入人設失敗",
|
||||
"persona.openJob": "開啟任務詳情",
|
||||
"persona.setDefaultMsg": "「{name}」已設為預設",
|
||||
"persona.confirmDelete": "確定刪除人設「{name}」?",
|
||||
|
|
@ -1724,7 +1702,6 @@ export const zhTW: MessageDict = {
|
|||
"persona.previewReplySample": "大安那間還行但人很多",
|
||||
|
||||
"inspire.playTitle": "靈感串場",
|
||||
"wizard.topic.fallbackTopic": "生活小題",
|
||||
|
||||
"play.err.needLead": "請選擇主帳號",
|
||||
"play.err.needRoot": "請至少有一則主貼",
|
||||
|
|
@ -1793,6 +1770,7 @@ export const en: MessageDict = {
|
|||
"common.save": "Save",
|
||||
"common.cancel": "Cancel",
|
||||
"common.loading": "Loading…",
|
||||
"common.retry": "Retry",
|
||||
"common.back": "Back",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
|
|
@ -1821,6 +1799,7 @@ export const en: MessageDict = {
|
|||
"api.err.400004": "Invalid or expired verification code",
|
||||
"api.err.400020": "You cannot suspend your own account",
|
||||
"api.err.400021": "Cannot demote or suspend the last active admin",
|
||||
"auth.unreachable": "Can't reach the server, so your sign-in state is unknown. Check your connection and retry.",
|
||||
"api.err.401001": "Please sign in",
|
||||
"api.err.401002": "Session expired. Please sign in again.",
|
||||
"api.err.401003": "Account not found. Please sign in again.",
|
||||
|
|
@ -1902,6 +1881,7 @@ export const en: MessageDict = {
|
|||
"verify.after": "After verification you can use Today, Studio, Patrol, and more.",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.loadFail": "Could not load settings. Refresh to try again.",
|
||||
"settings.localeCurrency": "Language & currency",
|
||||
"settings.locale": "Interface language",
|
||||
"settings.currency": "Display currency",
|
||||
|
|
@ -2111,6 +2091,7 @@ export const en: MessageDict = {
|
|||
"crew.connecting": "Connecting…",
|
||||
"crew.tokenRenewHint": "Tokens auto-renew via background jobs (~day 30). Check Jobs; no manual refresh.",
|
||||
"crew.empty": "No accounts yet",
|
||||
"crew.loadFail": "Could not load accounts",
|
||||
"crew.unusable": "Unavailable",
|
||||
"crew.expires": "Expires {time}",
|
||||
"crew.lastRefresh": "Last extended {time}",
|
||||
|
|
@ -2230,6 +2211,7 @@ export const en: MessageDict = {
|
|||
"outbox.status.cancelled": "Cancelled",
|
||||
"outbox.detail.missingId": "Missing id",
|
||||
"outbox.detail.notFound": "Outbox item not found",
|
||||
"outbox.detail.loadFail": "Could not load this outbox item",
|
||||
"outbox.detail.loading": "Loading…",
|
||||
"outbox.detail.sendingHint": "Publishing to Threads (often 10–30s). This page updates automatically…",
|
||||
"outbox.detail.doneHint": "Published to Threads successfully.",
|
||||
|
|
@ -2430,13 +2412,9 @@ export const en: MessageDict = {
|
|||
"wizard.topic.topicPh": "What should this thread discuss?",
|
||||
"wizard.topic.aiView": "AI persona view",
|
||||
"wizard.topic.personaNotReady": "Persona not ready",
|
||||
"wizard.topic.generating": "Generating…",
|
||||
"wizard.topic.aiSuggest": "AI suggest",
|
||||
"wizard.topic.quickFill": "Quick fill",
|
||||
"wizard.topic.sampleTitle": "Weekend coffee chat",
|
||||
"wizard.topic.sampleTopic": "Looking for a reliable café this weekend — lots of outlets, stay-all-day friendly.",
|
||||
"wizard.topic.aiTitle": "AI topic",
|
||||
"wizard.topic.personaOpen": "{name} opens",
|
||||
"wizard.crew.title": "2. Cast",
|
||||
"wizard.crew.lead": "Lead",
|
||||
"wizard.crew.noUsable": "No usable accounts",
|
||||
|
|
@ -2554,6 +2532,7 @@ export const en: MessageDict = {
|
|||
"jobs.detail": "Details",
|
||||
"jobs.loadMore": "Load more ({n} more)",
|
||||
"jobs.notFound": "Job not found",
|
||||
"jobs.detailLoadFail": "Could not load this job",
|
||||
"jobs.progress": "Progress {n}%",
|
||||
"jobs.updated": "Updated {time}",
|
||||
"jobs.backList": "Back to list",
|
||||
|
|
@ -2849,21 +2828,8 @@ export const en: MessageDict = {
|
|||
"scout.sending": "Sending…",
|
||||
"scout.needAccountBefore": "Connect a usable account under",
|
||||
"scout.needAccountAfter": "first.",
|
||||
"scout.knowledge": "Related knowledge",
|
||||
"scout.knowledgeWithLabel": "Related · {label}",
|
||||
"scout.knowledgeLearn": "Related · to learn",
|
||||
"scout.collapseKnowledge": "Collapse knowledge",
|
||||
"scout.expandLearn": "Expand · {n} notes",
|
||||
"scout.expandKnowledge": "Expand knowledge",
|
||||
"scout.deleteKnowledgeRun": "Delete this batch's knowledge & hits",
|
||||
"scout.loadingKnowledge": "Preparing related knowledge…",
|
||||
"scout.notesCount": "{n} notes",
|
||||
"scout.noKnowledge": "No related knowledge yet",
|
||||
"scout.product": "Product",
|
||||
"scout.painsSolved": "Pains we solve",
|
||||
"scout.focus": "Focus",
|
||||
"scout.all": "All",
|
||||
"scout.noWebSummary": "No web summaries yet",
|
||||
"scout.queue": "Matches · {n}",
|
||||
"scout.valueQueue": "Value replies · {n}",
|
||||
"scout.activityQueue": "Activity short replies · {n}",
|
||||
|
|
@ -2871,9 +2837,6 @@ export const en: MessageDict = {
|
|||
"scout.collapseQueue": "Collapse queue",
|
||||
"scout.expandQueue": "Expand queue",
|
||||
"scout.noOtherPending": "No other pending",
|
||||
"scout.learnPoints": "Key takeaways",
|
||||
"scout.replyHooks": "Reply hooks",
|
||||
"scout.badgeCore": "Closest to topic",
|
||||
"scout.unnamedRun": "Unnamed batch",
|
||||
"scout.thisRun": "this batch",
|
||||
"scout.confirmDeleteRun": "Delete patrol batch “{label}”?\\nHits and related knowledge will be removed. This cannot be undone.",
|
||||
|
|
@ -2916,17 +2879,6 @@ export const en: MessageDict = {
|
|||
"scout.stanceActivity": "Short reply · activity",
|
||||
"scout.stanceProduct": "Empathy · soft product",
|
||||
"scout.stanceRelation": "Engage · build rapport",
|
||||
"scout.tier.core": "Closest",
|
||||
"scout.tier.coreHint": "Directly on the pain/keywords — read first",
|
||||
"scout.tier.adjacent": "Adjacent",
|
||||
"scout.tier.adjacentHint": "Nearby context, broaden search",
|
||||
"scout.tier.broad": "Broad",
|
||||
"scout.tier.broadHint": "Background & contrast — optional",
|
||||
"scout.relation.solves_pain": "Hits the pain",
|
||||
"scout.relation.nearby_scene": "Nearby scene",
|
||||
"scout.relation.myth": "Myth-busting",
|
||||
"scout.relation.contrast": "Contrast shopping",
|
||||
"scout.relation.background": "Background",
|
||||
|
||||
"brands.title": "Brands",
|
||||
"brands.railAria": "Brand list",
|
||||
|
|
@ -3148,6 +3100,8 @@ export const en: MessageDict = {
|
|||
"inspire.useDraft": "Use this draft",
|
||||
"inspire.openPlay": "Open play",
|
||||
"inspire.thinking": "Thinking…",
|
||||
"inspire.stop": "Stop generating",
|
||||
"inspire.stopped": "Generation stopped",
|
||||
"inspire.pinnedAria": "This-round references (for the AI)",
|
||||
"inspire.pinned": "References",
|
||||
"inspire.pinnedCount": "· {n}",
|
||||
|
|
@ -3320,6 +3274,7 @@ export const en: MessageDict = {
|
|||
"persona.jobRunning": "Analyzing in background… will refresh when ready (see Jobs for progress)",
|
||||
"persona.jobDone": "Background analysis finished · fingerprint saved",
|
||||
"persona.jobFailed": "Background analysis failed — check Jobs for the error or retry",
|
||||
"persona.loadFail": "Could not load personas",
|
||||
"persona.openJob": "Open job details",
|
||||
"persona.setDefaultMsg": "“{name}” set as default",
|
||||
"persona.confirmDelete": "Delete persona “{name}”?",
|
||||
|
|
@ -3475,7 +3430,6 @@ export const en: MessageDict = {
|
|||
"persona.previewReplySample": "The one in Da’an is fine but crowded",
|
||||
|
||||
"inspire.playTitle": "Inspired thread",
|
||||
"wizard.topic.fallbackTopic": "Everyday topic",
|
||||
|
||||
"play.err.needLead": "Pick a lead account",
|
||||
"play.err.needRoot": "Add at least one root post",
|
||||
|
|
|
|||
|
|
@ -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 key)。UI 用 t(err.message) 顯示雙語。
|
||||
* 與 play.err.* 同一風格。
|
||||
*/
|
||||
export const INVITE_ERR = {
|
||||
notFound: "invite.err.notFound",
|
||||
needAdmin: "invite.err.needAdmin",
|
||||
memberNotFound: "invite.err.memberNotFound",
|
||||
selfParent: "invite.err.selfParent",
|
||||
parentNotFound: "invite.err.parentNotFound",
|
||||
cycle: "invite.err.cycle",
|
||||
/** 已有邀請人,不可再補填 */
|
||||
alreadyBound: "invite.err.alreadyBound",
|
||||
/** 邀請碼空白/格式 */
|
||||
codeRequired: "invite.err.codeRequired",
|
||||
/** 邀請碼不存在 */
|
||||
codeNotFound: "invite.err.codeNotFound",
|
||||
/** 不能填自己的碼 */
|
||||
codeSelf: "invite.err.codeSelf",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 會員自行補填邀請碼(僅當尚無邀請人)。
|
||||
* 成功後 parent_uid 指向持有該碼的會員。
|
||||
*/
|
||||
export function applyInviteCode(uid: string, rawCode: string): MyInviteNetwork {
|
||||
const code = (rawCode || "").trim().toUpperCase();
|
||||
if (!code) throw new Error(INVITE_ERR.codeRequired);
|
||||
|
||||
const users = loadTenantUsers();
|
||||
const me = users.find((u) => u.uid === uid);
|
||||
if (!me) throw new Error(INVITE_ERR.notFound);
|
||||
|
||||
if ((me.parent_uid || "").trim()) {
|
||||
throw new Error(INVITE_ERR.alreadyBound);
|
||||
}
|
||||
|
||||
const inviter = users.find(
|
||||
(u) => (u.invite_code || "").trim().toUpperCase() === code,
|
||||
);
|
||||
if (!inviter) throw new Error(INVITE_ERR.codeNotFound);
|
||||
if (inviter.uid === uid) throw new Error(INVITE_ERR.codeSelf);
|
||||
|
||||
// 理論上 me 無 parent,不會成環;仍擋「邀請人是自己的子孫」
|
||||
if (isDescendantOf(uid, inviter.uid, users)) {
|
||||
throw new Error(INVITE_ERR.cycle);
|
||||
}
|
||||
|
||||
upsertTenantUser({
|
||||
...me,
|
||||
parent_uid: inviter.uid,
|
||||
});
|
||||
return getMyInviteNetwork(uid);
|
||||
}
|
||||
|
||||
/** 一般會員:自己的邀請碼 + 上線 + 直屬下線 */
|
||||
export function getMyInviteNetwork(uid: string): MyInviteNetwork {
|
||||
const users = loadTenantUsers();
|
||||
const briefs = buildInviteBriefs(users);
|
||||
const me = briefs.get(uid);
|
||||
if (!me) throw new Error(INVITE_ERR.notFound);
|
||||
|
||||
const upline = me.parent_uid ? briefs.get(me.parent_uid) || null : null;
|
||||
const downlines = users
|
||||
.filter((u) => (u.parent_uid || "") === uid)
|
||||
.map((u) => briefs.get(u.uid)!)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.joined_at - a.joined_at);
|
||||
|
||||
return {
|
||||
me,
|
||||
upline,
|
||||
downlines,
|
||||
total_downline_count: me.total_downline_count,
|
||||
};
|
||||
}
|
||||
|
||||
/** 管理員:森林(多根) */
|
||||
export function buildInviteForest(users?: TenantUserRecord[]): InviteTreeNode[] {
|
||||
const list = users ?? loadTenantUsers();
|
||||
const briefs = buildInviteBriefs(list);
|
||||
const byParent = new Map<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"));
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
/**
|
||||
* Backend uid is int64 from 1_000_000 (7–8 numeric digits).
|
||||
* Display always pad to 8 characters: 01000000, 01000001, …
|
||||
*/
|
||||
export function formatMemberUid(uid: string | number | null | undefined): string {
|
||||
if (uid === null || uid === undefined || uid === "") return "";
|
||||
const n = typeof uid === "number" ? uid : Number(uid);
|
||||
if (!Number.isFinite(n) || n < 0) return String(uid);
|
||||
return String(Math.trunc(n)).padStart(8, "0");
|
||||
}
|
||||
|
||||
/** Parse display or raw uid string back to API path/body form (no leading zeros required). */
|
||||
export function parseMemberUid(raw: string): string {
|
||||
const s = raw.trim();
|
||||
if (!s) return "";
|
||||
// keep as decimal string without leading zeros for JSON number path
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n)) return s;
|
||||
return String(Math.trunc(n));
|
||||
}
|
||||
|
|
@ -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 || "2~3 短段",
|
||||
examples: d?.examples || "",
|
||||
avoid: d?.avoid || persona?.guard?.avoid?.join("、") || "硬廣",
|
||||
cta: d?.ctaStyle || "輕輕問一句",
|
||||
audience: d?.audience || "會求經驗的人",
|
||||
};
|
||||
}
|
||||
|
||||
/** 產出時附帶使用的人設 block(除錯/預覽用) */
|
||||
export function explainPersonaUsage(persona?: Persona | null, mode: "post" | "reply" | "outreach" | "inspire" = "post") {
|
||||
return {
|
||||
ready: isPersonaReady(persona),
|
||||
name: persona?.name || "(未選)",
|
||||
block: buildPersonaPromptBlock(persona, mode),
|
||||
};
|
||||
}
|
||||
|
||||
export function mockGenerateTopic(seed: string, persona?: Persona | null): string {
|
||||
const b = fingerprintBits(persona);
|
||||
const base = seed.trim() || "日常小發現";
|
||||
return [
|
||||
`(視角:${persona?.name || "預設"} · ${b.tone})`,
|
||||
`${b.hooks}:最近卡在「${base}」。`,
|
||||
`想聽 ${b.audience} 的真實經驗——不是規格文。`,
|
||||
`用字偏向:${b.fingerprint}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function mockGenerateRoot(topic: string, persona?: Persona | null): string {
|
||||
const b = fingerprintBits(persona);
|
||||
const t = topic.trim() || "一個大家會想回的小題目";
|
||||
const body = [
|
||||
clip(t, 120),
|
||||
"",
|
||||
`我自己目前卡在「有沒有實際用過、會不會踩雷」。`,
|
||||
b.examples ? `(像我會說的:${clip(b.examples, 48)})` : "",
|
||||
b.cta,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return `(${b.tone} · 避開:${clip(b.avoid, 24)})\n${body}`;
|
||||
}
|
||||
|
||||
export function mockGenerateReply(opts: {
|
||||
context: string;
|
||||
speakerName: string;
|
||||
/** 有才套用語氣;沒有就中性口吻 */
|
||||
persona?: Persona | null;
|
||||
/** 有才輕帶品牌視角;沒有就不提 */
|
||||
brandName?: string;
|
||||
brandBrief?: string;
|
||||
isLead?: boolean;
|
||||
}): string {
|
||||
const { context, speakerName, persona, brandName, brandBrief, isLead } = opts;
|
||||
const hasPersona = Boolean(persona);
|
||||
const b = fingerprintBits(persona);
|
||||
const clipCtx = clip(context, 48) || "前面那則";
|
||||
const head = hasPersona ? `(${speakerName} · ${b.tone})` : `(${speakerName})`;
|
||||
const brandLine =
|
||||
brandName && brandBrief
|
||||
? `補充一點 ${brandName} 相關經驗:${clip(brandBrief, 48)}`
|
||||
: brandName
|
||||
? `補充一點和 ${brandName} 有關的實際用法。`
|
||||
: "";
|
||||
|
||||
if (isLead) {
|
||||
return [
|
||||
head,
|
||||
`懂你說的「${clipCtx}」。`,
|
||||
brandLine || "想再問一句:你實際怎麼選的?",
|
||||
hasPersona ? `(避開 ${clip(b.avoid, 24)})` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
return [
|
||||
head,
|
||||
brandLine || `我這邊經驗是:${clipCtx} 真的有差。`,
|
||||
brandLine ? `對「${clipCtx}」這點,務實做法通常是先對情境再對規格。` : "如果在意使用情境,我可以再補細節。",
|
||||
hasPersona ? `用字:${b.fingerprint}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function mockGenerateOwnPostReply(opts: {
|
||||
postText: string;
|
||||
replyText?: string;
|
||||
persona?: Persona | null;
|
||||
}): string {
|
||||
const b = fingerprintBits(opts.persona);
|
||||
const target = clip(opts.replyText || opts.postText, 40);
|
||||
return [
|
||||
`(作者本人 · ${b.tone})`,
|
||||
`懂你說的「${target}」。`,
|
||||
`我自己是先抓痛點再對規格,通常看使用情境而不是只看規格表。`,
|
||||
`你比較在意哪一點?`,
|
||||
opts.persona?.guard?.banAiTone ? "(已關 AI 腔/客服腔)" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function mockGenerateScoutDraft(opts: {
|
||||
postText: string;
|
||||
brandName: string;
|
||||
brandBrief?: string;
|
||||
targetAudience?: string;
|
||||
productLabel?: string;
|
||||
productContext?: string;
|
||||
painHint?: string;
|
||||
placementUrl?: string;
|
||||
persona?: Persona | null;
|
||||
/** theme = 接話;product = 可輕帶解法;activity = 短回養帳號 */
|
||||
mode?: "product" | "theme" | "activity";
|
||||
}): string {
|
||||
const b = fingerprintBits(opts.persona);
|
||||
const clipPost = clip(opts.postText, 36);
|
||||
const mode = opts.mode || (opts.productLabel ? "product" : "theme");
|
||||
|
||||
if (mode === "activity") {
|
||||
return [
|
||||
`同感「${clipPost}」這段 🙌`,
|
||||
`我也常這樣,後來比較會先停一下再決定。`,
|
||||
`你現在比較偏哪一種狀態?`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (mode === "theme") {
|
||||
return [
|
||||
`看到你聊「${clipPost}」——`,
|
||||
`我之前也卡過類似的點,後來比較有感的是先釐清自己在意什麼。`,
|
||||
`你現在最卡的是哪一段?可以再多講一點(真想聽,不是套公式)。`,
|
||||
opts.persona?.guard?.banAiTone ? "" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`看到你提到「${clipPost}」很有感。`,
|
||||
];
|
||||
if (opts.painHint) {
|
||||
lines.push(`這類「${clip(opts.painHint, 28)}」的情況,身邊也常聽到。`);
|
||||
}
|
||||
if (opts.productContext) {
|
||||
lines.push(clip(opts.productContext, 120));
|
||||
} else if (opts.productLabel) {
|
||||
lines.push(`我自己後來會先從「${opts.productLabel}」這類情境想解法,不一定一步到位。`);
|
||||
} else {
|
||||
lines.push(`整理經驗時也常遇到類似情況——可以分享一個比較務實的切入點(非業配)。`);
|
||||
}
|
||||
if (opts.placementUrl) {
|
||||
lines.push(opts.placementUrl);
|
||||
}
|
||||
lines.push(`你比較在意哪一點?`);
|
||||
if (b.tone) {
|
||||
// light fingerprint without fake meta banner
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function mockGenerateInspiration(
|
||||
topic: string,
|
||||
persona?: Persona | null,
|
||||
): {
|
||||
title: string;
|
||||
hook: string;
|
||||
angle: string;
|
||||
} {
|
||||
const b = fingerprintBits(persona);
|
||||
const t = topic.trim() || "生活小題";
|
||||
return {
|
||||
title: `${t} · ${persona?.name || "日常視角"}`,
|
||||
hook: `${b.hooks}(主題:${t};對 ${b.audience})`,
|
||||
angle:
|
||||
persona?.style?.dimensions?.d4Topics?.summary ||
|
||||
persona?.brief ||
|
||||
"先痛點、再經驗、最後輕帶觀點,避免一句廣告。",
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
// 粗分類:用路徑關鍵字推痛點/tags(mock 啟發式)
|
||||
let pain_points: string[] = [];
|
||||
let match_tags: string[] = [...words.slice(0, 5)];
|
||||
let angle = "依商品頁摘要整理的賣點與使用情境(請再人工改準)。";
|
||||
|
||||
if (/敏感|無香|香精|抗敏|unscent|sensitive|hypo/i.test(blob)) {
|
||||
pain_points = ["香精/氣味太重受不了", "皮膚或頭皮容易刺癢", "找不到真的溫和選項"];
|
||||
match_tags = [...new Set([...match_tags, "無香", "敏感肌", "溫和", "香精"])];
|
||||
angle = "主打低刺激/無香體驗;適合先講使用感受,再輕帶規格與通路。";
|
||||
} else if (/咖啡|cafe|coffee|插座|座位|第三空間/i.test(blob)) {
|
||||
pain_points = ["平日下午沒地方久坐", "店裡沒插座或趕客", "人多坐不久"];
|
||||
match_tags = [...new Set([...match_tags, "咖啡廳", "插座", "筆電", "不限時"])];
|
||||
angle = "強調座位與工作友善,分享實際待店經驗優於硬推商品。";
|
||||
} else if (/寶寶|嬰|幼兒|baby|kids/i.test(blob)) {
|
||||
pain_points = ["怕洗劑太刺激", "怕洗不乾淨", "不知道怎麼選成分"];
|
||||
match_tags = [...new Set([...match_tags, "寶寶", "溫和", "嬰幼兒"])];
|
||||
angle = "親子情境:務實講洗淨與溫和的取捨,避免恐嚇式行銷。";
|
||||
} else if (/洗|沐|護|detergent|shampoo|soap|skincare|保養/i.test(blob)) {
|
||||
pain_points = ["不知道適不適合自己膚況", "行銷話術看不懂", "用完反而不適"];
|
||||
match_tags = [...new Set([...match_tags, "選品", "成分", "使用心得"])];
|
||||
angle = "先對齊使用情境與頻率,再對規格;語氣務實不硬廣。";
|
||||
} else {
|
||||
pain_points = [`在找「${labelBase}」相關解法或真實心得`, "被行銷文搞混、想聽實際用過的人講"];
|
||||
if (!match_tags.length) match_tags = [labelBase, "推薦", "心得"];
|
||||
angle = `從「${labelBase}」商品頁推測的使用情境;請依真實賣點改寫。`;
|
||||
}
|
||||
|
||||
const product_context = [`【${labelBase}】`, angle].join("\n");
|
||||
|
||||
return {
|
||||
label: labelBase.slice(0, 48),
|
||||
product_context,
|
||||
pain_points,
|
||||
match_tags: match_tags.map((t) => t.slice(0, 24)).filter(Boolean).slice(0, 12),
|
||||
placement_url: url.toString(),
|
||||
source_note: "已填入,可再改",
|
||||
};
|
||||
}
|
||||
|
|
@ -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] }));
|
||||
}
|
||||
|
|
@ -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}」,你們會先問什麼?`,
|
||||
];
|
||||
}
|
||||
|
|
@ -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") ? "2~4 個短段落,段間空行" : "一段講完再補一句問句",
|
||||
punctuation: /…|\.\.\./.test(blob) ? "愛用省略號與問號" : "逗號與問號為主,少驚嘆",
|
||||
contentPatterns: "情境 → 感受或觀察 → 輕問一句",
|
||||
knowledgeTranslation: "先講使用情境,再講重點,不丟術語牆",
|
||||
ctaStyle: "自然邀留言(「你們呢」「有人也…嗎」),不命令",
|
||||
examples: samples[0].slice(0, 80),
|
||||
avoid: base.style.draft.avoid || "硬銷、條列教學、客服腔、假裝中立實則業配",
|
||||
};
|
||||
|
||||
const dim = (summary: string, ev = evidence): StyleDimension => ({ summary, evidence: ev });
|
||||
|
||||
const dimensions: Partial<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 或 0~1 個"),
|
||||
d7Conversion: dim(draft.ctaStyle),
|
||||
d8Risk: dim(draft.avoid, []),
|
||||
};
|
||||
|
||||
const draftText = serializeDraftText(draft);
|
||||
const avoidList = draft.avoid
|
||||
.split(/[、,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
...base,
|
||||
brief: base.brief || `${tone};對「${draft.audience}」說話`,
|
||||
status: "ready",
|
||||
voice: draft.tone,
|
||||
notes: base.brief,
|
||||
style: {
|
||||
dimensions,
|
||||
draft,
|
||||
draftText,
|
||||
source: source.kind === "benchmark" ? "benchmark" : "manual",
|
||||
benchmarkUsername: source.kind === "benchmark" ? source.username.replace(/^@/, "") : undefined,
|
||||
sourceLabel: source.kind === "manual" ? label : undefined,
|
||||
sampleCount: samples.length,
|
||||
samplePreviews: samples.slice(0, 5).map((s) => (s.length > 100 ? `${s.slice(0, 100)}…` : s)),
|
||||
analyzedAt: nowUnixNano(),
|
||||
},
|
||||
guard: {
|
||||
...emptyGuard(),
|
||||
...base.guard,
|
||||
avoid: avoidList.length ? avoidList : emptyGuard().avoid,
|
||||
banAiTone: true,
|
||||
maxChars: base.guard?.maxChars || 280,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 從貼上文字分析(手動樣本)。
|
||||
*/
|
||||
export async function mockAnalyzePersonaFromText(
|
||||
persona: Persona,
|
||||
rawText: string,
|
||||
sourceLabel?: string,
|
||||
): Promise<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: "2~3 短段,段間空行",
|
||||
punctuation: "問號收尾,少驚嘆",
|
||||
contentPatterns: "痛點 → 自己經驗 → 輕問",
|
||||
knowledgeTranslation: "用生活例子講,不丟規格表",
|
||||
ctaStyle: "「有人也這樣嗎」",
|
||||
examples: opts.examples,
|
||||
avoid: opts.avoid || "硬廣、說教、AI 腔",
|
||||
};
|
||||
const dim = (summary: string): StyleDimension => ({ summary, evidence: [opts.examples.slice(0, 40)] });
|
||||
return {
|
||||
dimensions: {
|
||||
d1Tone: dim(opts.tone),
|
||||
d2Structure: dim("情境開場 → 經驗 → 提問"),
|
||||
d3Interaction: dim("先接話再補觀點"),
|
||||
d4Topics: dim("生活選品與真實經驗"),
|
||||
d5Rhythm: dim(draft.rhythm),
|
||||
d6Visual: dim("換行分段,少 emoji"),
|
||||
d7Conversion: dim(draft.ctaStyle),
|
||||
d8Risk: dim(draft.avoid),
|
||||
},
|
||||
draft,
|
||||
draftText: serializeDraftText(draft),
|
||||
source: "seed",
|
||||
sampleCount: 3,
|
||||
analyzedAt: nowUnixNano(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import type { BrandProduct } from "../domain/types";
|
||||
|
||||
/** 掃描關鍵字池:產品痛點 + tags(可多產品合併) */
|
||||
export function buildScanKeywordPool(
|
||||
products: BrandProduct | BrandProduct[] | null | undefined,
|
||||
): string[] {
|
||||
const list = !products ? [] : Array.isArray(products) ? products : [products];
|
||||
const pool = list.flatMap((product) => [
|
||||
...(product.match_tags || []),
|
||||
...(product.pain_points || []),
|
||||
product.label || "",
|
||||
])
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
return [...new Set(pool)];
|
||||
}
|
||||
|
||||
export function scoreProductForPost(
|
||||
product: BrandProduct,
|
||||
searchTag: string,
|
||||
postText: string,
|
||||
): number {
|
||||
const tags = [...product.match_tags, ...product.pain_points, product.label].filter(Boolean);
|
||||
const hay = `${searchTag} ${postText}`.toLowerCase();
|
||||
let hits = 0;
|
||||
for (const tag of tags) {
|
||||
const t = tag.toLowerCase();
|
||||
if (t && hay.includes(t)) hits += 1;
|
||||
}
|
||||
if (!hits) return 0;
|
||||
// 0–100
|
||||
return Math.min(99, 40 + hits * 18);
|
||||
}
|
||||
|
||||
export function resolveProductForPost(opts: {
|
||||
products: BrandProduct[];
|
||||
preferredProductId?: string | null;
|
||||
searchTag: string;
|
||||
postText: string;
|
||||
}): { product: BrandProduct | null; score: number } {
|
||||
const { products, preferredProductId, searchTag, postText } = opts;
|
||||
if (!products.length) return { product: null, score: 0 };
|
||||
|
||||
let best: BrandProduct | null = null;
|
||||
let bestScore = 0;
|
||||
for (const p of products) {
|
||||
const s = scoreProductForPost(p, searchTag, postText);
|
||||
if (s > bestScore) {
|
||||
bestScore = s;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
if (best && bestScore >= 40) return { product: best, score: bestScore };
|
||||
|
||||
if (preferredProductId) {
|
||||
const pref = products.find((p) => p.id === preferredProductId);
|
||||
if (pref) return { product: pref, score: Math.max(bestScore, 35) };
|
||||
}
|
||||
return { product: products[0] || null, score: bestScore || 30 };
|
||||
}
|
||||
|
||||
export function opportunityLine(product: BrandProduct | null, searchTag: string): string {
|
||||
if (!product) return `關鍵字「${searchTag}」命中;尚未對上產品線`;
|
||||
const pain = product.pain_points[0] || product.match_tags[0] || product.label;
|
||||
return `對上「${product.label}」· 痛點/語境:${pain}`;
|
||||
}
|
||||
|
|
@ -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("—");
|
||||
});
|
||||
});
|
||||
|
|
@ -92,8 +92,11 @@ export function formatRelativeFromNow(
|
|||
|
||||
/** 過去時間:通知列表用(剛剛/N 分前) */
|
||||
export function formatTimeAgo(nano: number | null | undefined, now = nowUnixNano()): string {
|
||||
if (nano == null || nano <= 0) return "—";
|
||||
const diffMs = Math.floor((now - nano) / 1_000_000);
|
||||
// 這裡是唯一漏掉正規化的時間格式化函式:後端若回秒或毫秒,未換算的數值會遠小於 now,
|
||||
// 算出來就變成「56 年前」。
|
||||
const n = normalizeUnixNano(nano ?? null);
|
||||
if (n == null || n <= 0) return "—";
|
||||
const diffMs = Math.floor((now - n) / 1_000_000);
|
||||
if (diffMs < 45_000) return t("time.justNow");
|
||||
const min = Math.floor(diffMs / 60_000);
|
||||
if (min < 60) return t("time.minAgo", { n: min });
|
||||
|
|
|
|||
|
|
@ -92,12 +92,19 @@ export function CrewPage() {
|
|||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
// 帳號列表失敗時若只留空陣列,畫面會顯示「尚未連接帳號」,跟真的沒帳號分不出來。
|
||||
setAccounts(await repos.accounts.list());
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t("crew.loadFail"));
|
||||
return;
|
||||
}
|
||||
const list = await repos.growth.listAccountHealth().catch(() => [] as AccountHealth[]);
|
||||
const map: Record<string, AccountHealth> = {};
|
||||
for (const h of list) map[h.threads_account_id] = h;
|
||||
setOpHealth(map);
|
||||
}, [repos.accounts, repos.growth]);
|
||||
}, [repos.accounts, repos.growth, t]);
|
||||
|
||||
const pagedAccounts = useMemo(
|
||||
() => pageSlice(accounts, accPage, ACCOUNTS_PAGE),
|
||||
|
|
@ -224,7 +231,7 @@ export function CrewPage() {
|
|||
) : null}
|
||||
|
||||
{accounts.length === 0 ? (
|
||||
<EmptyState title={t("crew.empty")} />
|
||||
error ? null : <EmptyState title={t("crew.empty")} />
|
||||
) : (
|
||||
<>
|
||||
<ul className="hb-compact-list">
|
||||
|
|
|
|||
|
|
@ -32,39 +32,64 @@ export function JobDetailPage() {
|
|||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const displayedJobStatus = job?.status;
|
||||
const isActive =
|
||||
displayedJobStatus === "pending" ||
|
||||
displayedJobStatus === "queued" ||
|
||||
displayedJobStatus === "running";
|
||||
const needsPayload = displayedJobStatus === "succeeded" && !job?.payload;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const fromLive = liveJobs.find((j) => j.id === id);
|
||||
if (fromLive) {
|
||||
setJob(fromLive);
|
||||
setLoading(false);
|
||||
setLoadFailed(false);
|
||||
return;
|
||||
}
|
||||
void repos.jobs.get(id).then(setJob);
|
||||
}, [id, repos.jobs, tick, liveJobs, revision]);
|
||||
void repos.jobs
|
||||
.get(id)
|
||||
.then((j) => {
|
||||
setJob(j);
|
||||
setLoadFailed(false);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// 讀不到跟「這個任務不存在」是兩件事,分開記才不會謊稱找不到。
|
||||
setLoadFailed(true);
|
||||
setError(formatApiError(e, "jobs.detailLoadFail"));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, repos.jobs, tick, liveJobs, revision, formatApiError]);
|
||||
|
||||
// 進行中:額外直接 get,避免 list 延遲;終態也 get 一次拿 payload(result_text)
|
||||
// 進行中:額外直接 get,避免 list 延遲
|
||||
useEffect(() => {
|
||||
if (!id || !job) return;
|
||||
const active =
|
||||
job.status === "pending" || job.status === "queued" || job.status === "running";
|
||||
if (active) {
|
||||
if (!id || !isActive) return;
|
||||
// 只看 isActive,不看整個 job:否則每次輪詢寫回新物件都會重建 interval,計時器一直被歸零。
|
||||
const timer = window.setInterval(() => {
|
||||
void repos.jobs.get(id).then((j) => {
|
||||
void repos.jobs.get(id).then(
|
||||
(j) => {
|
||||
if (j) setJob(j);
|
||||
});
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}, 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}
|
||||
}, [id, isActive, repos.jobs]);
|
||||
|
||||
// succeeded 但 list 可能沒 payload → 補 get
|
||||
if (job.status === "succeeded" && !job.payload) {
|
||||
void repos.jobs.get(id).then((j) => {
|
||||
useEffect(() => {
|
||||
if (!id || !needsPayload) return;
|
||||
void repos.jobs.get(id).then(
|
||||
(j) => {
|
||||
if (j) setJob(j);
|
||||
});
|
||||
}
|
||||
}, [id, job?.status, job?.payload, repos.jobs]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}, [id, needsPayload, repos.jobs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !displayedJobStatus) return;
|
||||
|
|
@ -138,6 +163,23 @@ export function JobDetailPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (!job && loading) {
|
||||
return <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) {
|
||||
return (
|
||||
<EmptyState
|
||||
|
|
|
|||
|
|
@ -24,29 +24,39 @@ export function OutboxDetailPage() {
|
|||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const [b, acc] = await Promise.all([repos.outbox.get(id), repos.accounts.list()]);
|
||||
setBundle(b);
|
||||
setAccounts(acc);
|
||||
setLoadFailed(false);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
// 載入失敗與「這篇不存在」是兩件事,分開記,否則畫面會謊稱找不到。
|
||||
setLoadFailed(true);
|
||||
setError(e instanceof Error ? e.message : t("outbox.detail.loadFail"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, repos.outbox, repos.accounts]);
|
||||
}, [id, repos.outbox, repos.accounts, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, tick]);
|
||||
|
||||
// 發文中自動輪詢:worker 可能 10~30s 才完成,不輪詢會一直卡舊狀態/舊錯誤
|
||||
// 發文中自動輪詢:worker 可能 10~30s 才完成,不輪詢會一直卡舊狀態/舊錯誤。
|
||||
// 只看 inFlight 布林:依賴整個 bundle 的話,每次輪詢寫回新物件都會重建 interval。
|
||||
const inFlight = isInFlightBundle(bundle);
|
||||
useEffect(() => {
|
||||
if (!isInFlightBundle(bundle)) return;
|
||||
const id = window.setInterval(() => {
|
||||
if (!inFlight) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void load();
|
||||
}, 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [bundle, load]);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [inFlight, load]);
|
||||
|
||||
async function run(action: () => Promise<OutboxBundle>) {
|
||||
setBusy(true);
|
||||
|
|
@ -79,12 +89,28 @@ export function OutboxDetailPage() {
|
|||
|
||||
if (!id) return <EmptyState title={t("outbox.detail.missingId")} />;
|
||||
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")} />;
|
||||
|
||||
const nameOf = (accountId: string) =>
|
||||
accounts.find((a) => a.id === accountId)?.display_name || accountId;
|
||||
|
||||
const inFlight = isInFlightBundle(bundle);
|
||||
const allPublished = bundle.steps.length > 0 && bundle.steps.every((s) => s.status === "published");
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,16 +10,8 @@ import type {
|
|||
ScoutHomeworkRecord,
|
||||
ScoutPost,
|
||||
ScoutPurpose,
|
||||
ScoutResearchNote,
|
||||
ScoutResearchTier,
|
||||
ScoutRunBrief,
|
||||
} from "../domain/types";
|
||||
import {
|
||||
groupNotesByTier,
|
||||
noteLearnPoints,
|
||||
noteReplyHooks,
|
||||
formatResearchLink,
|
||||
} from "../lib/mockResearch";
|
||||
import { newId } from "../lib/id";
|
||||
import { allowHttpUrl } from "../lib/externalUrl";
|
||||
import { bumpScoutTodayDone, loadScoutToday, saveScoutToday } from "../lib/scoutToday";
|
||||
|
|
@ -128,76 +120,6 @@ function shortRunLabel(label: string, max = 20): string {
|
|||
return `${t.slice(0, Math.max(1, max - 1))}…`;
|
||||
}
|
||||
|
||||
/** 知識圖譜節點卡:學習重點 + 回帖鉤子 + 來源 */
|
||||
function KnowledgeNoteCard({
|
||||
note,
|
||||
badge,
|
||||
compact,
|
||||
}: {
|
||||
note: ScoutResearchNote;
|
||||
badge?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const link = formatResearchLink(note.url, note.source_label);
|
||||
const points = noteLearnPoints(note);
|
||||
const hooks = noteReplyHooks(note);
|
||||
const relationLabel = note.relation ? t(`scout.relation.${note.relation}`) : null;
|
||||
const showPoints = compact ? points.slice(0, 3) : points;
|
||||
const showHooks = compact ? hooks.slice(0, 1) : hooks;
|
||||
const safeUrl = allowHttpUrl(note.url);
|
||||
|
||||
return (
|
||||
<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 [draftText, setDraftText] = useState("");
|
||||
|
||||
const [report, setReport] = useState<ScoutRunBrief | null>(null);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [valueQueuePage, setValueQueuePage] = useState(1);
|
||||
const [activityQueuePage, setActivityQueuePage] = useState(1);
|
||||
const [researchTier, setResearchTier] = useState<"all" | ScoutResearchTier>("all");
|
||||
const [homeworkList, setHomeworkList] = useState<ScoutHomeworkRecord[]>([]);
|
||||
/** 目前檢視的海巡批次(每次按開始 = 一筆) */
|
||||
const [activeRunKey, setActiveRunKey] = useState<string | null>(null);
|
||||
|
|
@ -431,15 +350,6 @@ export function ScoutPage() {
|
|||
setActiveRunKey(key);
|
||||
setValueQueuePage(1);
|
||||
setActivityQueuePage(1);
|
||||
const hw = homeworkList.find((h) => h.theme_key === key);
|
||||
if (hw) {
|
||||
setReport(hw.brief);
|
||||
setResearchTier(
|
||||
hw.brief.research_notes?.some((n) => (n.tier || "core") === "core") ? "core" : "all",
|
||||
);
|
||||
} else {
|
||||
setReport(null);
|
||||
}
|
||||
const pending = posts
|
||||
.filter((p) => postRunKey(p) === key && isPending(p))
|
||||
.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||
|
|
@ -462,7 +372,6 @@ export function ScoutPage() {
|
|||
setHomeworkList(hw);
|
||||
if (activeRunKey === key) {
|
||||
setActiveRunKey(null);
|
||||
setReport(null);
|
||||
setCurrentId(null);
|
||||
} else if (currentId && !list.some((p) => p.id === currentId)) {
|
||||
pickNext(currentId, list, activeRunKey);
|
||||
|
|
@ -964,175 +873,6 @@ export function ScoutPage() {
|
|||
)}
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -46,8 +46,16 @@ export function SettingsPage() {
|
|||
/** 進頁:一支 GetAi 同時拿設定 + 模型清單 + 上次選定 model */
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const a = await repos.settings.getAi();
|
||||
const p = await repos.settings.getPlacement();
|
||||
let a: AiSettings;
|
||||
let p: PlacementSettings;
|
||||
try {
|
||||
a = await repos.settings.getAi();
|
||||
p = await repos.settings.getPlacement();
|
||||
} catch (e) {
|
||||
// ai 留 null → 整個設定表單不渲染,沒有訊息的話畫面會是一片空白。
|
||||
setError(e instanceof Error ? e.message : t("settings.loadFail"));
|
||||
return;
|
||||
}
|
||||
const provider = normalizeProvider(a.provider);
|
||||
const list = a.models?.length
|
||||
? a.models
|
||||
|
|
@ -69,11 +77,9 @@ export function SettingsPage() {
|
|||
models: list,
|
||||
});
|
||||
setPlacement(p);
|
||||
if (a.models_error) {
|
||||
setError(a.models_error);
|
||||
}
|
||||
setError(a.models_error || "");
|
||||
})();
|
||||
}, [repos.settings]);
|
||||
}, [repos.settings, t]);
|
||||
|
||||
/** 換 provider:同一支 getAi(?provider=) 帶回 models + selected_model */
|
||||
async function onProviderChange(nextId: string) {
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ export function UsagePage() {
|
|||
}
|
||||
}, [repos.usage, repos.growth, t]);
|
||||
|
||||
/** 失敗會 throw:呼叫端的存檔流程要能知道刷新沒成功,不能自己吞掉。 */
|
||||
async function loadTenant(query = applied) {
|
||||
if (!isAdmin) return;
|
||||
setTenant(
|
||||
|
|
@ -133,7 +134,11 @@ export function UsagePage() {
|
|||
}, [loadMine, tick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "tenant" && isAdmin) void loadTenant(applied);
|
||||
if (tab !== "tenant" || !isAdmin) return;
|
||||
void loadTenant(applied).then(
|
||||
() => setError(""),
|
||||
(e: unknown) => setError(e instanceof Error ? e.message : t("usage.fail")),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, isAdmin, repos.usage, tick, applied]);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
InspireSessionSummary,
|
||||
TrendItem,
|
||||
} from "../../domain/types";
|
||||
import { isAbortError } from "../../lib/abort";
|
||||
import { newId } from "../../lib/id";
|
||||
import { saveComposeDraftBody } from "../../lib/composeBridge";
|
||||
import { nowUnixNano } from "../../lib/time";
|
||||
|
|
@ -72,6 +73,8 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
const [error, setError] = useState("");
|
||||
/** stream 中的助手暫存字(尚未 done) */
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
/** 進行中的串流;按停止或離開頁面時用它中止,避免背景繼續燒 AI 額度 */
|
||||
const streamAbort = useRef<AbortController | null>(null);
|
||||
const [useWeb, setUseWeb] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newKind, setNewKind] = useState<InspireElementKind>("role");
|
||||
|
|
@ -164,6 +167,11 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [session?.messages.length, busy, streamingText]);
|
||||
|
||||
// 離開頁面時關掉還開著的串流,否則 SSE 連線與後端的 AI 呼叫都會繼續跑到結束。
|
||||
useEffect(() => {
|
||||
return () => streamAbort.current?.abort();
|
||||
}, []);
|
||||
|
||||
// 從今日等入口:?topic=xxx → 預填輸入框(只一次)
|
||||
useEffect(() => {
|
||||
if (topicSeeded.current) return;
|
||||
|
|
@ -298,6 +306,18 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
return [...ids].sort().join(",");
|
||||
}
|
||||
|
||||
/** 換上新的 controller,順手中止前一輪沒收乾淨的串流。 */
|
||||
function beginStream(): AbortSignal {
|
||||
streamAbort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
streamAbort.current = controller;
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
streamAbort.current?.abort();
|
||||
}
|
||||
|
||||
/** 聊天:必須有輸入。 */
|
||||
function resolveSendPayload(mode: "chat" | "generate"): { message: string; mode: "chat" | "generate" } | null {
|
||||
const text = input.trim();
|
||||
|
|
@ -333,6 +353,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
],
|
||||
});
|
||||
}
|
||||
const signal = beginStream();
|
||||
try {
|
||||
const result = await repos.inspiration.chatStream(
|
||||
{
|
||||
|
|
@ -341,6 +362,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
mode: "generate",
|
||||
personaId,
|
||||
sessionId: session?.id,
|
||||
signal,
|
||||
},
|
||||
(chunk) => {
|
||||
setStreamingText((prev) => prev + chunk);
|
||||
|
|
@ -376,7 +398,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
refresh();
|
||||
} catch (e) {
|
||||
setStreamingText("");
|
||||
setMessage(e instanceof Error ? e.message : t("inspire.fail"));
|
||||
setMessage(isAbortError(e) ? t("inspire.stopped") : e instanceof Error ? e.message : t("inspire.fail"));
|
||||
try {
|
||||
setSession(await repos.inspiration.getSession());
|
||||
} catch {
|
||||
|
|
@ -417,6 +439,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
});
|
||||
}
|
||||
setInput("");
|
||||
const signal = beginStream();
|
||||
try {
|
||||
const result = await repos.inspiration.chatStream(
|
||||
{
|
||||
|
|
@ -426,6 +449,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
personaId,
|
||||
sessionId: session?.id,
|
||||
useWeb,
|
||||
signal,
|
||||
},
|
||||
(chunk) => {
|
||||
setStreamingText((prev) => prev + chunk);
|
||||
|
|
@ -489,7 +513,7 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
refresh();
|
||||
} catch (e) {
|
||||
setStreamingText("");
|
||||
setMessage(e instanceof Error ? e.message : t("inspire.fail"));
|
||||
setMessage(isAbortError(e) ? t("inspire.stopped") : e instanceof Error ? e.message : t("inspire.fail"));
|
||||
// 失敗時重拉 session 去掉樂觀 user bubble 若後端沒寫入
|
||||
try {
|
||||
setSession(await repos.inspiration.getSession());
|
||||
|
|
@ -990,6 +1014,13 @@ export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
) : 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { Button, Card, Input, Select, Textarea } from "../../../components/ui";
|
|||
import { useRepos } from "../../../data/DataContext";
|
||||
import type { Persona } from "../../../domain/types";
|
||||
import { useI18n } from "../../../i18n/I18nContext";
|
||||
import { mockDelay, mockGenerateTopic } from "../../../lib/mockAi";
|
||||
import { isPersonaReady, personaOptionLabel } from "../../../lib/personaPrompt";
|
||||
import type { WizardDraft } from "../wizardState";
|
||||
|
||||
|
|
@ -17,7 +16,6 @@ export function TopicStep({ draft, onChange }: Props) {
|
|||
const { t } = useI18n();
|
||||
const [personas, setPersonas] = useState<Persona[]>([]);
|
||||
const [personaId, setPersonaId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
|
|
@ -28,29 +26,6 @@ export function TopicStep({ draft, onChange }: Props) {
|
|||
})();
|
||||
}, [repos.personas]);
|
||||
|
||||
async function aiTopic() {
|
||||
const persona = personas.find((p) => p.id === personaId);
|
||||
if (!isPersonaReady(persona)) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await mockDelay();
|
||||
const topic = mockGenerateTopic(
|
||||
draft.topic || draft.title || t("wizard.topic.fallbackTopic"),
|
||||
persona,
|
||||
);
|
||||
onChange({
|
||||
topic,
|
||||
title:
|
||||
draft.title ||
|
||||
(persona ? t("wizard.topic.personaOpen", { name: persona.name }) : t("wizard.topic.aiTitle")),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const ready = isPersonaReady(personas.find((p) => p.id === personaId));
|
||||
|
||||
return (
|
||||
|
|
@ -90,9 +65,6 @@ export function TopicStep({ draft, onChange }: Props) {
|
|||
</p>
|
||||
) : null}
|
||||
<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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export function PainKeywordsPage() {
|
|||
const data = await apiRequest<NonNullable<typeof result>>("/api/v1/public/tools/pain-keywords", {
|
||||
method: "POST",
|
||||
body: { product_brief: brief, audience },
|
||||
// 免登入端點:不要把會員 JWT 送出去,也不要在 401 時觸發 refresh。
|
||||
auth: false,
|
||||
});
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ export function StyleQuizPage() {
|
|||
const data = await apiRequest<NonNullable<typeof result>>("/api/v1/public/tools/style-quiz", {
|
||||
method: "POST",
|
||||
body: { samples },
|
||||
// 免登入端點:不要把會員 JWT 送出去,也不要在 401 時觸發 refresh。
|
||||
auth: false,
|
||||
});
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -895,6 +895,12 @@ svg {
|
|||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.hb-inspire-stop {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
/* —— 輕量 markdown(聊天泡泡:逐行換行,不擠成一團) —— */
|
||||
.hb-md {
|
||||
display: flex;
|
||||
|
|
@ -1778,279 +1784,6 @@ svg {
|
|||
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 {
|
||||
opacity: 0.75;
|
||||
|
|
|
|||
Loading…
Reference in New Issue