update dashboard

This commit is contained in:
王性驊 2026-06-24 00:55:10 +08:00
parent 413d5f0b10
commit 28388d8210
174 changed files with 16994 additions and 648 deletions

View File

@ -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
- 本機:`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

View File

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

View File

@ -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"
}

View File

@ -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 = "已儲存網址,但未授權存取該網站(同步時會再詢問)";

View File

@ -51,8 +51,8 @@
<h1>同步 Threads Session</h1>
<p>
1. 在 Chrome 登入 threads.com<br />
2. 在巡樓側欄切換到目標帳號<br />
3. 按下方按鈕同步到 server
2. 開啟巡樓網頁並登入、切換帳號<br />
3. 按下方按鈕(會自動讀取巡樓 JWT
</p>
<button id="sync">同步到巡樓</button>
<div id="status"></div>

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
34256

Binary file not shown.

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
34261

View File

@ -0,0 +1 @@
34262

View File

@ -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 庫。

View File

@ -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 serverproxy 到 :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 ## 格式化並測試

View File

@ -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.Secretworker 需帶同一把
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

View File

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

View File

@ -20,5 +20,8 @@ import (
"auth.api"
"member.api"
"permission.api"
"threads_account.api"
"persona.api"
"worker_internal.api"
)

View File

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

View File

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

View File

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

View File

@ -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 {

View File

@ -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"`
}

View File

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

View File

@ -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 正在分析 D1D8…")
_, _ = 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
}

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
你是巡樓管理台的 AI 助手。回答要簡潔、可執行,優先協助島民完成 Threads 經營與背景任務相關問題。

View File

@ -0,0 +1,43 @@
你是巡樓管理台的「島民嚮導」——親切、直接,而且**可以代使用者操作畫面**。
## 任務
- 回答使用者提出的問題
- 需要操作畫面時**直接做**,不要叫使用者自己 copy、自己點
## 不要主動講這一頁
- 使用者**沒問**這頁、畫面、欄位、怎麼用時,不要主動介紹「你現在在某某頁」
- 只有使用者問這頁、問怎麼寫欄位內容、或要你幫忙操作時,才使用【可互動元素】
## 靜默操作(重要)
- `islander-actions` 區塊是**系統通道**,使用者**看不到**;禁止在回覆正文裡寫 JSON、程式碼、ref 清單
- 不要說「我會執行以下操作」「請看 action 區塊」;用人話簡短說結果即可
- 需要 navigate / click / fill 時,把 action 只放在 `islander-actions` 區塊末尾,正文維持自然對話
## 幫使用者寫進欄位
當使用者問「某某欄位可以怎麼寫」(例如人設頁的「一句話定位」):
1. 先用 13 句說明思路或給建議文案
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

View File

@ -0,0 +1,5 @@
你是 Haixun 巡樓系統的 AI 模組。遵守以下共通規則:
- 使用繁體中文,語氣直接、可執行,避免空泛口號。
- 不得捏造未提供的貼文、帳號狀態、API 結果或使用者設定。
- 若資料不足,明確說明缺什麼,不要腦補。

View File

@ -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":""}}

View File

@ -0,0 +1,5 @@
你是 Threads 創作者風格研究員。只能根據提供的近期貼文歸納,不可捏造作者背景。
逐一輸出八個維度D1 語氣人格、D2 結構模板、D3 互動方式、D4 主題分布、D5 發文節奏、D6 視覺語法emoji、標點、換行、D7 轉換方式、D8 風險紅線。
每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。
證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。
最後產生「可供另一個帳號借鑑、但不可冒充或抄襲」的人設草稿。代表句必須是抽象仿寫範例,不可逐字複製原文。

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -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
}

View File

@ -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
}

View File

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

View File

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

View File

@ -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,

View File

@ -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(),

View File

@ -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
}

View File

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

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -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
}

View File

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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -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
}

View File

@ -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
}

View File

@ -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)

View File

@ -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, "|")

View File

@ -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,

View File

@ -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"`

View File

@ -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
}

View File

@ -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
}

View File

@ -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")

View File

@ -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")

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

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

View File

@ -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"`
}

View File

@ -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"`
}

View File

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

View File

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

View File

@ -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
}

View File

@ -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
}

View File

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

View File

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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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"

File diff suppressed because it is too large Load Diff

View File

@ -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": {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 128 128"><path fill="#006CA8" d="M37.08 80.79S9.61 74.92 9.14 75.01s-5.97 5.59-5.78 6.44C3.55 82.31 27.61 95 27.61 95l6.91-7.29zm-3.79 19.42s13.35 23.11 13.92 23.11s6.72-4.74 6.72-5.21s-5.68-28.03-5.68-28.03l-9.76 1.14z"/><path fill="#9A9A9A" d="M22.79 25.78s-1.92 2.17-2.33 2.94s-.48 2.09.4 2.56s1.95.15 2.48-.44s3.14-3.8 3.14-3.8s-3.46-1.44-3.69-1.26m14.02 7.85s-1.55 1.84-2.18 2.68c-.61.81-.6 2.08.25 2.68c1.22.86 2.43-.1 3.09-.81c.61-.66 2.33-2.68 2.33-2.68zm13.27 7.14s-2.03 2.03-2.63 2.84c-.67.89-.82 2.19.1 2.89c1.01.76 2.13.41 3.04-.41c.91-.81 2.94-3.04 2.94-3.04zm35.03 34.28s-2.38 1.65-2.97 2.29c-.58.64-.97 1.92-.22 2.59c.74.67 1.86.61 2.52.17s3.96-2.93 3.96-2.93s-3.02-2.23-3.29-2.12m8.02 12.56s-1.97 1.38-2.79 2.03c-.8.63-1.11 1.86-.44 2.66c.95 1.14 2.38.53 3.19.01c.76-.48 2.94-2 2.94-2zm7.34 12.84s-2.45 1.48-3.24 2.12c-.86.71-1.32 1.93-.59 2.83c.8.98 1.97.9 3.05.34s3.58-2.24 3.58-2.24zm-43.2-76.72s-.35-2.33 1.54-4.96s8.16-8.77 9.13-8.82s3.29 1.18 5.62 3.47c2.22 2.17 3.25 4.96 3.29 5.62s-4.34 5-5.48 6.05s-4.21 4.47-4.21 4.47z"/><path fill="#C8C8C8" d="M60.13 24.08s7.68-9.39 9.74-7.98c2.08 1.42-6.23 9.48-6.23 9.48z"/><path fill="#848484" d="M58.37 23.48s4.35-4.41 4.83-5.11s.04-1.75-.35-2.33c-.39-.57-.99-.79-.99-.79s-3.07 2.75-4.12 5.16c-.82 1.87-.68 2.82-.68 2.82zm7.49 2.27s1.65-1.71 2.48-2.39c.75-.61 1.86-.55 2.52-.16c.85.5 1.05 1.36 1.05 1.36l-3.06 3.25z"/><path fill="#9A9A9A" d="M101.96 59.45s2.51-2.92 3.97-4.38c1.51-1.51 3.97-3.56 4.47-3.52c.5.05 2.71 1.39 4.79 3.52c2.37 2.42 3.84 5.43 3.88 5.71c.05.27-4.38 4.7-6.35 6.39c-1.41 1.21-10.18 4.79-10.18 4.34c.02-.46-.58-12.06-.58-12.06"/><path fill="#C8C8C8" d="M103.41 65.1s7.26-7.96 8.92-6.47s-8.31 10.45-8.31 10.45z"/><path fill="#848484" d="m102.87 63.1l2.65-2.92c.94-1.03.88-2.41.6-3.01c-.36-.77-1.29-.95-1.29-.95l-3.45 3.67zm2.51 6.61s4.16-3.75 4.87-4.34s1.86-1.01 2.77-.38c.68.47.98 1.04.98 1.04s-3.2 3.48-5.13 4.71c-1.21.77-4.07 1.21-4.07 1.21z"/><path fill="#006CA8" d="M11.65 8.92c-.98.08-7.19 7.42-7.41 7.96s0 1.42 1.53 2.18c1.52.76 44.47 23.44 45.13 23.98c.65.55 9.16 10.03 9.16 10.03s21.04-14.5 20.93-14.93c-.11-.44-.76-9.48-.76-9.48S13.07 8.81 11.65 8.92m61.38 56.91s12.86 11.66 13.95 13.08s23.33 41.97 23.66 42.62s1.74 1.2 2.51.44s6.54-5.78 6.65-6.98s-21.37-70.42-21.37-70.42l-16.57 6z"/><path fill="#014EAC" d="M66.81 43.26s-10.14-9.81-10.9-10.36c-.76-.54-48.29-20.16-48.29-20.16l-1.95 2.15s48.18 20.35 48.95 20.89s10.2 10.2 10.2 10.2zM82.4 60.45s11.17 13.72 11.84 14.61s20.26 45.65 20.26 45.65l1.95-1.82S96.88 73.22 96.53 72.6S83.17 55.9 83.17 55.9z"/><path fill="#9A9A9A" d="M24.22 98.4s-4.18 5.98-4.18 6.45c0 .48 2.8 3.54 3.12 3.54s6.4-4.34 6.4-4.34l-.85-4.34z"/><path fill="#C8C8C8" d="M24.22 98.4s8.28-13.93 17.15-26.38c9.11-12.79 16.5-20.16 27.86-32.52C83.48 24 92.97 15.22 101.75 9.31c7.28-4.9 17.68-8.22 21.39-4.91c4.07 3.64-.42 15.12-4.99 21.66c-10.37 14.84-19.27 21.9-29.48 31.81c-10.07 9.77-18.58 18.25-29.12 26.16c-7.1 5.33-30 20.04-30 20.04z"/><path fill="#3B5361" d="m103.92 11.05l2.77 2.92s4.64-4.47 8.11-1.14c3.56 3.41-1.35 8.25-1.35 8.25l2.99 3.34s8.11-9.18 1.49-15.15c-5.76-5.2-14.01 1.78-14.01 1.78"/><path fill="#9A9A9A" d="M47.84 74c-1.29 1.24-16.53 19.28-17.38 20.35c-1.09 1.38-1.59 3.04-.36 4.06c1.23 1.01 2.56.01 3.77-.8c1.52-1.01 19.18-16.59 20.35-17.67c1.88-1.74 2.2-4.88.22-6.66c-2.18-1.96-4.64-1.16-6.6.72"/><path fill="#DFDFDF" d="M47.29 69.91c-.87-.75-1.95-.25-3.09.98c-.98 1.07-1.43 2.73-.45 3.4s2.28-.31 3.09-1.3c.81-.98 1.43-2.23.45-3.08m2.82-5.82c-.9 1.03-2.21 2.73-.98 3.94c1.21 1.19 3.04-.32 3.84-1.22c.79-.9 9.84-11.52 21.29-22.96c11.18-11.18 18.7-18.25 19.86-19.49c.98-1.05.89-2.72.03-3.25c-1.02-.63-2.15-.23-3.55 1.17S79.7 32.37 70.74 41.43S51.23 62.8 50.11 64.09"/><path fill="#3B5361" d="M105.47 41.61s-.55-3.72-2.86-2.26c-2.48 1.57-.63 5.83-.63 5.83s.93-.87 1.92-1.91c.82-.85 1.3-1.35 1.57-1.66m-8.87 8.75s-.39-4.77-3.01-2.81c-2.09 1.57-.67 6.26-.67 6.26s1.2-1.09 1.98-1.83c.79-.75 1.7-1.62 1.7-1.62m-8.46 8.02s-.3-3.96-2.65-2.65s-.78 5.99-.78 5.99s1.04-.94 1.74-1.64s1.69-1.7 1.69-1.7m-9.58 9.26s-.18-3.94-2.49-2.89c-2.4 1.09-.96 6.14-.96 6.14s1.17-1.09 1.82-1.7c.66-.6 1.63-1.55 1.63-1.55"/></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 128 128"><path fill="#E2A610" d="M53.32 18.8c0-7.09 5.77-12.86 12.86-12.86s12.86 5.77 12.86 12.86s-5.77 12.86-12.86 12.86S53.32 25.9 53.32 18.8m7.48 0c0 2.97 2.41 5.38 5.38 5.38s5.38-2.41 5.38-5.38s-2.41-5.38-5.38-5.38s-5.38 2.42-5.38 5.38"/><path fill="#9E740B" d="M77.24 12.28s.57 2.27-2.06 3.51c-2.63 1.23-4.37.32-4.37.32c.47.8.75 1.71.75 2.7c0 2.97-2.41 5.38-5.38 5.38s-5.38-2.41-5.38-5.38c0-.3.07-1.43.55-2.37c-4.46 2.12-7.85.31-7.85.31c-.11.67-.18 1.36-.18 2.06c0 7.09 5.77 12.86 12.86 12.86S79.04 25.9 79.04 18.8c0-2.38-.66-4.61-1.8-6.52"/><path fill="#FFCA28" d="M10.46 97.8c6.77-6.65 10.99-8.89 12.71-18.53s.34-29.95 7.92-43.25C38.01 23.84 50.71 18.8 63.1 18.8c.3 0 .6.02.9.02c.3-.01.6-.02.9-.02c12.39 0 25.09 5.04 32.01 17.21c7.57 13.31 6.2 33.62 7.92 43.25c1.72 9.64 5.94 11.88 12.71 18.53c2.92 2.87 6.45 7.44 6.46 10.38s-1.49 4.01-5.06 5.51c-10.1 4.25-23.6 8.37-54.94 8.37s-44.84-4.12-54.94-8.37c-3.57-1.5-5.07-2.56-5.06-5.51c.01-2.93 3.54-7.5 6.46-10.37"/><path fill="#4E342E" d="M113.7 108.38c0-4.43-22.25-8.02-49.7-8.02s-49.7 3.59-49.7 8.02s22.25 9.72 49.7 9.72s49.7-5.29 49.7-9.72"/><path fill="#E2A610" d="M93.84 44.79c.37 1.41.68 2.82.93 4.2c1.27 7.06 1.04 14.3 1.61 21.45c.77 9.57 2.47 14.61 6.34 19.11c.51.59-.01 1.5-.78 1.38c-5.17-.79-9.32-1.58-14.05-4.7c-7.06-4.65-8.8-13.4-8.85-21.26c-.08-11.7.14-23.03-.79-27.33c-1.29-5.99-2.49-9.18-4.45-12.09c-2.99-4.44 8.62 1.72 10.44 3.09c5.07 3.83 7.98 9.99 9.6 16.15"/><path fill="#FFF59D" d="M30.89 60.32c-.12-7.58-.06-15.42 2.96-22.38c1.81-4.16 4.88-7.91 8.8-10.24c3.08-1.83 9.34-3.85 11.59.3c.45.83.63 1.8.66 2.75c.08 3.31-1.64 6.37-3.31 9.23c-4.94 8.48-6.91 17.75-9.52 27.15c-1.07 3.88-2.43 7.75-4.76 11.03c-1.6 2.25-10.51 10.25-8.47 3.02C30.8 74.19 31 67.6 30.89 60.32"/><path fill="#E2A610" d="M73.09 106.82c-.01-1.72-.94-2.71-3.08-3.45c-4.44-1.53-10-1.25-13.24.49c-3.4 1.82-1.04 11.91 7.23 11.91s9.1-7.64 9.09-8.95"/><path fill="#FFF59D" d="M33.25 91.79c-8.78 1.54-15.14 4.87-17.89 7.57c-2.18 2.13-2.18 3.81 1.66 2.06c2.89-1.32 12.15-4 20.26-4.81c13.93-1.4 22.53-1.43 23.96-1.4c3.35.07 3.63-2.51-3.14-3.42c-6.77-.9-16.07-1.53-24.85 0m27.21 22.03c1.16.8 2.7 1.19 4 .65s2.14-2.19 1.5-3.44c-.25-.49-.68-.86-1.11-1.2c-1.19-.93-2.51-1.69-3.91-2.25c-.55-.22-1.13-.42-1.73-.38c-.59.03-1.21.34-1.44.89c-.99 2.25.99 4.57 2.69 5.73"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 128 128"><path fill="#E4EAEE" d="M23.45 62.3c.72-.72-1.27-9.29 7.6-15.91s14.92-2.67 15.77-2.96c.84-.28 4.79-17.6 21.4-22.1s33.93 3.94 38.01 18.02c3.73 12.87.84 21.54 1.27 22.1c.42.56 8.45.28 13.09 7.74s2.96 12.11 2.96 12.11l-29.56 9.15h-47.3S5.02 79.47 4.6 77.5s.53-8.37 7.32-12.25c5.9-3.37 10.26-1.68 11.53-2.95"/><path fill="#BACDD2" d="M35.16 92.84s-15.78 3.3-26.45-4.96C2.29 82.9 4.63 74.83 4.63 74.83s4.6 4.65 13.89 5.91c9.29 1.27 19.71.84 19.71.84s2.6 4.44 12.39 6.48c12.27 2.55 18.74-3.73 18.74-3.73s3.36 4.02 15.19 4.3s18.46-7.98 19.57-8.17c.56-.09 3.82 2.87 10.28 1.83c6.15-.99 9.39-3.66 9.39-3.66s.89 6.62-5.3 10.7c-4.83 3.18-13.23 3.52-13.23 3.52s-1.28 4.91-7.05 8.48c-5.36 3.33-14.6 4.44-21.44 2.4c-8.59-2.56-10.72-6.47-10.72-6.47s-6.4 3.75-16.4 2.48c-9.45-1.18-14.49-6.9-14.49-6.9"/></svg>

After

Width:  |  Height:  |  Size: 880 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 128 128"><path fill="#5B9821" d="M84.5 37.89s-10.95 2.89-26.19 25.75c-11.77 17.65-12.95 31.48-22.8 40.75c-5.01 4.71-13.09 7.94-13.09 7.94s-6.47-.15-6.91.15c-.44.29-1.73 3.53.69 7.51c2 3.29 5.05 4.12 6.08 4.26c1.03.15 2.94-4.27 3.68-4.86s9.43-3.93 15.6-10.74c12.65-13.98 12.45-26.11 21.63-42.08c10.15-17.65 22.66-26.19 22.66-26.19z"/><path fill="#5B9821" d="M122.69 3.39c-.39-.43-25.34-.05-38.15 11.52c-15.22 13.74-11.1 32.76-11.1 32.76s17.77 4.21 31.18-7.93c15.64-14.15 18.46-35.92 18.07-36.35M72.4 70.49c.82.64 2 1.39 3.69 2.23c9.56 4.78 21.5 2.16 27.35-.16c8.13-3.23 20.21-12.95 20.21-13.69s-5.5-3.38-13.32-5.71s-18.27-2.44-26.21.95c-7.28 3.11-10.67 8.58-12.54 11.86c0 0-2.9.46-5 .82c-3.7.63-6.55 1.9-6.55 1.9l-2.85 5.92s5.81-2.64 8.77-3.28s6.45-.84 6.45-.84M54.31 8.73c-.93.19-5.33 7.06-8.03 13.63c-2.13 5.17-4.26 18.38-.45 26.52s8.22 9.68 8.22 9.68s.54 2.37.48 5.55c-.03 1.8-.21 6.76-.21 6.76l4.35-1.89s.29-4.16.27-6.18c-.03-2.13-.29-4.82-.29-4.82s7.19-6.73 7.72-17.18c.42-8.24-.46-15.89-5-24.14c-3.56-6.43-6.53-8.03-7.06-7.93M5.8 24.95c-1.08 1.23 1.37 4.33 1.59 11.31c.17 5.71 1.16 25.05 6.87 34.98s13.63 12.37 16.17 13.21c2.54.85 8.03.95 8.03.95s2.33 2.01 3.17 4.86c.85 2.85 1.69 7.08 1.69 7.08l4.02-5.92s-1.16-4.65-2.33-6.76c-1.16-2.11-1.59-2.85-1.59-2.85s2.57-15.99-1.8-29.17C36.45 37 27.57 31.08 19.33 27.59S6.54 24.11 5.8 24.95m44.5 68.06s1.89-1.15 5.27-1.23c3.11-.07 5.04.51 5.04.51s5.81-6.67 12.31-8.58c6.13-1.8 14.22-3.41 28.47 2.5c14.27 5.92 22.47 11.66 22.37 13.46c-.11 1.8-14.13 8.22-19.94 10.22c-5.81 2.01-19.14 6.23-31.64 1.4c-9.93-3.84-12.15-13-12.15-13s-2.3-1.27-4.94-1.12c-4.83.26-8.9 3.87-8.9 3.87z"/><path fill="#8DC02C" d="M77.97 38.72c1.57.97 5.5-8.88 9.3-13.42s10.89-9.19 10.15-11.2s-9.09.85-14.8 8.24c-5.7 7.4-6.02 15.54-4.65 16.38m1.07 24.81c1.14 1.57 6.37-2.17 11.63-3.38c5.41-1.25 11.2.51 10.99-2.43c-.2-2.78-7.93-3.06-13-1.69s-10.99 5.59-9.62 7.5M50.51 48.58c1.93-.08 1.27-6.02 1.37-11.63s1.06-14.06-1.06-14.16c-2.11-.11-3.89 5.47-4.02 12.15c-.1 5.5 1.17 13.74 3.71 13.64M12.14 38.06c-1.27.42-.37 3.38-.32 4.76c.32 9.09 3.38 23.15 8.35 29.28c3.86 4.76 8.24 8.24 11.2 6.13s-2.67-6.67-5.5-11.2c-4.54-7.29-7.4-16.59-8.88-21.14s-2.46-8.63-4.85-7.83"/><path fill="#8DC02D" d="M67.41 92.04c.19 2.77 6.21 1.32 12.59.69s11.63-1.51 11.38-3.85c-.23-2.18-7.36-2.41-12.99-2.01s-11.15 2.7-10.98 5.17"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 128 128"><path fill="#ED6D30" d="M12.24 114.81c-1.33 0-1.15 1.59-1.15 4.59s-.18 4.68 1.41 4.68s102.24-.18 103.3-.26c1.06-.09.79-3.97.79-5.47c0-1.85.26-3.44-1.23-3.53c-1.5-.09-102.59-.01-103.12-.01"/><path fill="#F78A51" d="m12.58 90.36l.1 24.54l102.14-.1l.23-25.1l-2.25-2.52l-99.4-.17z"/><path fill="#ED6D30" d="m14.67 88.61l-2.09 1.75l-.01 3.89s.68.48 1.36.14s1.63-1 2.47-1.07c1.34-.1 1.23 1.18 2.52 1.18s2.46-1.29 3.55-1.29s1.59 1.38 2.75 1.31s1.81-1.24 3.26-1.2c1.16.03 1.36 1.2 2.79 1.2s2.16-1.36 3.38-1.36s1.62 1.44 2.98 1.44s1.73-1.44 3.29-1.44s1.43 1.36 2.72 1.22s1.42-1.26 2.98-1.33s1.72 1.47 3.08 1.47s1.32-1.11 2.48-1.11s22.68-.05 23.5-.05s2.08 1.44 3.44 1.44s.98-1.54 2.47-1.58c.89-.02 2.62 1.48 3.64 1.48s1.39-1.54 2.54-1.61s2.86 1.7 4.22 1.56s.68-1.5 1.7-1.63c1.02-.14 3.06 1.5 4.42 1.56c1.36.07.48-1.77 1.9-1.77s2.52 1.56 4.01 1.63s.48-1.56 1.97-1.7s2.58 2.04 4.01 1.9s.82-1.7 2.04-1.77s2.92 1.02 2.92 1.02l.06-4.22l-58.76-18.56z"/><path fill="#51362F" d="M51.87 123.96s.25-29.46.21-30.18c-.05-.87.72-1.08 1.34-1.08h21.8c.62 0 .98.46 1.03 1.03s-.1 30.18-.1 30.18z"/><path fill="#6C4D43" d="M8.45 76.25s-4.12 9.11-4.08 9.8s1.19 4.88 3.03 4.99c2.52.15 2.98-2.06 3.4-2.06s.98 2.16 2.42 2.37c1.68.24 2.89-2.46 3.54-2.46s.97 2.35 2.49 2.35s3.14-2.54 3.74-2.49s1.18 2.7 2.42 2.7c1.25 0 2.47-2.26 3.55-2.37c.56-.05 1.09 2.53 2.11 2.57c1.02.05 2.76-2.67 3.64-2.67s1.57 2.81 2.91 2.68s3.15-2.63 3.79-2.63s1.34 2.53 2.39 2.58c.95.04 2.52-2.38 3.16-2.42s1.41 2.47 2.47 2.47s2.21-2.72 3.09-2.77s1.25 2.56 2.31 2.56s2.63-2.63 3.6-2.67c.97-.05 1.5 2.52 2.47 2.52s2.05-2.61 2.74-2.64c1.07-.04 1.75 2.54 3.23 2.59s2.08-2.49 3.23-2.49s1.48 2.4 2.77 2.45s2.35-2.49 3.32-2.45c.97.05 1.61 2.35 2.68 2.35s2.12-2.35 2.91-2.31c.78.05 1.85 2.4 2.91 2.4s1.94-2.43 2.45-2.45c.98-.03 1.98 2.54 3 2.45s2.2-2.62 3.08-2.58c.88.05 2.23 2.87 3.29 2.78s1.96-2.77 2.93-2.73s1.75 2.47 2.98 2.47c1.13 0 1.76-2.32 2.78-2.37s1.97 2.22 3.03 2.11s1.49-2.22 2.42-2.26c.92-.05 2.28 2.39 3.62 2.34s2.03-1.94 2.77-1.98s1.38 2.06 3.18 2.03c1.62-.02 3.74-3.05 3.32-5.17s-6.18-10.57-6.18-10.57z"/><path fill="#A37F69" d="M17.18 52.1s-5.37 7.54-8.56 14.43S4.2 77.5 4 81.52c-.2 3.93.37 4.53.37 4.53l5.83-6.37s.06 6.02.49 6.08s4.92-6.21 5.22-6.27c.31-.06.31 6.33.74 6.33s5.04-6.45 5.35-6.45s.43 6.39.8 6.39s4.55-6.51 4.86-6.45s1.41 6.82 1.78 6.82s4.24-6.88 4.67-6.88s.92 7.13 1.41 7.13s4.3-6.88 4.61-6.88s1.54 6.82 2.03 6.76s3.87-6.76 4.18-6.64S47.45 86.2 48 86.2s3.5-6.76 3.99-6.76S53.72 86 54.09 86s3.44-6.58 3.81-6.64s2.34 7.25 2.95 7.25s2.46-6.76 2.89-6.7s3.32 6.45 3.56 6.45s2.4-6.7 2.64-6.7s3.13 6.08 3.56 6.15c.43.06 2.03-6.08 2.34-5.96s3.26 6.45 3.63 6.51s1.66-6.64 2.03-6.64s4.42 7.37 4.73 7.31s.68-7.44 1.04-7.5c.37-.06 5.22 7.25 5.59 7.19s.18-7.44.43-7.44s4.24 6.88 4.79 6.82s1.04-6.45 1.41-6.45s4.79 7.5 5.35 7.44s.12-7.01.37-7.07s5.22 6.27 5.47 6.21s.18-6.82.61-6.88s5.04 6.58 5.47 6.45c.43-.12.49-6.76.74-6.82s6.01 6.85 6.01 6.85s1.12-6.01-.86-11.77s-6.79-13.85-9.12-17.29c-2.32-3.44-3.68-5.03-3.68-5.03l-56.81-7.28z"/><path fill="#BD9177" d="M36.88 28.99s-7.59 7.47-11.21 11.79s-8.5 11.31-8.5 11.31s-1.77 8.18-1.25 8.24c.53.06 5.72-7.76 6.19-7.71c.47.06-.76 7.88-.29 8s6.25-8.11 6.54-8.17s-.93 8.11-.53 8.23s6.25-8.17 6.6-8.17s.82 7.65 1.28 7.65s4.32-7.76 4.67-7.71c.35.06 1.17 8 1.75 8.11c.58.12 3.27-8.23 3.74-8.29s1.63 8.76 2.16 8.76s3.33-8.64 3.68-8.64s1.23 8.64 1.81 8.64s3.21-8.81 3.56-8.81s2.86 8.46 3.27 8.35c.41-.12 2.63-8.46 3.04-8.41c.41.06 2.33 8.17 3.04 8.29c.7.12 2.57-7.88 3.15-7.88s2.1 8.87 2.8 8.87s2.28-8.87 2.63-8.87s3.56 8.76 4.09 8.81c.53.06 1.11-8.35 1.75-8.41s4.09 8.41 4.44 8.35s.64-8.29.99-8.35s4.26 7.71 4.9 7.71s.23-8.06.82-8c.58.06 6.42 8.46 6.95 8.46s-1.52-8.76-1.05-8.87c.47-.12 6.6 9.34 7.24 9.16s-1.63-8.58-1.23-8.7c.41-.12 6.77 8.23 7.36 8c.58-.23-1.38-8.99-1.38-8.99s-5.86-7.65-8.89-11.38c-3.04-3.74-7.82-8.7-7.82-8.7L69.61 19.89z"/><path fill="#D2A590" d="M36.88 28.99s-2.87 5.41-2.29 5.75s7.69-6.49 8.38-6.32s-2.3 7.23-1.32 7.46s5.68-7.75 6.26-7.64c.57.11-1.21 7.75-.29 8.04s4.88-8.33 5.51-8.21c.63.11.46 8.61 1.03 8.67s3.5-8.27 3.9-8.27s1.55 8.79 2.24 8.84c.69.06 2.75-9.15 3.21-9.15s2.76 8.69 3.51 8.69s1.2-8.77 1.6-8.94s3.76 8.42 4.72 8.42c.83 0-.58-8.55-.06-8.67c.52-.11 5.28 8.84 6.26 8.79c.98-.06-.72-8.35-.26-8.35s5.72 8.29 6.58 8.29s-1.8-8.43-1.28-8.55c.52-.11 8.35 7.39 8.81 7.27c.46-.11-.23-3.46-.23-3.46S74.27 12.23 63.7 12.18c-10.8-.06-26.82 16.81-26.82 16.81"/></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 128 128"><path fill="#81D4FA" d="M47.63 12.67c19.85 0 34.8 14.95 34.8 34.8s-16.15 36.01-36 36.01s-34.8-14.95-34.8-34.8s16.15-36.01 36-36.01" opacity=".6"/><path fill="#616161" d="M47.63 12.67c19.85 0 34.8 14.95 34.8 34.8s-16.15 36.01-36 36.01s-34.8-14.95-34.8-34.8s16.15-36.01 36-36.01m-29.61 4.1C-1.66 36.45 8 65.2 15.69 73.05c14.23 14.52 40.83 22.79 60.73 2.88c18.43-18.43 11.81-41.8-1.17-56.09c-8.62-9.48-37.56-22.74-57.23-3.07"/><path fill="#BDBDBD" d="m99.78 90.86l-8.33 8.38l-19.62-19.8s-1.66-3.19 1.62-6.47s6.71-1.91 6.71-1.91z"/><path fill="#616161" d="M88.5 76.96c-1.13-.9-5.05-3-9.36 1.28c-4.3 4.28-2.55 7.66-1.7 8.91c0 0 30.53 33.87 31.92 35.27c2.05 2.05 6.26.3 10.16-3.6s5.78-7.67 3.58-9.87c-1.79-1.79-33.47-31.09-34.6-31.99"/><path fill="#BDBDBD" d="M43.92 8.64c19.85 0 36 16.15 36 36s-16.15 36-36 36s-36-16.15-36-36s16.15-36 36-36m0-3.96c-22.06 0-39.95 17.89-39.95 39.95s17.89 39.95 39.95 39.95S83.87 66.7 83.87 44.63S65.98 4.68 43.92 4.68"/><ellipse cx="117.01" cy="116.31" fill="#424242" rx="9.25" ry="3.56" transform="rotate(-45.001 117.004 116.31)"/><linearGradient id="SVGryhNIeut" x1="20.385" x2="36.781" y1="18.024" y2="44.616" gradientUnits="userSpaceOnUse"><stop offset=".285" stop-color="#FFF"/><stop offset="1" stop-color="#FFF" stop-opacity="0"/></linearGradient><path fill="url(#SVGryhNIeut)" d="M26.52 23.6c-6.52 6.83-9.08 14.39-9.14 22.79c-.02 3.09.41 6.36 2.32 8.78s5.7 3.57 8.2 1.76c1.66-1.2 2.35-3.29 3.16-5.16c1.24-2.87 2.97-5.54 5.1-7.84c2.66-2.88 5.92-5.18 8.46-8.16s4.35-7.01 3.33-10.8c-1.01-3.71-4.67-6.33-8.48-6.76s-9.19 1.45-12.95 5.39"/><path fill="#FFF" d="M64.05 75.78c0-.46.22-.88.59-1.15c1.95-1.39 7.2-4.64 11.73-13.28C79 56.31 80 52.11 80.28 50.61c.79-4.24 3.01-2.82 2.7-.08c-.36 3.12-3.07 16.85-16.68 26.38c-.95.67-2.25.03-2.25-1.13M26.39 11.39c-.13 1.3-2.34 2.53-2.73 2.78c-2.02 1.3-7.02 3.85-11.59 12.47c-2.13 4.01-3.66 8.14-4.18 10.19c-.85 3.35-3.14 2.68-2.7-.05c.5-3.1 3.85-16.69 17.88-25.58c.99-.62 3.49-1.53 3.32.19" opacity=".59"/><linearGradient id="SVGt3VJqe6y" x1="58.951" x2="63.085" y1="95.509" y2="52.792" gradientUnits="userSpaceOnUse"><stop offset=".285" stop-color="#FFF"/><stop offset="1" stop-color="#FFF" stop-opacity="0"/></linearGradient><path fill="url(#SVGt3VJqe6y)" d="M55.92 73.93c-4.45.81-5.77-1.05-5.21-2.9c.44-1.47 2.08-2.1 3.55-2.54c7.84-2.35 14.46-8.84 19.27-15.28c.11 5.59-1.36 8.83-4.05 12.15c-2.63 3.23-6.72 7.32-13.56 8.57"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Some files were not shown because too many files have changed in this diff Show More