diff --git a/extension/haixun-threads-sync/README.md b/extension/haixun-threads-sync/README.md
index 34b1fce..c5baa02 100644
--- a/extension/haixun-threads-sync/README.md
+++ b/extension/haixun-threads-sync/README.md
@@ -1,23 +1,66 @@
# 巡樓 Threads Session 同步(Chrome 擴充)
-從你已登入的 Chrome Threads 帳號,一鍵把 session 傳到遠端 Linux server,不需要本機腳本。
+從你已登入的 Chrome Threads 帳號,一鍵把 session 傳到巡樓 Go 後端(`:8890`)。
## 安裝
+**方式 A(推薦)**:巡樓「設定」頁 → **下載擴充套件(ZIP)** → 解壓後載入。
+
+**方式 B**:直接選擇 repo 資料夾 `extension/haixun-threads-sync`。
+
1. Chrome 打開 `chrome://extensions`
2. 開啟「開發人員模式」
3. 點「載入未封裝項目」
-4. 選擇此資料夾:`extension/haixun-threads-sync`
+4. 選擇解壓後的 `haixun-threads-sync` 資料夾
-## 使用(多帳號)
+## 必要條件
-1. 在 Chrome 正常登入 threads.com(要同步哪個帳號就登入哪個)
-2. 開啟巡樓網頁並登入
-3. 在側欄**切換到目標經營帳號**
-4. 到「設定」頁按 **「從 Chrome 同步」**,或點擴充功能圖示 →「同步到巡樓」
+- 後端 API 在跑:`make run`(預設 `http://127.0.0.1:8890`)
+- 前端在跑:`make web-dev`(預設 `http://localhost:5173`)
+- 已在 Chrome 登入 `threads.com` 或 `threads.net`
+- 已在巡樓網頁登入(JWT 存在 localStorage)
+
+## 使用方式 A:連線設定頁(推薦)
+
+1. 開啟 `http://localhost:5173` 並登入
+2. 頂部切換目標經營帳號
+3. 到「帳號 → 連線設定」
+4. 切換到 **開發模式(爬蟲)**
+5. 確認顯示「擴充已偵測」
+6. 按 **「從 Chrome 同步到目前帳號」**
+
+> 關閉開發模式 = 全部走 Threads API,不需要同步 Chrome session。
+
+## 使用方式 B:擴充圖示
+
+1. 先開著巡樓分頁並登入
+2. 點擴充圖示 →「同步到巡樓」
+3. 擴充會自動讀取巡樓分頁的 JWT 與目前帳號
## 設定 server 網址
-擴充功能選項填入你的 Linux server,例如 `https://haixun.example.com`。
+擴充選項填入**前端網址**(不是 8890),例如:
-首次同步會詢問是否允許存取該網站(讀取 `haixun_session` cookie)。
\ No newline at end of file
+- 本機:`http://localhost:5173` 或 `http://127.0.0.1:5173`
+- 遠端:`https://haixun.example.com`
+
+儲存後會自動注入網頁橋接腳本。API 在本機 dev 時會自動改打 `:8890`。
+
+## 常見問題
+
+| 現象 | 原因與處理 |
+|------|------------|
+| 尚未偵測擴充 | 擴充安裝/更新後頁面不會自動注入腳本 → `chrome://extensions` 重新載入擴充,再 **F5 刷新巡樓頁** |
+| 等待擴充逾時 | 巡樓網址與擴充選項不一致(`localhost` vs `127.0.0.1`)→ 統一用同一個,或在擴充選項重新儲存 |
+| 找不到登入狀態 / JWT | 開錯網站:要用 **新版** `http://localhost:5173`(Go 後台),不是舊版 Next `:3000` |
+| 無法連線後端 :8890 | 只開了 `make web-dev` 沒開 API → 另開終端執行 `cd haixun-backend && make run` |
+| invalid access token | JWT 過期(15 分鐘)→ 在巡樓頁重新整理或重新登入後再同步 |
+| 找不到 cookies | 先在 Chrome 開 threads.com / threads.net 完成登入,再同步 |
+| 缺少帳號 ID | 在巡樓頂部切換經營帳號,或打開 `/threads/:id/connections` |
+| 開發模式未開啟 | 連線頁底部「開發工具」要先 **開啟開發模式**,同步按鈕才會出現 |
+
+## 技術說明
+
+- 網頁透過 `postMessage` 與 `content-haixun.js` 溝通
+- 擴充 background 收集 Threads/Instagram cookies,組成 Playwright `storageState`
+- 呼叫 `POST /api/v1/threads-accounts/:id/session/import`(Bearer JWT)
\ No newline at end of file
diff --git a/extension/haixun-threads-sync/content-haixun.js b/extension/haixun-threads-sync/content-haixun.js
index c0824ac..17e927a 100644
--- a/extension/haixun-threads-sync/content-haixun.js
+++ b/extension/haixun-threads-sync/content-haixun.js
@@ -1,39 +1,57 @@
-window.addEventListener("message", (event) => {
- if (event.source !== window) return;
+(() => {
+ if (globalThis.__HAIXUN_THREADS_BRIDGE__) return;
+ globalThis.__HAIXUN_THREADS_BRIDGE__ = true;
- if (event.data?.type === "HAIXUN_PING_EXTENSION") {
- window.postMessage({ type: "HAIXUN_EXTENSION_READY" }, "*");
- return;
+ const ROOT = document.documentElement;
+ ROOT.dataset.haixunExtension = "1";
+ ROOT.dataset.haixunExtensionVersion = "2";
+
+ function announceReady() {
+ window.postMessage({ type: "HAIXUN_EXTENSION_READY", version: 2 }, "*");
}
- if (event.data?.type !== "HAIXUN_REQUEST_THREADS_SYNC") return;
+ window.addEventListener("message", (event) => {
+ if (event.source !== window) return;
- chrome.runtime
- .sendMessage({
- action: "sync",
- serverUrl: event.data.serverUrl ?? window.location.origin,
- accountId: event.data.accountId,
- })
- .then((result) => {
- window.postMessage(
- {
- type: "HAIXUN_THREADS_SYNC_RESULT",
- ...result,
- },
- "*"
- );
- })
- .catch((error) => {
- window.postMessage(
- {
- type: "HAIXUN_THREADS_SYNC_RESULT",
- success: false,
- valid: false,
- message: error instanceof Error ? error.message : "同步失敗",
- },
- "*"
- );
- });
-});
+ if (event.data?.type === "HAIXUN_PING_EXTENSION") {
+ announceReady();
+ return;
+ }
-window.postMessage({ type: "HAIXUN_EXTENSION_READY" }, "*");
\ No newline at end of file
+ if (event.data?.type !== "HAIXUN_REQUEST_THREADS_SYNC") return;
+
+ chrome.runtime
+ .sendMessage({
+ action: "sync",
+ serverUrl: event.data.serverUrl ?? window.location.origin,
+ accountId: event.data.accountId,
+ accessToken: event.data.accessToken,
+ apiVersion: event.data.apiVersion ?? "go-v1",
+ })
+ .then((result) => {
+ window.postMessage(
+ {
+ type: "HAIXUN_THREADS_SYNC_RESULT",
+ ...(result ?? {}),
+ },
+ "*"
+ );
+ })
+ .catch((error) => {
+ const message =
+ chrome.runtime.lastError?.message ??
+ (error instanceof Error ? error.message : "同步失敗");
+ window.postMessage(
+ {
+ type: "HAIXUN_THREADS_SYNC_RESULT",
+ success: false,
+ valid: false,
+ message,
+ },
+ "*"
+ );
+ });
+ });
+
+ announceReady();
+})();
\ No newline at end of file
diff --git a/extension/haixun-threads-sync/manifest.json b/extension/haixun-threads-sync/manifest.json
index 15a6aed..db0f584 100644
--- a/extension/haixun-threads-sync/manifest.json
+++ b/extension/haixun-threads-sync/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "巡樓 Threads Session 同步",
- "version": "1.0.0",
+ "version": "1.1.1",
"description": "從 Chrome 已登入的 Threads 一鍵同步 session 到巡樓 server",
"permissions": ["cookies", "storage", "tabs", "scripting"],
"host_permissions": [
@@ -12,7 +12,13 @@
"https://instagram.com/*",
"https://*.facebook.com/*",
"http://localhost:3000/*",
- "http://127.0.0.1:3000/*"
+ "http://127.0.0.1:3000/*",
+ "http://localhost:4173/*",
+ "http://127.0.0.1:4173/*",
+ "http://localhost:5173/*",
+ "http://127.0.0.1:5173/*",
+ "http://localhost:8890/*",
+ "http://127.0.0.1:8890/*"
],
"optional_host_permissions": ["http://*/*", "https://*/*"],
"action": {
@@ -23,5 +29,27 @@
"service_worker": "service-worker.js",
"type": "module"
},
+ "content_scripts": [
+ {
+ "matches": [
+ "http://localhost:3000/*",
+ "http://127.0.0.1:3000/*",
+ "http://localhost:4173/*",
+ "http://127.0.0.1:4173/*",
+ "http://localhost:5173/*",
+ "http://127.0.0.1:5173/*",
+ "http://localhost:8890/*",
+ "http://127.0.0.1:8890/*"
+ ],
+ "js": ["content-haixun.js"],
+ "run_at": "document_start"
+ }
+ ],
+ "web_accessible_resources": [
+ {
+ "resources": ["content-haixun.js"],
+ "matches": ["http://*/*", "https://*/*"]
+ }
+ ],
"options_page": "options.html"
}
\ No newline at end of file
diff --git a/extension/haixun-threads-sync/options.js b/extension/haixun-threads-sync/options.js
index 9cd1c21..db65746 100644
--- a/extension/haixun-threads-sync/options.js
+++ b/extension/haixun-threads-sync/options.js
@@ -3,7 +3,7 @@ const saveBtn = document.getElementById("save");
const savedEl = document.getElementById("saved");
chrome.storage.sync.get(["serverUrl"], ({ serverUrl }) => {
- input.value = serverUrl ?? "http://localhost:3000";
+ input.value = serverUrl ?? "http://localhost:5173";
});
saveBtn.addEventListener("click", async () => {
@@ -21,7 +21,16 @@ saveBtn.addEventListener("click", async () => {
await chrome.storage.sync.set({ serverUrl: origin });
- const granted = await chrome.permissions.request({ origins: [`${origin}/*`] });
+ const url = new URL(origin);
+ const port = url.port ? `:${url.port}` : "";
+ const origins = new Set([`${origin}/*`]);
+ if (url.hostname === "localhost") {
+ origins.add(`${url.protocol}//127.0.0.1${port}/*`);
+ } else if (url.hostname === "127.0.0.1") {
+ origins.add(`${url.protocol}//localhost${port}/*`);
+ }
+
+ const granted = await chrome.permissions.request({ origins: [...origins] });
if (granted) {
try {
await chrome.scripting.unregisterContentScripts({ ids: ["haixun-bridge"] });
@@ -31,12 +40,29 @@ saveBtn.addEventListener("click", async () => {
await chrome.scripting.registerContentScripts([
{
id: "haixun-bridge",
- matches: [`${origin}/*`],
+ matches: [...origins].map((item) => item.replace(/\/\*$/, "/*")),
js: ["content-haixun.js"],
- runAt: "document_idle",
+ runAt: "document_start",
},
]);
- savedEl.textContent = `已儲存:${origin}(已啟用網頁同步)`;
+ const tabQueries = [...origins].map((pattern) => chrome.tabs.query({ url: pattern }));
+ await Promise.all(tabQueries).then(async (groups) => {
+ const tabs = groups.flat();
+ const seen = new Set();
+ for (const tab of tabs) {
+ if (!tab.id || seen.has(tab.id)) continue;
+ seen.add(tab.id);
+ try {
+ await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ files: ["content-haixun.js"],
+ });
+ } catch {
+ // ignore
+ }
+ }
+ });
+ savedEl.textContent = `已儲存:${origin}(已注入網頁橋接)`;
savedEl.style.color = "#166534";
} else {
savedEl.textContent = "已儲存網址,但未授權存取該網站(同步時會再詢問)";
diff --git a/extension/haixun-threads-sync/popup.html b/extension/haixun-threads-sync/popup.html
index 28fee0c..10022ae 100644
--- a/extension/haixun-threads-sync/popup.html
+++ b/extension/haixun-threads-sync/popup.html
@@ -51,8 +51,8 @@
同步 Threads Session
1. 在 Chrome 登入 threads.com
- 2. 在巡樓側欄切換到目標帳號
- 3. 按下方按鈕同步到 server
+ 2. 開啟巡樓網頁並登入、切換帳號
+ 3. 按下方按鈕(會自動讀取巡樓 JWT)
diff --git a/extension/haixun-threads-sync/popup.js b/extension/haixun-threads-sync/popup.js
index d18d49a..c44eae5 100644
--- a/extension/haixun-threads-sync/popup.js
+++ b/extension/haixun-threads-sync/popup.js
@@ -15,16 +15,24 @@ syncBtn.addEventListener("click", async () => {
const response = await chrome.runtime.sendMessage({
action: "sync",
serverUrl,
+ apiVersion: "go-v1",
});
- if (response?.valid) {
+ if (!response) {
+ throw new Error(
+ chrome.runtime.lastError?.message ??
+ "擴充背景程序無回應。請到 chrome://extensions 重新載入擴充"
+ );
+ }
+
+ if (response.success !== false && response.valid !== false) {
setStatus(
response.username
? `成功:@${response.username}\n${response.message ?? ""}`
: response.message ?? "同步成功"
);
} else {
- setStatus(response?.message ?? "同步失敗", true);
+ setStatus(response.message ?? "同步失敗", true);
}
} catch (error) {
setStatus(error instanceof Error ? error.message : "同步失敗", true);
diff --git a/extension/haixun-threads-sync/service-worker.js b/extension/haixun-threads-sync/service-worker.js
index 84f5054..a615f80 100644
--- a/extension/haixun-threads-sync/service-worker.js
+++ b/extension/haixun-threads-sync/service-worker.js
@@ -1,17 +1,69 @@
import { buildStorageState } from "./storage-state.js";
const CONTENT_SCRIPT_ID = "haixun-bridge";
+const GO_API_SUCCESS_CODE = 102000;
+const DEV_WEB_PORTS = new Set(["3000", "4173", "5173"]);
-async function getHaixunSessionToken(serverOrigin) {
- const cookie = await chrome.cookies.get({
- url: serverOrigin,
- name: "haixun_session",
- });
- return cookie?.value ?? null;
+function unwrapGoApiResponse(raw) {
+ if (raw && typeof raw === "object" && "code" in raw) {
+ if (raw.code !== GO_API_SUCCESS_CODE) {
+ throw new Error(raw.message || `API error ${raw.code}`);
+ }
+ return raw.data && typeof raw.data === "object" ? raw.data : {};
+ }
+ return raw && typeof raw === "object" ? raw : {};
+}
+
+function parseApiError(raw, status) {
+ if (raw && typeof raw === "object") {
+ if (typeof raw.message === "string" && raw.message.trim()) return raw.message;
+ if (typeof raw.error === "string" && raw.error.trim()) return raw.error;
+ }
+ return `匯入失敗(HTTP ${status})`;
+}
+
+function equivalentOrigins(origin) {
+ const url = new URL(origin);
+ const port = url.port ? `:${url.port}` : "";
+ const origins = new Set([url.origin]);
+ if (url.hostname === "localhost") {
+ origins.add(`${url.protocol}//127.0.0.1${port}`);
+ } else if (url.hostname === "127.0.0.1") {
+ origins.add(`${url.protocol}//localhost${port}`);
+ }
+ return [...origins];
+}
+
+function resolveApiBase(serverUrl) {
+ const url = new URL(serverUrl);
+ if (DEV_WEB_PORTS.has(url.port)) {
+ // Prefer 127.0.0.1 to match backend default bind address.
+ const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname;
+ return `${url.protocol}//${host}:8890`;
+ }
+ return url.origin;
+}
+
+async function requestHostPermission(serverOrigin) {
+ const origins = equivalentOrigins(serverOrigin).map((item) => `${item}/*`);
+ const granted = await chrome.permissions.contains({ origins });
+ if (granted) return true;
+ return chrome.permissions.request({ origins });
+}
+
+async function queryHaixunTabs(serverOrigin) {
+ const tabsById = new Map();
+ for (const origin of equivalentOrigins(serverOrigin)) {
+ const tabs = await chrome.tabs.query({ url: `${origin}/*` });
+ for (const tab of tabs) {
+ if (tab.id != null) tabsById.set(tab.id, tab);
+ }
+ }
+ return [...tabsById.values()];
}
async function ensureContentScript(serverOrigin) {
- const pattern = `${serverOrigin}/*`;
+ const patterns = equivalentOrigins(serverOrigin).map((item) => `${item}/*`);
const existing = await chrome.scripting.getRegisteredContentScripts();
if (existing.some((script) => script.id === CONTENT_SCRIPT_ID)) {
return;
@@ -20,49 +72,108 @@ async function ensureContentScript(serverOrigin) {
await chrome.scripting.registerContentScripts([
{
id: CONTENT_SCRIPT_ID,
- matches: [pattern],
+ matches: patterns,
js: ["content-haixun.js"],
- runAt: "document_idle",
+ runAt: "document_start",
},
]);
}
-async function requestHostPermission(serverOrigin) {
- const granted = await chrome.permissions.contains({
- origins: [`${serverOrigin}/*`],
- });
- if (granted) return true;
-
- return chrome.permissions.request({ origins: [`${serverOrigin}/*`] });
+async function injectBridgeIntoTabs(serverOrigin) {
+ const tabs = await queryHaixunTabs(serverOrigin);
+ for (const tab of tabs) {
+ if (!tab.id) continue;
+ try {
+ await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ files: ["content-haixun.js"],
+ });
+ } catch {
+ // Ignore duplicate injection / restricted pages.
+ }
+ }
}
-export async function syncThreadsSession(serverUrl, accountId) {
+async function readAuthFromHaixunTab(serverOrigin) {
+ const tabs = await queryHaixunTabs(serverOrigin);
+ for (const tab of tabs) {
+ if (!tab.id) continue;
+ const [result] = await chrome.scripting.executeScript({
+ target: { tabId: tab.id },
+ func: () => {
+ const pathname = location.pathname ?? "";
+ const fromPath = pathname.match(/\/threads\/([^/]+)/)?.[1] ?? "";
+ return {
+ accessToken: localStorage.getItem("haixun.access_token") ?? "",
+ accountId:
+ fromPath || localStorage.getItem("haixun.active_threads_account_id") || "",
+ origin: location.origin,
+ };
+ },
+ });
+ const payload = result?.result;
+ if (payload?.accessToken) {
+ return payload;
+ }
+ }
+ return null;
+}
+
+export async function syncThreadsSessionToGo(serverUrl, accountId, accessToken) {
const origin = new URL(serverUrl).origin;
- const sessionToken = await getHaixunSessionToken(origin);
+ const apiBase = resolveApiBase(serverUrl);
- if (!sessionToken) {
- throw new Error(`請先在 Chrome 開啟並登入 ${origin}`);
+ if (!accountId) {
+ throw new Error(
+ "缺少經營帳號 ID。請在巡樓頂部切換帳號,或開啟 /threads/:id/connections 後再同步"
+ );
+ }
+ if (!accessToken) {
+ const hint = equivalentOrigins(origin).join(" 或 ");
+ throw new Error(
+ `找不到巡樓登入狀態。請在 Chrome 開啟並登入 ${hint}(需為新版巡樓 :5173,不是舊版 :3000)`
+ );
}
- const storageState = await buildStorageState();
+ let storageState;
+ try {
+ storageState = await buildStorageState();
+ } catch (error) {
+ throw error;
+ }
- const res = await fetch(`${origin}/api/session/import`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Cookie: `haixun_session=${sessionToken}`,
- },
- body: JSON.stringify({
- storageState,
- ...(accountId ? { accountId } : {}),
- }),
- });
+ let res;
+ try {
+ res = await fetch(
+ `${apiBase}/api/v1/threads-accounts/${encodeURIComponent(accountId)}/session/import`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({ storageState }),
+ }
+ );
+ } catch (error) {
+ const msg = error instanceof Error ? error.message : String(error);
+ if (/fetch|network|failed/i.test(msg)) {
+ throw new Error(
+ `無法連線後端 ${apiBase}。請確認已執行 make run(或 backend:8d),且 :8890 有在跑`
+ );
+ }
+ throw error;
+ }
- const data = await res.json().catch(() => ({}));
+ const raw = await res.json().catch(() => ({}));
if (!res.ok) {
- throw new Error(data.error ?? data.message ?? `匯入失敗(HTTP ${res.status})`);
+ throw new Error(parseApiError(raw, res.status));
}
+ const data = unwrapGoApiResponse(raw);
+ if (data.valid === false) {
+ throw new Error(data.message || "Session 驗證失敗");
+ }
return data;
}
@@ -74,15 +185,19 @@ async function resolveServerUrl(partial) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const activeUrl = tabs[0]?.url;
- if (activeUrl && !activeUrl.includes("threads.com") && !activeUrl.includes("threads.net")) {
+ if (activeUrl) {
try {
- return new URL(activeUrl).origin;
+ const origin = new URL(activeUrl).origin;
+ const port = new URL(activeUrl).port;
+ if (DEV_WEB_PORTS.has(port) || activeUrl.includes("/threads/")) {
+ return origin;
+ }
} catch {
// ignore
}
}
- throw new Error("請在擴充功能選項設定巡樓網址,或先開啟巡樓分頁");
+ throw new Error("請在擴充功能選項設定巡樓網址(例如 http://localhost:5173),或先開啟巡樓分頁");
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
@@ -97,8 +212,27 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
}
await ensureContentScript(serverUrl);
- const result = await syncThreadsSession(serverUrl, message.accountId);
- sendResponse({ success: true, ...result });
+ await injectBridgeIntoTabs(serverUrl);
+
+ let accountId = message.accountId ?? "";
+ let accessToken = message.accessToken ?? "";
+ if (!accountId || !accessToken) {
+ const auth = await readAuthFromHaixunTab(serverUrl);
+ if (auth) {
+ accountId = accountId || auth.accountId;
+ accessToken = accessToken || auth.accessToken;
+ }
+ }
+
+ const result = await syncThreadsSessionToGo(serverUrl, accountId, accessToken);
+ sendResponse({
+ success: true,
+ valid: result.valid !== false,
+ synced: result.synced ?? true,
+ account_id: result.account_id ?? result.accountId,
+ username: result.username,
+ message: result.message || "Chrome session 已同步",
+ });
} catch (error) {
sendResponse({
success: false,
@@ -114,6 +248,6 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
chrome.runtime.onInstalled.addListener(async () => {
const { serverUrl } = await chrome.storage.sync.get(["serverUrl"]);
if (!serverUrl) {
- await chrome.storage.sync.set({ serverUrl: "http://localhost:3000" });
+ await chrome.storage.sync.set({ serverUrl: "http://localhost:5173" });
}
});
\ No newline at end of file
diff --git a/extension/haixun-threads-sync/storage-state.js b/extension/haixun-threads-sync/storage-state.js
index 502e1b6..e247044 100644
--- a/extension/haixun-threads-sync/storage-state.js
+++ b/extension/haixun-threads-sync/storage-state.js
@@ -1,6 +1,18 @@
/** @typedef {{ name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite: string }} PlaywrightCookie */
-const COOKIE_DOMAINS = ["threads.com", "instagram.com", "facebook.com"];
+const COOKIE_DOMAINS = [
+ "threads.com",
+ "threads.net",
+ "instagram.com",
+ "facebook.com",
+];
+
+const THREADS_TAB_URLS = [
+ "https://www.threads.com/*",
+ "https://threads.com/*",
+ "https://www.threads.net/*",
+ "https://threads.net/*",
+];
function mapSameSite(sameSite) {
if (sameSite === "no_restriction") return "None";
@@ -38,13 +50,27 @@ export async function collectThreadsCookies() {
}
}
+ // Some Chrome builds store Meta login cookies only on exact host URLs.
+ const urls = [
+ "https://www.threads.com/",
+ "https://www.threads.net/",
+ "https://www.instagram.com/",
+ ];
+ for (const url of urls) {
+ const batch = await chrome.cookies.getAll({ url });
+ for (const cookie of batch) {
+ const key = `${cookie.domain}|${cookie.path}|${cookie.name}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ merged.push(toPlaywrightCookie(cookie));
+ }
+ }
+
return merged;
}
export async function collectThreadsLocalStorage() {
- const tabs = await chrome.tabs.query({
- url: ["https://www.threads.com/*", "https://threads.com/*", "https://www.threads.net/*"],
- });
+ const tabs = await chrome.tabs.query({ url: THREADS_TAB_URLS });
if (!tabs.length || tabs[0].id == null) {
return [];
@@ -76,7 +102,9 @@ export async function buildStorageState() {
const origins = await collectThreadsLocalStorage();
if (!cookies.length) {
- throw new Error("找不到 Threads / Instagram cookies。請先在 Chrome 登入 threads.com");
+ throw new Error(
+ "找不到 Threads / Instagram cookies。請先在 Chrome 開啟 threads.com 或 threads.net 並完成登入"
+ );
}
return JSON.stringify({ cookies, origins });
diff --git a/haixun-backend/.run/api.pid b/haixun-backend/.run/api.pid
new file mode 100644
index 0000000..92f629b
--- /dev/null
+++ b/haixun-backend/.run/api.pid
@@ -0,0 +1 @@
+34256
diff --git a/haixun-backend/.run/logs/api.log b/haixun-backend/.run/logs/api.log
new file mode 100644
index 0000000..875862d
Binary files /dev/null and b/haixun-backend/.run/logs/api.log differ
diff --git a/haixun-backend/.run/logs/web.log b/haixun-backend/.run/logs/web.log
new file mode 100644
index 0000000..fc99fcd
--- /dev/null
+++ b/haixun-backend/.run/logs/web.log
@@ -0,0 +1,9 @@
+
+> haixun-web@0.1.0 dev
+> vite
+
+
+ VITE v6.4.3 ready in 146 ms
+
+ ➜ Local: http://localhost:5173/
+ ➜ Network: use --host to expose
diff --git a/haixun-backend/.run/logs/worker.log b/haixun-backend/.run/logs/worker.log
new file mode 100644
index 0000000..637c188
--- /dev/null
+++ b/haixun-backend/.run/logs/worker.log
@@ -0,0 +1,5 @@
+
+> haixun-master@0.1.0 worker:style-8d
+> . scripts/playwright-env.sh && npx playwright install chromium && tsx haixun-backend/worker/style-8d-worker.ts
+
+[8d-worker] started id=local-style-8d-node-34347 api=http://127.0.0.1:8890
diff --git a/haixun-backend/.run/web.pid b/haixun-backend/.run/web.pid
new file mode 100644
index 0000000..fcf2cb8
--- /dev/null
+++ b/haixun-backend/.run/web.pid
@@ -0,0 +1 @@
+34261
diff --git a/haixun-backend/.run/worker.pid b/haixun-backend/.run/worker.pid
new file mode 100644
index 0000000..6e5fcbe
--- /dev/null
+++ b/haixun-backend/.run/worker.pid
@@ -0,0 +1 @@
+34262
diff --git a/haixun-backend/AGENTS.md b/haixun-backend/AGENTS.md
index 311f67b..613aed7 100644
--- a/haixun-backend/AGENTS.md
+++ b/haixun-backend/AGENTS.md
@@ -356,6 +356,34 @@ make web-build # tsc + vite build
4. 新導覽項:改 `acAssets.ts` 的 `navApps`,並在 `AcIcon` 補 SVG path。
5. 完成後執行 `make web-build`。
+### 島民頁面互動(可推廣 runtime)
+
+掛在 `Layout` 底下的新頁面**自動**支援島民操作,不需每頁手寫 executor。
+
+模組入口:`web/src/lib/islander/index.ts`
+
+| 層 | 職責 |
+|----|------|
+| `pageSnapshot` | 掃描 `.ac-app-shell` 內可互動元素,產生 `hx-*` ref |
+| `islanderActions` | 解析/剝除 `islander-actions` JSON 區塊 |
+| `actionExecutor` | 執行 navigate/click/fill/select/scroll 等;可 `registerIslanderActionHandler` 擴充 |
+| `islanderAgent` | 串流回覆 → 執行 action → 回傳結果 → 自動 follow-up |
+| `buildIslanderContext` | 組裝送給後端的頁面快照 |
+
+**零設定(預設)**:路由掛在 `Layout` 即可;島民讀 DOM + `PageTitle` / `h1` 辨識頁面。預設**不**主動介紹這一頁;僅在使用者明確問頁面/操作時才附【可互動元素】(`userWantsPageContext`)。
+
+**可選增強**(擇一):
+
+1. `useIslanderPage({ title, purpose, hints, suggestions })` — 頁面內動態註冊說明
+2. `registerIslanderPage(/^\/foo/, { title, ... })` — 在 `siteGuide.ts` 或模組 init 靜態註冊
+3. HTML 慣例:`data-islander-label`(元素名稱)、`data-islander-kind`(類型)、`data-islander-ignore`(排除)、`data-islander-page-title`(頁名)
+
+Action 協定(AI 回覆末尾):
+
+```islander-actions
+[{ "type": "navigate", "path": "/settings" }, { "type": "click", "ref": "hx-3" }]
+```
+
### 前端禁忌
- 不要引入 MUI / Ant Design / Chakra 等大型 UI 庫。
diff --git a/haixun-backend/Makefile b/haixun-backend/Makefile
index 708ed11..5f67a29 100644
--- a/haixun-backend/Makefile
+++ b/haixun-backend/Makefile
@@ -26,9 +26,28 @@ fmt: ## gofmt + goimports
test: ## 執行測試
$(GO) test ./...
-run: ## 啟動 API
+run: ## 啟動 API(前景)
$(GO) run ./gateway.go -f etc/gateway.yaml
+dev-all: ## 一鍵啟動 Mongo/Redis + API + 前端 + 8D worker(背景)
+ bash scripts/start-all.sh
+
+stop-all: ## 一鍵停止全部開發服務
+ bash scripts/stop-all.sh
+
+restart-all: ## 一鍵重啟全部開發服務
+ bash scripts/restart-all.sh
+
+status-all: ## 查看全部開發服務狀態
+ bash scripts/status-all.sh
+
+stop: stop-all ## 同 stop-all
+
+restart: restart-all ## 同 restart-all
+
+dev-8d: ## 一鍵啟動 API + Node 8D worker(前景,Ctrl+C 結束)
+ bash scripts/dev-with-style-8d.sh
+
CONFIG ?= etc/gateway.yaml
INIT_TENANT ?= default
INIT_EMAIL ?= admin@30cm.net
@@ -46,7 +65,13 @@ web-install: ## 安裝前端依賴
web-dev: web-install ## 啟動前端 dev server(proxy 到 :8890)
cd web && npm run dev
-web-build: web-install ## 建置前端靜態檔
+extension-pack: ## 打包 Chrome 擴充為 web/public/downloads/*.zip
+ bash scripts/package-extension.sh
+
+web-build: web-install extension-pack ## 建置前端靜態檔
cd web && npm run build
+node-worker-style-8d: ## 啟動 Node 8D 爬蟲 worker
+ cd .. && npm run worker:style-8d
+
check: fmt test ## 格式化並測試
diff --git a/haixun-backend/README.md b/haixun-backend/README.md
index 84c34ef..b77593e 100644
--- a/haixun-backend/README.md
+++ b/haixun-backend/README.md
@@ -35,6 +35,37 @@ http://127.0.0.1:8890
curl http://127.0.0.1:8890/api/v1/health
```
+### 8D Node 爬蟲 worker 驗證
+
+`style-8d` job 由 `worker_type=node` 消費。啟動 Gateway 與 Redis 後,另開一個終端:
+
+```bash
+make node-worker-style-8d
+```
+
+也可以在 repo 根目錄執行:
+
+```bash
+npm run worker:style-8d
+```
+
+常用環境變數:
+
+```text
+HAIXUN_BACKEND_URL=http://127.0.0.1:8890
+HAIXUN_WORKER_SECRET=... # 若 etc/gateway.yaml 設了 InternalWorker.Secret,worker 需帶同一把
+HAIXUN_NODE_WORKER_ID=local-8d # 可選,方便辨識 lock holder
+HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1;要嚴格一點可調高
+```
+
+前端在人設詳情頁按「開始 8D 分析」後,任務會進入:
+
+```text
+確認連線 -> 抓取樣本 -> AI 8D -> 儲存策略
+```
+
+目前 Node worker 先用 Playwright 抓 Threads 公開頁樣本並產生可驗證的 8D 結構;若公開頁無法讀到足夠樣本,job 會標記為 `failed` 並顯示原因,不會停在等待狀態。
+
## 專案結構
```text
diff --git a/haixun-backend/generate/api/ai.api b/haixun-backend/generate/api/ai.api
index bdf97c5..526ab5b 100644
--- a/haixun-backend/generate/api/ai.api
+++ b/haixun-backend/generate/api/ai.api
@@ -35,6 +35,10 @@ type (
Text string `json:"text"`
FinishReason string `json:"finish_reason,optional"`
}
+ IslanderChatReq {
+ Messages []AIMessage `json:"messages" validate:"required,min=1,dive"` // 對話訊息
+ Context string `json:"context,optional"` // 目前頁面與站內導覽快照
+ }
)
@server (
@@ -64,4 +68,16 @@ service gateway {
@handler chatStream
post /chat/stream (AIChatReq)
+}
+
+@server (
+ group: ai
+ prefix: /api/v1/ai
+ middleware: Auth
+ tags: "AI - Islander Guide"
+ summary: "Floating islander chat; member JWT via Authorization; AI key from member settings"
+)
+service gateway {
+ @handler islanderChatStream
+ post /islander/chat/stream (IslanderChatReq)
}
\ No newline at end of file
diff --git a/haixun-backend/generate/api/gateway.api b/haixun-backend/generate/api/gateway.api
index 99b1e81..be8bbe2 100644
--- a/haixun-backend/generate/api/gateway.api
+++ b/haixun-backend/generate/api/gateway.api
@@ -20,5 +20,8 @@ import (
"auth.api"
"member.api"
"permission.api"
+ "threads_account.api"
+ "persona.api"
+ "worker_internal.api"
)
diff --git a/haixun-backend/generate/api/persona.api b/haixun-backend/generate/api/persona.api
new file mode 100644
index 0000000..daed105
--- /dev/null
+++ b/haixun-backend/generate/api/persona.api
@@ -0,0 +1,77 @@
+syntax = "v1"
+
+type (
+ PersonaData {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name,omitempty"`
+ Persona string `json:"persona,omitempty"`
+ Brief string `json:"brief,omitempty"`
+ ProductBrief string `json:"product_brief,omitempty"`
+ TargetAudience string `json:"target_audience,omitempty"`
+ Goals string `json:"goals,omitempty"`
+ StyleProfile string `json:"style_profile,omitempty"`
+ StyleBenchmark string `json:"style_benchmark,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ ListPersonasData {
+ List []PersonaData `json:"list"`
+ }
+
+ CreatePersonaReq {
+ DisplayName string `json:"display_name,optional"`
+ }
+
+ PersonaPath {
+ ID string `path:"id" validate:"required"`
+ }
+
+ UpdatePersonaReq {
+ DisplayName *string `json:"display_name,optional"`
+ Persona *string `json:"persona,optional"`
+ Brief *string `json:"brief,optional"`
+ ProductBrief *string `json:"product_brief,optional"`
+ TargetAudience *string `json:"target_audience,optional"`
+ Goals *string `json:"goals,optional"`
+ StyleProfile *string `json:"style_profile,optional"`
+ StyleBenchmark *string `json:"style_benchmark,optional"`
+ }
+
+ StartPersonaStyleAnalysisReq {
+ BenchmarkUsername string `json:"benchmark_username" validate:"required"`
+ }
+
+ StartPersonaStyleAnalysisData {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ Message string `json:"message"`
+ }
+)
+
+@server(
+ group: persona
+ prefix: /api/v1/personas
+ middleware: AuthJWT
+ tags: "Persona"
+ summary: "Reusable persona profiles with 8D style strategy. Requires Bearer JWT."
+)
+service gateway {
+ @handler listPersonas
+ get / returns (ListPersonasData)
+
+ @handler createPersona
+ post / (CreatePersonaReq) returns (PersonaData)
+
+ @handler getPersona
+ get /:id (PersonaPath) returns (PersonaData)
+
+ @handler updatePersona
+ patch /:id (PersonaPath, UpdatePersonaReq) returns (PersonaData)
+
+ @handler deletePersona
+ delete /:id (PersonaPath)
+
+ @handler startPersonaStyleAnalysis
+ post /:id/style-analysis (PersonaPath, StartPersonaStyleAnalysisReq) returns (StartPersonaStyleAnalysisData)
+}
\ No newline at end of file
diff --git a/haixun-backend/generate/api/threads_account.api b/haixun-backend/generate/api/threads_account.api
new file mode 100644
index 0000000..5862541
--- /dev/null
+++ b/haixun-backend/generate/api/threads_account.api
@@ -0,0 +1,137 @@
+syntax = "v1"
+
+type (
+ ThreadsAccountData {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name,omitempty"`
+ Username string `json:"username,omitempty"`
+ ThreadsUserID string `json:"threads_user_id,omitempty"`
+ PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account
+ BrowserConnected bool `json:"browser_connected"`
+ ApiConnected bool `json:"api_connected"`
+ Status string `json:"status"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ ListThreadsAccountsData {
+ List []ThreadsAccountData `json:"list"`
+ ActiveAccountID string `json:"active_account_id"`
+ }
+
+ CreateThreadsAccountReq {
+ DisplayName string `json:"display_name,optional"`
+ Activate bool `json:"activate,optional"`
+ }
+
+ UpdateThreadsAccountReq {
+ DisplayName *string `json:"display_name,optional"`
+ PersonaID *string `json:"persona_id,optional"` // deprecated: use persona_id in publish payload instead
+ }
+
+ ThreadsAccountPath {
+ ID string `path:"id" validate:"required"`
+ }
+
+ ThreadsAccountConnectionPrefs {
+ SearchViaApi bool `json:"search_via_api"`
+ SearchSourceMode string `json:"search_source_mode"`
+ PublishViaApi bool `json:"publish_via_api"`
+ DevMode bool `json:"dev_mode"`
+ ScrapeReplies bool `json:"scrape_replies"`
+ RepliesPerPost int `json:"replies_per_post"`
+ PublishHeaded bool `json:"publish_headed"`
+ PlaywrightDebug bool `json:"playwright_debug"`
+ }
+
+ ThreadsAccountConnectionData {
+ AccountID string `json:"account_id"`
+ AccountName string `json:"account_name"`
+ Username string `json:"username,omitempty"`
+ BrowserConnected bool `json:"browser_connected"`
+ ApiConnected bool `json:"api_connected"`
+ Prefs ThreadsAccountConnectionPrefs `json:"prefs"`
+ }
+
+ UpdateThreadsAccountConnectionReq {
+ SearchViaApi *bool `json:"search_via_api,optional"`
+ SearchSourceMode *string `json:"search_source_mode,optional"`
+ PublishViaApi *bool `json:"publish_via_api,optional"`
+ DevMode *bool `json:"dev_mode,optional"`
+ ScrapeReplies *bool `json:"scrape_replies,optional"`
+ RepliesPerPost *int `json:"replies_per_post,optional"`
+ PublishHeaded *bool `json:"publish_headed,optional"`
+ PlaywrightDebug *bool `json:"playwright_debug,optional"`
+ }
+
+ ImportThreadsAccountSessionReq {
+ StorageState string `json:"storageState" validate:"required"`
+ }
+
+ ImportThreadsAccountSessionData {
+ Success bool `json:"success"`
+ Valid bool `json:"valid"`
+ Synced bool `json:"synced"`
+ AccountID string `json:"account_id"`
+ Username string `json:"username,omitempty"`
+ Message string `json:"message"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ ThreadsAccountAiSettingsData {
+ AccountID string `json:"account_id"`
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ ResearchProvider string `json:"research_provider,omitempty"`
+ ResearchModel string `json:"research_model,omitempty"`
+ ApiKeys map[string]string `json:"api_keys"`
+ ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
+ }
+
+ UpdateThreadsAccountAiSettingsReq {
+ Provider *string `json:"provider,optional"`
+ Model *string `json:"model,optional"`
+ ResearchProvider *string `json:"research_provider,optional"`
+ ResearchModel *string `json:"research_model,optional"`
+ ApiKeys map[string]string `json:"api_keys,optional"`
+ }
+)
+
+@server(
+ group: threads_account
+ prefix: /api/v1/threads-accounts
+ middleware: AuthJWT
+ tags: "ThreadsAccount"
+ summary: "Threads operating account endpoints. Requires Bearer JWT."
+)
+service gateway {
+ @handler listThreadsAccounts
+ get / returns (ListThreadsAccountsData)
+
+ @handler createThreadsAccount
+ post / (CreateThreadsAccountReq) returns (ThreadsAccountData)
+
+ @handler getThreadsAccount
+ get /:id (ThreadsAccountPath) returns (ThreadsAccountData)
+
+ @handler updateThreadsAccount
+ patch /:id (ThreadsAccountPath, UpdateThreadsAccountReq) returns (ThreadsAccountData)
+
+ @handler activateThreadsAccount
+ post /:id/activate (ThreadsAccountPath)
+
+ @handler getThreadsAccountConnection
+ get /:id/connection (ThreadsAccountPath) returns (ThreadsAccountConnectionData)
+
+ @handler updateThreadsAccountConnection
+ patch /:id/connection (ThreadsAccountPath, UpdateThreadsAccountConnectionReq) returns (ThreadsAccountConnectionData)
+
+ @handler importThreadsAccountSession
+ post /:id/session/import (ThreadsAccountPath, ImportThreadsAccountSessionReq) returns (ImportThreadsAccountSessionData)
+
+ @handler getThreadsAccountAiSettings
+ get /:id/ai-settings (ThreadsAccountPath) returns (ThreadsAccountAiSettingsData)
+
+ @handler updateThreadsAccountAiSettings
+ put /:id/ai-settings (ThreadsAccountPath, UpdateThreadsAccountAiSettingsReq) returns (ThreadsAccountAiSettingsData)
+}
\ No newline at end of file
diff --git a/haixun-backend/generate/api/worker_internal.api b/haixun-backend/generate/api/worker_internal.api
new file mode 100644
index 0000000..fd5354e
--- /dev/null
+++ b/haixun-backend/generate/api/worker_internal.api
@@ -0,0 +1,142 @@
+syntax = "v1"
+
+type (
+ ClaimWorkerJobReq {
+ WorkerType string `json:"worker_type" validate:"required"`
+ WorkerID string `json:"worker_id" validate:"required"`
+ }
+
+ WorkerJobPath {
+ ID string `path:"id" validate:"required"`
+ }
+
+ WorkerJobReq {
+ WorkerJobPath
+ WorkerID string `json:"worker_id" validate:"required"`
+ }
+
+ WorkerHeartbeatReq {
+ WorkerJobPath
+ WorkerID string `json:"worker_id" validate:"required"`
+ TTLSeconds int `json:"ttl_seconds,optional"`
+ }
+
+ WorkerProgressReq {
+ WorkerJobPath
+ WorkerID string `json:"worker_id" validate:"required"`
+ Phase string `json:"phase,optional"`
+ Summary string `json:"summary,optional"`
+ Percentage int `json:"percentage,optional"`
+ Steps []JobStepProgressData `json:"steps,optional"`
+ }
+
+ WorkerCompleteReq {
+ WorkerJobPath
+ WorkerID string `json:"worker_id" validate:"required"`
+ Result map[string]interface{} `json:"result,optional"`
+ }
+
+ WorkerFailReq {
+ WorkerJobPath
+ WorkerID string `json:"worker_id" validate:"required"`
+ Error string `json:"error" validate:"required"`
+ Phase string `json:"phase,optional"`
+ }
+
+ WorkerCancelCheckData {
+ Cancelled bool `json:"cancelled"`
+ }
+
+ WorkerOKData {
+ OK bool `json:"ok"`
+ }
+
+ StorePersonaStyleProfileReq {
+ ID string `path:"id" validate:"required"`
+ TenantID string `json:"tenant_id" validate:"required"`
+ OwnerUID string `json:"owner_uid" validate:"required"`
+ StyleProfile string `json:"style_profile" validate:"required"`
+ StyleBenchmark string `json:"style_benchmark,optional"`
+ }
+
+ StorePersonaStyleProfileData {
+ ID string `json:"id"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ WorkerThreadsAccountSessionReq {
+ ID string `path:"id" validate:"required"`
+ TenantID string `json:"tenant_id" validate:"required"`
+ OwnerUID string `json:"owner_uid" validate:"required"`
+ }
+
+ WorkerThreadsAccountSessionData {
+ AccountID string `json:"account_id"`
+ StorageState string `json:"storage_state"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ AnalyzeStyle8DPostReq {
+ Text string `json:"text"`
+ Permalink string `json:"permalink,optional"`
+ LikeCount int `json:"like_count,optional"`
+ ReplyCount int `json:"reply_count,optional"`
+ }
+
+ AnalyzeStyle8DReq {
+ ID string `path:"id" validate:"required"`
+ WorkerID string `json:"worker_id" validate:"required"`
+ TenantID string `json:"tenant_id" validate:"required"`
+ OwnerUID string `json:"owner_uid" validate:"required"`
+ PersonaID string `json:"persona_id" validate:"required"`
+ ThreadsAccountID string `json:"threads_account_id" validate:"required"`
+ Username string `json:"username" validate:"required"`
+ Posts []AnalyzeStyle8DPostReq `json:"posts" validate:"required"`
+ Steps []JobStepProgressData `json:"steps,optional"`
+ }
+
+ AnalyzeStyle8DData {
+ PersonaID string `json:"persona_id"`
+ PostCount int `json:"post_count"`
+ StyleProfile string `json:"style_profile"`
+ StyleBenchmark string `json:"style_benchmark"`
+ }
+)
+
+@server(
+ group: job
+ prefix: /api/v1/internal
+ tags: "Internal Worker"
+ summary: "Internal worker endpoints protected by X-Worker-Secret when InternalWorker.Secret is configured."
+)
+service gateway {
+ @handler claimWorkerJob
+ post /workers/jobs/claim (ClaimWorkerJobReq) returns (JobData)
+
+ @handler refreshWorkerJobLock
+ post /workers/jobs/:id/heartbeat (WorkerHeartbeatReq) returns (WorkerOKData)
+
+ @handler checkWorkerJobCancel
+ post /workers/jobs/:id/cancel-check (WorkerJobReq) returns (WorkerCancelCheckData)
+
+ @handler ackWorkerJobCancel
+ post /workers/jobs/:id/cancel-ack (WorkerJobReq) returns (JobData)
+
+ @handler updateWorkerJobProgress
+ post /workers/jobs/:id/progress (WorkerProgressReq) returns (JobData)
+
+ @handler completeWorkerJob
+ post /workers/jobs/:id/complete (WorkerCompleteReq) returns (JobData)
+
+ @handler failWorkerJob
+ post /workers/jobs/:id/fail (WorkerFailReq) returns (JobData)
+
+ @handler storePersonaStyleProfileFromWorker
+ patch /workers/personas/:id/style-profile (StorePersonaStyleProfileReq) returns (StorePersonaStyleProfileData)
+
+ @handler getWorkerThreadsAccountSession
+ post /workers/threads-accounts/:id/session (WorkerThreadsAccountSessionReq) returns (WorkerThreadsAccountSessionData)
+
+ @handler analyzeStyle8DFromWorker
+ post /workers/jobs/:id/analyze-style-8d (AnalyzeStyle8DReq) returns (AnalyzeStyle8DData)
+}
diff --git a/haixun-backend/internal/bootstrap/admin_test.go b/haixun-backend/internal/bootstrap/admin_test.go
index cae8a2d..b36df77 100644
--- a/haixun-backend/internal/bootstrap/admin_test.go
+++ b/haixun-backend/internal/bootstrap/admin_test.go
@@ -58,6 +58,16 @@ func (m *memoryMemberRepo) UpdateProfile(context.Context, string, string, domrep
return nil, app.For(code.Member).SysNotImplemented("not implemented")
}
+func (m *memoryMemberRepo) SetActiveThreadsAccountID(_ context.Context, tenantID, uid, accountID string) error {
+ for _, item := range m.byEmail {
+ if item.TenantID == tenantID && item.UID == uid {
+ item.ActiveThreadsAccountID = accountID
+ return nil
+ }
+ }
+ return app.For(code.Member).ResNotFound("member not found")
+}
+
func (m *memoryMemberRepo) SetRoles(_ context.Context, tenantID, uid string, roles []string) error {
for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid {
diff --git a/haixun-backend/internal/config/config.go b/haixun-backend/internal/config/config.go
index 99d4c98..03628c8 100644
--- a/haixun-backend/internal/config/config.go
+++ b/haixun-backend/internal/config/config.go
@@ -37,12 +37,17 @@ type AuthConf struct {
DevHeaderFallback bool `json:",default=true"`
}
+type InternalWorkerConf struct {
+ Secret string `json:",optional"`
+}
+
type Config struct {
rest.RestConf
- Mongo MongoConf `json:",optional"`
- Redis RedisConf `json:",optional"`
- Auth AuthConf `json:",optional"`
- JobWorker JobWorkerConf `json:",optional"`
- JobScheduler JobSchedulerConf `json:",optional"`
- JobReaper JobReaperConf `json:",optional"`
+ Mongo MongoConf `json:",optional"`
+ Redis RedisConf `json:",optional"`
+ Auth AuthConf `json:",optional"`
+ InternalWorker InternalWorkerConf `json:",optional"`
+ JobWorker JobWorkerConf `json:",optional"`
+ JobScheduler JobSchedulerConf `json:",optional"`
+ JobReaper JobReaperConf `json:",optional"`
}
diff --git a/haixun-backend/internal/handler/ai/islander_chat_stream_handler.go b/haixun-backend/internal/handler/ai/islander_chat_stream_handler.go
new file mode 100644
index 0000000..d46da7f
--- /dev/null
+++ b/haixun-backend/internal/handler/ai/islander_chat_stream_handler.go
@@ -0,0 +1,55 @@
+package ai
+
+import (
+ "fmt"
+ "net/http"
+
+ "haixun-backend/internal/logic/ai"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func IslanderChatStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.IslanderChatReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := ai.NewIslanderChatStreamLogic(r.Context(), svcCtx)
+ stream, err := l.ChatStream(&req)
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(fmt.Errorf("server does not support streaming")))
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+
+ for event := range stream {
+ writeSSE(w, event.Type, event)
+ flusher.Flush()
+ if event.Type == "done" || event.Type == "error" {
+ return
+ }
+ }
+ writeSSE(w, "done", map[string]string{"finish_reason": "stop"})
+ flusher.Flush()
+ }
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/handler/job/worker_handlers.go b/haixun-backend/internal/handler/job/worker_handlers.go
new file mode 100644
index 0000000..aef0302
--- /dev/null
+++ b/haixun-backend/internal/handler/job/worker_handlers.go
@@ -0,0 +1,549 @@
+package job
+
+import (
+ "net/http"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ libprompt "haixun-backend/internal/library/prompt"
+ "haixun-backend/internal/library/style8d"
+ joblogic "haixun-backend/internal/logic/job"
+ "haixun-backend/internal/model/ai/domain/enum"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ jobentity "haixun-backend/internal/model/job/domain/entity"
+ jobenum "haixun-backend/internal/model/job/domain/enum"
+ jobusecase "haixun-backend/internal/model/job/domain/usecase"
+ personausecase "haixun-backend/internal/model/persona/domain/usecase"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+const workerSecretHeader = "X-Worker-Secret"
+
+type workerJobPath struct {
+ ID string `path:"id"`
+}
+
+type claimWorkerJobReq struct {
+ WorkerType string `json:"worker_type"`
+ WorkerID string `json:"worker_id"`
+}
+
+type workerJobReq struct {
+ workerJobPath
+ WorkerID string `json:"worker_id"`
+}
+
+type workerHeartbeatReq struct {
+ workerJobPath
+ WorkerID string `json:"worker_id"`
+ TTLSeconds int `json:"ttl_seconds,optional"`
+}
+
+type workerProgressReq struct {
+ workerJobPath
+ WorkerID string `json:"worker_id"`
+ Phase string `json:"phase,optional"`
+ Summary string `json:"summary,optional"`
+ Percentage *int `json:"percentage,optional"`
+ Steps []types.JobStepProgressData `json:"steps,optional"`
+}
+
+type workerCompleteReq struct {
+ workerJobPath
+ WorkerID string `json:"worker_id"`
+ Result map[string]interface{} `json:"result,optional"`
+}
+
+type workerFailReq struct {
+ workerJobPath
+ WorkerID string `json:"worker_id"`
+ Error string `json:"error"`
+ Phase string `json:"phase,optional"`
+}
+
+type storePersonaStyleProfileReq struct {
+ ID string `path:"id"`
+ TenantID string `json:"tenant_id"`
+ OwnerUID string `json:"owner_uid"`
+ StyleProfile string `json:"style_profile"`
+ StyleBenchmark string `json:"style_benchmark,optional"`
+}
+
+type workerThreadsAccountSessionReq struct {
+ ID string `path:"id"`
+ TenantID string `json:"tenant_id"`
+ OwnerUID string `json:"owner_uid"`
+}
+
+type analyzeStyle8DPostReq struct {
+ Text string `json:"text"`
+ Permalink string `json:"permalink,optional"`
+ LikeCount int `json:"like_count,optional"`
+ ReplyCount int `json:"reply_count,optional"`
+}
+
+type analyzeStyle8DReq struct {
+ workerJobPath
+ WorkerID string `json:"worker_id"`
+ TenantID string `json:"tenant_id"`
+ OwnerUID string `json:"owner_uid"`
+ PersonaID string `json:"persona_id"`
+ ThreadsAccountID string `json:"threads_account_id"`
+ Username string `json:"username"`
+ Posts []analyzeStyle8DPostReq `json:"posts"`
+ Steps []types.JobStepProgressData `json:"steps,optional"`
+}
+
+func ClaimWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := requireWorkerSecret(r, svcCtx); err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ var req claimWorkerJobReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ run, err := svcCtx.Job.ClaimNext(r.Context(), jobusecase.ClaimNextRequest{
+ WorkerType: req.WorkerType,
+ WorkerID: req.WorkerID,
+ })
+ if err != nil || run == nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ data := joblogic.ToJobData(run)
+ response.Write(r.Context(), w, &data, nil)
+ }
+}
+
+func RefreshWorkerJobLockHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := requireWorkerSecret(r, svcCtx); err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ var req workerHeartbeatReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ ttl := req.TTLSeconds
+ if ttl <= 0 {
+ ttl = 300
+ }
+ err := svcCtx.Job.RefreshRunLock(r.Context(), req.ID, req.WorkerID, ttl)
+ response.Write(r.Context(), w, map[string]bool{"ok": err == nil}, err)
+ }
+}
+
+func CheckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := requireWorkerSecret(r, svcCtx); err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ var req workerJobReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ cancelled, err := svcCtx.Job.IsCancelRequested(r.Context(), req.ID)
+ response.Write(r.Context(), w, map[string]bool{"cancelled": cancelled}, err)
+ }
+}
+
+func AckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := requireWorkerSecret(r, svcCtx); err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ var req workerJobReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ run, err := svcCtx.Job.AcknowledgeCancel(r.Context(), jobusecase.AcknowledgeCancelRequest{
+ JobID: req.ID,
+ WorkerID: req.WorkerID,
+ })
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ data := joblogic.ToJobData(run)
+ response.Write(r.Context(), w, &data, nil)
+ }
+}
+
+func UpdateWorkerJobProgressHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := requireWorkerSecret(r, svcCtx); err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ var req workerProgressReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ percentage := -1
+ if req.Percentage != nil {
+ percentage = *req.Percentage
+ }
+ run, err := svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
+ JobID: req.ID,
+ WorkerID: req.WorkerID,
+ Phase: req.Phase,
+ Summary: req.Summary,
+ Percentage: percentage,
+ Steps: toEntitySteps(req.Steps),
+ })
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ data := joblogic.ToJobData(run)
+ response.Write(r.Context(), w, &data, nil)
+ }
+}
+
+func CompleteWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := requireWorkerSecret(r, svcCtx); err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ var req workerCompleteReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ run, err := svcCtx.Job.CompleteRun(r.Context(), jobusecase.CompleteRunRequest{
+ JobID: req.ID,
+ WorkerID: req.WorkerID,
+ Result: req.Result,
+ })
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ data := joblogic.ToJobData(run)
+ response.Write(r.Context(), w, &data, nil)
+ }
+}
+
+func FailWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := requireWorkerSecret(r, svcCtx); err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ var req workerFailReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ run, err := svcCtx.Job.FailRun(r.Context(), jobusecase.FailRunRequest{
+ JobID: req.ID,
+ WorkerID: req.WorkerID,
+ Error: req.Error,
+ Phase: req.Phase,
+ })
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ data := joblogic.ToJobData(run)
+ response.Write(r.Context(), w, &data, nil)
+ }
+}
+
+func StorePersonaStyleProfileFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := requireWorkerSecret(r, svcCtx); err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ var req storePersonaStyleProfileReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if strings.TrimSpace(req.StyleProfile) == "" {
+ response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("style_profile is required"))
+ return
+ }
+ profile := strings.TrimSpace(req.StyleProfile)
+ benchmark := strings.TrimPrefix(strings.TrimSpace(req.StyleBenchmark), "@")
+ item, err := svcCtx.Persona.Update(r.Context(), personausecase.UpdateRequest{
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ PersonaID: req.ID,
+ Patch: personausecase.PersonaPatch{
+ StyleProfile: &profile,
+ StyleBenchmark: &benchmark,
+ },
+ })
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ response.Write(r.Context(), w, map[string]any{"id": item.ID, "update_at": item.UpdateAt}, nil)
+ }
+}
+
+func AnalyzeStyle8DFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := requireWorkerSecret(r, svcCtx); err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ var req analyzeStyle8DReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if strings.TrimSpace(req.WorkerID) == "" {
+ response.Write(r.Context(), w, nil, app.For(code.Job).InputMissingRequired("worker_id is required"))
+ return
+ }
+ if strings.TrimSpace(req.PersonaID) == "" {
+ response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("persona_id is required"))
+ return
+ }
+ if strings.TrimSpace(req.ThreadsAccountID) == "" {
+ response.Write(r.Context(), w, nil, app.For(code.ThreadsAccount).InputMissingRequired("threads_account_id is required"))
+ return
+ }
+ if len(req.Posts) == 0 {
+ response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("posts is required"))
+ return
+ }
+
+ credential, err := svcCtx.ThreadsAccount.ResolveWorkerAiCredential(
+ r.Context(),
+ req.TenantID,
+ req.OwnerUID,
+ req.ThreadsAccountID,
+ )
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ providerID, err := mapWorkerAIProvider(credential.Provider)
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+
+ posts := make([]style8d.Post, 0, len(req.Posts))
+ for _, item := range req.Posts {
+ text := strings.TrimSpace(item.Text)
+ if text == "" {
+ continue
+ }
+ posts = append(posts, style8d.Post{
+ Text: text,
+ Permalink: strings.TrimSpace(item.Permalink),
+ LikeCount: item.LikeCount,
+ ReplyCount: item.ReplyCount,
+ })
+ }
+ if len(posts) == 0 {
+ response.Write(r.Context(), w, nil, app.For(code.Persona).InputInvalidFormat("posts contain no readable text"))
+ return
+ }
+
+ steps := toEntitySteps(req.Steps)
+ steps = markWorkerStep(steps, "style", jobenum.StepStatusRunning, "AI 正在分析 D1–D8…")
+ _, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
+ JobID: req.ID,
+ WorkerID: req.WorkerID,
+ Phase: "style",
+ Summary: "AI 正在分析八個風格維度…",
+ Percentage: 55,
+ Steps: steps,
+ })
+
+ username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
+ systemPrompt, err := libprompt.Style8DSystem()
+ if err != nil {
+ response.Write(r.Context(), w, nil, app.For(code.AI).SysInternal("prompt config load failed"))
+ return
+ }
+ result, err := svcCtx.AI.GenerateText(r.Context(), domai.GenerateRequest{
+ Provider: providerID,
+ Model: credential.Model,
+ Credential: domai.Credential{
+ APIKey: credential.APIKey,
+ },
+ System: systemPrompt,
+ Messages: []domai.Message{
+ {Role: "user", Content: style8d.BuildUserPrompt(username, posts)},
+ },
+ })
+ if err != nil {
+ if strings.Contains(err.Error(), "HTTP 401") {
+ err = app.For(code.AI).SvcThirdParty(
+ "8D AI 分析授權失敗:目前帳號的研究用 Provider API key 無效或未授權。請到「設定 > 帳號 AI 設定」確認 research provider=" +
+ credential.Provider + "、model=" + credential.Model + ",並重新貼上對應 provider 的 API key",
+ )
+ }
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+
+ parsed, err := style8d.ParseLLMOutput(result.Text)
+ if err != nil {
+ response.Write(r.Context(), w, nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:"+err.Error()))
+ return
+ }
+ profile := style8d.BuildStoredProfile(username, posts, parsed)
+ profileJSON, err := profile.JSON()
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+
+ steps = markWorkerStep(steps, "style", jobenum.StepStatusSucceeded, "8D 風格策略已產生")
+ steps = markWorkerStep(steps, "store", jobenum.StepStatusRunning, "寫入人設風格策略…")
+ _, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
+ JobID: req.ID,
+ WorkerID: req.WorkerID,
+ Phase: "store",
+ Summary: "8D 分析完成,寫入人設…",
+ Percentage: 88,
+ Steps: steps,
+ })
+
+ _, err = svcCtx.Persona.Update(r.Context(), personausecase.UpdateRequest{
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ PersonaID: req.PersonaID,
+ Patch: personausecase.PersonaPatch{
+ StyleProfile: &profileJSON,
+ StyleBenchmark: &username,
+ },
+ })
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+
+ steps = markWorkerStep(steps, "store", jobenum.StepStatusSucceeded, "8D 策略已寫入人設")
+ _, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
+ JobID: req.ID,
+ WorkerID: req.WorkerID,
+ Phase: "store",
+ Summary: "8D 策略已寫入人設",
+ Percentage: 92,
+ Steps: steps,
+ })
+
+ response.Write(r.Context(), w, map[string]any{
+ "persona_id": req.PersonaID,
+ "post_count": len(posts),
+ "style_profile": profileJSON,
+ "style_benchmark": username,
+ }, nil)
+ }
+}
+
+func GetWorkerThreadsAccountSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if err := requireWorkerSecret(r, svcCtx); err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ var req workerThreadsAccountSessionReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ session, err := svcCtx.ThreadsAccount.GetBrowserSession(r.Context(), req.TenantID, req.OwnerUID, req.ID)
+ if err != nil {
+ response.Write(r.Context(), w, nil, err)
+ return
+ }
+ response.Write(r.Context(), w, map[string]any{
+ "account_id": session.AccountID,
+ "storage_state": session.StorageState,
+ "update_at": session.UpdateAt,
+ }, nil)
+ }
+}
+
+func requireWorkerSecret(r *http.Request, svcCtx *svc.ServiceContext) error {
+ secret := strings.TrimSpace(svcCtx.Config.InternalWorker.Secret)
+ if secret == "" {
+ return nil
+ }
+ if r.Header.Get(workerSecretHeader) != secret {
+ return app.For(code.Auth).AuthUnauthorized("invalid worker secret")
+ }
+ return nil
+}
+
+func mapWorkerAIProvider(provider string) (enum.ProviderID, error) {
+ switch strings.TrimSpace(provider) {
+ case string(enum.ProviderOpenCode):
+ return enum.ProviderOpenCode, nil
+ case string(enum.ProviderXAI):
+ return enum.ProviderXAI, nil
+ default:
+ return "", app.For(code.AI).InputInvalidFormat("worker 8D 分析目前僅支援 opencode-go 與 xai,請在 AI 設定調整 research provider")
+ }
+}
+
+func markWorkerStep(steps []jobentity.StepProgress, stepID string, status jobenum.StepStatus, message string) []jobentity.StepProgress {
+ now := clock.NowUnixNano()
+ found := false
+ for i := range steps {
+ if steps[i].ID != stepID {
+ continue
+ }
+ found = true
+ steps[i].Status = status
+ steps[i].Message = message
+ if status == jobenum.StepStatusRunning && steps[i].StartedAt == nil {
+ steps[i].StartedAt = &now
+ }
+ if status == jobenum.StepStatusSucceeded || status == jobenum.StepStatusFailed {
+ steps[i].EndedAt = &now
+ }
+ }
+ if !found {
+ item := jobentity.StepProgress{ID: stepID, Status: status, Message: message}
+ if status == jobenum.StepStatusRunning {
+ item.StartedAt = &now
+ }
+ if status == jobenum.StepStatusSucceeded || status == jobenum.StepStatusFailed {
+ item.EndedAt = &now
+ }
+ steps = append(steps, item)
+ }
+ return steps
+}
+
+func toEntitySteps(steps []types.JobStepProgressData) []jobentity.StepProgress {
+ out := make([]jobentity.StepProgress, 0, len(steps))
+ for _, step := range steps {
+ out = append(out, jobentity.StepProgress{
+ ID: step.ID,
+ Status: jobenum.StepStatus(step.Status),
+ StartedAt: step.StartedAt,
+ EndedAt: step.EndedAt,
+ Message: step.Message,
+ })
+ }
+ return out
+}
diff --git a/haixun-backend/internal/handler/persona/handlers.go b/haixun-backend/internal/handler/persona/handlers.go
new file mode 100644
index 0000000..0120443
--- /dev/null
+++ b/haixun-backend/internal/handler/persona/handlers.go
@@ -0,0 +1,105 @@
+package persona
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/persona"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ListPersonasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := persona.NewListPersonasLogic(r.Context(), svcCtx)
+ data, err := l.ListPersonas()
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func CreatePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.CreatePersonaReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := persona.NewCreatePersonaLogic(r.Context(), svcCtx)
+ data, err := l.CreatePersona(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func GetPersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PersonaPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := persona.NewGetPersonaLogic(r.Context(), svcCtx)
+ data, err := l.GetPersona(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func UpdatePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpdatePersonaHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := persona.NewUpdatePersonaLogic(r.Context(), svcCtx)
+ data, err := l.UpdatePersona(&req.PersonaPath, &req.UpdatePersonaReq)
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func DeletePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PersonaPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := persona.NewDeletePersonaLogic(r.Context(), svcCtx)
+ err := l.DeletePersona(&req)
+ response.Write(r.Context(), w, nil, err)
+ }
+}
+
+func StartPersonaStyleAnalysisHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.StartPersonaStyleAnalysisHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := persona.NewStartPersonaStyleAnalysisLogic(r.Context(), svcCtx)
+ data, err := l.StartPersonaStyleAnalysis(&req.PersonaPath, &req.StartPersonaStyleAnalysisReq)
+ response.Write(r.Context(), w, data, err)
+ }
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/handler/routes.go b/haixun-backend/internal/handler/routes.go
index c092906..801afcb 100644
--- a/haixun-backend/internal/handler/routes.go
+++ b/haixun-backend/internal/handler/routes.go
@@ -12,13 +12,71 @@ import (
member "haixun-backend/internal/handler/member"
normal "haixun-backend/internal/handler/normal"
permission "haixun-backend/internal/handler/permission"
+ persona "haixun-backend/internal/handler/persona"
setting "haixun-backend/internal/handler/setting"
+ threadsaccount "haixun-backend/internal/handler/threads_account"
"haixun-backend/internal/svc"
"github.com/zeromicro/go-zero/rest"
)
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
+ server.AddRoutes(
+ []rest.Route{
+ {
+ Method: http.MethodPost,
+ Path: "/workers/jobs/claim",
+ Handler: job.ClaimWorkerJobHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/workers/jobs/:id/heartbeat",
+ Handler: job.RefreshWorkerJobLockHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/workers/jobs/:id/cancel-check",
+ Handler: job.CheckWorkerJobCancelHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/workers/jobs/:id/cancel-ack",
+ Handler: job.AckWorkerJobCancelHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/workers/jobs/:id/progress",
+ Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/workers/jobs/:id/complete",
+ Handler: job.CompleteWorkerJobHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/workers/jobs/:id/fail",
+ Handler: job.FailWorkerJobHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPatch,
+ Path: "/workers/personas/:id/style-profile",
+ Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/workers/threads-accounts/:id/session",
+ Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/workers/jobs/:id/analyze-style-8d",
+ Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
+ },
+ },
+ rest.WithPrefix("/api/v1/internal"),
+ )
+
server.AddRoutes(
[]rest.Route{
{
@@ -54,6 +112,20 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/ai"),
)
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodPost,
+ Path: "/islander/chat/stream",
+ Handler: ai.IslanderChatStreamHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/ai"),
+ )
+
server.AddRoutes(
[]rest.Route{
{
@@ -217,6 +289,104 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/permissions"),
)
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/",
+ Handler: persona.ListPersonasHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/",
+ Handler: persona.CreatePersonaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id",
+ Handler: persona.GetPersonaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPatch,
+ Path: "/:id",
+ Handler: persona.UpdatePersonaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodDelete,
+ Path: "/:id",
+ Handler: persona.DeletePersonaHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/style-analysis",
+ Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/personas"),
+ )
+
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.AuthJWT},
+ []rest.Route{
+ {
+ Method: http.MethodGet,
+ Path: "/",
+ Handler: threadsaccount.ListThreadsAccountsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/",
+ Handler: threadsaccount.CreateThreadsAccountHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id",
+ Handler: threadsaccount.GetThreadsAccountHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPatch,
+ Path: "/:id",
+ Handler: threadsaccount.UpdateThreadsAccountHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/activate",
+ Handler: threadsaccount.ActivateThreadsAccountHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/connection",
+ Handler: threadsaccount.GetThreadsAccountConnectionHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPatch,
+ Path: "/:id/connection",
+ Handler: threadsaccount.UpdateThreadsAccountConnectionHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/:id/session/import",
+ Handler: threadsaccount.ImportThreadsAccountSessionHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/:id/ai-settings",
+ Handler: threadsaccount.GetThreadsAccountAiSettingsHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPut,
+ Path: "/:id/ai-settings",
+ Handler: threadsaccount.UpdateThreadsAccountAiSettingsHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/threads-accounts"),
+ )
+
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
diff --git a/haixun-backend/internal/handler/threads_account/handlers.go b/haixun-backend/internal/handler/threads_account/handlers.go
new file mode 100644
index 0000000..453d89e
--- /dev/null
+++ b/haixun-backend/internal/handler/threads_account/handlers.go
@@ -0,0 +1,173 @@
+package threads_account
+
+import (
+ "net/http"
+
+ "haixun-backend/internal/logic/threads_account"
+ "haixun-backend/internal/response"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func ListThreadsAccountsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := threads_account.NewListThreadsAccountsLogic(r.Context(), svcCtx)
+ data, err := l.ListThreadsAccounts()
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func CreateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.CreateThreadsAccountReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewCreateThreadsAccountLogic(r.Context(), svcCtx)
+ data, err := l.CreateThreadsAccount(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func GetThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewGetThreadsAccountLogic(r.Context(), svcCtx)
+ data, err := l.GetThreadsAccount(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func UpdateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpdateThreadsAccountHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewUpdateThreadsAccountLogic(r.Context(), svcCtx)
+ data, err := l.UpdateThreadsAccount(&req.ThreadsAccountPath, &req.UpdateThreadsAccountReq)
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func ActivateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewActivateThreadsAccountLogic(r.Context(), svcCtx)
+ err := l.ActivateThreadsAccount(&req)
+ response.Write(r.Context(), w, map[string]bool{"success": err == nil}, err)
+ }
+}
+
+func GetThreadsAccountConnectionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewGetThreadsAccountConnectionLogic(r.Context(), svcCtx)
+ data, err := l.GetThreadsAccountConnection(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func UpdateThreadsAccountConnectionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpdateThreadsAccountConnectionHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewUpdateThreadsAccountConnectionLogic(r.Context(), svcCtx)
+ data, err := l.UpdateThreadsAccountConnection(&req.ThreadsAccountPath, &req.UpdateThreadsAccountConnectionReq)
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func ImportThreadsAccountSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ImportThreadsAccountSessionHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewImportThreadsAccountSessionLogic(r.Context(), svcCtx)
+ data, err := l.ImportThreadsAccountSession(&req.ThreadsAccountPath, &req.ImportThreadsAccountSessionReq)
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func GetThreadsAccountAiSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ThreadsAccountPath
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewGetThreadsAccountAiSettingsLogic(r.Context(), svcCtx)
+ data, err := l.GetThreadsAccountAiSettings(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
+
+func UpdateThreadsAccountAiSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpdateThreadsAccountAiSettingsHandlerReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ l := threads_account.NewUpdateThreadsAccountAiSettingsLogic(r.Context(), svcCtx)
+ data, err := l.UpdateThreadsAccountAiSettings(&req.ThreadsAccountPath, &req.UpdateThreadsAccountAiSettingsReq)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/haixun-backend/internal/library/errors/code/types.go b/haixun-backend/internal/library/errors/code/types.go
index d3de14a..dbc02f3 100644
--- a/haixun-backend/internal/library/errors/code/types.go
+++ b/haixun-backend/internal/library/errors/code/types.go
@@ -13,6 +13,8 @@ const (
Auth Scope = 35
Member Scope = 36
Permission Scope = 37
+ ThreadsAccount Scope = 38
+ Persona Scope = 39
CategoryMultiplier = 1000
ScopeMultiplier = 1000000
DefaultDetail Detail = 0
diff --git a/haixun-backend/internal/library/prompt/compose.go b/haixun-backend/internal/library/prompt/compose.go
new file mode 100644
index 0000000..ca557a9
--- /dev/null
+++ b/haixun-backend/internal/library/prompt/compose.go
@@ -0,0 +1,61 @@
+package prompt
+
+import "strings"
+
+const overlaySeparator = "\n\n---\n\n"
+
+// ComposeSystem prepends global overlay to the base system prompt before provider send.
+func ComposeSystem(base string) (string, error) {
+ base = strings.TrimSpace(base)
+ overlayText, err := Overlay()
+ if err != nil {
+ return "", err
+ }
+ overlayText = strings.TrimSpace(overlayText)
+ if overlayText == "" {
+ return base, nil
+ }
+ if base == "" {
+ return overlayText, nil
+ }
+ return overlayText + overlaySeparator + base, nil
+}
+
+// Style8DSystem loads 8D slots from config files and composes the outgoing system prompt.
+func Style8DSystem() (string, error) {
+ system, err := Slot(KeyStyle8DSystem)
+ if err != nil {
+ return "", err
+ }
+ schema, err := Slot(KeyStyle8DSchema)
+ if err != nil {
+ return "", err
+ }
+ return ComposeSystem(system + schema)
+}
+
+// IslanderSystem composes the islander guide system prompt with optional live page context.
+func IslanderSystem(pageContext string) (string, error) {
+ base, err := Slot(KeyIslanderSystem)
+ if err != nil {
+ return "", err
+ }
+ pageContext = strings.TrimSpace(pageContext)
+ if pageContext != "" {
+ base = base + "\n\n---\n\n" + pageContext
+ }
+ return ComposeSystem(base)
+}
+
+// AIChatSystem composes the outgoing system prompt for console AI chat.
+func AIChatSystem(clientSystem string) (string, error) {
+ base := strings.TrimSpace(clientSystem)
+ if base == "" {
+ var err error
+ base, err = Slot(KeyAIChatSystem)
+ if err != nil {
+ return "", err
+ }
+ }
+ return ComposeSystem(base)
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/library/prompt/compose_test.go b/haixun-backend/internal/library/prompt/compose_test.go
new file mode 100644
index 0000000..15cff43
--- /dev/null
+++ b/haixun-backend/internal/library/prompt/compose_test.go
@@ -0,0 +1,49 @@
+package prompt
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestComposeSystemOverlayFirst(t *testing.T) {
+ got, err := ComposeSystem("基礎 system")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "基礎 system") {
+ t.Fatalf("missing base: %q", got)
+ }
+ if !strings.Contains(got, overlaySeparator) {
+ t.Fatalf("missing separator: %q", got)
+ }
+}
+
+func TestStyle8DSystemIncludesSchema(t *testing.T) {
+ got, err := Style8DSystem()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "Threads 創作者風格研究員") || !strings.Contains(got, `"d1Tone"`) {
+ t.Fatalf("missing expected fragments: %q", got)
+ }
+}
+
+func TestIslanderSystemIncludesContext(t *testing.T) {
+ got, err := IslanderSystem("【目前頁面】\n- 路徑:/settings")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "島民嚮導") || !strings.Contains(got, "/settings") {
+ t.Fatalf("missing expected fragments: %q", got)
+ }
+}
+
+func TestAIChatSystemUsesDefault(t *testing.T) {
+ got, err := AIChatSystem("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got, "巡樓管理台") {
+ t.Fatalf("got %q", got)
+ }
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/library/prompt/errors.go b/haixun-backend/internal/library/prompt/errors.go
new file mode 100644
index 0000000..e6b2d94
--- /dev/null
+++ b/haixun-backend/internal/library/prompt/errors.go
@@ -0,0 +1,15 @@
+package prompt
+
+import "fmt"
+
+type unknownKeyError struct {
+ key string
+}
+
+func ErrUnknownKey(key string) error {
+ return unknownKeyError{key: key}
+}
+
+func (e unknownKeyError) Error() string {
+ return fmt.Sprintf("unknown prompt key: %s", e.key)
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/library/prompt/files/ai.chat.system.md b/haixun-backend/internal/library/prompt/files/ai.chat.system.md
new file mode 100644
index 0000000..361e981
--- /dev/null
+++ b/haixun-backend/internal/library/prompt/files/ai.chat.system.md
@@ -0,0 +1 @@
+你是巡樓管理台的 AI 助手。回答要簡潔、可執行,優先協助島民完成 Threads 經營與背景任務相關問題。
\ No newline at end of file
diff --git a/haixun-backend/internal/library/prompt/files/ai.islander.system.md b/haixun-backend/internal/library/prompt/files/ai.islander.system.md
new file mode 100644
index 0000000..0686314
--- /dev/null
+++ b/haixun-backend/internal/library/prompt/files/ai.islander.system.md
@@ -0,0 +1,43 @@
+你是巡樓管理台的「島民嚮導」——親切、直接,而且**可以代使用者操作畫面**。
+
+## 任務
+- 回答使用者提出的問題
+- 需要操作畫面時**直接做**,不要叫使用者自己 copy、自己點
+
+## 不要主動講這一頁
+- 使用者**沒問**這頁、畫面、欄位、怎麼用時,不要主動介紹「你現在在某某頁」
+- 只有使用者問這頁、問怎麼寫欄位內容、或要你幫忙操作時,才使用【可互動元素】
+
+## 靜默操作(重要)
+- `islander-actions` 區塊是**系統通道**,使用者**看不到**;禁止在回覆正文裡寫 JSON、程式碼、ref 清單
+- 不要說「我會執行以下操作」「請看 action 區塊」;用人話簡短說結果即可
+- 需要 navigate / click / fill 時,把 action 只放在 `islander-actions` 區塊末尾,正文維持自然對話
+
+## 幫使用者寫進欄位
+當使用者問「某某欄位可以怎麼寫」(例如人設頁的「一句話定位」):
+1. 先用 1–3 句說明思路或給建議文案
+2. 從【可互動元素】找到對應 textarea(看 label / placeholder,如「一句話定位」)
+3. 用 `fill` 把建議文字**直接填進欄位**,不要叫使用者自己貼
+4. 正文**必須寫出建議文案全文**(方便使用者複製),結尾再說「我也幫你填進去了,可以再微調」
+
+範例(正文給使用者看的):
+「這個帳號可以定位成:幫想轉職的工程師,用真實面試經驗拆解求職焦慮。我幫你填進一句話定位了,不滿意再跟我說。」
+
+範例(僅系統執行,放區塊末尾、勿在正文重複):
+
+```islander-actions
+[{ "type": "fill", "label": "一句話定位", "value": "幫想轉職的工程師,用真實面試經驗拆解求職焦慮" }]
+```
+
+`fill` 可用 `label`(對應欄位名稱,如「一句話定位」)或 `ref`(hx-*)。
+
+## 支援的 action
+- `navigate` / `click` / `fill` / `select` / `focus` / `highlight` / `scroll` / `wait`
+- ref 只能來自快照中的 `hx-*`;密碼欄不可 fill;不要操作登出
+
+## 語氣
+- 繁體中文,短句
+- 不要企業八股、不要任天堂/Nook 用語
+
+## 限制
+- 不要要求使用者貼 API key
\ No newline at end of file
diff --git a/haixun-backend/internal/library/prompt/files/overlay.md b/haixun-backend/internal/library/prompt/files/overlay.md
new file mode 100644
index 0000000..d8dc36a
--- /dev/null
+++ b/haixun-backend/internal/library/prompt/files/overlay.md
@@ -0,0 +1,5 @@
+你是 Haixun 巡樓系統的 AI 模組。遵守以下共通規則:
+
+- 使用繁體中文,語氣直接、可執行,避免空泛口號。
+- 不得捏造未提供的貼文、帳號狀態、API 結果或使用者設定。
+- 若資料不足,明確說明缺什麼,不要腦補。
\ No newline at end of file
diff --git a/haixun-backend/internal/library/prompt/files/style8d.schema.md b/haixun-backend/internal/library/prompt/files/style8d.schema.md
new file mode 100644
index 0000000..a3d8960
--- /dev/null
+++ b/haixun-backend/internal/library/prompt/files/style8d.schema.md
@@ -0,0 +1,3 @@
+
+只回傳以下 JSON 結構,不要 markdown:
+{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","examples":"","avoid":""}}
\ No newline at end of file
diff --git a/haixun-backend/internal/library/prompt/files/style8d.system.md b/haixun-backend/internal/library/prompt/files/style8d.system.md
new file mode 100644
index 0000000..5862add
--- /dev/null
+++ b/haixun-backend/internal/library/prompt/files/style8d.system.md
@@ -0,0 +1,5 @@
+你是 Threads 創作者風格研究員。只能根據提供的近期貼文歸納,不可捏造作者背景。
+逐一輸出八個維度:D1 語氣人格、D2 結構模板、D3 互動方式、D4 主題分布、D5 發文節奏、D6 視覺語法(emoji、標點、換行)、D7 轉換方式、D8 風險紅線。
+每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。
+證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。
+最後產生「可供另一個帳號借鑑、但不可冒充或抄襲」的人設草稿。代表句必須是抽象仿寫範例,不可逐字複製原文。
\ No newline at end of file
diff --git a/haixun-backend/internal/library/prompt/registry.go b/haixun-backend/internal/library/prompt/registry.go
new file mode 100644
index 0000000..5f1c4a4
--- /dev/null
+++ b/haixun-backend/internal/library/prompt/registry.go
@@ -0,0 +1,95 @@
+package prompt
+
+import (
+ "embed"
+ "strings"
+ "sync"
+)
+
+//go:embed files
+var files embed.FS
+
+const (
+ fileOverlay = "files/overlay.md"
+ fileStyle8DSystem = "files/style8d.system.md"
+ fileStyle8DSchema = "files/style8d.schema.md"
+ fileAIChatSystem = "files/ai.chat.system.md"
+ fileIslanderSystem = "files/ai.islander.system.md"
+)
+
+// Keys identify prompt slots loaded from internal/library/prompt/files/*.md.
+const (
+ KeyStyle8DSystem = "style8d.system"
+ KeyStyle8DSchema = "style8d.schema"
+ KeyAIChatSystem = "ai.chat.system"
+ KeyIslanderSystem = "ai.islander.system"
+)
+
+var slotFiles = map[string]string{
+ KeyStyle8DSystem: fileStyle8DSystem,
+ KeyStyle8DSchema: fileStyle8DSchema,
+ KeyAIChatSystem: fileAIChatSystem,
+ KeyIslanderSystem: fileIslanderSystem,
+}
+
+var (
+ loadOnce sync.Once
+ loadErr error
+ overlay string
+ slotTexts map[string]string
+)
+
+func initRegistry() {
+ overlay, loadErr = readFile(fileOverlay)
+ if loadErr != nil {
+ return
+ }
+ slotTexts = make(map[string]string, len(slotFiles))
+ for key, path := range slotFiles {
+ text, err := readFile(path)
+ if err != nil {
+ loadErr = err
+ return
+ }
+ slotTexts[key] = text
+ }
+}
+
+func readFile(path string) (string, error) {
+ raw, err := files.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(raw)), nil
+}
+
+func ensureLoaded() error {
+ loadOnce.Do(initRegistry)
+ return loadErr
+}
+
+// Overlay returns the global prepend text (Agents.md style).
+func Overlay() (string, error) {
+ if err := ensureLoaded(); err != nil {
+ return "", err
+ }
+ return overlay, nil
+}
+
+// Slot returns one configured prompt body.
+func Slot(key string) (string, error) {
+ if err := ensureLoaded(); err != nil {
+ return "", err
+ }
+ text, ok := slotTexts[key]
+ if !ok {
+ return "", ErrUnknownKey(key)
+ }
+ return text, nil
+}
+
+// KnownKey reports whether key is registered.
+func KnownKey(key string) bool {
+ _, ok := slotFiles[key]
+ return ok
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/library/style8d/analyze.go b/haixun-backend/internal/library/style8d/analyze.go
new file mode 100644
index 0000000..15b4269
--- /dev/null
+++ b/haixun-backend/internal/library/style8d/analyze.go
@@ -0,0 +1,391 @@
+package style8d
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "regexp"
+ "strings"
+ "time"
+)
+
+type Post struct {
+ Text string `json:"text"`
+ Permalink string `json:"permalink,omitempty"`
+ LikeCount int `json:"like_count,omitempty"`
+ ReplyCount int `json:"reply_count,omitempty"`
+}
+
+type Dimension struct {
+ Summary string `json:"summary"`
+ Evidence []string `json:"evidence"`
+}
+
+type PersonaDraft struct {
+ Identity string `json:"identity"`
+ Tone string `json:"tone"`
+ Audience string `json:"audience"`
+ Hooks string `json:"hooks"`
+ Examples string `json:"examples"`
+ Avoid string `json:"avoid"`
+}
+
+type LLMOutput struct {
+ D1Tone Dimension `json:"d1Tone"`
+ D2Structure Dimension `json:"d2Structure"`
+ D3Interaction Dimension `json:"d3Interaction"`
+ D4Topics Dimension `json:"d4Topics"`
+ D5Rhythm Dimension `json:"d5Rhythm"`
+ D6Visual Dimension `json:"d6Visual"`
+ D7Conversion Dimension `json:"d7Conversion"`
+ D8Risk Dimension `json:"d8Risk"`
+ PersonaDraft PersonaDraft `json:"personaDraft"`
+}
+
+type Engagement struct {
+ MeasuredPosts int `json:"measuredPosts"`
+ MedianInteractions int `json:"medianInteractions"`
+ AverageInteractions int `json:"averageInteractions"`
+ PostsAboveThreshold int `json:"postsAboveThreshold"`
+ Threshold int `json:"threshold"`
+ Verdict string `json:"verdict"`
+}
+
+type StoredProfile struct {
+ Username string `json:"username"`
+ AnalyzedAt string `json:"analyzedAt"`
+ PostCount int `json:"postCount"`
+ Engagement Engagement `json:"engagement"`
+ SamplePosts []Post `json:"samplePosts,omitempty"`
+ Analysis map[string]Dimension `json:"analysis"`
+ PersonaDraft string `json:"personaDraft"`
+}
+
+func BuildUserPrompt(username string, posts []Post) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "對標帳號:@%s\n近期貼文樣本:\n\n", strings.TrimPrefix(username, "@"))
+ limit := len(posts)
+ if limit > 12 {
+ limit = 12
+ }
+ for i := 0; i < limit; i++ {
+ post := posts[i]
+ text := post.Text
+ if len(text) > 500 {
+ text = text[:500]
+ }
+ fmt.Fprintf(&b, "[%d] %d讚 %d回覆\n%s\n\n", i+1, post.LikeCount, post.ReplyCount, text)
+ }
+ return b.String()
+}
+
+func EvaluateEngagement(posts []Post, threshold int) Engagement {
+ if threshold <= 0 {
+ threshold = 10
+ }
+ values := make([]float64, 0, len(posts))
+ for _, post := range posts {
+ score := float64(post.LikeCount) + float64(post.ReplyCount)*2
+ if score > 0 {
+ values = append(values, score)
+ }
+ }
+ median := 0.0
+ if len(values) > 0 {
+ sorted := append([]float64(nil), values...)
+ for i := 0; i < len(sorted); i++ {
+ for j := i + 1; j < len(sorted); j++ {
+ if sorted[j] < sorted[i] {
+ sorted[i], sorted[j] = sorted[j], sorted[i]
+ }
+ }
+ }
+ mid := len(sorted) / 2
+ if len(sorted)%2 == 1 {
+ median = sorted[mid]
+ } else {
+ median = (sorted[mid-1] + sorted[mid]) / 2
+ }
+ }
+ avg := 0.0
+ for _, v := range values {
+ avg += v
+ }
+ if len(values) > 0 {
+ avg /= float64(len(values))
+ }
+ above := 0
+ for _, v := range values {
+ if v >= float64(threshold) {
+ above++
+ }
+ }
+ verdict := "unknown"
+ if len(values) >= 3 {
+ if median >= float64(threshold) && above >= 3 {
+ verdict = "strong"
+ } else {
+ verdict = "usable"
+ }
+ }
+ return Engagement{
+ MeasuredPosts: len(values),
+ MedianInteractions: int(math.Round(median)),
+ AverageInteractions: int(math.Round(avg)),
+ PostsAboveThreshold: above,
+ Threshold: threshold,
+ Verdict: verdict,
+ }
+}
+
+func SerializePersonaDraft(draft PersonaDraft) string {
+ parts := []string{
+ "【我是誰】\n" + strings.TrimSpace(draft.Identity),
+ "【語氣】\n" + strings.TrimSpace(draft.Tone),
+ "【對誰說】\n" + strings.TrimSpace(draft.Audience),
+ "【開場習慣】\n" + strings.TrimSpace(draft.Hooks),
+ "【代表句範例】\n" + strings.TrimSpace(draft.Examples),
+ "【避免】\n" + strings.TrimSpace(draft.Avoid),
+ }
+ return strings.Join(parts, "\n\n")
+}
+
+func ParseLLMOutput(raw string) (LLMOutput, error) {
+ payload, err := extractJSONObject(raw)
+ if err != nil {
+ return LLMOutput{}, err
+ }
+ var root map[string]json.RawMessage
+ if err := json.Unmarshal(payload, &root); err != nil {
+ return LLMOutput{}, fmt.Errorf("parse style8d json: %w", err)
+ }
+ for _, key := range []string{"analysis", "style8d", "style8D", "result", "data", "output"} {
+ if nested, ok := root[key]; ok {
+ var merged map[string]json.RawMessage
+ if err := json.Unmarshal(nested, &merged); err == nil {
+ for k, v := range merged {
+ if _, exists := root[k]; !exists {
+ root[k] = v
+ }
+ }
+ }
+ }
+ }
+ out := LLMOutput{
+ D1Tone: parseDimension(root, "d1Tone", "d1", "D1", "tone"),
+ D2Structure: parseDimension(root, "d2Structure", "d2", "D2", "structure"),
+ D3Interaction: parseDimension(root, "d3Interaction", "d3", "D3", "interaction"),
+ D4Topics: parseDimension(root, "d4Topics", "d4", "D4", "topics"),
+ D5Rhythm: parseDimension(root, "d5Rhythm", "d5", "D5", "rhythm"),
+ D6Visual: parseDimension(root, "d6Visual", "d6", "D6", "visual"),
+ D7Conversion: parseDimension(root, "d7Conversion", "d7", "D7", "conversion"),
+ D8Risk: parseDimension(root, "d8Risk", "d8", "D8", "risk"),
+ PersonaDraft: parsePersonaDraft(root),
+ }
+ if err := validateOutput(out); err != nil {
+ return LLMOutput{}, err
+ }
+ return out, nil
+}
+
+func buildSamplePosts(posts []Post) []Post {
+ limit := len(posts)
+ if limit > 12 {
+ limit = 12
+ }
+ out := make([]Post, 0, limit)
+ for i := 0; i < limit; i++ {
+ post := posts[i]
+ text := strings.TrimSpace(post.Text)
+ if len(text) > 500 {
+ text = text[:500]
+ }
+ out = append(out, Post{
+ Text: text,
+ Permalink: strings.TrimSpace(post.Permalink),
+ LikeCount: post.LikeCount,
+ ReplyCount: post.ReplyCount,
+ })
+ }
+ return out
+}
+
+func BuildStoredProfile(username string, posts []Post, out LLMOutput) StoredProfile {
+ return StoredProfile{
+ Username: strings.TrimPrefix(strings.TrimSpace(username), "@"),
+ AnalyzedAt: time.Now().UTC().Format(time.RFC3339),
+ PostCount: len(posts),
+ Engagement: EvaluateEngagement(posts, 10),
+ SamplePosts: buildSamplePosts(posts),
+ Analysis: map[string]Dimension{
+ "d1Tone": trimDimension(out.D1Tone),
+ "d2Structure": trimDimension(out.D2Structure),
+ "d3Interaction": trimDimension(out.D3Interaction),
+ "d4Topics": trimDimension(out.D4Topics),
+ "d5Rhythm": trimDimension(out.D5Rhythm),
+ "d6Visual": trimDimension(out.D6Visual),
+ "d7Conversion": trimDimension(out.D7Conversion),
+ "d8Risk": trimDimension(out.D8Risk),
+ },
+ PersonaDraft: SerializePersonaDraft(out.PersonaDraft),
+ }
+}
+
+func (p StoredProfile) JSON() (string, error) {
+ raw, err := json.Marshal(p)
+ if err != nil {
+ return "", err
+ }
+ return string(raw), nil
+}
+
+func trimDimension(d Dimension) Dimension {
+ d.Summary = strings.TrimSpace(d.Summary)
+ if len(d.Evidence) > 4 {
+ d.Evidence = d.Evidence[:4]
+ }
+ out := make([]string, 0, len(d.Evidence))
+ for _, item := range d.Evidence {
+ item = strings.TrimSpace(item)
+ if item != "" {
+ out = append(out, item)
+ }
+ }
+ d.Evidence = out
+ return d
+}
+
+func validateOutput(out LLMOutput) error {
+ missing := []string{}
+ for name, dim := range map[string]Dimension{
+ "D1": out.D1Tone, "D2": out.D2Structure, "D3": out.D3Interaction, "D4": out.D4Topics,
+ "D5": out.D5Rhythm, "D6": out.D6Visual, "D7": out.D7Conversion, "D8": out.D8Risk,
+ } {
+ if strings.TrimSpace(dim.Summary) == "" {
+ missing = append(missing, name)
+ }
+ }
+ if len(missing) > 0 {
+ return fmt.Errorf("LLM 回傳缺少維度摘要:%s", strings.Join(missing, ", "))
+ }
+ if strings.TrimSpace(out.PersonaDraft.Identity) == "" && strings.TrimSpace(out.PersonaDraft.Tone) == "" {
+ return fmt.Errorf("LLM 回傳缺少 personaDraft")
+ }
+ return nil
+}
+
+var codeFenceRE = regexp.MustCompile("(?s)^```(?:json)?\\s*(.*?)\\s*```$")
+
+func extractJSONObject(raw string) ([]byte, error) {
+ text := strings.TrimSpace(raw)
+ if text == "" {
+ return nil, fmt.Errorf("empty LLM response")
+ }
+ if m := codeFenceRE.FindStringSubmatch(text); len(m) == 2 {
+ text = strings.TrimSpace(m[1])
+ }
+ start := strings.Index(text, "{")
+ end := strings.LastIndex(text, "}")
+ if start < 0 || end <= start {
+ return nil, fmt.Errorf("LLM response does not contain JSON object")
+ }
+ return []byte(text[start : end+1]), nil
+}
+
+func parseDimension(root map[string]json.RawMessage, keys ...string) Dimension {
+ for _, key := range keys {
+ if raw, ok := root[key]; ok {
+ if dim := decodeDimension(raw); strings.TrimSpace(dim.Summary) != "" {
+ return dim
+ }
+ }
+ }
+ return Dimension{}
+}
+
+func decodeDimension(raw json.RawMessage) Dimension {
+ var asString string
+ if err := json.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" {
+ return Dimension{Summary: strings.TrimSpace(asString)}
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &obj); err != nil {
+ return Dimension{}
+ }
+ summary := firstString(obj, "summary", "description", "analysis", "conclusion")
+ evidence := firstStringSlice(obj, "evidence", "examples", "quotes", "proof")
+ return Dimension{Summary: summary, Evidence: evidence}
+}
+
+func parsePersonaDraft(root map[string]json.RawMessage) PersonaDraft {
+ for _, key := range []string{"personaDraft", "persona", "persona_draft", "voiceProfile"} {
+ raw, ok := root[key]
+ if !ok {
+ continue
+ }
+ var asString string
+ if err := json.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" {
+ return PersonaDraft{Identity: strings.TrimSpace(asString)}
+ }
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &obj); err != nil {
+ continue
+ }
+ return PersonaDraft{
+ Identity: firstString(obj, "identity", "role"),
+ Tone: firstString(obj, "tone", "voice"),
+ Audience: firstString(obj, "audience", "targetAudience"),
+ Hooks: firstString(obj, "hooks", "openings"),
+ Examples: firstString(obj, "examples", "sample"),
+ Avoid: firstString(obj, "avoid", "risks"),
+ }
+ }
+ return PersonaDraft{}
+}
+
+func firstString(obj map[string]json.RawMessage, keys ...string) string {
+ for _, key := range keys {
+ raw, ok := obj[key]
+ if !ok {
+ continue
+ }
+ var value string
+ if err := json.Unmarshal(raw, &value); err == nil {
+ value = strings.TrimSpace(value)
+ if value != "" {
+ return value
+ }
+ }
+ }
+ return ""
+}
+
+func firstStringSlice(obj map[string]json.RawMessage, keys ...string) []string {
+ for _, key := range keys {
+ raw, ok := obj[key]
+ if !ok {
+ continue
+ }
+ var values []string
+ if err := json.Unmarshal(raw, &values); err == nil {
+ out := make([]string, 0, len(values))
+ for _, item := range values {
+ item = strings.TrimSpace(item)
+ if item != "" {
+ out = append(out, item)
+ }
+ }
+ if len(out) > 0 {
+ return out
+ }
+ }
+ var single string
+ if err := json.Unmarshal(raw, &single); err == nil {
+ single = strings.TrimSpace(single)
+ if single != "" {
+ return []string{single}
+ }
+ }
+ }
+ return nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/library/style8d/analyze_test.go b/haixun-backend/internal/library/style8d/analyze_test.go
new file mode 100644
index 0000000..196b982
--- /dev/null
+++ b/haixun-backend/internal/library/style8d/analyze_test.go
@@ -0,0 +1,26 @@
+package style8d
+
+import "testing"
+
+func TestParseLLMOutput(t *testing.T) {
+ raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","examples":"有時候真的會累","avoid":"不要硬銷"}}`
+ out, err := ParseLLMOutput(raw)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if out.D1Tone.Summary != "口語親近" {
+ t.Fatalf("d1=%q", out.D1Tone.Summary)
+ }
+ profile := BuildStoredProfile("demo", []Post{{
+ Text: "sample",
+ Permalink: "https://www.threads.net/@demo/post/abc",
+ LikeCount: 3,
+ ReplyCount: 1,
+ }}, out)
+ if profile.Analysis["d1Tone"].Summary == "" {
+ t.Fatal("stored profile missing d1")
+ }
+ if len(profile.SamplePosts) != 1 || profile.SamplePosts[0].Permalink == "" {
+ t.Fatalf("samplePosts=%+v", profile.SamplePosts)
+ }
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/ai/actor.go b/haixun-backend/internal/logic/ai/actor.go
new file mode 100644
index 0000000..0886859
--- /dev/null
+++ b/haixun-backend/internal/logic/ai/actor.go
@@ -0,0 +1,17 @@
+package ai
+
+import (
+ "context"
+
+ "haixun-backend/internal/library/authctx"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+)
+
+func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
+ actor, ok := authctx.ActorFromContext(ctx)
+ if !ok {
+ return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ return actor.TenantID, actor.UID, nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/ai/chat_logic.go b/haixun-backend/internal/logic/ai/chat_logic.go
index fd74880..0bdf7b2 100644
--- a/haixun-backend/internal/logic/ai/chat_logic.go
+++ b/haixun-backend/internal/logic/ai/chat_logic.go
@@ -3,6 +3,7 @@ package ai
import (
"context"
+ libprompt "haixun-backend/internal/library/prompt"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
@@ -17,7 +18,11 @@ func NewChatLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChatLogic {
}
func (l *ChatLogic) Chat(req *types.AIChatReq, token string) (*types.AIChatData, error) {
- result, err := l.svcCtx.AI.GenerateText(l.ctx, toGenerateRequest(req, token))
+ system, err := libprompt.AIChatSystem(req.System)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.AI.GenerateText(l.ctx, toGenerateRequest(req, token, system))
if err != nil {
return nil, err
}
diff --git a/haixun-backend/internal/logic/ai/chat_stream_logic.go b/haixun-backend/internal/logic/ai/chat_stream_logic.go
index b0b7da2..f432e79 100644
--- a/haixun-backend/internal/logic/ai/chat_stream_logic.go
+++ b/haixun-backend/internal/logic/ai/chat_stream_logic.go
@@ -3,6 +3,7 @@ package ai
import (
"context"
+ libprompt "haixun-backend/internal/library/prompt"
domai "haixun-backend/internal/model/ai/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
@@ -18,5 +19,9 @@ func NewChatStreamLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChatSt
}
func (l *ChatStreamLogic) ChatStream(req *types.AIChatReq, token string) (<-chan domai.StreamEvent, error) {
- return l.svcCtx.AI.StreamText(l.ctx, toGenerateRequest(req, token))
+ system, err := libprompt.AIChatSystem(req.System)
+ if err != nil {
+ return nil, err
+ }
+ return l.svcCtx.AI.StreamText(l.ctx, toGenerateRequest(req, token, system))
}
diff --git a/haixun-backend/internal/logic/ai/islander_chat_stream_logic.go b/haixun-backend/internal/logic/ai/islander_chat_stream_logic.go
new file mode 100644
index 0000000..936fc6a
--- /dev/null
+++ b/haixun-backend/internal/logic/ai/islander_chat_stream_logic.go
@@ -0,0 +1,35 @@
+package ai
+
+import (
+ "context"
+
+ libprompt "haixun-backend/internal/library/prompt"
+ domai "haixun-backend/internal/model/ai/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type IslanderChatStreamLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewIslanderChatStreamLogic(ctx context.Context, svcCtx *svc.ServiceContext) *IslanderChatStreamLogic {
+ return &IslanderChatStreamLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *IslanderChatStreamLogic) ChatStream(req *types.IslanderChatReq) (<-chan domai.StreamEvent, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+ system, err := libprompt.IslanderSystem(req.Context)
+ if err != nil {
+ return nil, err
+ }
+ return l.svcCtx.AI.StreamText(l.ctx, toCredentialGenerateRequest(req.Messages, credential, system))
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/ai/mapper.go b/haixun-backend/internal/logic/ai/mapper.go
index f319b22..3cce202 100644
--- a/haixun-backend/internal/logic/ai/mapper.go
+++ b/haixun-backend/internal/logic/ai/mapper.go
@@ -3,10 +3,42 @@ package ai
import (
"haixun-backend/internal/model/ai/domain/enum"
domai "haixun-backend/internal/model/ai/domain/usecase"
+ threadsdom "haixun-backend/internal/model/threads_account/domain/usecase"
"haixun-backend/internal/types"
)
-func toGenerateRequest(req *types.AIChatReq, token string) domai.GenerateRequest {
+func toCredentialGenerateRequest(
+ messages []types.AIMessage,
+ credential *threadsdom.WorkerAiCredential,
+ system string,
+) domai.GenerateRequest {
+ domMessages := make([]domai.Message, 0, len(messages))
+ for _, msg := range messages {
+ domMessages = append(domMessages, domai.Message{
+ Role: msg.Role,
+ Content: msg.Content,
+ })
+ }
+ provider := ""
+ model := ""
+ apiKey := ""
+ if credential != nil {
+ provider = credential.Provider
+ model = credential.Model
+ apiKey = credential.APIKey
+ }
+ return domai.GenerateRequest{
+ Provider: enum.ProviderID(provider),
+ Model: model,
+ Credential: domai.Credential{
+ APIKey: apiKey,
+ },
+ System: system,
+ Messages: domMessages,
+ }
+}
+
+func toGenerateRequest(req *types.AIChatReq, token string, system string) domai.GenerateRequest {
messages := make([]domai.Message, 0, len(req.Messages))
for _, msg := range req.Messages {
messages = append(messages, domai.Message{
@@ -20,7 +52,7 @@ func toGenerateRequest(req *types.AIChatReq, token string) domai.GenerateRequest
Credential: domai.Credential{
APIKey: token,
},
- System: req.System,
+ System: system,
Messages: messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
diff --git a/haixun-backend/internal/logic/job/mapper.go b/haixun-backend/internal/logic/job/mapper.go
index b7d12f2..5775675 100644
--- a/haixun-backend/internal/logic/job/mapper.go
+++ b/haixun-backend/internal/logic/job/mapper.go
@@ -78,6 +78,10 @@ func toJobData(run *entity.Run) types.JobData {
}
}
+func ToJobData(run *entity.Run) types.JobData {
+ return toJobData(run)
+}
+
func toJobScheduleData(schedule *entity.Schedule) types.JobScheduleData {
return types.JobScheduleData{
ID: schedule.ID.Hex(),
diff --git a/haixun-backend/internal/logic/persona/actor.go b/haixun-backend/internal/logic/persona/actor.go
new file mode 100644
index 0000000..897666b
--- /dev/null
+++ b/haixun-backend/internal/logic/persona/actor.go
@@ -0,0 +1,17 @@
+package persona
+
+import (
+ "context"
+
+ "haixun-backend/internal/library/authctx"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+)
+
+func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
+ actor, ok := authctx.ActorFromContext(ctx)
+ if !ok {
+ return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ return actor.TenantID, actor.UID, nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/persona/mapper.go b/haixun-backend/internal/logic/persona/mapper.go
new file mode 100644
index 0000000..a6f917c
--- /dev/null
+++ b/haixun-backend/internal/logic/persona/mapper.go
@@ -0,0 +1,49 @@
+package persona
+
+import (
+ domusecase "haixun-backend/internal/model/persona/domain/usecase"
+ "haixun-backend/internal/types"
+)
+
+func toPersonaData(item domusecase.PersonaSummary) types.PersonaData {
+ return types.PersonaData{
+ ID: item.ID,
+ DisplayName: item.DisplayName,
+ Persona: item.Persona,
+ Brief: item.Brief,
+ ProductBrief: item.ProductBrief,
+ TargetAudience: item.TargetAudience,
+ Goals: item.Goals,
+ StyleProfile: item.StyleProfile,
+ StyleBenchmark: item.StyleBenchmark,
+ CreateAt: item.CreateAt,
+ UpdateAt: item.UpdateAt,
+ }
+}
+
+func toListData(result *domusecase.ListResult) *types.ListPersonasData {
+ if result == nil {
+ return &types.ListPersonasData{List: []types.PersonaData{}}
+ }
+ list := make([]types.PersonaData, 0, len(result.List))
+ for _, item := range result.List {
+ list = append(list, toPersonaData(item))
+ }
+ return &types.ListPersonasData{List: list}
+}
+
+func toPersonaPatch(req *types.UpdatePersonaReq) domusecase.PersonaPatch {
+ if req == nil {
+ return domusecase.PersonaPatch{}
+ }
+ return domusecase.PersonaPatch{
+ DisplayName: req.DisplayName,
+ Persona: req.Persona,
+ Brief: req.Brief,
+ ProductBrief: req.ProductBrief,
+ TargetAudience: req.TargetAudience,
+ Goals: req.Goals,
+ StyleProfile: req.StyleProfile,
+ StyleBenchmark: req.StyleBenchmark,
+ }
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/persona/persona_logic.go b/haixun-backend/internal/logic/persona/persona_logic.go
new file mode 100644
index 0000000..8557599
--- /dev/null
+++ b/haixun-backend/internal/logic/persona/persona_logic.go
@@ -0,0 +1,126 @@
+package persona
+
+import (
+ "context"
+
+ domusecase "haixun-backend/internal/model/persona/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ListPersonasLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListPersonasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPersonasLogic {
+ return &ListPersonasLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListPersonasLogic) ListPersonas() (*types.ListPersonasData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.Persona.List(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+ return toListData(result), nil
+}
+
+type CreatePersonaLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreatePersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreatePersonaLogic {
+ return &CreatePersonaLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *CreatePersonaLogic) CreatePersona(req *types.CreatePersonaReq) (*types.PersonaData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ displayName := ""
+ if req != nil {
+ displayName = req.DisplayName
+ }
+ item, err := l.svcCtx.Persona.Create(l.ctx, domusecase.CreateRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ DisplayName: displayName,
+ })
+ if err != nil {
+ return nil, err
+ }
+ out := toPersonaData(*item)
+ return &out, nil
+}
+
+type GetPersonaLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetPersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPersonaLogic {
+ return &GetPersonaLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetPersonaLogic) GetPersona(req *types.PersonaPath) (*types.PersonaData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ out := toPersonaData(*item)
+ return &out, nil
+}
+
+type UpdatePersonaLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpdatePersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePersonaLogic {
+ return &UpdatePersonaLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+type DeletePersonaLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewDeletePersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePersonaLogic {
+ return &DeletePersonaLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *DeletePersonaLogic) DeletePersona(req *types.PersonaPath) error {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return err
+ }
+ return l.svcCtx.Persona.Delete(l.ctx, tenantID, uid, req.ID)
+}
+
+func (l *UpdatePersonaLogic) UpdatePersona(req *types.PersonaPath, body *types.UpdatePersonaReq) (*types.PersonaData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.Persona.Update(l.ctx, domusecase.UpdateRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ PersonaID: req.ID,
+ Patch: toPersonaPatch(body),
+ })
+ if err != nil {
+ return nil, err
+ }
+ out := toPersonaData(*item)
+ return &out, nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/persona/style_analysis_logic.go b/haixun-backend/internal/logic/persona/style_analysis_logic.go
new file mode 100644
index 0000000..92e77be
--- /dev/null
+++ b/haixun-backend/internal/logic/persona/style_analysis_logic.go
@@ -0,0 +1,68 @@
+package persona
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ jobdom "haixun-backend/internal/model/job/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type StartPersonaStyleAnalysisLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewStartPersonaStyleAnalysisLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaStyleAnalysisLogic {
+ return &StartPersonaStyleAnalysisLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *StartPersonaStyleAnalysisLogic) StartPersonaStyleAnalysis(
+ req *types.PersonaPath,
+ body *types.StartPersonaStyleAnalysisReq,
+) (*types.StartPersonaStyleAnalysisData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ username := ""
+ if body != nil {
+ username = strings.TrimPrefix(strings.TrimSpace(body.BenchmarkUsername), "@")
+ }
+ if username == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("benchmark_username is required")
+ }
+
+ if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil {
+ return nil, err
+ }
+ activeAccountID := ""
+ if member, err := l.svcCtx.Member.GetByUID(l.ctx, tenantID, uid); err == nil {
+ activeAccountID = member.ActiveThreadsAccountID
+ }
+
+ run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
+ TemplateType: "style-8d",
+ Scope: "persona",
+ ScopeID: req.ID,
+ Payload: map[string]any{
+ "persona_id": req.ID,
+ "benchmark_username": username,
+ "tenant_id": tenantID,
+ "owner_uid": uid,
+ "threads_account_id": activeAccountID,
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &types.StartPersonaStyleAnalysisData{
+ JobID: run.ID.Hex(),
+ Status: string(run.Status),
+ Message: "8D 分析已在背景執行,可自由切換頁面",
+ }, nil
+}
diff --git a/haixun-backend/internal/logic/threads_account/activate_threads_account_logic.go b/haixun-backend/internal/logic/threads_account/activate_threads_account_logic.go
new file mode 100644
index 0000000..dcb18d4
--- /dev/null
+++ b/haixun-backend/internal/logic/threads_account/activate_threads_account_logic.go
@@ -0,0 +1,25 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ActivateThreadsAccountLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewActivateThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ActivateThreadsAccountLogic {
+ return &ActivateThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ActivateThreadsAccountLogic) ActivateThreadsAccount(req *types.ThreadsAccountPath) error {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return err
+ }
+ return l.svcCtx.ThreadsAccount.Activate(l.ctx, tenantID, uid, req.ID)
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/threads_account/actor.go b/haixun-backend/internal/logic/threads_account/actor.go
new file mode 100644
index 0000000..c0c5929
--- /dev/null
+++ b/haixun-backend/internal/logic/threads_account/actor.go
@@ -0,0 +1,17 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/library/authctx"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+)
+
+func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
+ actor, ok := authctx.ActorFromContext(ctx)
+ if !ok {
+ return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
+ }
+ return actor.TenantID, actor.UID, nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/threads_account/ai_settings_logic.go b/haixun-backend/internal/logic/threads_account/ai_settings_logic.go
new file mode 100644
index 0000000..49c7ce4
--- /dev/null
+++ b/haixun-backend/internal/logic/threads_account/ai_settings_logic.go
@@ -0,0 +1,86 @@
+package threads_account
+
+import (
+ "context"
+
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type GetThreadsAccountAiSettingsLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetThreadsAccountAiSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsAccountAiSettingsLogic {
+ return &GetThreadsAccountAiSettingsLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetThreadsAccountAiSettingsLogic) GetThreadsAccountAiSettings(req *types.ThreadsAccountPath) (*types.ThreadsAccountAiSettingsData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ data, err := l.svcCtx.ThreadsAccount.GetAiSettings(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ return toAiSettingsData(data), nil
+}
+
+type UpdateThreadsAccountAiSettingsLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpdateThreadsAccountAiSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateThreadsAccountAiSettingsLogic {
+ return &UpdateThreadsAccountAiSettingsLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *UpdateThreadsAccountAiSettingsLogic) UpdateThreadsAccountAiSettings(
+ req *types.ThreadsAccountPath,
+ body *types.UpdateThreadsAccountAiSettingsReq,
+) (*types.ThreadsAccountAiSettingsData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ data, err := l.svcCtx.ThreadsAccount.UpdateAiSettings(l.ctx, tenantID, uid, req.ID, toAiSettingsPatch(body))
+ if err != nil {
+ return nil, err
+ }
+ return toAiSettingsData(data), nil
+}
+
+func toAiSettingsData(data *domusecase.AiSettings) *types.ThreadsAccountAiSettingsData {
+ if data == nil {
+ return nil
+ }
+ configured := map[string]interface{}{}
+ for provider, ok := range data.ApiKeysConfigured {
+ configured[provider] = ok
+ }
+ return &types.ThreadsAccountAiSettingsData{
+ AccountID: data.AccountID,
+ Provider: data.Provider,
+ Model: data.Model,
+ ResearchProvider: data.ResearchProvider,
+ ResearchModel: data.ResearchModel,
+ ApiKeys: data.ApiKeys,
+ ApiKeysConfigured: configured,
+ }
+}
+
+func toAiSettingsPatch(req *types.UpdateThreadsAccountAiSettingsReq) domusecase.AiSettingsPatch {
+ if req == nil {
+ return domusecase.AiSettingsPatch{}
+ }
+ return domusecase.AiSettingsPatch{
+ Provider: req.Provider,
+ Model: req.Model,
+ ResearchProvider: req.ResearchProvider,
+ ResearchModel: req.ResearchModel,
+ ApiKeys: req.ApiKeys,
+ }
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/threads_account/connection_logic.go b/haixun-backend/internal/logic/threads_account/connection_logic.go
new file mode 100644
index 0000000..7f0edc0
--- /dev/null
+++ b/haixun-backend/internal/logic/threads_account/connection_logic.go
@@ -0,0 +1,59 @@
+package threads_account
+
+import (
+ "context"
+
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type GetThreadsAccountConnectionLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetThreadsAccountConnectionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsAccountConnectionLogic {
+ return &GetThreadsAccountConnectionLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetThreadsAccountConnectionLogic) GetThreadsAccountConnection(req *types.ThreadsAccountPath) (*types.ThreadsAccountConnectionData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ data, err := l.svcCtx.ThreadsAccount.GetConnection(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ return toConnectionData(data), nil
+}
+
+type UpdateThreadsAccountConnectionLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpdateThreadsAccountConnectionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateThreadsAccountConnectionLogic {
+ return &UpdateThreadsAccountConnectionLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *UpdateThreadsAccountConnectionLogic) UpdateThreadsAccountConnection(
+ req *types.ThreadsAccountPath,
+ body *types.UpdateThreadsAccountConnectionReq,
+) (*types.ThreadsAccountConnectionData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ data, err := l.svcCtx.ThreadsAccount.UpdateConnection(l.ctx, domusecase.UpdateConnectionRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ Prefs: toConnectionPatch(body),
+ })
+ if err != nil {
+ return nil, err
+ }
+ return toConnectionData(data), nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/threads_account/create_threads_account_logic.go b/haixun-backend/internal/logic/threads_account/create_threads_account_logic.go
new file mode 100644
index 0000000..26e7432
--- /dev/null
+++ b/haixun-backend/internal/logic/threads_account/create_threads_account_logic.go
@@ -0,0 +1,44 @@
+package threads_account
+
+import (
+ "context"
+
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type CreateThreadsAccountLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreateThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateThreadsAccountLogic {
+ return &CreateThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *CreateThreadsAccountLogic) CreateThreadsAccount(req *types.CreateThreadsAccountReq) (*types.ThreadsAccountData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ activate := true
+ displayName := ""
+ if req != nil {
+ if req.Activate != nil {
+ activate = *req.Activate
+ }
+ displayName = req.DisplayName
+ }
+ item, err := l.svcCtx.ThreadsAccount.Create(l.ctx, domusecase.CreateRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ DisplayName: displayName,
+ Activate: activate,
+ })
+ if err != nil {
+ return nil, err
+ }
+ out := toThreadsAccountData(*item)
+ return &out, nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/threads_account/get_threads_account_logic.go b/haixun-backend/internal/logic/threads_account/get_threads_account_logic.go
new file mode 100644
index 0000000..2bb12e7
--- /dev/null
+++ b/haixun-backend/internal/logic/threads_account/get_threads_account_logic.go
@@ -0,0 +1,30 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type GetThreadsAccountLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsAccountLogic {
+ return &GetThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *GetThreadsAccountLogic) GetThreadsAccount(req *types.ThreadsAccountPath) (*types.ThreadsAccountData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ out := toThreadsAccountData(*item)
+ return &out, nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/threads_account/list_threads_accounts_logic.go b/haixun-backend/internal/logic/threads_account/list_threads_accounts_logic.go
new file mode 100644
index 0000000..b090346
--- /dev/null
+++ b/haixun-backend/internal/logic/threads_account/list_threads_accounts_logic.go
@@ -0,0 +1,29 @@
+package threads_account
+
+import (
+ "context"
+
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ListThreadsAccountsLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewListThreadsAccountsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsAccountsLogic {
+ return &ListThreadsAccountsLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ListThreadsAccountsLogic) ListThreadsAccounts() (*types.ListThreadsAccountsData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid)
+ if err != nil {
+ return nil, err
+ }
+ return toListData(result), nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/threads_account/mapper.go b/haixun-backend/internal/logic/threads_account/mapper.go
new file mode 100644
index 0000000..8eb7a79
--- /dev/null
+++ b/haixun-backend/internal/logic/threads_account/mapper.go
@@ -0,0 +1,74 @@
+package threads_account
+
+import (
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+ "haixun-backend/internal/types"
+)
+
+func toThreadsAccountData(item domusecase.AccountSummary) types.ThreadsAccountData {
+ return types.ThreadsAccountData{
+ ID: item.ID,
+ DisplayName: item.DisplayName,
+ Username: item.Username,
+ ThreadsUserID: item.ThreadsUserID,
+ PersonaID: item.PersonaID,
+ BrowserConnected: item.BrowserConnected,
+ ApiConnected: item.ApiConnected,
+ Status: item.Status,
+ CreateAt: item.CreateAt,
+ UpdateAt: item.UpdateAt,
+ }
+}
+
+func toListData(result *domusecase.ListResult) *types.ListThreadsAccountsData {
+ if result == nil {
+ return &types.ListThreadsAccountsData{List: []types.ThreadsAccountData{}}
+ }
+ list := make([]types.ThreadsAccountData, 0, len(result.List))
+ for _, item := range result.List {
+ list = append(list, toThreadsAccountData(item))
+ }
+ return &types.ListThreadsAccountsData{
+ List: list,
+ ActiveAccountID: result.ActiveAccountID,
+ }
+}
+
+func toConnectionData(data *domusecase.ConnectionData) *types.ThreadsAccountConnectionData {
+ if data == nil {
+ return nil
+ }
+ return &types.ThreadsAccountConnectionData{
+ AccountID: data.AccountID,
+ AccountName: data.AccountName,
+ Username: data.Username,
+ BrowserConnected: data.BrowserConnected,
+ ApiConnected: data.ApiConnected,
+ Prefs: types.ThreadsAccountConnectionPrefs{
+ SearchViaApi: data.Prefs.SearchViaApi,
+ SearchSourceMode: data.Prefs.SearchSourceMode,
+ PublishViaApi: data.Prefs.PublishViaApi,
+ DevMode: data.Prefs.DevMode,
+ ScrapeReplies: data.Prefs.ScrapeReplies,
+ RepliesPerPost: data.Prefs.RepliesPerPost,
+ PublishHeaded: data.Prefs.PublishHeaded,
+ PlaywrightDebug: data.Prefs.PlaywrightDebug,
+ },
+ }
+}
+
+func toConnectionPatch(req *types.UpdateThreadsAccountConnectionReq) domusecase.ConnectionPrefsPatch {
+ if req == nil {
+ return domusecase.ConnectionPrefsPatch{}
+ }
+ return domusecase.ConnectionPrefsPatch{
+ SearchViaApi: req.SearchViaApi,
+ SearchSourceMode: req.SearchSourceMode,
+ PublishViaApi: req.PublishViaApi,
+ DevMode: req.DevMode,
+ ScrapeReplies: req.ScrapeReplies,
+ RepliesPerPost: req.RepliesPerPost,
+ PublishHeaded: req.PublishHeaded,
+ PlaywrightDebug: req.PlaywrightDebug,
+ }
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/logic/threads_account/session_logic.go b/haixun-backend/internal/logic/threads_account/session_logic.go
new file mode 100644
index 0000000..f5b2e24
--- /dev/null
+++ b/haixun-backend/internal/logic/threads_account/session_logic.go
@@ -0,0 +1,46 @@
+package threads_account
+
+import (
+ "context"
+
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type ImportThreadsAccountSessionLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewImportThreadsAccountSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ImportThreadsAccountSessionLogic {
+ return &ImportThreadsAccountSessionLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *ImportThreadsAccountSessionLogic) ImportThreadsAccountSession(
+ req *types.ThreadsAccountPath,
+ body *types.ImportThreadsAccountSessionReq,
+) (*types.ImportThreadsAccountSessionData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := l.svcCtx.ThreadsAccount.ImportBrowserSession(l.ctx, domusecase.ImportBrowserSessionRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ StorageState: body.StorageState,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &types.ImportThreadsAccountSessionData{
+ Success: true,
+ Valid: result.Valid,
+ Synced: result.Synced,
+ AccountID: result.AccountID,
+ Username: result.Username,
+ Message: result.Message,
+ UpdateAt: result.UpdateAt,
+ }, nil
+}
diff --git a/haixun-backend/internal/logic/threads_account/update_threads_account_logic.go b/haixun-backend/internal/logic/threads_account/update_threads_account_logic.go
new file mode 100644
index 0000000..85a7a8d
--- /dev/null
+++ b/haixun-backend/internal/logic/threads_account/update_threads_account_logic.go
@@ -0,0 +1,40 @@
+package threads_account
+
+import (
+ "context"
+
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+ "haixun-backend/internal/svc"
+ "haixun-backend/internal/types"
+)
+
+type UpdateThreadsAccountLogic struct {
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewUpdateThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateThreadsAccountLogic {
+ return &UpdateThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
+}
+
+func (l *UpdateThreadsAccountLogic) UpdateThreadsAccount(
+ req *types.ThreadsAccountPath,
+ body *types.UpdateThreadsAccountReq,
+) (*types.ThreadsAccountData, error) {
+ tenantID, uid, err := actorFrom(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ item, err := l.svcCtx.ThreadsAccount.Update(l.ctx, domusecase.UpdateAccountRequest{
+ TenantID: tenantID,
+ OwnerUID: uid,
+ AccountID: req.ID,
+ DisplayName: body.DisplayName,
+ PersonaID: body.PersonaID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ out := toThreadsAccountData(*item)
+ return &out, nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/model/job/domain/usecase/job.go b/haixun-backend/internal/model/job/domain/usecase/job.go
index 63cac56..98d9641 100644
--- a/haixun-backend/internal/model/job/domain/usecase/job.go
+++ b/haixun-backend/internal/model/job/domain/usecase/job.go
@@ -88,6 +88,7 @@ type UseCase interface {
GetTemplate(ctx context.Context, templateType string) (*entity.Template, error)
UpsertTemplate(ctx context.Context, req UpsertTemplateRequest) (*entity.Template, error)
EnsureDemoTemplate(ctx context.Context) error
+ EnsureStyle8DTemplate(ctx context.Context) error
CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error)
GetRun(ctx context.Context, jobID string) (*entity.Run, error)
diff --git a/haixun-backend/internal/model/job/usecase/helpers.go b/haixun-backend/internal/model/job/usecase/helpers.go
index c8b87e0..66ca404 100644
--- a/haixun-backend/internal/model/job/usecase/helpers.go
+++ b/haixun-backend/internal/model/job/usecase/helpers.go
@@ -25,6 +25,12 @@ func buildDedupeKey(template *entity.Template, scope, scopeID string, payload ma
parts = append(parts, target)
}
}
+ case "benchmark_username":
+ if payload != nil {
+ if username, ok := payload["benchmark_username"].(string); ok {
+ parts = append(parts, username)
+ }
+ }
}
}
raw := strings.Join(parts, "|")
diff --git a/haixun-backend/internal/model/job/usecase/usecase.go b/haixun-backend/internal/model/job/usecase/usecase.go
index c00971b..77dfa2d 100644
--- a/haixun-backend/internal/model/job/usecase/usecase.go
+++ b/haixun-backend/internal/model/job/usecase/usecase.go
@@ -13,7 +13,11 @@ import (
domusecase "haixun-backend/internal/model/job/domain/usecase"
)
-const demoTemplateType = "demo_long_task"
+const (
+ demoTemplateType = "demo_long_task"
+ style8DTemplateType = "style-8d"
+ style8DWorkerType = "node"
+)
type UseCase = domusecase.UseCase
@@ -54,6 +58,40 @@ func (u *jobUseCase) EnsureDemoTemplate(ctx context.Context) error {
return err
}
+func (u *jobUseCase) EnsureStyle8DTemplate(ctx context.Context) error {
+ _, err := u.templates.Upsert(ctx, style8DTemplate())
+ return err
+}
+
+func style8DTemplate() *entity.Template {
+ return &entity.Template{
+ Type: style8DTemplateType,
+ Version: 1,
+ Name: "Threads 8D Style Analysis",
+ Description: "Scrape benchmark posts and analyze D1-D8 style profile for a Threads account",
+ Enabled: true,
+ Repeatable: true,
+ ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
+ DedupeKeys: []string{"scope_id", "benchmark_username"},
+ TimeoutSeconds: 480,
+ CancelPolicy: entity.CancelPolicy{
+ Supported: true,
+ Mode: "cooperative",
+ GraceSeconds: 30,
+ },
+ RetryPolicy: entity.RetryPolicy{
+ MaxAttempts: 1,
+ BackoffSeconds: []int{},
+ },
+ Steps: []entity.TemplateStep{
+ {ID: "session", Name: "Confirm Threads connection", WorkerType: style8DWorkerType, TimeoutSeconds: 60, Cancelable: true},
+ {ID: "samples", Name: "Fetch recent posts", WorkerType: style8DWorkerType, TimeoutSeconds: 180, Cancelable: true},
+ {ID: "style", Name: "AI 8D analysis", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 240, Cancelable: true},
+ {ID: "store", Name: "Save persona strategy", WorkerType: string(enum.WorkerTypeGo), TimeoutSeconds: 60, Cancelable: false},
+ },
+ }
+}
+
func demoTemplate() *entity.Template {
return &entity.Template{
Type: demoTemplateType,
diff --git a/haixun-backend/internal/model/member/domain/entity/member.go b/haixun-backend/internal/model/member/domain/entity/member.go
index e111090..f8d4036 100644
--- a/haixun-backend/internal/model/member/domain/entity/member.go
+++ b/haixun-backend/internal/model/member/domain/entity/member.go
@@ -32,6 +32,7 @@ type Member struct {
Origin Origin `bson:"origin"`
PasswordHash string `bson:"password_hash,omitempty"`
Roles []string `bson:"roles,omitempty"`
+ ActiveThreadsAccountID string `bson:"active_threads_account_id,omitempty"`
BusinessEmail string `bson:"business_email,omitempty"`
BusinessEmailVerified bool `bson:"business_email_verified"`
BusinessPhone string `bson:"business_phone,omitempty"`
diff --git a/haixun-backend/internal/model/member/domain/repository/member.go b/haixun-backend/internal/model/member/domain/repository/member.go
index aae8fac..e4734f7 100644
--- a/haixun-backend/internal/model/member/domain/repository/member.go
+++ b/haixun-backend/internal/model/member/domain/repository/member.go
@@ -21,4 +21,5 @@ type Repository interface {
FindByEmail(ctx context.Context, tenantID, email string) (*entity.Member, error)
UpdateProfile(ctx context.Context, tenantID, uid string, update ProfileUpdate) (*entity.Member, error)
SetRoles(ctx context.Context, tenantID, uid string, roles []string) error
+ SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error
}
diff --git a/haixun-backend/internal/model/member/domain/usecase/member.go b/haixun-backend/internal/model/member/domain/usecase/member.go
index 734c20f..284c839 100644
--- a/haixun-backend/internal/model/member/domain/usecase/member.go
+++ b/haixun-backend/internal/model/member/domain/usecase/member.go
@@ -43,4 +43,5 @@ type UseCase interface {
Login(ctx context.Context, req LoginRequest) (*entity.Member, *AuthToken, error)
GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error)
UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*entity.Member, error)
+ SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error
}
diff --git a/haixun-backend/internal/model/member/repository/mongo.go b/haixun-backend/internal/model/member/repository/mongo.go
index 6945af0..d28ef09 100644
--- a/haixun-backend/internal/model/member/repository/mongo.go
+++ b/haixun-backend/internal/model/member/repository/mongo.go
@@ -73,6 +73,27 @@ func (r *mongoRepository) FindByEmail(ctx context.Context, tenantID, email strin
return r.findOne(ctx, bson.M{"tenant_id": tenantID, "email": normalizeEmail(email)})
}
+func (r *mongoRepository) SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error {
+ if r.collection == nil {
+ return app.For(code.Member).DBUnavailable("Mongo is not configured")
+ }
+ if tenantID == "" || uid == "" {
+ return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
+ }
+ res, err := r.collection.UpdateOne(
+ ctx,
+ bson.M{"tenant_id": tenantID, "uid": uid},
+ bson.M{"$set": bson.M{"active_threads_account_id": strings.TrimSpace(accountID), "update_at": clock.NowUnixNano()}},
+ )
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
+ return app.For(code.Member).ResNotFound("member not found")
+ }
+ return nil
+}
+
func (r *mongoRepository) SetRoles(ctx context.Context, tenantID, uid string, roles []string) error {
if r.collection == nil {
return app.For(code.Member).DBUnavailable("Mongo is not configured")
diff --git a/haixun-backend/internal/model/member/usecase/usecase.go b/haixun-backend/internal/model/member/usecase/usecase.go
index 3c1e771..7881650 100644
--- a/haixun-backend/internal/model/member/usecase/usecase.go
+++ b/haixun-backend/internal/model/member/usecase/usecase.go
@@ -82,6 +82,13 @@ func (u *memberUseCase) GetByUID(ctx context.Context, tenantID, uid string) (*en
return u.repo.FindByUID(ctx, tenantID, uid)
}
+func (u *memberUseCase) SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error {
+ if tenantID == "" || uid == "" {
+ return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
+ }
+ return u.repo.SetActiveThreadsAccountID(ctx, tenantID, uid, accountID)
+}
+
func (u *memberUseCase) UpdateProfile(ctx context.Context, req domusecase.UpdateProfileRequest) (*entity.Member, error) {
if req.TenantID == "" || req.UID == "" {
return nil, app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
diff --git a/haixun-backend/internal/model/persona/domain/entity/persona.go b/haixun-backend/internal/model/persona/domain/entity/persona.go
new file mode 100644
index 0000000..ca33dd7
--- /dev/null
+++ b/haixun-backend/internal/model/persona/domain/entity/persona.go
@@ -0,0 +1,27 @@
+package entity
+
+const CollectionName = "personas"
+
+type Status string
+
+const (
+ StatusOpen Status = "open"
+ StatusDeleted Status = "deleted"
+)
+
+type Persona struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ DisplayName string `bson:"display_name,omitempty"`
+ Persona string `bson:"persona,omitempty"`
+ Brief string `bson:"brief,omitempty"`
+ ProductBrief string `bson:"product_brief,omitempty"`
+ TargetAudience string `bson:"target_audience,omitempty"`
+ Goals string `bson:"goals,omitempty"`
+ StyleProfile string `bson:"style_profile,omitempty"`
+ StyleBenchmark string `bson:"style_benchmark,omitempty"`
+ Status Status `bson:"status"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/model/persona/domain/repository/repository.go b/haixun-backend/internal/model/persona/domain/repository/repository.go
new file mode 100644
index 0000000..962fb06
--- /dev/null
+++ b/haixun-backend/internal/model/persona/domain/repository/repository.go
@@ -0,0 +1,16 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/persona/domain/entity"
+)
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ Create(ctx context.Context, persona *entity.Persona) (*entity.Persona, error)
+ FindByID(ctx context.Context, tenantID, ownerUID, personaID string) (*entity.Persona, error)
+ ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Persona, error)
+ Update(ctx context.Context, tenantID, ownerUID, personaID string, patch map[string]interface{}) (*entity.Persona, error)
+ SoftDelete(ctx context.Context, tenantID, ownerUID, personaID string) error
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/model/persona/domain/usecase/usecase.go b/haixun-backend/internal/model/persona/domain/usecase/usecase.go
new file mode 100644
index 0000000..c6e4ef4
--- /dev/null
+++ b/haixun-backend/internal/model/persona/domain/usecase/usecase.go
@@ -0,0 +1,55 @@
+package usecase
+
+import (
+ "context"
+)
+
+type PersonaSummary struct {
+ ID string
+ DisplayName string
+ Persona string
+ Brief string
+ ProductBrief string
+ TargetAudience string
+ Goals string
+ StyleProfile string
+ StyleBenchmark string
+ CreateAt int64
+ UpdateAt int64
+}
+
+type CreateRequest struct {
+ TenantID string
+ OwnerUID string
+ DisplayName string
+}
+
+type UpdateRequest struct {
+ TenantID string
+ OwnerUID string
+ PersonaID string
+ Patch PersonaPatch
+}
+
+type PersonaPatch struct {
+ DisplayName *string
+ Persona *string
+ Brief *string
+ ProductBrief *string
+ TargetAudience *string
+ Goals *string
+ StyleProfile *string
+ StyleBenchmark *string
+}
+
+type ListResult struct {
+ List []PersonaSummary
+}
+
+type UseCase interface {
+ List(ctx context.Context, tenantID, ownerUID string) (*ListResult, error)
+ Create(ctx context.Context, req CreateRequest) (*PersonaSummary, error)
+ Get(ctx context.Context, tenantID, ownerUID, personaID string) (*PersonaSummary, error)
+ Update(ctx context.Context, req UpdateRequest) (*PersonaSummary, error)
+ Delete(ctx context.Context, tenantID, ownerUID, personaID string) error
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/model/persona/repository/mongo.go b/haixun-backend/internal/model/persona/repository/mongo.go
new file mode 100644
index 0000000..7a3c560
--- /dev/null
+++ b/haixun-backend/internal/model/persona/repository/mongo.go
@@ -0,0 +1,138 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/persona/domain/entity"
+ domrepo "haixun-backend/internal/model/persona/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{collection: db.Collection(entity.CollectionName)}
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "update_at", Value: -1}}},
+ {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "_id", Value: 1}}, Options: options.Index().SetUnique(true)},
+ })
+ return err
+}
+
+func (r *mongoRepository) Create(ctx context.Context, persona *entity.Persona) (*entity.Persona, error) {
+ if r.collection == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ now := clock.NowUnixNano()
+ persona.CreateAt = now
+ persona.UpdateAt = now
+ if persona.Status == "" {
+ persona.Status = entity.StatusOpen
+ }
+ _, err := r.collection.InsertOne(ctx, persona)
+ if err != nil {
+ return nil, err
+ }
+ return persona, nil
+}
+
+func (r *mongoRepository) FindByID(ctx context.Context, tenantID, ownerUID, personaID string) (*entity.Persona, error) {
+ return r.findOne(ctx, bson.M{
+ "_id": strings.TrimSpace(personaID),
+ "tenant_id": tenantID,
+ "owner_uid": ownerUID,
+ "status": entity.StatusOpen,
+ })
+}
+
+func (r *mongoRepository) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Persona, error) {
+ if r.collection == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ cursor, err := r.collection.Find(
+ ctx,
+ bson.M{"tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
+ options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}),
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close(ctx)
+ var items []*entity.Persona
+ if err := cursor.All(ctx, &items); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, personaID string, patch map[string]interface{}) (*entity.Persona, error) {
+ if r.collection == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ if len(patch) == 0 {
+ return r.FindByID(ctx, tenantID, ownerUID, personaID)
+ }
+ patch["update_at"] = clock.NowUnixNano()
+ var out entity.Persona
+ err := r.collection.FindOneAndUpdate(
+ ctx,
+ bson.M{"_id": personaID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
+ bson.M{"$set": patch},
+ options.FindOneAndUpdate().SetReturnDocument(options.After),
+ ).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.Persona).ResNotFound("persona not found")
+ }
+ return &out, err
+}
+
+func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, personaID string) error {
+ if r.collection == nil {
+ return app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ res, err := r.collection.UpdateOne(
+ ctx,
+ bson.M{"_id": personaID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
+ bson.M{"$set": bson.M{"status": entity.StatusDeleted, "update_at": clock.NowUnixNano()}},
+ )
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
+ return app.For(code.Persona).ResNotFound("persona not found")
+ }
+ return nil
+}
+
+func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Persona, error) {
+ if r.collection == nil {
+ return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
+ }
+ var out entity.Persona
+ err := r.collection.FindOne(ctx, filter).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.Persona).ResNotFound("persona not found")
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/model/persona/usecase/usecase.go b/haixun-backend/internal/model/persona/usecase/usecase.go
new file mode 100644
index 0000000..65c7d6a
--- /dev/null
+++ b/haixun-backend/internal/model/persona/usecase/usecase.go
@@ -0,0 +1,172 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/persona/domain/entity"
+ domrepo "haixun-backend/internal/model/persona/domain/repository"
+ domusecase "haixun-backend/internal/model/persona/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type personaUseCase struct {
+ repo domrepo.Repository
+}
+
+func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
+ return &personaUseCase{repo: repo}
+}
+
+func (u *personaUseCase) List(ctx context.Context, tenantID, ownerUID string) (*domusecase.ListResult, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ items, err := u.repo.ListByOwner(ctx, tenantID, ownerUID)
+ if err != nil {
+ return nil, err
+ }
+ list := make([]domusecase.PersonaSummary, 0, len(items))
+ for _, item := range items {
+ list = append(list, toSummary(item))
+ }
+ return &domusecase.ListResult{List: list}, nil
+}
+
+func (u *personaUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.PersonaSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
+ return nil, err
+ }
+ displayName := strings.TrimSpace(req.DisplayName)
+ if displayName == "" {
+ existing, err := u.repo.ListByOwner(ctx, req.TenantID, req.OwnerUID)
+ if err != nil {
+ return nil, err
+ }
+ displayName = "人設 " + itoa(len(existing)+1)
+ }
+ item, err := u.repo.Create(ctx, &entity.Persona{
+ ID: uuid.NewString(),
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ DisplayName: displayName,
+ Status: entity.StatusOpen,
+ })
+ if err != nil {
+ return nil, err
+ }
+ summary := toSummary(item)
+ return &summary, nil
+}
+
+func (u *personaUseCase) Get(ctx context.Context, tenantID, ownerUID, personaID string) (*domusecase.PersonaSummary, error) {
+ item, err := u.assertOwned(ctx, tenantID, ownerUID, personaID)
+ if err != nil {
+ return nil, err
+ }
+ summary := toSummary(item)
+ return &summary, nil
+}
+
+func (u *personaUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID string) error {
+ if _, err := u.assertOwned(ctx, tenantID, ownerUID, personaID); err != nil {
+ return err
+ }
+ return u.repo.SoftDelete(ctx, tenantID, ownerUID, personaID)
+}
+
+func (u *personaUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.PersonaSummary, error) {
+ if _, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
+ return nil, err
+ }
+ patch := patchToMap(req.Patch)
+ item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, patch)
+ if err != nil {
+ return nil, err
+ }
+ summary := toSummary(item)
+ return &summary, nil
+}
+
+func (u *personaUseCase) assertOwned(ctx context.Context, tenantID, ownerUID, personaID string) (*entity.Persona, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(personaID) == "" {
+ return nil, app.For(code.Persona).InputMissingRequired("persona id is required")
+ }
+ return u.repo.FindByID(ctx, tenantID, ownerUID, personaID)
+}
+
+func requireActor(tenantID, ownerUID string) error {
+ if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
+ return app.For(code.Persona).InputMissingRequired("tenant_id and uid are required")
+ }
+ return nil
+}
+
+func toSummary(item *entity.Persona) domusecase.PersonaSummary {
+ if item == nil {
+ return domusecase.PersonaSummary{}
+ }
+ return domusecase.PersonaSummary{
+ ID: item.ID,
+ DisplayName: item.DisplayName,
+ Persona: item.Persona,
+ Brief: item.Brief,
+ ProductBrief: item.ProductBrief,
+ TargetAudience: item.TargetAudience,
+ Goals: item.Goals,
+ StyleProfile: item.StyleProfile,
+ StyleBenchmark: item.StyleBenchmark,
+ CreateAt: item.CreateAt,
+ UpdateAt: item.UpdateAt,
+ }
+}
+
+func patchToMap(patch domusecase.PersonaPatch) map[string]interface{} {
+ out := map[string]interface{}{}
+ if patch.DisplayName != nil {
+ out["display_name"] = strings.TrimSpace(*patch.DisplayName)
+ }
+ if patch.Persona != nil {
+ out["persona"] = strings.TrimSpace(*patch.Persona)
+ }
+ if patch.Brief != nil {
+ out["brief"] = strings.TrimSpace(*patch.Brief)
+ }
+ if patch.ProductBrief != nil {
+ out["product_brief"] = strings.TrimSpace(*patch.ProductBrief)
+ }
+ if patch.TargetAudience != nil {
+ out["target_audience"] = strings.TrimSpace(*patch.TargetAudience)
+ }
+ if patch.Goals != nil {
+ out["goals"] = strings.TrimSpace(*patch.Goals)
+ }
+ if patch.StyleProfile != nil {
+ out["style_profile"] = strings.TrimSpace(*patch.StyleProfile)
+ }
+ if patch.StyleBenchmark != nil {
+ out["style_benchmark"] = strings.TrimSpace(*patch.StyleBenchmark)
+ }
+ return out
+}
+
+func itoa(n int) string {
+ if n <= 0 {
+ return "1"
+ }
+ buf := make([]byte, 0, 12)
+ for n > 0 {
+ buf = append(buf, byte('0'+n%10))
+ n /= 10
+ }
+ for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
+ buf[i], buf[j] = buf[j], buf[i]
+ }
+ return string(buf)
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/model/threads_account/domain/entity/account.go b/haixun-backend/internal/model/threads_account/domain/entity/account.go
new file mode 100644
index 0000000..21a258a
--- /dev/null
+++ b/haixun-backend/internal/model/threads_account/domain/entity/account.go
@@ -0,0 +1,23 @@
+package entity
+
+const CollectionName = "threads_accounts"
+
+type Status string
+
+const (
+ StatusOpen Status = "open"
+ StatusDeleted Status = "deleted"
+)
+
+type Account struct {
+ ID string `bson:"_id"`
+ TenantID string `bson:"tenant_id"`
+ OwnerUID string `bson:"owner_uid"`
+ DisplayName string `bson:"display_name,omitempty"`
+ Username string `bson:"username,omitempty"`
+ ThreadsUserID string `bson:"threads_user_id,omitempty"`
+ PersonaID string `bson:"persona_id,omitempty"`
+ Status Status `bson:"status"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/model/threads_account/domain/entity/secrets.go b/haixun-backend/internal/model/threads_account/domain/entity/secrets.go
new file mode 100644
index 0000000..d412961
--- /dev/null
+++ b/haixun-backend/internal/model/threads_account/domain/entity/secrets.go
@@ -0,0 +1,11 @@
+package entity
+
+const SecretsCollectionName = "threads_account_secrets"
+
+type Secrets struct {
+ AccountID string `bson:"_id"`
+ BrowserStorageState string `bson:"browser_storage_state,omitempty"`
+ APIAccessToken string `bson:"api_access_token,omitempty"`
+ APITokenExpiresAt int64 `bson:"api_token_expires_at,omitempty"`
+ UpdateAt int64 `bson:"update_at"`
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/model/threads_account/domain/repository/repository.go b/haixun-backend/internal/model/threads_account/domain/repository/repository.go
new file mode 100644
index 0000000..3c31e1e
--- /dev/null
+++ b/haixun-backend/internal/model/threads_account/domain/repository/repository.go
@@ -0,0 +1,22 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/model/threads_account/domain/entity"
+)
+
+type Repository interface {
+ EnsureIndexes(ctx context.Context) error
+ Create(ctx context.Context, account *entity.Account) (*entity.Account, error)
+ FindByID(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error)
+ ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Account, error)
+ UpdateShell(ctx context.Context, tenantID, ownerUID, accountID string, displayName, username, personaID *string) (*entity.Account, error)
+ SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error
+}
+
+type SecretsRepository interface {
+ EnsureIndexes(ctx context.Context) error
+ FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error)
+ SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error)
+}
diff --git a/haixun-backend/internal/model/threads_account/domain/usecase/usecase.go b/haixun-backend/internal/model/threads_account/domain/usecase/usecase.go
new file mode 100644
index 0000000..cefd974
--- /dev/null
+++ b/haixun-backend/internal/model/threads_account/domain/usecase/usecase.go
@@ -0,0 +1,139 @@
+package usecase
+
+import (
+ "context"
+)
+
+type AccountSummary struct {
+ ID string
+ DisplayName string
+ Username string
+ ThreadsUserID string
+ PersonaID string
+ BrowserConnected bool
+ ApiConnected bool
+ Status string
+ CreateAt int64
+ UpdateAt int64
+}
+
+type ConnectionPrefs struct {
+ SearchViaApi bool
+ SearchSourceMode string
+ PublishViaApi bool
+ DevMode bool
+ ScrapeReplies bool
+ RepliesPerPost int
+ PublishHeaded bool
+ PlaywrightDebug bool
+}
+
+type ConnectionData struct {
+ AccountID string
+ AccountName string
+ Username string
+ BrowserConnected bool
+ ApiConnected bool
+ Prefs ConnectionPrefs
+}
+
+type CreateRequest struct {
+ TenantID string
+ OwnerUID string
+ DisplayName string
+ Activate bool
+}
+
+type UpdateAccountRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ DisplayName *string
+ PersonaID *string
+}
+
+type UpdateConnectionRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ Prefs ConnectionPrefsPatch
+}
+
+type ImportBrowserSessionRequest struct {
+ TenantID string
+ OwnerUID string
+ AccountID string
+ StorageState string
+}
+
+type ImportBrowserSessionResult struct {
+ AccountID string
+ Username string
+ Synced bool
+ Valid bool
+ Message string
+ UpdateAt int64
+}
+
+type BrowserSessionData struct {
+ AccountID string
+ StorageState string
+ UpdateAt int64
+}
+
+type AiSettings struct {
+ AccountID string
+ Provider string
+ Model string
+ ResearchProvider string
+ ResearchModel string
+ ApiKeys map[string]string
+ ApiKeysConfigured map[string]bool
+}
+
+type AiSettingsPatch struct {
+ Provider *string
+ Model *string
+ ResearchProvider *string
+ ResearchModel *string
+ ApiKeys map[string]string
+}
+
+// WorkerAiCredential carries raw provider settings for internal workers (never expose to clients).
+type WorkerAiCredential struct {
+ Provider string
+ Model string
+ APIKey string
+}
+
+type ConnectionPrefsPatch struct {
+ SearchViaApi *bool
+ SearchSourceMode *string
+ PublishViaApi *bool
+ DevMode *bool
+ ScrapeReplies *bool
+ RepliesPerPost *int
+ PublishHeaded *bool
+ PlaywrightDebug *bool
+}
+
+type ListResult struct {
+ List []AccountSummary
+ ActiveAccountID string
+}
+
+type UseCase interface {
+ List(ctx context.Context, tenantID, ownerUID string) (*ListResult, error)
+ Create(ctx context.Context, req CreateRequest) (*AccountSummary, error)
+ Get(ctx context.Context, tenantID, ownerUID, accountID string) (*AccountSummary, error)
+ Update(ctx context.Context, req UpdateAccountRequest) (*AccountSummary, error)
+ Activate(ctx context.Context, tenantID, ownerUID, accountID string) error
+ GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*ConnectionData, error)
+ UpdateConnection(ctx context.Context, req UpdateConnectionRequest) (*ConnectionData, error)
+ ImportBrowserSession(ctx context.Context, req ImportBrowserSessionRequest) (*ImportBrowserSessionResult, error)
+ GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*BrowserSessionData, error)
+ GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*AiSettings, error)
+ UpdateAiSettings(ctx context.Context, tenantID, ownerUID, accountID string, patch AiSettingsPatch) (*AiSettings, error)
+ ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error)
+ ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
+}
diff --git a/haixun-backend/internal/model/threads_account/repository/mongo.go b/haixun-backend/internal/model/threads_account/repository/mongo.go
new file mode 100644
index 0000000..559db73
--- /dev/null
+++ b/haixun-backend/internal/model/threads_account/repository/mongo.go
@@ -0,0 +1,144 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/threads_account/domain/entity"
+ domrepo "haixun-backend/internal/model/threads_account/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type mongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewMongoRepository(db *mongo.Database) domrepo.Repository {
+ if db == nil {
+ return &mongoRepository{}
+ }
+ return &mongoRepository{collection: db.Collection(entity.CollectionName)}
+}
+
+func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
+ {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "update_at", Value: -1}}},
+ {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "_id", Value: 1}}, Options: options.Index().SetUnique(true)},
+ })
+ return err
+}
+
+func (r *mongoRepository) Create(ctx context.Context, account *entity.Account) (*entity.Account, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ now := clock.NowUnixNano()
+ account.CreateAt = now
+ account.UpdateAt = now
+ if account.Status == "" {
+ account.Status = entity.StatusOpen
+ }
+ _, err := r.collection.InsertOne(ctx, account)
+ if err != nil {
+ return nil, err
+ }
+ return account, nil
+}
+
+func (r *mongoRepository) FindByID(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error) {
+ return r.findOne(ctx, bson.M{
+ "_id": strings.TrimSpace(accountID),
+ "tenant_id": tenantID,
+ "owner_uid": ownerUID,
+ "status": entity.StatusOpen,
+ })
+}
+
+func (r *mongoRepository) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Account, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ cursor, err := r.collection.Find(
+ ctx,
+ bson.M{"tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
+ options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}),
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer cursor.Close(ctx)
+ var items []*entity.Account
+ if err := cursor.All(ctx, &items); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+func (r *mongoRepository) UpdateShell(ctx context.Context, tenantID, ownerUID, accountID string, displayName, username, personaID *string) (*entity.Account, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ set := bson.M{"update_at": clock.NowUnixNano()}
+ if displayName != nil {
+ set["display_name"] = strings.TrimSpace(*displayName)
+ }
+ if username != nil {
+ set["username"] = strings.TrimPrefix(strings.TrimSpace(*username), "@")
+ }
+ if personaID != nil {
+ set["persona_id"] = strings.TrimSpace(*personaID)
+ }
+ var out entity.Account
+ err := r.collection.FindOneAndUpdate(
+ ctx,
+ bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
+ bson.M{"$set": set},
+ options.FindOneAndUpdate().SetReturnDocument(options.After),
+ ).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("threads account not found")
+ }
+ return &out, err
+}
+
+func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error {
+ if r.collection == nil {
+ return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ res, err := r.collection.UpdateOne(
+ ctx,
+ bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
+ bson.M{"$set": bson.M{"status": entity.StatusDeleted, "update_at": clock.NowUnixNano()}},
+ )
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
+ return app.For(code.ThreadsAccount).ResNotFound("threads account not found")
+ }
+ return nil
+}
+
+func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Account, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ var out entity.Account
+ err := r.collection.FindOne(ctx, filter).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("threads account not found")
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/model/threads_account/repository/secrets_mongo.go b/haixun-backend/internal/model/threads_account/repository/secrets_mongo.go
new file mode 100644
index 0000000..3054eef
--- /dev/null
+++ b/haixun-backend/internal/model/threads_account/repository/secrets_mongo.go
@@ -0,0 +1,75 @@
+package repository
+
+import (
+ "context"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ "haixun-backend/internal/model/threads_account/domain/entity"
+ domrepo "haixun-backend/internal/model/threads_account/domain/repository"
+
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type secretsMongoRepository struct {
+ collection *mongo.Collection
+}
+
+func NewSecretsMongoRepository(db *mongo.Database) domrepo.SecretsRepository {
+ if db == nil {
+ return &secretsMongoRepository{}
+ }
+ return &secretsMongoRepository{collection: db.Collection(entity.SecretsCollectionName)}
+}
+
+func (r *secretsMongoRepository) EnsureIndexes(ctx context.Context) error {
+ if r.collection == nil {
+ return nil
+ }
+ _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{
+ Keys: bson.D{{Key: "_id", Value: 1}},
+ })
+ return err
+}
+
+func (r *secretsMongoRepository) FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ var out entity.Secrets
+ err := r.collection.FindOne(ctx, bson.M{"_id": accountID}).Decode(&out)
+ if err == mongo.ErrNoDocuments {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error) {
+ if r.collection == nil {
+ return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
+ }
+ now := clock.NowUnixNano()
+ var out entity.Secrets
+ err := r.collection.FindOneAndUpdate(
+ ctx,
+ bson.M{"_id": accountID},
+ bson.M{
+ "$set": bson.M{
+ "browser_storage_state": storageState,
+ "update_at": now,
+ },
+ "$setOnInsert": bson.M{"_id": accountID},
+ },
+ options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After),
+ ).Decode(&out)
+ if err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
diff --git a/haixun-backend/internal/model/threads_account/usecase/ai_credentials.go b/haixun-backend/internal/model/threads_account/usecase/ai_credentials.go
new file mode 100644
index 0000000..18aa917
--- /dev/null
+++ b/haixun-backend/internal/model/threads_account/usecase/ai_credentials.go
@@ -0,0 +1,266 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+ settingdomain "haixun-backend/internal/model/setting/domain/usecase"
+)
+
+const keyAiCredentials = "ai.credentials"
+
+var knownAiProviders = []string{
+ "opencode-go",
+ "xai",
+ "openai",
+ "anthropic",
+ "google",
+}
+
+func (u *threadsAccountUseCase) GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.AiSettings, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
+ if err != nil {
+ return nil, err
+ }
+ return toPublicAiSettings(account.ID, stored), nil
+}
+
+func (u *threadsAccountUseCase) ResolveMemberAiCredential(
+ ctx context.Context,
+ tenantID, ownerUID string,
+) (*domusecase.WorkerAiCredential, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ stored, err := u.loadAiCredentials(ctx, ownerUID, "")
+ if err != nil {
+ return nil, err
+ }
+ provider := strings.TrimSpace(stored.Provider)
+ model := strings.TrimSpace(stored.Model)
+ apiKey := strings.TrimSpace(stored.ApiKeys[provider])
+ if provider == "" || apiKey == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 AI API key")
+ }
+ return &domusecase.WorkerAiCredential{
+ Provider: provider,
+ Model: model,
+ APIKey: apiKey,
+ }, nil
+}
+
+func (u *threadsAccountUseCase) ResolveWorkerAiCredential(
+ ctx context.Context,
+ tenantID, ownerUID, accountID string,
+) (*domusecase.WorkerAiCredential, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
+ if err != nil {
+ return nil, err
+ }
+ provider := strings.TrimSpace(stored.ResearchProvider)
+ model := strings.TrimSpace(stored.ResearchModel)
+ if provider == "" {
+ provider = strings.TrimSpace(stored.Provider)
+ }
+ if model == "" {
+ model = strings.TrimSpace(stored.Model)
+ }
+ apiKey := strings.TrimSpace(stored.ApiKeys[provider])
+ if apiKey == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("AI API key is not configured for provider " + provider)
+ }
+ return &domusecase.WorkerAiCredential{
+ Provider: provider,
+ Model: model,
+ APIKey: apiKey,
+ }, nil
+}
+
+func (u *threadsAccountUseCase) UpdateAiSettings(
+ ctx context.Context,
+ tenantID, ownerUID, accountID string,
+ patch domusecase.AiSettingsPatch,
+) (*domusecase.AiSettings, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ current, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
+ if err != nil {
+ return nil, err
+ }
+ next := applyAiSettingsPatch(current, patch)
+ if err := u.saveAiCredentials(ctx, ownerUID, next); err != nil {
+ return nil, err
+ }
+ return toPublicAiSettings(account.ID, next), nil
+}
+
+type aiCredentials struct {
+ Provider string
+ Model string
+ ResearchProvider string
+ ResearchModel string
+ ApiKeys map[string]string
+}
+
+func defaultAiCredentials() aiCredentials {
+ return aiCredentials{
+ Provider: "opencode-go",
+ Model: "deepseek-v4-pro",
+ ResearchProvider: "opencode-go",
+ ResearchModel: "deepseek-v4-flash",
+ ApiKeys: map[string]string{},
+ }
+}
+
+func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID, accountID string) (aiCredentials, error) {
+ defaults := defaultAiCredentials()
+ setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyAiCredentials)
+ if err == nil {
+ return mergeAiCredentials(defaults, setting.Value), nil
+ }
+ if !isSettingNotFound(err) {
+ return defaults, err
+ }
+ // Legacy: per-account storage migrates to user scope on first read.
+ legacy, err := u.settings.Get(ctx, settingScopeAccount, accountID, keyAiCredentials)
+ if err != nil {
+ if isSettingNotFound(err) {
+ return defaults, nil
+ }
+ return defaults, err
+ }
+ creds := mergeAiCredentials(defaults, legacy.Value)
+ if saveErr := u.saveAiCredentials(ctx, ownerUID, creds); saveErr != nil {
+ return creds, saveErr
+ }
+ return creds, nil
+}
+
+func (u *threadsAccountUseCase) saveAiCredentials(ctx context.Context, ownerUID string, creds aiCredentials) error {
+ _, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
+ Scope: settingScopeUser,
+ ScopeID: ownerUID,
+ Key: keyAiCredentials,
+ Value: aiCredentialsToMap(creds),
+ })
+ return err
+}
+
+func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) aiCredentials {
+ if value == nil {
+ return defaults
+ }
+ if v, ok := value["provider"].(string); ok && strings.TrimSpace(v) != "" {
+ defaults.Provider = v
+ }
+ if v, ok := value["model"].(string); ok && strings.TrimSpace(v) != "" {
+ defaults.Model = v
+ }
+ if v, ok := value["research_provider"].(string); ok && strings.TrimSpace(v) != "" {
+ defaults.ResearchProvider = v
+ }
+ if v, ok := value["research_model"].(string); ok && strings.TrimSpace(v) != "" {
+ defaults.ResearchModel = v
+ }
+ if raw, ok := value["api_keys"].(map[string]interface{}); ok {
+ keys := map[string]string{}
+ for provider, item := range raw {
+ if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
+ keys[provider] = strings.TrimSpace(s)
+ }
+ }
+ defaults.ApiKeys = keys
+ }
+ return defaults
+}
+
+func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatch) aiCredentials {
+ if patch.Provider != nil && strings.TrimSpace(*patch.Provider) != "" {
+ current.Provider = strings.TrimSpace(*patch.Provider)
+ }
+ if patch.Model != nil && strings.TrimSpace(*patch.Model) != "" {
+ current.Model = strings.TrimSpace(*patch.Model)
+ }
+ if patch.ResearchProvider != nil && strings.TrimSpace(*patch.ResearchProvider) != "" {
+ current.ResearchProvider = strings.TrimSpace(*patch.ResearchProvider)
+ }
+ if patch.ResearchModel != nil && strings.TrimSpace(*patch.ResearchModel) != "" {
+ current.ResearchModel = strings.TrimSpace(*patch.ResearchModel)
+ }
+ if len(patch.ApiKeys) > 0 {
+ if current.ApiKeys == nil {
+ current.ApiKeys = map[string]string{}
+ }
+ for provider, value := range patch.ApiKeys {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" || isMaskedAPIKey(trimmed) {
+ continue
+ }
+ current.ApiKeys[provider] = trimmed
+ }
+ }
+ return current
+}
+
+func aiCredentialsToMap(creds aiCredentials) map[string]interface{} {
+ keys := map[string]interface{}{}
+ for provider, value := range creds.ApiKeys {
+ keys[provider] = value
+ }
+ return map[string]interface{}{
+ "provider": creds.Provider,
+ "model": creds.Model,
+ "research_provider": creds.ResearchProvider,
+ "research_model": creds.ResearchModel,
+ "api_keys": keys,
+ }
+}
+
+func toPublicAiSettings(accountID string, creds aiCredentials) *domusecase.AiSettings {
+ masked := map[string]string{}
+ configured := map[string]bool{}
+ for _, provider := range knownAiProviders {
+ raw := strings.TrimSpace(creds.ApiKeys[provider])
+ configured[provider] = raw != ""
+ if maskedValue := maskAPIKey(raw); maskedValue != "" {
+ masked[provider] = maskedValue
+ }
+ }
+ return &domusecase.AiSettings{
+ AccountID: accountID,
+ Provider: creds.Provider,
+ Model: creds.Model,
+ ResearchProvider: creds.ResearchProvider,
+ ResearchModel: creds.ResearchModel,
+ ApiKeys: masked,
+ ApiKeysConfigured: configured,
+ }
+}
+
+func maskAPIKey(key string) string {
+ trimmed := strings.TrimSpace(key)
+ if trimmed == "" {
+ return ""
+ }
+ if len(trimmed) <= 4 {
+ return "••••"
+ }
+ return "••••" + trimmed[len(trimmed)-4:]
+}
+
+func isMaskedAPIKey(value string) bool {
+ return strings.HasPrefix(value, "••••")
+}
\ No newline at end of file
diff --git a/haixun-backend/internal/model/threads_account/usecase/usecase.go b/haixun-backend/internal/model/threads_account/usecase/usecase.go
new file mode 100644
index 0000000..da8d4bf
--- /dev/null
+++ b/haixun-backend/internal/model/threads_account/usecase/usecase.go
@@ -0,0 +1,485 @@
+package usecase
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+
+ "haixun-backend/internal/library/clock"
+ app "haixun-backend/internal/library/errors"
+ "haixun-backend/internal/library/errors/code"
+ memberdomain "haixun-backend/internal/model/member/domain/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ settingdomain "haixun-backend/internal/model/setting/domain/usecase"
+ "haixun-backend/internal/model/threads_account/domain/entity"
+ domrepo "haixun-backend/internal/model/threads_account/domain/repository"
+ domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+const (
+ settingScopeUser = "user"
+ settingScopeAccount = "account"
+ keyConnectionPrefs = "connection.prefs"
+ defaultSearchSourceMode = "mixed"
+ defaultRepliesPerPost = 10
+)
+
+type threadsAccountUseCase struct {
+ repo domrepo.Repository
+ secretsRepo domrepo.SecretsRepository
+ members memberdomain.UseCase
+ settings settingdomain.UseCase
+ personas personadomain.UseCase
+}
+
+func NewUseCase(
+ repo domrepo.Repository,
+ secretsRepo domrepo.SecretsRepository,
+ members memberdomain.UseCase,
+ settings settingdomain.UseCase,
+ personas personadomain.UseCase,
+) domusecase.UseCase {
+ return &threadsAccountUseCase{
+ repo: repo,
+ secretsRepo: secretsRepo,
+ members: members,
+ settings: settings,
+ personas: personas,
+ }
+}
+
+func (u *threadsAccountUseCase) List(ctx context.Context, tenantID, ownerUID string) (*domusecase.ListResult, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ items, err := u.repo.ListByOwner(ctx, tenantID, ownerUID)
+ if err != nil {
+ return nil, err
+ }
+ member, err := u.members.GetByUID(ctx, tenantID, ownerUID)
+ if err != nil {
+ return nil, err
+ }
+ activeID := member.ActiveThreadsAccountID
+ if activeID == "" && len(items) > 0 {
+ activeID = items[0].ID
+ }
+ list := make([]domusecase.AccountSummary, 0, len(items))
+ for _, item := range items {
+ summary, err := u.toSummary(ctx, item)
+ if err != nil {
+ return nil, err
+ }
+ list = append(list, *summary)
+ }
+ return &domusecase.ListResult{List: list, ActiveAccountID: activeID}, nil
+}
+
+func (u *threadsAccountUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.AccountSummary, error) {
+ if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
+ return nil, err
+ }
+ displayName := strings.TrimSpace(req.DisplayName)
+ if displayName == "" {
+ existing, err := u.repo.ListByOwner(ctx, req.TenantID, req.OwnerUID)
+ if err != nil {
+ return nil, err
+ }
+ displayName = "帳號 " + itoa(len(existing)+1)
+ }
+ account, err := u.repo.Create(ctx, &entity.Account{
+ ID: uuid.NewString(),
+ TenantID: req.TenantID,
+ OwnerUID: req.OwnerUID,
+ DisplayName: displayName,
+ Status: entity.StatusOpen,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if req.Activate {
+ if err := u.members.SetActiveThreadsAccountID(ctx, req.TenantID, req.OwnerUID, account.ID); err != nil {
+ return nil, err
+ }
+ }
+ return u.toSummary(ctx, account)
+}
+
+func (u *threadsAccountUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.AccountSummary, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ return u.toSummary(ctx, account)
+}
+
+func (u *threadsAccountUseCase) Update(ctx context.Context, req domusecase.UpdateAccountRequest) (*domusecase.AccountSummary, error) {
+ if _, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil {
+ return nil, err
+ }
+ var personaID *string
+ if req.PersonaID != nil {
+ trimmed := strings.TrimSpace(*req.PersonaID)
+ if trimmed == "" {
+ empty := ""
+ personaID = &empty
+ } else {
+ if _, err := u.personas.Get(ctx, req.TenantID, req.OwnerUID, trimmed); err != nil {
+ return nil, err
+ }
+ personaID = &trimmed
+ }
+ }
+ account, err := u.repo.UpdateShell(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.DisplayName, nil, personaID)
+ if err != nil {
+ return nil, err
+ }
+ return u.toSummary(ctx, account)
+}
+
+func (u *threadsAccountUseCase) Activate(ctx context.Context, tenantID, ownerUID, accountID string) error {
+ if _, err := u.assertOwned(ctx, tenantID, ownerUID, accountID); err != nil {
+ return err
+ }
+ return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, accountID)
+}
+
+func (u *threadsAccountUseCase) GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.ConnectionData, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ prefs, err := u.loadConnectionPrefs(ctx, accountID)
+ if err != nil {
+ return nil, err
+ }
+ browserConnected, apiConnected, err := u.connectionFlags(ctx, accountID)
+ if err != nil {
+ return nil, err
+ }
+ return &domusecase.ConnectionData{
+ AccountID: account.ID,
+ AccountName: accountLabel(account),
+ Username: account.Username,
+ BrowserConnected: browserConnected,
+ ApiConnected: apiConnected,
+ Prefs: prefs,
+ }, nil
+}
+
+func (u *threadsAccountUseCase) UpdateConnection(ctx context.Context, req domusecase.UpdateConnectionRequest) (*domusecase.ConnectionData, error) {
+ account, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.AccountID)
+ if err != nil {
+ return nil, err
+ }
+ current, err := u.loadConnectionPrefs(ctx, req.AccountID)
+ if err != nil {
+ return nil, err
+ }
+ next := applyConnectionPatch(current, req.Prefs)
+ if err := u.saveConnectionPrefs(ctx, req.AccountID, next); err != nil {
+ return nil, err
+ }
+ browserConnected, apiConnected, err := u.connectionFlags(ctx, req.AccountID)
+ if err != nil {
+ return nil, err
+ }
+ return &domusecase.ConnectionData{
+ AccountID: account.ID,
+ AccountName: accountLabel(account),
+ Username: account.Username,
+ BrowserConnected: browserConnected,
+ ApiConnected: apiConnected,
+ Prefs: next,
+ }, nil
+}
+
+func (u *threadsAccountUseCase) ImportBrowserSession(ctx context.Context, req domusecase.ImportBrowserSessionRequest) (*domusecase.ImportBrowserSessionResult, error) {
+ account, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.AccountID)
+ if err != nil {
+ return nil, err
+ }
+ normalized, err := normalizeStorageState(req.StorageState)
+ if err != nil {
+ return nil, err
+ }
+ secrets, err := u.secretsRepo.SaveBrowserStorageState(ctx, account.ID, normalized)
+ if err != nil {
+ return nil, err
+ }
+ message := "Chrome session 已同步到開發模式爬蟲"
+ if account.Username != "" {
+ message = "Chrome session 已同步:@" + account.Username
+ }
+ return &domusecase.ImportBrowserSessionResult{
+ AccountID: account.ID,
+ Username: account.Username,
+ Synced: true,
+ Valid: true,
+ Message: message,
+ UpdateAt: secrets.UpdateAt,
+ }, nil
+}
+
+func (u *threadsAccountUseCase) GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.BrowserSessionData, error) {
+ account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
+ if err != nil {
+ return nil, err
+ }
+ secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID)
+ if err != nil {
+ return nil, err
+ }
+ if secrets == nil || strings.TrimSpace(secrets.BrowserStorageState) == "" {
+ return nil, app.For(code.ThreadsAccount).ResNotFound("browser session not synced")
+ }
+ return &domusecase.BrowserSessionData{
+ AccountID: account.ID,
+ StorageState: secrets.BrowserStorageState,
+ UpdateAt: secrets.UpdateAt,
+ }, nil
+}
+
+func (u *threadsAccountUseCase) assertOwned(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error) {
+ if err := requireActor(tenantID, ownerUID); err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(accountID) == "" {
+ return nil, app.For(code.ThreadsAccount).InputMissingRequired("account id is required")
+ }
+ return u.repo.FindByID(ctx, tenantID, ownerUID, accountID)
+}
+
+func (u *threadsAccountUseCase) toSummary(ctx context.Context, account *entity.Account) (*domusecase.AccountSummary, error) {
+ browserConnected, apiConnected, err := u.connectionFlags(ctx, account.ID)
+ if err != nil {
+ return nil, err
+ }
+ return &domusecase.AccountSummary{
+ ID: account.ID,
+ DisplayName: account.DisplayName,
+ Username: account.Username,
+ ThreadsUserID: account.ThreadsUserID,
+ PersonaID: account.PersonaID,
+ BrowserConnected: browserConnected,
+ ApiConnected: apiConnected,
+ Status: string(account.Status),
+ CreateAt: account.CreateAt,
+ UpdateAt: account.UpdateAt,
+ }, nil
+}
+
+func (u *threadsAccountUseCase) connectionFlags(ctx context.Context, accountID string) (bool, bool, error) {
+ secrets, err := u.secretsRepo.FindByAccountID(ctx, accountID)
+ if err != nil {
+ return false, false, err
+ }
+ if secrets == nil {
+ return false, false, nil
+ }
+ browserConnected := strings.TrimSpace(secrets.BrowserStorageState) != ""
+ apiConnected := strings.TrimSpace(secrets.APIAccessToken) != "" &&
+ (secrets.APITokenExpiresAt == 0 || secrets.APITokenExpiresAt > clock.NowUnixNano())
+ return browserConnected, apiConnected, nil
+}
+
+func (u *threadsAccountUseCase) loadConnectionPrefs(ctx context.Context, accountID string) (domusecase.ConnectionPrefs, error) {
+ defaults := defaultConnectionPrefs()
+ setting, err := u.settings.Get(ctx, settingScopeAccount, accountID, keyConnectionPrefs)
+ if err != nil {
+ if isSettingNotFound(err) {
+ return defaults, nil
+ }
+ return defaults, err
+ }
+ merged := mergeConnectionPrefs(defaults, setting.Value)
+ return deriveConnectionPrefsFromDevMode(merged.DevMode), nil
+}
+
+func (u *threadsAccountUseCase) saveConnectionPrefs(ctx context.Context, accountID string, prefs domusecase.ConnectionPrefs) error {
+ _, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
+ Scope: settingScopeAccount,
+ ScopeID: accountID,
+ Key: keyConnectionPrefs,
+ Value: connectionPrefsToMap(prefs),
+ })
+ return err
+}
+
+func requireActor(tenantID, ownerUID string) error {
+ if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
+ return app.For(code.ThreadsAccount).InputMissingRequired("tenant_id and uid are required")
+ }
+ return nil
+}
+
+func accountLabel(account *entity.Account) string {
+ if account == nil {
+ return "未命名帳號"
+ }
+ if name := strings.TrimSpace(account.DisplayName); name != "" {
+ return name
+ }
+ if name := strings.TrimSpace(account.Username); name != "" {
+ return "@" + strings.TrimPrefix(name, "@")
+ }
+ return "未命名帳號"
+}
+
+func defaultConnectionPrefs() domusecase.ConnectionPrefs {
+ return deriveConnectionPrefsFromDevMode(false)
+}
+
+// deriveConnectionPrefsFromDevMode maps the single dev_mode switch to concrete routing prefs.
+// dev_mode off → everything via Threads API; dev_mode on → everything via browser crawler.
+func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs {
+ if devMode {
+ return domusecase.ConnectionPrefs{
+ DevMode: true,
+ SearchViaApi: false,
+ SearchSourceMode: "browser",
+ PublishViaApi: false,
+ ScrapeReplies: true,
+ RepliesPerPost: defaultRepliesPerPost,
+ PublishHeaded: false,
+ PlaywrightDebug: false,
+ }
+ }
+ return domusecase.ConnectionPrefs{
+ DevMode: false,
+ SearchViaApi: true,
+ SearchSourceMode: "api",
+ PublishViaApi: true,
+ ScrapeReplies: false,
+ RepliesPerPost: defaultRepliesPerPost,
+ PublishHeaded: false,
+ PlaywrightDebug: false,
+ }
+}
+
+func applyConnectionPatch(_ domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs {
+ if patch.DevMode != nil {
+ return deriveConnectionPrefsFromDevMode(*patch.DevMode)
+ }
+ // Legacy callers may still send granular fields; normalize by current dev flag.
+ devMode := false
+ if patch.SearchViaApi != nil && !*patch.SearchViaApi {
+ devMode = true
+ }
+ if patch.PublishViaApi != nil && !*patch.PublishViaApi {
+ devMode = true
+ }
+ if patch.ScrapeReplies != nil && *patch.ScrapeReplies {
+ devMode = true
+ }
+ return deriveConnectionPrefsFromDevMode(devMode)
+}
+
+func mergeConnectionPrefs(defaults domusecase.ConnectionPrefs, value map[string]interface{}) domusecase.ConnectionPrefs {
+ if value == nil {
+ return defaults
+ }
+ if v, ok := value["search_via_api"].(bool); ok {
+ defaults.SearchViaApi = v
+ }
+ if v, ok := value["search_source_mode"].(string); ok && strings.TrimSpace(v) != "" {
+ defaults.SearchSourceMode = v
+ }
+ if v, ok := value["publish_via_api"].(bool); ok {
+ defaults.PublishViaApi = v
+ }
+ if v, ok := value["dev_mode"].(bool); ok {
+ defaults.DevMode = v
+ }
+ if v, ok := value["scrape_replies"].(bool); ok {
+ defaults.ScrapeReplies = v
+ }
+ if v, ok := asInt(value["replies_per_post"]); ok {
+ defaults.RepliesPerPost = v
+ }
+ if v, ok := value["publish_headed"].(bool); ok {
+ defaults.PublishHeaded = v
+ }
+ if v, ok := value["playwright_debug"].(bool); ok {
+ defaults.PlaywrightDebug = v
+ }
+ return defaults
+}
+
+func connectionPrefsToMap(prefs domusecase.ConnectionPrefs) map[string]interface{} {
+ return map[string]interface{}{
+ "search_via_api": prefs.SearchViaApi,
+ "search_source_mode": prefs.SearchSourceMode,
+ "publish_via_api": prefs.PublishViaApi,
+ "dev_mode": prefs.DevMode,
+ "scrape_replies": prefs.ScrapeReplies,
+ "replies_per_post": prefs.RepliesPerPost,
+ "publish_headed": prefs.PublishHeaded,
+ "playwright_debug": prefs.PlaywrightDebug,
+ }
+}
+
+type playwrightStorageState struct {
+ Cookies []interface{} `json:"cookies"`
+ Origins []interface{} `json:"origins,omitempty"`
+}
+
+func normalizeStorageState(input string) (string, error) {
+ trimmed := strings.TrimSpace(input)
+ if trimmed == "" {
+ return "", app.For(code.ThreadsAccount).InputMissingRequired("storageState is required")
+ }
+ var parsed playwrightStorageState
+ if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
+ return "", app.For(code.ThreadsAccount).InputInvalidFormat("storageState is not valid JSON")
+ }
+ if parsed.Cookies == nil {
+ return "", app.For(code.ThreadsAccount).InputInvalidFormat("storageState must include cookies array")
+ }
+ raw, err := json.Marshal(parsed)
+ if err != nil {
+ return "", err
+ }
+ return string(raw), nil
+}
+
+func isSettingNotFound(err error) bool {
+ var appErr *app.Error
+ if errors.As(err, &appErr) {
+ return appErr.Category() == code.ResNotFound
+ }
+ return false
+}
+
+func asInt(v interface{}) (int, bool) {
+ switch n := v.(type) {
+ case int:
+ return n, true
+ case int32:
+ return int(n), true
+ case int64:
+ return int(n), true
+ case float64:
+ return int(n), true
+ default:
+ return 0, false
+ }
+}
+
+func itoa(n int) string {
+ if n <= 0 {
+ return "1"
+ }
+ buf := make([]byte, 0, 12)
+ for n > 0 {
+ buf = append(buf, byte('0'+n%10))
+ n /= 10
+ }
+ for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
+ buf[i], buf[j] = buf[j], buf[i]
+ }
+ return string(buf)
+}
diff --git a/haixun-backend/internal/svc/service_context.go b/haixun-backend/internal/svc/service_context.go
index df0a7c7..2a9ad44 100644
--- a/haixun-backend/internal/svc/service_context.go
+++ b/haixun-backend/internal/svc/service_context.go
@@ -26,6 +26,13 @@ import (
permissionuc "haixun-backend/internal/model/permission/usecase"
settingrepo "haixun-backend/internal/model/setting/repository"
settingusecase "haixun-backend/internal/model/setting/usecase"
+ personadomain "haixun-backend/internal/model/persona/domain/usecase"
+ personarepo "haixun-backend/internal/model/persona/repository"
+ personausecase "haixun-backend/internal/model/persona/usecase"
+
+ threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
+ threadsaccountrepo "haixun-backend/internal/model/threads_account/repository"
+ threadsaccountusecase "haixun-backend/internal/model/threads_account/usecase"
jobworker "haixun-backend/internal/worker/job"
goredis "github.com/redis/go-redis/v9"
@@ -43,7 +50,9 @@ type ServiceContext struct {
Job jobusecase.UseCase
AuthToken authdomain.TokenUseCase
Member memberdomain.UseCase
- Permission permissiondomain.UseCase
+ Permission permissiondomain.UseCase
+ Persona personadomain.UseCase
+ ThreadsAccount threadsaccountdomain.UseCase
// Middlewares mounted per route group via generate/api `middleware:` directive.
AuthJWT rest.Middleware
@@ -115,6 +124,30 @@ func NewServiceContext(c config.Config) *ServiceContext {
if err := jobUseCase.EnsureDemoTemplate(ctx); err != nil {
panic(err)
}
+ if err := jobUseCase.EnsureStyle8DTemplate(ctx); err != nil {
+ panic(err)
+ }
+
+ personaRepository := personarepo.NewMongoRepository(mongoClient.Database())
+ if err := personaRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ personaUseCase := personausecase.NewUseCase(personaRepository)
+ threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database())
+ threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database())
+ if err := threadsAccountRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ if err := threadsAccountSecretsRepository.EnsureIndexes(ctx); err != nil {
+ panic(err)
+ }
+ threadsAccountUseCase := threadsaccountusecase.NewUseCase(
+ threadsAccountRepository,
+ threadsAccountSecretsRepository,
+ memberUseCase,
+ settingUseCase,
+ personaUseCase,
+ )
sc := &ServiceContext{
Config: c,
@@ -126,7 +159,9 @@ func NewServiceContext(c config.Config) *ServiceContext {
Job: jobUseCase,
AuthToken: authTokenUseCase,
Member: memberUseCase,
- Permission: permissionUseCase,
+ Permission: permissionUseCase,
+ Persona: personaUseCase,
+ ThreadsAccount: threadsAccountUseCase,
}
hostname, _ := os.Hostname()
diff --git a/haixun-backend/internal/types/types.go b/haixun-backend/internal/types/types.go
index 90da4fc..c8c6690 100644
--- a/haixun-backend/internal/types/types.go
+++ b/haixun-backend/internal/types/types.go
@@ -17,6 +17,11 @@ type AIChatReq struct {
MaxTokens *int `json:"max_tokens,optional"` // 最大輸出 token
}
+type IslanderChatReq struct {
+ Messages []AIMessage `json:"messages" validate:"required,min=1,dive"` // 對話訊息
+ Context string `json:"context,optional"` // 目前頁面與站內導覽快照
+}
+
type AIMessage struct {
Role string `json:"role" validate:"required,oneof=system user assistant"` // 訊息角色
Content string `json:"content" validate:"required"` // 訊息內容
@@ -374,6 +379,178 @@ type UpdateMemberMeReq struct {
Phone string `json:"phone,optional"`
}
+type PersonaData struct {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name,omitempty"`
+ Persona string `json:"persona,omitempty"`
+ Brief string `json:"brief,omitempty"`
+ ProductBrief string `json:"product_brief,omitempty"`
+ TargetAudience string `json:"target_audience,omitempty"`
+ Goals string `json:"goals,omitempty"`
+ StyleProfile string `json:"style_profile,omitempty"`
+ StyleBenchmark string `json:"style_benchmark,omitempty"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type ListPersonasData struct {
+ List []PersonaData `json:"list"`
+}
+
+type CreatePersonaReq struct {
+ DisplayName string `json:"display_name,optional"`
+}
+
+type PersonaPath struct {
+ ID string `path:"id" validate:"required"`
+}
+
+type UpdatePersonaReq struct {
+ DisplayName *string `json:"display_name,optional"`
+ Persona *string `json:"persona,optional"`
+ Brief *string `json:"brief,optional"`
+ ProductBrief *string `json:"product_brief,optional"`
+ TargetAudience *string `json:"target_audience,optional"`
+ Goals *string `json:"goals,optional"`
+ StyleProfile *string `json:"style_profile,optional"`
+ StyleBenchmark *string `json:"style_benchmark,optional"`
+}
+
+type StartPersonaStyleAnalysisReq struct {
+ BenchmarkUsername string `json:"benchmark_username" validate:"required"`
+}
+
+type StartPersonaStyleAnalysisData struct {
+ JobID string `json:"job_id"`
+ Status string `json:"status"`
+ Message string `json:"message"`
+}
+
+type UpdatePersonaHandlerReq struct {
+ PersonaPath
+ UpdatePersonaReq
+}
+
+type StartPersonaStyleAnalysisHandlerReq struct {
+ PersonaPath
+ StartPersonaStyleAnalysisReq
+}
+
+type ThreadsAccountData struct {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name,omitempty"`
+ Username string `json:"username,omitempty"`
+ ThreadsUserID string `json:"threads_user_id,omitempty"`
+ PersonaID string `json:"persona_id,omitempty"`
+ BrowserConnected bool `json:"browser_connected"`
+ ApiConnected bool `json:"api_connected"`
+ Status string `json:"status"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type ListThreadsAccountsData struct {
+ List []ThreadsAccountData `json:"list"`
+ ActiveAccountID string `json:"active_account_id"`
+}
+
+type CreateThreadsAccountReq struct {
+ DisplayName string `json:"display_name,optional"`
+ Activate *bool `json:"activate,optional"`
+}
+
+type UpdateThreadsAccountReq struct {
+ DisplayName *string `json:"display_name,optional"`
+ PersonaID *string `json:"persona_id,optional"`
+}
+
+type ThreadsAccountPath struct {
+ ID string `path:"id" validate:"required"`
+}
+
+type ThreadsAccountConnectionPrefs struct {
+ SearchViaApi bool `json:"search_via_api"`
+ SearchSourceMode string `json:"search_source_mode"`
+ PublishViaApi bool `json:"publish_via_api"`
+ DevMode bool `json:"dev_mode"`
+ ScrapeReplies bool `json:"scrape_replies"`
+ RepliesPerPost int `json:"replies_per_post"`
+ PublishHeaded bool `json:"publish_headed"`
+ PlaywrightDebug bool `json:"playwright_debug"`
+}
+
+type ThreadsAccountConnectionData struct {
+ AccountID string `json:"account_id"`
+ AccountName string `json:"account_name"`
+ Username string `json:"username,omitempty"`
+ BrowserConnected bool `json:"browser_connected"`
+ ApiConnected bool `json:"api_connected"`
+ Prefs ThreadsAccountConnectionPrefs `json:"prefs"`
+}
+
+type UpdateThreadsAccountConnectionReq struct {
+ SearchViaApi *bool `json:"search_via_api,optional"`
+ SearchSourceMode *string `json:"search_source_mode,optional"`
+ PublishViaApi *bool `json:"publish_via_api,optional"`
+ DevMode *bool `json:"dev_mode,optional"`
+ ScrapeReplies *bool `json:"scrape_replies,optional"`
+ RepliesPerPost *int `json:"replies_per_post,optional"`
+ PublishHeaded *bool `json:"publish_headed,optional"`
+ PlaywrightDebug *bool `json:"playwright_debug,optional"`
+}
+
+type ImportThreadsAccountSessionReq struct {
+ StorageState string `json:"storageState" validate:"required"`
+}
+
+type ImportThreadsAccountSessionData struct {
+ Success bool `json:"success"`
+ Valid bool `json:"valid"`
+ Synced bool `json:"synced"`
+ AccountID string `json:"account_id"`
+ Username string `json:"username,omitempty"`
+ Message string `json:"message"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type UpdateThreadsAccountConnectionHandlerReq struct {
+ ThreadsAccountPath
+ UpdateThreadsAccountConnectionReq
+}
+
+type ImportThreadsAccountSessionHandlerReq struct {
+ ThreadsAccountPath
+ ImportThreadsAccountSessionReq
+}
+
+type UpdateThreadsAccountHandlerReq struct {
+ ThreadsAccountPath
+ UpdateThreadsAccountReq
+}
+
+type ThreadsAccountAiSettingsData struct {
+ AccountID string `json:"account_id"`
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ ResearchProvider string `json:"research_provider,omitempty"`
+ ResearchModel string `json:"research_model,omitempty"`
+ ApiKeys map[string]string `json:"api_keys"`
+ ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
+}
+
+type UpdateThreadsAccountAiSettingsReq struct {
+ Provider *string `json:"provider,optional"`
+ Model *string `json:"model,optional"`
+ ResearchProvider *string `json:"research_provider,optional"`
+ ResearchModel *string `json:"research_model,optional"`
+ ApiKeys map[string]string `json:"api_keys,optional"`
+}
+
+type UpdateThreadsAccountAiSettingsHandlerReq struct {
+ ThreadsAccountPath
+ UpdateThreadsAccountAiSettingsReq
+}
+
type UpsertJobTemplateReq struct {
Type string `path:"type" validate:"required"` // template type
Version int `json:"version,optional"` // template version
diff --git a/haixun-backend/scripts/dev-with-style-8d.sh b/haixun-backend/scripts/dev-with-style-8d.sh
new file mode 100644
index 0000000..94997b6
--- /dev/null
+++ b/haixun-backend/scripts/dev-with-style-8d.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+BACKEND_DIR="$ROOT_DIR/haixun-backend"
+CONFIG_FILE="${HAIXUN_BACKEND_CONFIG:-etc/gateway.yaml}"
+BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}"
+
+pids=()
+owned_pids=()
+
+cleanup() {
+ local code=$?
+ if ((${#owned_pids[@]} > 0)); then
+ echo ""
+ echo "[dev-8d] stopping backend and worker..."
+ kill "${owned_pids[@]}" 2>/dev/null || true
+ wait "${owned_pids[@]}" 2>/dev/null || true
+ fi
+ exit "$code"
+}
+
+trap cleanup EXIT INT TERM
+
+if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
+ echo "[dev-8d] backend already running: $BACKEND_URL"
+else
+ echo "[dev-8d] starting Go backend: $CONFIG_FILE"
+ (
+ cd "$BACKEND_DIR"
+ go run ./gateway.go -f "$CONFIG_FILE"
+ ) &
+ pids+=("$!")
+ owned_pids+=("$!")
+
+ echo "[dev-8d] waiting for backend health..."
+ for _ in $(seq 1 30); do
+ if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
+ break
+ fi
+ if ! kill -0 "${pids[0]}" 2>/dev/null; then
+ wait "${pids[0]}"
+ fi
+ sleep 1
+ done
+
+ if ! curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
+ echo "[dev-8d] backend health check timed out: $BACKEND_URL" >&2
+ exit 1
+ fi
+fi
+
+echo "[dev-8d] starting Node 8D worker"
+(
+ cd "$ROOT_DIR"
+ npm run worker:style-8d
+) &
+pids+=("$!")
+owned_pids+=("$!")
+
+echo "[dev-8d] running pids=${pids[*]}"
+echo "[dev-8d] press Ctrl+C to stop both"
+
+while true; do
+ for pid in "${owned_pids[@]}"; do
+ if ! kill -0 "$pid" 2>/dev/null; then
+ wait "$pid"
+ exit $?
+ fi
+ done
+ sleep 1
+done
diff --git a/haixun-backend/scripts/package-extension.sh b/haixun-backend/scripts/package-extension.sh
new file mode 100755
index 0000000..47f378c
--- /dev/null
+++ b/haixun-backend/scripts/package-extension.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+EXT_DIR="$ROOT_DIR/extension/haixun-threads-sync"
+OUT_DIR="$ROOT_DIR/haixun-backend/web/public/downloads"
+OUT_FILE="$OUT_DIR/haixun-threads-sync.zip"
+
+if [[ ! -f "$EXT_DIR/manifest.json" ]]; then
+ echo "extension not found: $EXT_DIR" >&2
+ exit 1
+fi
+
+mkdir -p "$OUT_DIR"
+rm -f "$OUT_FILE"
+
+(
+ cd "$(dirname "$EXT_DIR")"
+ zip -qr "$OUT_FILE" "$(basename "$EXT_DIR")" \
+ -x "*.DS_Store" -x "*__MACOSX*"
+)
+
+VERSION="$(python3 -c "import json; print(json.load(open('$EXT_DIR/manifest.json'))['version'])")"
+echo "packed haixun-threads-sync v$VERSION -> $OUT_FILE"
\ No newline at end of file
diff --git a/haixun-backend/scripts/restart-all.sh b/haixun-backend/scripts/restart-all.sh
new file mode 100755
index 0000000..f8c1d04
--- /dev/null
+++ b/haixun-backend/scripts/restart-all.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+bash "$BACKEND_DIR/scripts/stop-all.sh"
+bash "$BACKEND_DIR/scripts/start-all.sh"
\ No newline at end of file
diff --git a/haixun-backend/scripts/start-all.sh b/haixun-backend/scripts/start-all.sh
new file mode 100755
index 0000000..98b9dba
--- /dev/null
+++ b/haixun-backend/scripts/start-all.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+BACKEND_DIR="$ROOT_DIR/haixun-backend"
+RUN_DIR="$BACKEND_DIR/.run"
+LOG_DIR="$RUN_DIR/logs"
+COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml"
+CONFIG_FILE="${HAIXUN_BACKEND_CONFIG:-etc/gateway.yaml}"
+BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}"
+WEB_URL="${HAIXUN_WEB_URL:-http://127.0.0.1:5173}"
+
+mkdir -p "$RUN_DIR" "$LOG_DIR"
+
+bash "$BACKEND_DIR/scripts/stop-all.sh"
+
+if ! command -v docker >/dev/null 2>&1; then
+ echo "[start-all] docker not found; skip mongo/redis" >&2
+else
+ echo "[start-all] starting mongo + redis..."
+ docker compose -f "$COMPOSE_FILE" up -d
+fi
+
+echo "[start-all] starting Go API ($CONFIG_FILE)..."
+(
+ cd "$BACKEND_DIR"
+ go run ./gateway.go -f "$CONFIG_FILE"
+) >"$LOG_DIR/api.log" 2>&1 &
+echo $! >"$RUN_DIR/api.pid"
+
+echo "[start-all] waiting for API health ($BACKEND_URL)..."
+for _ in $(seq 1 40); do
+ if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
+ break
+ fi
+ if ! kill -0 "$(cat "$RUN_DIR/api.pid")" 2>/dev/null; then
+ echo "[start-all] API exited early; see $LOG_DIR/api.log" >&2
+ tail -n 20 "$LOG_DIR/api.log" >&2 || true
+ exit 1
+ fi
+ sleep 1
+done
+if ! curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
+ echo "[start-all] API health check timed out; see $LOG_DIR/api.log" >&2
+ exit 1
+fi
+
+if [[ ! -d "$BACKEND_DIR/web/node_modules" ]]; then
+ echo "[start-all] installing web dependencies..."
+ (cd "$BACKEND_DIR/web" && npm install)
+fi
+
+echo "[start-all] starting web dev server..."
+(
+ cd "$BACKEND_DIR/web"
+ npm run dev
+) >"$LOG_DIR/web.log" 2>&1 &
+echo $! >"$RUN_DIR/web.pid"
+
+echo "[start-all] starting Node 8D worker..."
+(
+ cd "$ROOT_DIR"
+ npm run worker:style-8d
+) >"$LOG_DIR/worker.log" 2>&1 &
+echo $! >"$RUN_DIR/worker.pid"
+
+sleep 2
+
+echo ""
+echo "[start-all] all services started"
+echo " API: $BACKEND_URL"
+echo " Web: $WEB_URL"
+echo " Logs: $LOG_DIR/{api,web,worker}.log"
+echo " Stop: make -C haixun-backend stop-all"
+echo " Status: make -C haixun-backend status-all"
\ No newline at end of file
diff --git a/haixun-backend/scripts/status-all.sh b/haixun-backend/scripts/status-all.sh
new file mode 100755
index 0000000..7d740be
--- /dev/null
+++ b/haixun-backend/scripts/status-all.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+RUN_DIR="$BACKEND_DIR/.run"
+COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml"
+BACKEND_URL="${HAIXUN_BACKEND_URL:-http://127.0.0.1:8890}"
+WEB_URL="${HAIXUN_WEB_URL:-http://127.0.0.1:5173}"
+
+check_pid() {
+ local name="$1"
+ local file="$RUN_DIR/${name}.pid"
+ if [[ -f "$file" ]]; then
+ local pid
+ pid="$(cat "$file" 2>/dev/null || true)"
+ if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then
+ echo " $name: running (pid=$pid)"
+ return 0
+ fi
+ fi
+ echo " $name: stopped"
+ return 1
+}
+
+echo "Haixun dev services"
+echo ""
+
+if command -v docker >/dev/null 2>&1 && [[ -f "$COMPOSE_FILE" ]]; then
+ echo "Docker:"
+ docker compose -f "$COMPOSE_FILE" ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo " (docker compose not running)"
+ echo ""
+fi
+
+echo "Processes:"
+check_pid api || true
+check_pid web || true
+check_pid worker || true
+echo ""
+
+echo "Health:"
+if curl -fsS "$BACKEND_URL/api/v1/health" >/dev/null 2>&1; then
+ echo " API health: OK ($BACKEND_URL)"
+else
+ echo " API health: down ($BACKEND_URL)"
+fi
+if curl -fsS "$WEB_URL" >/dev/null 2>&1; then
+ echo " Web: OK ($WEB_URL)"
+else
+ echo " Web: down ($WEB_URL)"
+fi
\ No newline at end of file
diff --git a/haixun-backend/scripts/stop-all.sh b/haixun-backend/scripts/stop-all.sh
new file mode 100755
index 0000000..8068197
--- /dev/null
+++ b/haixun-backend/scripts/stop-all.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+BACKEND_DIR="$ROOT_DIR/haixun-backend"
+RUN_DIR="$BACKEND_DIR/.run"
+COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.yml"
+
+stop_pid_file() {
+ local name="$1"
+ local file="$RUN_DIR/${name}.pid"
+ if [[ ! -f "$file" ]]; then
+ return 0
+ fi
+ local pid
+ pid="$(cat "$file" 2>/dev/null || true)"
+ if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then
+ echo "[stop-all] stopping $name (pid=$pid)"
+ kill "$pid" 2>/dev/null || true
+ for _ in $(seq 1 10); do
+ kill -0 "$pid" 2>/dev/null || break
+ sleep 0.2
+ done
+ kill -9 "$pid" 2>/dev/null || true
+ fi
+ rm -f "$file"
+}
+
+echo "[stop-all] stopping tracked processes..."
+for name in worker web api; do
+ stop_pid_file "$name"
+done
+
+echo "[stop-all] stopping stray processes..."
+pkill -f "haixun-backend/worker/style-8d-worker" 2>/dev/null || true
+pkill -f "worker:style-8d" 2>/dev/null || true
+pkill -f "haixun-backend/web/node_modules/.bin/vite" 2>/dev/null || true
+pkill -f "go run ./gateway.go -f etc/gateway.yaml" 2>/dev/null || true
+pkill -f "dev-with-style-8d.sh" 2>/dev/null || true
+
+if command -v docker >/dev/null 2>&1 && [[ -f "$COMPOSE_FILE" ]]; then
+ echo "[stop-all] stopping docker compose (mongo + redis)..."
+ docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true
+fi
+
+echo "[stop-all] done"
\ No newline at end of file
diff --git a/haixun-backend/web/package-lock.json b/haixun-backend/web/package-lock.json
index 4bb8ace..959380f 100644
--- a/haixun-backend/web/package-lock.json
+++ b/haixun-backend/web/package-lock.json
@@ -8,9 +8,12 @@
"name": "haixun-web",
"version": "0.1.0",
"dependencies": {
+ "framer-motion": "^12.41.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-markdown": "^10.1.0",
"react-router-dom": "^7.6.2",
+ "remark-gfm": "^4.0.1",
"taipei-sans-tc": "^0.1.1"
},
"devDependencies": {
@@ -1522,18 +1525,58 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/debug": {
+ "version": "4.1.13",
+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1549,6 +1592,18 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz",
+ "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==",
+ "license": "ISC"
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1570,6 +1625,16 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
@@ -1638,6 +1703,66 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1662,14 +1787,12 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1683,6 +1806,28 @@
}
}
},
+ "node_modules/decode-named-character-reference": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1693,6 +1838,19 @@
"node": ">=8"
}
},
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.377",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.377.tgz",
@@ -1766,6 +1924,34 @@
"node": ">=6"
}
},
+ "node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+ "license": "MIT"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1784,6 +1970,33 @@
}
}
},
+ "node_modules/framer-motion": {
+ "version": "12.41.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.41.0.tgz",
+ "integrity": "sha512-OHAMNiCEON1RDBlRGuulsN5AD8ptMjvk5QWfFmYmBLPZ3zFGIJe60kQucQQf4cez1OzQmjYBWDY+dYfISkUdqg==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.41.0",
+ "motion-utils": "^12.39.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1816,6 +2029,118 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "license": "MIT"
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2132,6 +2457,16 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -2152,11 +2487,868 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
+ "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
+ "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
+ "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
+ "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
+ "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
+ "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/motion-dom": {
+ "version": "12.41.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.41.0.tgz",
+ "integrity": "sha512-Lk3J39fOGg6xNr1KRZsN6usDyBf8aP7MEbUPez1VCughHt79OrP7VGqNrPyFL0riaT7WS8t9DRw1M3BHtM/xKw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.39.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.39.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
+ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -2188,6 +3380,31 @@
"node": ">=18"
}
},
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2237,6 +3454,16 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -2258,6 +3485,33 @@
"react": "^19.2.7"
}
},
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -2306,6 +3560,72 @@
"react-dom": ">=18"
}
},
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -2383,6 +3703,48 @@
"node": ">=0.10.0"
}
},
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.14"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
"node_modules/tailwindcss": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
@@ -2427,6 +3789,32 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2441,6 +3829,93 @@
"node": ">=14.17"
}
},
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2472,6 +3947,34 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
@@ -2553,6 +4056,16 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
}
}
}
diff --git a/haixun-backend/web/package.json b/haixun-backend/web/package.json
index 781672f..6feb9aa 100644
--- a/haixun-backend/web/package.json
+++ b/haixun-backend/web/package.json
@@ -9,9 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
+ "framer-motion": "^12.41.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-markdown": "^10.1.0",
"react-router-dom": "^7.6.2",
+ "remark-gfm": "^4.0.1",
"taipei-sans-tc": "^0.1.1"
},
"devDependencies": {
diff --git a/haixun-backend/web/public/downloads/haixun-threads-sync.zip b/haixun-backend/web/public/downloads/haixun-threads-sync.zip
new file mode 100644
index 0000000..f9de146
Binary files /dev/null and b/haixun-backend/web/public/downloads/haixun-threads-sync.zip differ
diff --git a/haixun-backend/web/public/illustrations/island/airplane.svg b/haixun-backend/web/public/illustrations/island/airplane.svg
new file mode 100644
index 0000000..8e471dc
--- /dev/null
+++ b/haixun-backend/web/public/illustrations/island/airplane.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/haixun-backend/web/public/illustrations/island/bell.svg b/haixun-backend/web/public/illustrations/island/bell.svg
new file mode 100644
index 0000000..57adaa4
--- /dev/null
+++ b/haixun-backend/web/public/illustrations/island/bell.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/haixun-backend/web/public/illustrations/island/cloud.svg b/haixun-backend/web/public/illustrations/island/cloud.svg
new file mode 100644
index 0000000..472ef81
--- /dev/null
+++ b/haixun-backend/web/public/illustrations/island/cloud.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/haixun-backend/web/public/illustrations/island/desert-island.svg b/haixun-backend/web/public/illustrations/island/desert-island.svg
new file mode 100644
index 0000000..46495d9
--- /dev/null
+++ b/haixun-backend/web/public/illustrations/island/desert-island.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/haixun-backend/web/public/illustrations/island/herb.svg b/haixun-backend/web/public/illustrations/island/herb.svg
new file mode 100644
index 0000000..ab852bd
--- /dev/null
+++ b/haixun-backend/web/public/illustrations/island/herb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/haixun-backend/web/public/illustrations/island/hut.svg b/haixun-backend/web/public/illustrations/island/hut.svg
new file mode 100644
index 0000000..06dfea5
--- /dev/null
+++ b/haixun-backend/web/public/illustrations/island/hut.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/haixun-backend/web/public/illustrations/island/magnifying-glass.svg b/haixun-backend/web/public/illustrations/island/magnifying-glass.svg
new file mode 100644
index 0000000..70db69b
--- /dev/null
+++ b/haixun-backend/web/public/illustrations/island/magnifying-glass.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/haixun-backend/web/public/illustrations/island/passport.svg b/haixun-backend/web/public/illustrations/island/passport.svg
new file mode 100644
index 0000000..ccb702a
--- /dev/null
+++ b/haixun-backend/web/public/illustrations/island/passport.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/haixun-backend/web/public/illustrations/island/sun.svg b/haixun-backend/web/public/illustrations/island/sun.svg
new file mode 100644
index 0000000..c220901
--- /dev/null
+++ b/haixun-backend/web/public/illustrations/island/sun.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/haixun-backend/web/public/illustrations/island/water-wave.svg b/haixun-backend/web/public/illustrations/island/water-wave.svg
new file mode 100644
index 0000000..985060e
--- /dev/null
+++ b/haixun-backend/web/public/illustrations/island/water-wave.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/haixun-backend/web/src/App.tsx b/haixun-backend/web/src/App.tsx
index 67a10c7..8a8fcf6 100644
--- a/haixun-backend/web/src/App.tsx
+++ b/haixun-backend/web/src/App.tsx
@@ -11,9 +11,14 @@ import { JobTemplatesPage } from './pages/JobTemplatesPage'
import { JobsPage } from './pages/JobsPage'
import { LoginPage } from './pages/LoginPage'
import { PermissionsPage } from './pages/PermissionsPage'
+import { PersonaDetailPage } from './pages/PersonaDetailPage'
+import { PersonasPage } from './pages/PersonasPage'
import { ProfilePage } from './pages/ProfilePage'
import { RegisterPage } from './pages/RegisterPage'
import { SettingsPage } from './pages/SettingsPage'
+import { ThreadsAccountWorkspace } from './components/ThreadsAccountWorkspace'
+import { ThreadsAccountConnectionsPage } from './pages/ThreadsAccountConnectionsPage'
+import { ThreadsAccountPublishPage } from './pages/ThreadsAccountPublishPage'
export default function App() {
return (
@@ -26,6 +31,8 @@ export default function App() {
}>
}>
} />
+ } />
+ } />
} />
} />
} />
@@ -34,6 +41,11 @@ export default function App() {
} />
} />
} />
+ }>
+ } />
+ } />
+ } />
+
} />
diff --git a/haixun-backend/web/src/api/client.ts b/haixun-backend/web/src/api/client.ts
index a3b5250..08cb3bc 100644
--- a/haixun-backend/web/src/api/client.ts
+++ b/haixun-backend/web/src/api/client.ts
@@ -42,6 +42,27 @@ async function refreshTokens() {
storage.setUid(json.data.uid)
}
+async function parseEnvelope(res: Response): Promise> {
+ const text = await res.text()
+ if (!text.trim()) {
+ if (!res.ok) {
+ const hint =
+ res.status === 405
+ ? '後端尚未支援此操作,請重啟 API 服務(make run 或 scripts/restart-all.sh)'
+ : res.status >= 500
+ ? '後端 API 無法連線,請確認服務已啟動(scripts/start-all.sh 或 make -C haixun-backend start-all)'
+ : `HTTP ${res.status}`
+ throw new ApiError(res.status, hint)
+ }
+ return { code: SUCCESS_CODE, message: 'success', data: null as T }
+ }
+ try {
+ return JSON.parse(text) as ApiEnvelope
+ } catch {
+ throw new ApiError(res.status || 0, text.slice(0, 200) || 'invalid response')
+ }
+}
+
function buildURL(path: string, query?: RequestOptions['query']) {
const url = new URL(path, window.location.origin)
if (query) {
@@ -74,7 +95,7 @@ async function request(path: string, opts: RequestOptions = {}): Promise {
})
let res = await doFetch()
- let json = (await res.json()) as ApiEnvelope
+ let json = await parseEnvelope(res)
if (json.code !== SUCCESS_CODE && opts.auth && storage.getRefreshToken()) {
if (!refreshPromise) {
@@ -85,7 +106,7 @@ async function request(path: string, opts: RequestOptions = {}): Promise {
try {
await refreshPromise
res = await doFetch()
- json = (await res.json()) as ApiEnvelope
+ json = await parseEnvelope(res)
} catch {
/* fall through */
}
@@ -110,25 +131,31 @@ export const api = {
request(path, { ...opts, method: 'DELETE' }),
}
-export async function streamAIChat(
- body: Record,
+async function readStreamErrorMessage(res: Response): Promise {
+ const text = await res.text()
+ if (!text.trim()) {
+ if (res.status === 404) {
+ return '後端尚未載入島民 API,請重啟 API(make restart-all 或 scripts/restart-all.sh)'
+ }
+ return `HTTP ${res.status}`
+ }
+ try {
+ const json = JSON.parse(text) as { message?: string; code?: number }
+ if (json.message) return json.message
+ } catch {
+ return text.slice(0, 200) || `HTTP ${res.status}`
+ }
+ return `HTTP ${res.status}`
+}
+
+async function consumeAIEventStream(
+ res: Response,
onDelta: (text: string) => void,
onDone: (finishReason?: string) => void,
onError: (msg: string) => void,
) {
- const memberToken = storage.getAccessToken()
- const providerToken = storage.getAiProviderToken()
- const headers: Record = { 'Content-Type': 'application/json' }
- if (memberToken) headers['X-Member-Authorization'] = `Bearer ${memberToken}`
- if (providerToken) headers.Authorization = `Bearer ${providerToken}`
-
- const res = await fetch('/api/v1/ai/chat/stream', {
- method: 'POST',
- headers,
- body: JSON.stringify(body),
- })
if (!res.ok || !res.body) {
- onError(`HTTP ${res.status}`)
+ onError(await readStreamErrorMessage(res))
return
}
@@ -152,7 +179,12 @@ export async function streamAIChat(
}
if (!data) continue
try {
- const parsed = JSON.parse(data) as { type?: string; text?: string; finish_reason?: string; message?: string }
+ const parsed = JSON.parse(data) as {
+ type?: string
+ text?: string
+ finish_reason?: string
+ message?: string
+ }
if (event === 'error' || parsed.type === 'error') {
onError(parsed.message || 'stream error')
return
@@ -164,4 +196,42 @@ export async function streamAIChat(
}
}
}
+}
+
+export async function streamIslanderChat(
+ body: { messages: { role: string; content: string }[]; context: string },
+ onDelta: (text: string) => void,
+ onDone: (finishReason?: string) => void,
+ onError: (msg: string) => void,
+) {
+ const memberToken = storage.getAccessToken()
+ const headers: Record = { 'Content-Type': 'application/json' }
+ if (memberToken) headers.Authorization = `Bearer ${memberToken}`
+
+ const res = await fetch('/api/v1/ai/islander/chat/stream', {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
+ })
+ await consumeAIEventStream(res, onDelta, onDone, onError)
+}
+
+export async function streamAIChat(
+ body: Record,
+ onDelta: (text: string) => void,
+ onDone: (finishReason?: string) => void,
+ onError: (msg: string) => void,
+) {
+ const memberToken = storage.getAccessToken()
+ const providerToken = storage.getAiProviderToken()
+ const headers: Record = { 'Content-Type': 'application/json' }
+ if (memberToken) headers['X-Member-Authorization'] = `Bearer ${memberToken}`
+ if (providerToken) headers.Authorization = `Bearer ${providerToken}`
+
+ const res = await fetch('/api/v1/ai/chat/stream', {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
+ })
+ await consumeAIEventStream(res, onDelta, onDone, onError)
}
\ No newline at end of file
diff --git a/haixun-backend/web/src/components/AcIcon.tsx b/haixun-backend/web/src/components/AcIcon.tsx
index d84c7a5..275faa3 100644
--- a/haixun-backend/web/src/components/AcIcon.tsx
+++ b/haixun-backend/web/src/components/AcIcon.tsx
@@ -10,6 +10,13 @@ const stroke = {
const paths: Record = {
home: ,
+ persona: (
+ <>
+
+
+
+ >
+ ),
jobs: ,
schedule: (
<>
@@ -51,6 +58,13 @@ const paths: Record = {
>
),
+ threads: (
+ <>
+
+
+
+ >
+ ),
more: (
<>
diff --git a/haixun-backend/web/src/components/AccountAiSettings.tsx b/haixun-backend/web/src/components/AccountAiSettings.tsx
new file mode 100644
index 0000000..2863be3
--- /dev/null
+++ b/haixun-backend/web/src/components/AccountAiSettings.tsx
@@ -0,0 +1,266 @@
+import { useEffect, useState } from 'react'
+import { api, ApiError } from '../api/client'
+import {
+ DEFAULT_AI_CREDENTIALS,
+ getApiKeyStatus,
+ isMaskedKey,
+ PROVIDER_KEY_LABELS,
+ PROVIDER_OPTIONS,
+ PROVIDER_ORDER,
+ type ProviderApiKeys,
+ type ProviderId,
+} from '../lib/aiCredentials'
+import type { ThreadsAccountAiSettingsData } from '../types/api'
+import { Badge, Button, ErrorText, Field, Input, SectionTitle, Select, SuccessText } from './ui'
+
+type AccountAiSettingsProps = {
+ accountId: string
+ compact?: boolean
+}
+
+function aiSettingsPath(accountId: string) {
+ return `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings`
+}
+
+function parseConfigured(raw: Record | undefined): Record {
+ const base = getApiKeyStatus({})
+ if (!raw) return base
+ for (const provider of PROVIDER_ORDER) {
+ const value = raw[provider]
+ base[provider] = value === true || value === 'true'
+ }
+ return base
+}
+
+export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps) {
+ const [settings, setSettings] = useState(null)
+ const [keyInputs, setKeyInputs] = useState({})
+ const [configured, setConfigured] = useState>(() => getApiKeyStatus({}))
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+ const [message, setMessage] = useState('')
+
+ const load = async () => {
+ if (!accountId) return
+ setLoading(true)
+ setError('')
+ try {
+ const data = await api.get(aiSettingsPath(accountId), { auth: true })
+ setSettings(data)
+ setConfigured(parseConfigured(data.api_keys_configured))
+ setKeyInputs(data.api_keys ?? {})
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '載入 AI 設定失敗')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ load().catch(() => undefined)
+ }, [accountId])
+
+ const save = async () => {
+ if (!accountId || !settings) return
+ setSaving(true)
+ setError('')
+ setMessage('')
+ try {
+ const apiKeys: ProviderApiKeys = {}
+ for (const [provider, value] of Object.entries(keyInputs) as [ProviderId, string][]) {
+ const trimmed = value?.trim()
+ if (!trimmed || isMaskedKey(trimmed)) continue
+ apiKeys[provider] = trimmed
+ }
+ const data = await api.put(
+ aiSettingsPath(accountId),
+ {
+ provider: settings.provider,
+ model: settings.model,
+ research_provider: settings.research_provider,
+ research_model: settings.research_model,
+ api_keys: apiKeys,
+ },
+ { auth: true },
+ )
+ setSettings(data)
+ setConfigured(parseConfigured(data.api_keys_configured))
+ setKeyInputs(data.api_keys ?? {})
+ setMessage('AI 設定已儲存')
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '儲存失敗')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const provider = (settings?.provider as ProviderId) || DEFAULT_AI_CREDENTIALS.provider
+ const researchProvider =
+ (settings?.research_provider as ProviderId) || provider
+ const providerOption = PROVIDER_OPTIONS.find((item) => item.value === provider)
+ const researchOption = PROVIDER_OPTIONS.find((item) => item.value === researchProvider)
+ const currentProviderConfigured = configured[provider]
+
+ if (!accountId) {
+ return 請先從頂部選擇 Threads 經營帳號。
+ }
+
+ return (
+
+ {!compact ? (
+
+
AI API Key
+
+ 同一登入帳號下的所有經營帳號共用。8D 分析、產文等任務會讀取這組 provider 與 key。
+
+
+ ) : null}
+
+ {loading ? (
+
載入 AI 設定…
+ ) : settings ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {currentProviderConfigured ? '主要 Provider 已設定 key' : '主要 Provider 尚未設定 key'}
+
+
+
+
+ {PROVIDER_ORDER.map((providerId) => {
+ const meta = PROVIDER_KEY_LABELS[providerId]
+ const isConfigured = configured[providerId]
+ return (
+
+
+ {meta.label}
+
+ {isConfigured ? '已設定' : '未設定'}
+
+
+
{meta.hint}
+
+ setKeyInputs((prev) => ({ ...prev, [providerId]: e.target.value }))
+ }
+ placeholder={isConfigured ? '留空則保留現有 key' : '貼上 API key'}
+ autoComplete="off"
+ />
+ {meta.docsUrl ? (
+
+ 取得 {meta.label} API key →
+
+ ) : null}
+
+ )
+ })}
+
+
+
+ Key 只存在後端資料庫,綁定此登入帳號。留空或維持遮罩值不會覆寫已儲存的 key。
+
+
+
+
+
+ >
+ ) : (
+
無法載入設定。
+ )}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/components/AccountConnectionMode.tsx b/haixun-backend/web/src/components/AccountConnectionMode.tsx
new file mode 100644
index 0000000..6833237
--- /dev/null
+++ b/haixun-backend/web/src/components/AccountConnectionMode.tsx
@@ -0,0 +1,131 @@
+import { useEffect, useState } from 'react'
+import { api, ApiError } from '../api/client'
+import {
+ connectionModeDescription,
+ connectionModeLabel,
+ connectionReadyLabel,
+} from '../lib/connectionMode'
+import type { ThreadsAccountConnectionData } from '../types/api'
+import { useOnboarding } from '../onboarding/OnboardingContext'
+import { AcLink, Badge, ChoiceCard, ErrorText, SectionTitle, SuccessText } from './ui'
+
+type AccountConnectionModeProps = {
+ accountId: string
+ connectionsPath: string
+}
+
+export function AccountConnectionMode({ accountId, connectionsPath }: AccountConnectionModeProps) {
+ const { refresh: refreshOnboarding } = useOnboarding()
+ const [connection, setConnection] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+ const [message, setMessage] = useState('')
+
+ const load = async () => {
+ if (!accountId) return
+ setLoading(true)
+ setError('')
+ try {
+ const data = await api.get(
+ `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`,
+ { auth: true },
+ )
+ setConnection(data)
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '載入連線設定失敗')
+ setConnection(null)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ load().catch(() => undefined)
+ }, [accountId])
+
+ const saveDevMode = async (devMode: boolean) => {
+ if (!accountId) return
+ setBusy(true)
+ setError('')
+ setMessage('')
+ try {
+ const data = await api.patch(
+ `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`,
+ { dev_mode: devMode },
+ { auth: true },
+ )
+ setConnection(data)
+ setMessage(devMode ? '已切換為開發模式(爬蟲)' : '已切換為正式模式(API)')
+ await refreshOnboarding()
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '儲存失敗')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const prefs = connection?.prefs
+ const devMode = !!prefs?.dev_mode
+
+ return (
+
+
+
+
連線模式
+
+ 決定此帳號走 Threads 官方 API,或本機爬蟲。OAuth、Chrome 同步等細節在{' '}
+ 連線設定。
+
+
+ {connection ? (
+
+ {connectionModeLabel(devMode)}
+
+ {connectionReadyLabel(devMode, connection.browser_connected, connection.api_connected)}
+
+
+ ) : null}
+
+
+ {loading ? (
+
載入連線模式…
+ ) : prefs ? (
+ <>
+
{connectionModeDescription(devMode)}
+
+ saveDevMode(false)}
+ />
+ saveDevMode(true)}
+ />
+
+ >
+ ) : (
+
無法載入連線設定。
+ )}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/components/AccountDisplayNameSettings.tsx b/haixun-backend/web/src/components/AccountDisplayNameSettings.tsx
new file mode 100644
index 0000000..d33e793
--- /dev/null
+++ b/haixun-backend/web/src/components/AccountDisplayNameSettings.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react'
+import { ApiError } from '../api/client'
+import { useThreadsAccount } from '../threads/ThreadsAccountContext'
+import type { ThreadsAccountData } from '../types/api'
+import { Button, ErrorText, Field, Input, SectionTitle, SuccessText } from './ui'
+
+export function AccountDisplayNameSettings({
+ accountId,
+ account,
+ loading: accountsLoading,
+}: {
+ accountId: string
+ account: ThreadsAccountData | null
+ loading: boolean
+}) {
+ const { updateDisplayName } = useThreadsAccount()
+ const [displayName, setDisplayName] = useState('')
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+ const [saved, setSaved] = useState('')
+
+ useEffect(() => {
+ setDisplayName(account?.display_name?.trim() ?? '')
+ setError('')
+ setSaved('')
+ }, [accountId, account?.display_name, account?.update_at])
+
+ const username = account?.username?.trim().replace(/^@/, '')
+
+ async function save() {
+ setSaving(true)
+ setError('')
+ setSaved('')
+ try {
+ await updateDisplayName(accountId, displayName)
+ setSaved('顯示名稱已更新')
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '更新失敗')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
經營帳號顯示名稱
+
+ 只影響頂部帳號切換器與設定頁的顯示,不會改變 Threads 上的 @帳號。
+
+
+
+
+ setDisplayName(e.target.value)}
+ placeholder={username ? `例如:主帳號(未填則顯示 @${username})` : '例如:主帳號、品牌號 A'}
+ disabled={accountsLoading || saving}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') void save()
+ }}
+ />
+
+
+ {username ? (
+
+ 已連線 Threads 帳號:@{username}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/components/AccountSwitcher.tsx b/haixun-backend/web/src/components/AccountSwitcher.tsx
new file mode 100644
index 0000000..f5d3616
--- /dev/null
+++ b/haixun-backend/web/src/components/AccountSwitcher.tsx
@@ -0,0 +1,216 @@
+import { useEffect, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useOnboarding } from '../onboarding/OnboardingContext'
+import { useThreadsAccount } from '../threads/ThreadsAccountContext'
+import {
+ threadsAccountConnected,
+ threadsAccountLabel,
+ threadsAccountStatusLabel,
+} from '../lib/threadsAccount'
+import { ApiError } from '../api/client'
+import { Button, ErrorText, Field, Input } from './ui'
+import { AcIcon } from './AcIcon'
+
+function AccountAvatar({
+ connected,
+ size = 'md',
+}: {
+ connected: boolean
+ size?: 'sm' | 'md'
+}) {
+ const iconWrap = size === 'sm' ? '!h-7 !w-7' : '!h-8 !w-8'
+ return (
+
+
+
+ )
+}
+
+export function AccountSwitcher() {
+ const navigate = useNavigate()
+ const { accounts, activeAccountId, activeAccount, loading, switchAccount, createAccount } =
+ useThreadsAccount()
+ const { hasAccounts, refresh: refreshOnboarding } = useOnboarding()
+ const [open, setOpen] = useState(false)
+ const [panel, setPanel] = useState<'list' | 'create'>('list')
+ const [creating, setCreating] = useState(false)
+ const [displayName, setDisplayName] = useState('')
+ const [error, setError] = useState('')
+ const rootRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const onPointer = (event: MouseEvent) => {
+ if (!rootRef.current?.contains(event.target as Node)) {
+ setOpen(false)
+ setPanel('list')
+ }
+ }
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ if (panel === 'create') {
+ setPanel('list')
+ setError('')
+ return
+ }
+ setOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', onPointer)
+ document.addEventListener('keydown', onKey)
+ return () => {
+ document.removeEventListener('mousedown', onPointer)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [open, panel])
+
+ const connected = threadsAccountConnected(activeAccount)
+ const label = loading ? '載入中' : threadsAccountLabel(activeAccount)
+
+ function openCreatePanel() {
+ setPanel('create')
+ setDisplayName('')
+ setError('')
+ }
+
+ async function pickAccount(id: string) {
+ setOpen(false)
+ setPanel('list')
+ await switchAccount(id)
+ const snapshot = await refreshOnboarding()
+ navigate(snapshot.isComplete ? `/threads/${id}/publish` : '/settings')
+ }
+
+ async function handleCreate() {
+ setCreating(true)
+ setError('')
+ try {
+ const created = await createAccount({
+ displayName: displayName.trim() || undefined,
+ })
+ setOpen(false)
+ setPanel('list')
+ const snapshot = await refreshOnboarding()
+ navigate(snapshot.isComplete ? `/threads/${created.id}/publish` : '/settings')
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '建立帳號失敗')
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ return (
+
+
+
+ {open ? (
+
+ {panel === 'list' ? (
+ <>
+
經營帳號
+
+ {accounts.length ? (
+ accounts.map((account) => {
+ const isActive = account.id === activeAccountId
+ const itemConnected = threadsAccountConnected(account)
+ return (
+
+ )
+ })
+ ) : (
+
尚未建立 Threads 帳號
+ )}
+
+
+
+
+ >
+ ) : (
+
+
+
+
新增經營帳號
+
+
建立後進入連線設定;人設請到側欄「人設庫」建立並綁定。
+
+
+ setDisplayName(e.target.value)}
+ placeholder="側欄顯示用,不填則自動命名"
+ autoFocus
+ />
+
+
+
+
+
+ )}
+
+ ) : null}
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/components/AppSidebar.tsx b/haixun-backend/web/src/components/AppSidebar.tsx
new file mode 100644
index 0000000..05f9540
--- /dev/null
+++ b/haixun-backend/web/src/components/AppSidebar.tsx
@@ -0,0 +1,71 @@
+import { NavLink, useLocation } from 'react-router-dom'
+import { navGroupsForOnboarding } from '../lib/onboarding'
+import type { AcAppKey } from '../lib/acAssets'
+import { useOnboarding } from '../onboarding/OnboardingContext'
+import { AcIcon } from './AcIcon'
+
+function SidebarNavItem({
+ to,
+ label,
+ icon,
+ end,
+ matchPrefix,
+}: {
+ to: string
+ label: string
+ icon: AcAppKey
+ end?: boolean
+ matchPrefix?: string
+}) {
+ const { pathname } = useLocation()
+ return (
+ {
+ const prefixActive = matchPrefix ? pathname.startsWith(matchPrefix) : false
+ const active = isActive || prefixActive
+ return `ac-sidebar-nav-item ${active ? 'ac-sidebar-nav-item--active' : ''}`
+ }}
+ >
+
+ {label}
+
+ )
+}
+
+export function AppSidebar() {
+ const { isComplete } = useOnboarding()
+ const groups = navGroupsForOnboarding(isComplete)
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/components/AuthDecor.tsx b/haixun-backend/web/src/components/AuthDecor.tsx
index de8e13b..f3a1e7b 100644
--- a/haixun-backend/web/src/components/AuthDecor.tsx
+++ b/haixun-backend/web/src/components/AuthDecor.tsx
@@ -21,11 +21,32 @@ const clouds = [
{ id: 7, path: cloudMd, viewBox: '0 0 96 40', className: 'auth-cloud--7' },
] as const
+function PalmTree({ className }: { className: string }) {
+ return (
+
+ )
+}
+
export function SceneDecor() {
return (
+
+
{clouds.map((cloud) => (
+
+
)
}
diff --git a/haixun-backend/web/src/components/DevToolsPanel.tsx b/haixun-backend/web/src/components/DevToolsPanel.tsx
new file mode 100644
index 0000000..cbd2225
--- /dev/null
+++ b/haixun-backend/web/src/components/DevToolsPanel.tsx
@@ -0,0 +1,287 @@
+/**
+ * 過渡期開發工具。拔掉 DEV 模式時,刪除此檔案與所有引用即可。
+ * 包含:dev_mode 切換、Chrome 擴充同步、手動 session 匯入。
+ */
+import { useEffect, useState } from 'react'
+import { api, ApiError } from '../api/client'
+import { storage } from '../lib/storage'
+import {
+ isExtensionBridgePresent,
+ pingExtensionBridge,
+ requestExtensionSync,
+ waitForExtensionBridge,
+} from '../lib/extensionSync'
+import {
+ connectionModeDescription,
+ connectionModeLabel,
+ connectionReadyLabel,
+} from '../lib/connectionMode'
+import type { ImportThreadsAccountSessionData, ThreadsAccountConnectionData } from '../types/api'
+import { useOnboarding } from '../onboarding/OnboardingContext'
+import { useThreadsAccount } from '../threads/ThreadsAccountContext'
+import { ExtensionInstallCard } from './ExtensionInstallCard'
+import { Badge, Button, ChoiceCard, Field, Notice, Textarea } from './ui'
+
+type DevToolsPanelProps = {
+ accountId: string
+ connection: ThreadsAccountConnectionData | null
+ workspaceLoading?: boolean
+ onConnectionChange: (data: ThreadsAccountConnectionData) => void
+ onMessage: (message: string) => void
+ onError: (message: string) => void
+}
+
+export function DevToolsPanel({
+ accountId,
+ connection,
+ workspaceLoading,
+ onConnectionChange,
+ onMessage,
+ onError,
+}: DevToolsPanelProps) {
+ const { refresh: refreshOnboarding } = useOnboarding()
+ const { refresh: refreshAccounts } = useThreadsAccount()
+ const [open, setOpen] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [extensionReady, setExtensionReady] = useState(false)
+ const [syncBusy, setSyncBusy] = useState(false)
+ const [manualState, setManualState] = useState('')
+ const [importBusy, setImportBusy] = useState(false)
+ const [showManualImport, setShowManualImport] = useState(false)
+
+ const prefs = connection?.prefs
+
+ useEffect(() => {
+ let cancelled = false
+ const onBridgeMessage = (event: MessageEvent) => {
+ if (event.source !== window) return
+ if (event.data?.type === 'HAIXUN_EXTENSION_READY') setExtensionReady(true)
+ }
+ window.addEventListener('message', onBridgeMessage)
+ const timer = window.setInterval(() => {
+ if (cancelled) return
+ if (isExtensionBridgePresent()) {
+ setExtensionReady(true)
+ return
+ }
+ pingExtensionBridge()
+ }, 1500)
+ pingExtensionBridge()
+ waitForExtensionBridge(6000).then((ready) => {
+ if (!cancelled && ready) setExtensionReady(true)
+ })
+ return () => {
+ cancelled = true
+ window.removeEventListener('message', onBridgeMessage)
+ window.clearInterval(timer)
+ }
+ }, [])
+
+ const saveDevMode = async (devMode: boolean) => {
+ setBusy(true)
+ onError('')
+ onMessage('')
+ try {
+ const data = await api.patch(
+ `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`,
+ { dev_mode: devMode },
+ { auth: true },
+ )
+ onConnectionChange(data)
+ onMessage(devMode ? '已切換為開發模式(爬蟲)' : '已切換為正式模式(API)')
+ await refreshOnboarding()
+ } catch (e) {
+ onError(e instanceof ApiError ? e.message : '儲存失敗')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const syncChromeSession = async () => {
+ setSyncBusy(true)
+ onError('')
+ onMessage('')
+ try {
+ const ready = extensionReady || isExtensionBridgePresent() || (await waitForExtensionBridge(4000))
+ if (!ready) {
+ throw new Error(
+ '找不到巡樓 Chrome 擴充。請到 chrome://extensions 載入 extension/haixun-threads-sync,按「重新載入」後刷新此頁(F5)',
+ )
+ }
+ setExtensionReady(true)
+
+ await api.get('/api/v1/members/me', { auth: true })
+ const accessToken = storage.getAccessToken()
+ if (!accessToken) throw new Error('登入狀態已失效,請重新登入')
+
+ const result = await requestExtensionSync({
+ accountId,
+ accessToken,
+ serverUrl: window.location.origin,
+ })
+ if (result.success === false || result.valid === false) {
+ throw new Error(result.message || 'Chrome session 同步失敗')
+ }
+ onMessage(result.message || 'Chrome session 已同步')
+ await refreshAccounts()
+ await refreshOnboarding()
+ } catch (e) {
+ onError(e instanceof ApiError ? e.message : e instanceof Error ? e.message : 'Chrome session 同步失敗')
+ } finally {
+ setSyncBusy(false)
+ }
+ }
+
+ const importManualSession = async () => {
+ const trimmed = manualState.trim()
+ if (!trimmed) {
+ onError('請貼上 Playwright storage state JSON')
+ return
+ }
+ setImportBusy(true)
+ onError('')
+ onMessage('')
+ try {
+ JSON.parse(trimmed)
+ const data = await api.post(
+ `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/session/import`,
+ { storageState: trimmed },
+ { auth: true },
+ )
+ if (!data.valid) throw new Error(data.message || 'Session 驗證失敗')
+ setManualState('')
+ onMessage(data.message || 'Session 已匯入')
+ await refreshAccounts()
+ await refreshOnboarding()
+ } catch (e) {
+ if (e instanceof SyntaxError) {
+ onError('JSON 格式不正確,請貼上完整的 Playwright storage state')
+ } else {
+ onError(e instanceof ApiError ? e.message : e instanceof Error ? e.message : '匯入失敗')
+ }
+ } finally {
+ setImportBusy(false)
+ }
+ }
+
+ return (
+
+
+
+ {open && prefs ? (
+
+
+
+ {connectionModeLabel(prefs.dev_mode)}
+
+
+ {prefs.dev_mode
+ ? connectionReadyLabel(true, connection.browser_connected, false)
+ : '未啟用爬蟲'}
+
+
+
+
{connectionModeDescription(prefs.dev_mode)}
+
+
+ saveDevMode(false)}
+ />
+ saveDevMode(true)}
+ />
+
+
+ {prefs.dev_mode ? (
+ <>
+
+
+ {extensionReady ? '擴充已偵測' : '尚未偵測擴充'}
+
+
+
+
+
+
+ {showManualImport ? (
+
+
+
+
+
+ ) : null}
+ {!extensionReady ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+ 診斷:頁面 {window.location.origin}|JWT{' '}
+ {storage.getAccessToken() ? '有' : '無'}|後端目標{' '}
+ {window.location.port === '5173' ||
+ window.location.port === '4173' ||
+ window.location.port === '3000'
+ ? `${window.location.protocol}//${window.location.hostname === 'localhost' ? '127.0.0.1' : window.location.hostname}:8890`
+ : window.location.origin}
+
+
+ >
+ ) : (
+
+ )}
+
+ ) : null}
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/components/EvidenceList.tsx b/haixun-backend/web/src/components/EvidenceList.tsx
new file mode 100644
index 0000000..28a8242
--- /dev/null
+++ b/haixun-backend/web/src/components/EvidenceList.tsx
@@ -0,0 +1,88 @@
+import { useState } from 'react'
+import type { Style8DSamplePost } from '../lib/styleProfile'
+import { parseEvidenceItem, type EvidenceSegment } from '../lib/styleEvidence'
+
+function EvidenceSegments({ segments }: { segments: EvidenceSegment[] }) {
+ return (
+ <>
+ {segments.map((segment, index) => {
+ if (segment.type === 'link') {
+ return (
+
+ {segment.value}
+
+ )
+ }
+ if (segment.type === 'postRef') {
+ return (
+
+ {segment.value}
+
+ )
+ }
+ return {segment.value}
+ })}
+ >
+ )
+}
+
+type Props = {
+ evidence: string[]
+ samplePosts?: Style8DSamplePost[]
+ defaultOpen?: boolean
+}
+
+export function EvidenceList({ evidence, samplePosts = [], defaultOpen = false }: Props) {
+ const [open, setOpen] = useState(defaultOpen)
+ const items = evidence.map((item) => parseEvidenceItem(item, samplePosts))
+ const linkCount = items.filter((item) => item.permalink).length
+
+ if (!items.length) return null
+
+ return (
+
+
+
+ {open ? (
+
+ ) : null}
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/components/ExtensionInstallCard.tsx b/haixun-backend/web/src/components/ExtensionInstallCard.tsx
new file mode 100644
index 0000000..9761ae1
--- /dev/null
+++ b/haixun-backend/web/src/components/ExtensionInstallCard.tsx
@@ -0,0 +1,48 @@
+import {
+ EXTENSION_DOWNLOAD_URL,
+ EXTENSION_INSTALL_STEPS,
+ EXTENSION_VERSION,
+} from '../lib/extensionInstall'
+import { Badge, SectionTitle } from './ui'
+
+type ExtensionInstallCardProps = {
+ compact?: boolean
+ showSteps?: boolean
+}
+
+export function ExtensionInstallCard({ compact, showSteps = true }: ExtensionInstallCardProps) {
+ return (
+
+
+
+
Chrome 擴充套件
+
+ 開發模式(爬蟲)需安裝此擴充,才能從 Chrome 同步 Threads 登入態到巡樓。
+
+
+
v{EXTENSION_VERSION}
+
+
+ {showSteps ? (
+
+ {EXTENSION_INSTALL_STEPS.map((step, index) => (
+ -
+
+ {index + 1}
+
+ {step}
+
+ ))}
+
+ ) : null}
+
+
+ 下載擴充套件(ZIP)
+
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/components/JobMonitor.tsx b/haixun-backend/web/src/components/JobMonitor.tsx
new file mode 100644
index 0000000..a968e2c
--- /dev/null
+++ b/haixun-backend/web/src/components/JobMonitor.tsx
@@ -0,0 +1,430 @@
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../api/client'
+import { isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus'
+import { STYLE_8D_PIPELINE_STEPS } from '../lib/styleProfile'
+import type { JobData, Pagination } from '../types/api'
+import { Button, ProgressBar, StatusBadge } from './ui'
+
+const JOB_MONITOR_EXPANDED_KEY = 'haixun.job_monitor_expanded'
+const DOCK_SIZE = 48
+const DOCK_ANCHOR = { width: DOCK_SIZE, height: DOCK_SIZE }
+const EDGE_PADDING = 12
+const MOBILE_DOCK_HEIGHT = 88
+const DRAG_THRESHOLD = 5
+
+type MonitorPosition = { x: number; y: number }
+
+type DragSession = {
+ active: boolean
+ moved: boolean
+ pointerId: number
+ startX: number
+ startY: number
+ originX: number
+ originY: number
+}
+
+const ACTIVE_STATUSES = new Set(['pending', 'queued', 'running', 'waiting_worker', 'cancel_requested'])
+
+const STEP_STATUS_LABEL: Record = {
+ pending: '等待',
+ running: '進行中',
+ succeeded: '完成',
+ failed: '失敗',
+ skipped: '略過',
+ cancelled: '取消',
+}
+
+const DEFAULT_STEPS = [
+ { id: 'prepare', title: '準備', hint: '整理任務資料與環境' },
+ { id: 'execute', title: '執行', hint: 'Worker 正在處理任務' },
+ { id: 'finalize', title: '收尾', hint: '寫入結果與完成狀態' },
+]
+
+function isActiveJob(job: JobData) {
+ return ACTIVE_STATUSES.has(job.status)
+}
+
+function recentEnough(job: JobData) {
+ if (!isTerminalJobStatus(job.status)) return false
+ const updatedAtMs = Math.floor((job.update_at || job.create_at || 0) / 1_000_000)
+ if (!updatedAtMs) return false
+ return Date.now() - updatedAtMs < 3 * 60 * 1000
+}
+
+function shortJobTitle(job: JobData) {
+ if (job.template_type === 'style-8d') return '8D 風格分析'
+ if (job.template_type === 'demo_long_task') return 'Demo 任務'
+ return job.template_type
+}
+
+function phaseHint(job: JobData) {
+ if (job.template_type === 'style-8d') {
+ switch (job.phase) {
+ case 'session':
+ return '確認 Chrome session 與 Worker 心跳'
+ case 'samples':
+ return '讀取對標帳號近期貼文樣本'
+ case 'style':
+ return '交給 AI 分析 D1-D8 風格策略'
+ case 'store':
+ return '寫回人設,讓後續產文套用'
+ default:
+ return '等待 8D worker 更新階段'
+ }
+ }
+ return job.phase ? `目前階段:${job.phase}` : '等待 Worker 回報階段'
+}
+
+function stepDefinitions(job: JobData) {
+ if (job.template_type === 'style-8d') return STYLE_8D_PIPELINE_STEPS
+ const liveSteps = job.progress?.steps ?? []
+ if (liveSteps.length > 0) {
+ return liveSteps.map((step) => ({ id: step.id, title: step.id, hint: step.message || step.id }))
+ }
+ return DEFAULT_STEPS
+}
+
+function StepDot({ status }: { status: string }) {
+ const classes =
+ status === 'succeeded'
+ ? 'bg-success'
+ : status === 'running'
+ ? 'bg-brand shadow-[0_0_0_4px_var(--hx-brand-soft)]'
+ : status === 'failed'
+ ? 'bg-danger'
+ : status === 'cancelled'
+ ? 'bg-muted'
+ : 'bg-line'
+ return
+}
+
+function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Promise }) {
+ const steps = job.progress?.steps ?? []
+ const stepMap = new Map(steps.map((step) => [step.id, step]))
+ const definitions = stepDefinitions(job)
+ const activeStep = steps.find((step) => step.status === 'running')
+ const failedStep = steps.find((step) => step.status === 'failed')
+ const canCancel = isActiveJob(job)
+
+ return (
+
+
+
+
+ {shortJobTitle(job)}
+
+ {jobStatusLabel(job.status)}
+
+
+
+ Job {job.id.slice(0, 8)} · {job.progress?.percentage ?? 0}%
+
+
+ {canCancel ? (
+
+ ) : null}
+
+
+
+
+
+
現在在做
+
+ {job.progress?.summary || phaseHint(job)}
+
+ {activeStep?.message ? (
+
細節:{activeStep.message}
+ ) : null}
+ {failedStep?.message || job.error ? (
+
+ 錯誤:{failedStep?.message || job.error}
+
+ ) : null}
+
+
+
+ {definitions.map((step) => {
+ const live = stepMap.get(step.id)
+ const status = live?.status ?? 'pending'
+ return (
+ -
+
+
+
+ {step.title}
+ {STEP_STATUS_LABEL[status] ?? status}
+
+
+ {live?.message || step.hint}
+
+
+
+ )
+ })}
+
+
+
+ 查看完整事件與原始資料
+
+
+ )
+}
+
+function readExpandedPreference() {
+ try {
+ return localStorage.getItem(JOB_MONITOR_EXPANDED_KEY) === '1'
+ } catch {
+ return false
+ }
+}
+
+function defaultMonitorPosition() {
+ const mobile = window.innerWidth < 1024
+ const bottomGap = mobile ? MOBILE_DOCK_HEIGHT : EDGE_PADDING
+ return {
+ x: EDGE_PADDING,
+ y: Math.max(EDGE_PADDING, window.innerHeight - bottomGap - DOCK_SIZE),
+ }
+}
+
+function clampMonitorPosition(
+ position: MonitorPosition,
+ size: { width: number; height: number },
+): MonitorPosition {
+ const maxX = Math.max(EDGE_PADDING, window.innerWidth - size.width - EDGE_PADDING)
+ const maxY = Math.max(EDGE_PADDING, window.innerHeight - size.height - EDGE_PADDING)
+ return {
+ x: Math.min(Math.max(position.x, EDGE_PADDING), maxX),
+ y: Math.min(Math.max(position.y, EDGE_PADDING), maxY),
+ }
+}
+
+export function JobMonitor() {
+ const [jobs, setJobs] = useState([])
+ const [expanded, setExpanded] = useState(readExpandedPreference)
+ const [loading, setLoading] = useState(false)
+ const [position, setPosition] = useState(defaultMonitorPosition)
+ const [dragging, setDragging] = useState(false)
+ const rootRef = useRef(null)
+ const dragRef = useRef({
+ active: false,
+ moved: false,
+ pointerId: -1,
+ startX: 0,
+ startY: 0,
+ originX: 0,
+ originY: 0,
+ })
+
+ const setExpandedPersisted = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
+ setExpanded((prev) => {
+ const next = typeof value === 'function' ? value(prev) : value
+ try {
+ localStorage.setItem(JOB_MONITOR_EXPANDED_KEY, next ? '1' : '0')
+ } catch {
+ // ignore quota / private mode
+ }
+ return next
+ })
+ }, [])
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ try {
+ const data = await api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', {
+ auth: true,
+ query: { page: 1, pageSize: 12 },
+ })
+ setJobs(data.list ?? [])
+ } catch {
+ // 登入初期或 gateway 重啟時保持安靜,避免干擾主要操作。
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ load().catch(() => undefined)
+ const timer = window.setInterval(() => load().catch(() => undefined), 2000)
+ return () => window.clearInterval(timer)
+ }, [load])
+
+ const syncClampedPosition = useCallback(() => {
+ setPosition((prev) => {
+ const next = clampMonitorPosition(prev, DOCK_ANCHOR)
+ if (next.x === prev.x && next.y === prev.y) return prev
+ return next
+ })
+ }, [])
+
+ useLayoutEffect(() => {
+ setPosition(defaultMonitorPosition())
+ }, [])
+
+ useEffect(() => {
+ const onResize = () => syncClampedPosition()
+ window.addEventListener('resize', onResize)
+ return () => window.removeEventListener('resize', onResize)
+ }, [syncClampedPosition])
+
+ const beginDockDrag = (event: React.PointerEvent) => {
+ event.preventDefault()
+ event.currentTarget.setPointerCapture(event.pointerId)
+ dragRef.current = {
+ active: true,
+ moved: false,
+ pointerId: event.pointerId,
+ startX: event.clientX,
+ startY: event.clientY,
+ originX: position.x,
+ originY: position.y,
+ }
+ }
+
+ const moveDockDrag = (event: React.PointerEvent) => {
+ const drag = dragRef.current
+ if (!drag.active || drag.pointerId !== event.pointerId) return
+
+ const dx = event.clientX - drag.startX
+ const dy = event.clientY - drag.startY
+ if (!drag.moved && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
+ drag.moved = true
+ setDragging(true)
+ }
+ if (!drag.moved) return
+
+ setPosition(clampMonitorPosition({ x: drag.originX + dx, y: drag.originY + dy }, DOCK_ANCHOR))
+ }
+
+ const endDockDrag = (event: React.PointerEvent) => {
+ const drag = dragRef.current
+ if (!drag.active || drag.pointerId !== event.pointerId) return
+
+ drag.active = false
+ setDragging(false)
+
+ if (drag.moved) {
+ setPosition((prev) => clampMonitorPosition(prev, DOCK_ANCHOR))
+ return
+ }
+
+ setExpandedPersisted((value) => !value)
+ }
+
+ const visibleJobs = useMemo(() => {
+ const active = jobs.filter(isActiveJob)
+ const recent = jobs.filter((job) => recentEnough(job) && !active.some((item) => item.id === job.id))
+ return [...active, ...recent].slice(0, 4)
+ }, [jobs])
+
+ const current = visibleJobs[0]
+ const activeCount = visibleJobs.filter(isActiveJob).length
+
+ const cancelJob = async (id: string) => {
+ await api.post(`/api/v1/jobs/${id}/cancel`, { reason: 'ui cancel' }, { auth: true })
+ await load()
+ }
+
+ const dockTone =
+ activeCount > 0
+ ? 'ac-job-monitor__dock--active'
+ : current?.status === 'failed'
+ ? 'ac-job-monitor__dock--failed'
+ : current
+ ? 'ac-job-monitor__dock--done'
+ : 'ac-job-monitor__dock--idle'
+
+ const openUpward = position.y > window.innerHeight * 0.42
+
+ return (
+
+
+ {activeCount > 0 && !expanded && current ? (
+
+
+ {current.progress?.summary || phaseHint(current)}
+
+
+ ) : null}
+
+
+
+ {expanded ? (
+
+
+
+
+ Task Watch
+
+
任務觀察站
+
+ 拖曳「任」可移到螢幕任意位置;可自由切頁,worker 會在背景繼續跑。
+
+
+
+
+
+ {loading && visibleJobs.length === 0 ? (
+
更新任務狀態中…
+ ) : visibleJobs.length === 0 ? (
+
+
目前沒有進行中或剛完成的任務。
+
+ 前往任務列表
+
+
+ ) : (
+
+ {visibleJobs.map((job) => (
+
+ ))}
+
+ )}
+
+ ) : null}
+
+
+ )
+}
diff --git a/haixun-backend/web/src/components/Layout.tsx b/haixun-backend/web/src/components/Layout.tsx
index 3c2d269..a99152f 100644
--- a/haixun-backend/web/src/components/Layout.tsx
+++ b/haixun-backend/web/src/components/Layout.tsx
@@ -1,117 +1,63 @@
-import { NavLink, Outlet, useLocation } from 'react-router-dom'
-import { navApps } from '../lib/acAssets'
-import type { AcAppKey } from '../lib/acAssets'
-import { useAuth } from '../auth/AuthContext'
+import { Outlet } from 'react-router-dom'
import { AuthTicketIcon, SceneDecor } from './AuthDecor'
-import { AcIcon } from './AcIcon'
+import { AppSidebar } from './AppSidebar'
import { MobileBottomNav } from './MobileBottomNav'
-import { ThemeToggle } from './ThemeToggle'
-
-function AppTile({
- to,
- label,
- icon,
- end,
- matchPrefix,
-}: {
- to: string
- label: string
- icon: AcAppKey
- end?: boolean
- matchPrefix?: string
-}) {
- const { pathname } = useLocation()
- return (
- {
- const prefixActive = matchPrefix ? pathname.startsWith(matchPrefix) : false
- const active = isActive || prefixActive
- return `ac-app-tile ${active ? 'ac-app-tile--active' : ''}`
- }}
- >
-
- {label}
-
- )
-}
+import { AccountSwitcher } from './AccountSwitcher'
+import { MemberMenu } from './MemberMenu'
+import { JobMonitor } from './JobMonitor'
+import { IslanderCompanion } from './islander/IslanderCompanion'
+import { IslanderPageProvider } from '../lib/islander'
+import { OnboardingBanner } from './OnboardingBanner'
+import { OnboardingRouteGuard } from './OnboardingRouteGuard'
+import { OnboardingProvider } from '../onboarding/OnboardingContext'
+import { ThreadsAccountProvider } from '../threads/ThreadsAccountContext'
export function Layout() {
- const { member, uid, logout } = useAuth()
-
return (
-
-
+
+
+
+
-
+
+
-
-
)
}
\ No newline at end of file
diff --git a/haixun-backend/web/src/pages/JobsPage.tsx b/haixun-backend/web/src/pages/JobsPage.tsx
index 882c4fd..da974da 100644
--- a/haixun-backend/web/src/pages/JobsPage.tsx
+++ b/haixun-backend/web/src/pages/JobsPage.tsx
@@ -3,7 +3,21 @@ import { Link } from 'react-router-dom'
import { api } from '../api/client'
import { ApiError } from '../api/client'
import { useAuth } from '../auth/AuthContext'
-import { Button, Card, ErrorText, Field, Input, PageTitle, Textarea } from '../components/ui'
+import {
+ Button,
+ Card,
+ ErrorText,
+ Field,
+ Input,
+ PageTitle,
+ ProgressBar,
+ StatusBadge,
+ Table,
+ TableAction,
+ TablePanel,
+ TableShell,
+ Textarea,
+} from '../components/ui'
import { isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus'
import type { JobData, Pagination } from '../types/api'
@@ -78,7 +92,7 @@ export function JobsPage() {
return (
-
+
怎麼知道任務有沒有在跑?
-
@@ -116,68 +130,62 @@ export function JobsPage() {
- {pagination ? (
-
共 {pagination.total} 筆
- ) : null}
-
-
-
-
- | ID |
- Status |
- Progress |
- Template |
- 操作 |
-
-
-
- {jobs.map((j) => (
-
- |
- {j.id.slice(0, 8)}…
-
- 查看任務詳情與事件
-
- |
-
-
- {jobStatusLabel(j.status)}
-
- / {j.phase}
- |
-
- {j.progress.percentage}%
- {j.progress.summary ? (
- {j.progress.summary}
- ) : null}
- |
- {j.template_type} |
-
-
-
- |
+
+
+
+
+
+ | ID |
+ 狀態 |
+ 進度 |
+ 模板 |
+ 操作 |
- ))}
-
-
-
+
+
+ {jobs.map((j) => (
+
+ |
+ {j.id.slice(0, 8)}…
+
+ 查看詳情
+
+ |
+
+
+ {jobStatusLabel(j.status)}
+
+ {j.phase}
+ |
+
+
+
+
+ {j.progress.percentage}%
+
+ {j.progress.summary ? (
+ {j.progress.summary}
+ ) : null}
+
+ |
+ {j.template_type} |
+
+
+ cancel(j.id)}>
+ 取消
+
+ retry(j.id)}>重試
+
+ |
+
+ ))}
+
+
+
+
)
}
\ No newline at end of file
diff --git a/haixun-backend/web/src/pages/LoginPage.tsx b/haixun-backend/web/src/pages/LoginPage.tsx
index 4fc49c6..7754e76 100644
--- a/haixun-backend/web/src/pages/LoginPage.tsx
+++ b/haixun-backend/web/src/pages/LoginPage.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { Link, Navigate, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { ApiError } from '../api/client'
@@ -6,13 +6,17 @@ import { AuthShell } from '../components/AuthShell'
import { Button, ErrorText, Field, Input } from '../components/ui'
export function LoginPage() {
- const { login, setTenantId, tenantId, isAuthenticated } = useAuth()
+ const { login, setTenantId, isAuthenticated } = useAuth()
const navigate = useNavigate()
- const [email, setEmail] = useState('admin@haixun.local')
- const [password, setPassword] = useState('Admin-Pass-1!')
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
+ useEffect(() => {
+ setTenantId('default')
+ }, [setTenantId])
+
if (isAuthenticated) return
const onSubmit = async (e: React.FormEvent) => {
@@ -36,9 +40,6 @@ export function LoginPage() {
輸入帳號密碼,就能開始今天的
巡樓任務。
diff --git a/haixun-backend/web/src/pages/PersonaDetailPage.tsx b/haixun-backend/web/src/pages/PersonaDetailPage.tsx
new file mode 100644
index 0000000..17199e6
--- /dev/null
+++ b/haixun-backend/web/src/pages/PersonaDetailPage.tsx
@@ -0,0 +1,473 @@
+import type { ReactNode } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { api, ApiError } from '../api/client'
+import { AcIcon } from '../components/AcIcon'
+import {
+ LeafSparkleArt,
+ PassportIslandArt,
+ ResearchHutArt,
+ StampBellArt,
+} from '../components/PersonaIslandArt'
+import { Style8DAnalysisMeta } from '../components/Style8DAnalysisMeta'
+import { Style8DJobPanel } from '../components/Style8DJobPanel'
+import { Style8DDimensionEditor } from '../components/Style8DDimensionEditor'
+import {
+ AcLink,
+ Badge,
+ Button,
+ Card,
+ ErrorText,
+ Field,
+ Input,
+ Notice,
+ ProgressBar,
+ SuccessText,
+ Textarea,
+} from '../components/ui'
+import { isTerminalJobStatus } from '../lib/jobStatus'
+import {
+ createEmptyStyle8DProfile,
+ parseStyle8DProfile,
+ type StoredStyle8DProfile,
+ type Style8DKey,
+} from '../lib/styleProfile'
+import type { JobData, PersonaData } from '../types/api'
+
+const emptyPersona = (): PersonaData => ({
+ id: '',
+ display_name: '',
+ persona: '',
+ brief: '',
+ product_brief: '',
+ target_audience: '',
+ goals: '',
+ style_profile: '',
+ style_benchmark: '',
+ create_at: 0,
+ update_at: 0,
+})
+
+function profileCompleteness(draft: PersonaData, has8D: boolean) {
+ const fields = [
+ draft.display_name,
+ draft.brief,
+ draft.persona,
+ draft.target_audience,
+ draft.product_brief,
+ draft.goals,
+ ]
+ const filled = fields.filter((v) => v?.trim()).length + (has8D ? 1 : 0)
+ return Math.round((filled / 7) * 100)
+}
+
+function ZoneSign({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+export function PersonaDetailPage() {
+ const navigate = useNavigate()
+ const { id = '' } = useParams()
+ const [draft, setDraft] = useState(emptyPersona())
+ const [benchmarkUsername, setBenchmarkUsername] = useState('')
+ const [styleResult, setStyleResult] = useState(null)
+ const [styleJob, setStyleJob] = useState(null)
+ const [styleJobId, setStyleJobId] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [busy, setBusy] = useState(null)
+ const [message, setMessage] = useState('')
+ const [error, setError] = useState('')
+
+ const loadPersona = useCallback(async () => {
+ if (!id) return
+ const data = await api.get(`/api/v1/personas/${encodeURIComponent(id)}`, { auth: true })
+ setDraft({ ...emptyPersona(), ...data })
+ setStyleResult(parseStyle8DProfile(data.style_profile))
+ setBenchmarkUsername(data.style_benchmark ?? '')
+ }, [id])
+
+ const refreshStyleJob = useCallback(
+ async (jobId: string) => {
+ const job = await api.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { auth: true })
+ if (isTerminalJobStatus(job.status)) {
+ setStyleJob(null)
+ setStyleJobId(null)
+ if (job.status === 'succeeded') {
+ await loadPersona()
+ setMessage('8D 分析完成,風格策略已寫入人設')
+ } else {
+ setError(job.error?.trim() || job.progress?.summary || '8D 分析失敗')
+ }
+ return
+ }
+ setStyleJob(job)
+ setStyleJobId(job.id)
+ },
+ [loadPersona],
+ )
+
+ const loadActiveStyleJob = useCallback(async () => {
+ if (!id) return
+ const data = await api.get<{ list: JobData[] }>('/api/v1/jobs', {
+ auth: true,
+ query: { page: 1, pageSize: 20, scope: 'persona', scope_id: id },
+ })
+ const active = (data.list ?? []).find(
+ (job) => job.template_type === 'style-8d' && !isTerminalJobStatus(job.status),
+ )
+ if (!active) {
+ setStyleJob(null)
+ setStyleJobId(null)
+ return
+ }
+ setStyleJob(active)
+ setStyleJobId(active.id)
+ }, [id])
+
+ useEffect(() => {
+ if (!id) return
+ setLoading(true)
+ setError('')
+ Promise.all([loadPersona(), loadActiveStyleJob()])
+ .catch((e) => setError(e instanceof ApiError ? e.message : '載入人設失敗'))
+ .finally(() => setLoading(false))
+ }, [id, loadPersona, loadActiveStyleJob])
+
+ useEffect(() => {
+ if (!styleJobId) return
+ const timer = window.setInterval(() => {
+ refreshStyleJob(styleJobId).catch(() => undefined)
+ }, 3000)
+ return () => window.clearInterval(timer)
+ }, [styleJobId, refreshStyleJob])
+
+ const visibleStyle =
+ styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, '').trim())
+
+ const completeness = useMemo(
+ () => profileCompleteness(draft, !!styleResult),
+ [draft, styleResult],
+ )
+
+ const save = async () => {
+ if (!id) return
+ setBusy('save')
+ setError('')
+ setMessage('')
+ try {
+ const data = await api.patch(
+ `/api/v1/personas/${encodeURIComponent(id)}`,
+ {
+ display_name: draft.display_name,
+ persona: draft.persona,
+ brief: draft.brief,
+ product_brief: draft.product_brief,
+ target_audience: draft.target_audience,
+ goals: draft.goals,
+ style_profile: draft.style_profile,
+ style_benchmark: benchmarkUsername.replace(/^@/, '').trim(),
+ },
+ { auth: true },
+ )
+ setDraft({ ...emptyPersona(), ...data })
+ setStyleResult(parseStyle8DProfile(data.style_profile))
+ setBenchmarkUsername(data.style_benchmark ?? '')
+ setMessage('人設已儲存')
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '儲存失敗')
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ const analyzeBenchmark = async () => {
+ if (!id || !benchmarkUsername.trim()) return
+ setBusy('8d')
+ setError('')
+ setMessage('')
+ try {
+ const data = await api.post<{ job_id: string; message?: string }>(
+ `/api/v1/personas/${encodeURIComponent(id)}/style-analysis`,
+ { benchmark_username: benchmarkUsername.trim() },
+ { auth: true },
+ )
+ setMessage(data.message ?? '8D 分析已在背景執行,可自由切換頁面')
+ setStyleJobId(data.job_id)
+ await refreshStyleJob(data.job_id)
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '8D 分析失敗')
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ const updateStyleDimension = (key: Style8DKey, summary: string) => {
+ const base = styleResult ?? createEmptyStyle8DProfile(benchmarkUsername.replace(/^@/, '').trim())
+ const next: StoredStyle8DProfile = {
+ ...base,
+ analysis: {
+ ...base.analysis,
+ [key]: { ...base.analysis[key], summary },
+ },
+ }
+ setStyleResult(next)
+ setDraft((prev) => ({ ...prev, style_profile: JSON.stringify(next) }))
+ }
+
+ const syncPersonaFrom8D = () => {
+ if (!styleResult?.personaDraft) return
+ setDraft((prev) => ({ ...prev, persona: styleResult.personaDraft }))
+ }
+
+ const remove = async () => {
+ if (!id) return
+ const label = draft.display_name?.trim() || '未命名人設'
+ if (!window.confirm(`確定要刪除「${label}」?刪除後無法復原。`)) return
+ setBusy('delete')
+ setError('')
+ setMessage('')
+ try {
+ await api.delete(`/api/v1/personas/${encodeURIComponent(id)}`, { auth: true })
+ navigate('/personas')
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '刪除失敗')
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ if (!id) {
+ return (
+
+ 未指定人設。
+
+ )
+ }
+
+ return (
+
+
+ ← 返回人設庫
+
+
+ {/* 島民護照 Hero */}
+
+
+
+ {/* 左欄:身份檔案 */}
+
+
+ {/* 右欄:8D 風格雷達 */}
+
+ 8D 風格雷達
+
+
+
+
+
+
對標帳號研究台
+
+ 分析後會存成這份人設的風格策略,產文與回覆都會自動套用。
+
+
+
+ setBenchmarkUsername(e.target.value)}
+ placeholder="@對標帳號"
+ disabled={loading || !!styleJob}
+ className="flex-1"
+ />
+
+
+
+
+
+ {styleJob ? (
+
+
可自由切換頁面,任務不會中斷
+
+ {styleJob.status === 'queued' || styleJob.status === 'waiting_worker' ? (
+
+
+
+ ) : null}
+
+ ) : null}
+
+ {!styleResult && !styleJob ? (
+
+ ) : null}
+
+ {styleResult ?
: null}
+
+
+
+ {styleResult ? (
+
+
+
+ 8D 已會自動生效;這顆按鈕只在你想把摘要同步到人設文字時使用。
+
+
+ ) : null}
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/pages/PersonasPage.tsx b/haixun-backend/web/src/pages/PersonasPage.tsx
new file mode 100644
index 0000000..7a99e4e
--- /dev/null
+++ b/haixun-backend/web/src/pages/PersonasPage.tsx
@@ -0,0 +1,140 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { api, ApiError } from '../api/client'
+import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, TableAction } from '../components/ui'
+import { parseStyle8DProfile } from '../lib/styleProfile'
+import { useOnboarding } from '../onboarding/OnboardingContext'
+import type { ListPersonasData, PersonaData } from '../types/api'
+
+export function PersonasPage() {
+ const navigate = useNavigate()
+ const { refresh: refreshOnboarding } = useOnboarding()
+ const [personas, setPersonas] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [creating, setCreating] = useState(false)
+ const [deletingId, setDeletingId] = useState(null)
+ const [newName, setNewName] = useState('')
+ const [error, setError] = useState('')
+
+ const load = useCallback(async () => {
+ setError('')
+ const data = await api.get('/api/v1/personas', { auth: true })
+ setPersonas(data.list ?? [])
+ }, [])
+
+ useEffect(() => {
+ load()
+ .catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗'))
+ .finally(() => setLoading(false))
+ }, [load])
+
+ const remove = async (item: PersonaData) => {
+ const label = item.display_name?.trim() || '未命名人設'
+ if (!window.confirm(`確定要刪除「${label}」?刪除後無法復原。`)) return
+ setDeletingId(item.id)
+ setError('')
+ try {
+ await api.delete(`/api/v1/personas/${encodeURIComponent(item.id)}`, { auth: true })
+ setPersonas((prev) => prev.filter((p) => p.id !== item.id))
+ await refreshOnboarding()
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '刪除失敗')
+ } finally {
+ setDeletingId(null)
+ }
+ }
+
+ const create = async () => {
+ setCreating(true)
+ setError('')
+ try {
+ const created = await api.post(
+ '/api/v1/personas',
+ { display_name: newName.trim() || undefined },
+ { auth: true },
+ )
+ setNewName('')
+ await refreshOnboarding()
+ navigate(`/personas/${created.id}`)
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '建立失敗')
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ setNewName(e.target.value)}
+ placeholder="例如:醫療衛教、個人品牌"
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') void create()
+ }}
+ />
+
+
+
+
+ {loading ? (
+
+ 載入中…
+
+ ) : personas.length ? (
+
+ {personas.map((item) => (
+
+
+
+
+
+ {item.display_name || '未命名人設'}
+
+
+ {parseStyle8DProfile(item.style_profile) ? (
+ 8D 已建立
+ ) : null}
+ {item.style_benchmark ? (
+
+ 對標 @{item.style_benchmark.replace(/^@/, '')}
+
+ ) : null}
+
+
+ {item.brief ? (
+
{item.brief}
+ ) : (
+
尚未填寫定位與 8D 策略
+ )}
+
+
void remove(item)}
+ >
+ {deletingId === item.id ? '刪除中…' : '刪除'}
+
+
+
+ ))}
+
+ ) : (
+
+ 尚無人設。建立後可設定語氣、受眾與 8D 對標分析。
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/pages/RegisterPage.tsx b/haixun-backend/web/src/pages/RegisterPage.tsx
index 6ef79ec..d4ccafc 100644
--- a/haixun-backend/web/src/pages/RegisterPage.tsx
+++ b/haixun-backend/web/src/pages/RegisterPage.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { Link, Navigate, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { ApiError } from '../api/client'
@@ -6,7 +6,7 @@ import { AuthShell } from '../components/AuthShell'
import { Button, ErrorText, Field, Input } from '../components/ui'
export function RegisterPage() {
- const { register, setTenantId, tenantId, isAuthenticated } = useAuth()
+ const { register, setTenantId, isAuthenticated } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -14,6 +14,10 @@ export function RegisterPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
+ useEffect(() => {
+ setTenantId('default')
+ }, [setTenantId])
+
if (isAuthenticated) return
const onSubmit = async (e: React.FormEvent) => {
@@ -33,14 +37,10 @@ export function RegisterPage() {
return (