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) => ( @@ -52,6 +73,8 @@ export function SceneDecor() { /> + +
) } 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 ? ( +
+ +