update dashboard
|
|
@ -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)
|
||||
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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 = "已儲存網址,但未授權存取該網站(同步時會再詢問)";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
34256
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
34261
|
||||
|
|
@ -0,0 +1 @@
|
|||
34262
|
||||
|
|
@ -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 庫。
|
||||
|
|
|
|||
|
|
@ -26,9 +26,28 @@ fmt: ## gofmt + goimports
|
|||
test: ## 執行測試
|
||||
$(GO) test ./...
|
||||
|
||||
run: ## 啟動 API
|
||||
run: ## 啟動 API(前景)
|
||||
$(GO) run ./gateway.go -f etc/gateway.yaml
|
||||
|
||||
dev-all: ## 一鍵啟動 Mongo/Redis + API + 前端 + 8D worker(背景)
|
||||
bash scripts/start-all.sh
|
||||
|
||||
stop-all: ## 一鍵停止全部開發服務
|
||||
bash scripts/stop-all.sh
|
||||
|
||||
restart-all: ## 一鍵重啟全部開發服務
|
||||
bash scripts/restart-all.sh
|
||||
|
||||
status-all: ## 查看全部開發服務狀態
|
||||
bash scripts/status-all.sh
|
||||
|
||||
stop: stop-all ## 同 stop-all
|
||||
|
||||
restart: restart-all ## 同 restart-all
|
||||
|
||||
dev-8d: ## 一鍵啟動 API + Node 8D worker(前景,Ctrl+C 結束)
|
||||
bash scripts/dev-with-style-8d.sh
|
||||
|
||||
CONFIG ?= etc/gateway.yaml
|
||||
INIT_TENANT ?= default
|
||||
INIT_EMAIL ?= admin@30cm.net
|
||||
|
|
@ -46,7 +65,13 @@ web-install: ## 安裝前端依賴
|
|||
web-dev: web-install ## 啟動前端 dev server(proxy 到 :8890)
|
||||
cd web && npm run dev
|
||||
|
||||
web-build: web-install ## 建置前端靜態檔
|
||||
extension-pack: ## 打包 Chrome 擴充為 web/public/downloads/*.zip
|
||||
bash scripts/package-extension.sh
|
||||
|
||||
web-build: web-install extension-pack ## 建置前端靜態檔
|
||||
cd web && npm run build
|
||||
|
||||
node-worker-style-8d: ## 啟動 Node 8D 爬蟲 worker
|
||||
cd .. && npm run worker:style-8d
|
||||
|
||||
check: fmt test ## 格式化並測試
|
||||
|
|
|
|||
|
|
@ -35,6 +35,37 @@ http://127.0.0.1:8890
|
|||
curl http://127.0.0.1:8890/api/v1/health
|
||||
```
|
||||
|
||||
### 8D Node 爬蟲 worker 驗證
|
||||
|
||||
`style-8d` job 由 `worker_type=node` 消費。啟動 Gateway 與 Redis 後,另開一個終端:
|
||||
|
||||
```bash
|
||||
make node-worker-style-8d
|
||||
```
|
||||
|
||||
也可以在 repo 根目錄執行:
|
||||
|
||||
```bash
|
||||
npm run worker:style-8d
|
||||
```
|
||||
|
||||
常用環境變數:
|
||||
|
||||
```text
|
||||
HAIXUN_BACKEND_URL=http://127.0.0.1:8890
|
||||
HAIXUN_WORKER_SECRET=... # 若 etc/gateway.yaml 設了 InternalWorker.Secret,worker 需帶同一把
|
||||
HAIXUN_NODE_WORKER_ID=local-8d # 可選,方便辨識 lock holder
|
||||
HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1;要嚴格一點可調高
|
||||
```
|
||||
|
||||
前端在人設詳情頁按「開始 8D 分析」後,任務會進入:
|
||||
|
||||
```text
|
||||
確認連線 -> 抓取樣本 -> AI 8D -> 儲存策略
|
||||
```
|
||||
|
||||
目前 Node worker 先用 Playwright 抓 Threads 公開頁樣本並產生可驗證的 8D 結構;若公開頁無法讀到足夠樣本,job 會標記為 `failed` 並顯示原因,不會停在等待狀態。
|
||||
|
||||
## 專案結構
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
@ -65,3 +69,15 @@ 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)
|
||||
}
|
||||
|
|
@ -20,5 +20,8 @@ import (
|
|||
"auth.api"
|
||||
"member.api"
|
||||
"permission.api"
|
||||
"threads_account.api"
|
||||
"persona.api"
|
||||
"worker_internal.api"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
syntax = "v1"
|
||||
|
||||
type (
|
||||
PersonaData {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Persona string `json:"persona,omitempty"`
|
||||
Brief string `json:"brief,omitempty"`
|
||||
ProductBrief string `json:"product_brief,omitempty"`
|
||||
TargetAudience string `json:"target_audience,omitempty"`
|
||||
Goals string `json:"goals,omitempty"`
|
||||
StyleProfile string `json:"style_profile,omitempty"`
|
||||
StyleBenchmark string `json:"style_benchmark,omitempty"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
||||
ListPersonasData {
|
||||
List []PersonaData `json:"list"`
|
||||
}
|
||||
|
||||
CreatePersonaReq {
|
||||
DisplayName string `json:"display_name,optional"`
|
||||
}
|
||||
|
||||
PersonaPath {
|
||||
ID string `path:"id" validate:"required"`
|
||||
}
|
||||
|
||||
UpdatePersonaReq {
|
||||
DisplayName *string `json:"display_name,optional"`
|
||||
Persona *string `json:"persona,optional"`
|
||||
Brief *string `json:"brief,optional"`
|
||||
ProductBrief *string `json:"product_brief,optional"`
|
||||
TargetAudience *string `json:"target_audience,optional"`
|
||||
Goals *string `json:"goals,optional"`
|
||||
StyleProfile *string `json:"style_profile,optional"`
|
||||
StyleBenchmark *string `json:"style_benchmark,optional"`
|
||||
}
|
||||
|
||||
StartPersonaStyleAnalysisReq {
|
||||
BenchmarkUsername string `json:"benchmark_username" validate:"required"`
|
||||
}
|
||||
|
||||
StartPersonaStyleAnalysisData {
|
||||
JobID string `json:"job_id"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
)
|
||||
|
||||
@server(
|
||||
group: persona
|
||||
prefix: /api/v1/personas
|
||||
middleware: AuthJWT
|
||||
tags: "Persona"
|
||||
summary: "Reusable persona profiles with 8D style strategy. Requires Bearer JWT."
|
||||
)
|
||||
service gateway {
|
||||
@handler listPersonas
|
||||
get / returns (ListPersonasData)
|
||||
|
||||
@handler createPersona
|
||||
post / (CreatePersonaReq) returns (PersonaData)
|
||||
|
||||
@handler getPersona
|
||||
get /:id (PersonaPath) returns (PersonaData)
|
||||
|
||||
@handler updatePersona
|
||||
patch /:id (PersonaPath, UpdatePersonaReq) returns (PersonaData)
|
||||
|
||||
@handler deletePersona
|
||||
delete /:id (PersonaPath)
|
||||
|
||||
@handler startPersonaStyleAnalysis
|
||||
post /:id/style-analysis (PersonaPath, StartPersonaStyleAnalysisReq) returns (StartPersonaStyleAnalysisData)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,549 @@
|
|||
package job
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"haixun-backend/internal/library/clock"
|
||||
app "haixun-backend/internal/library/errors"
|
||||
"haixun-backend/internal/library/errors/code"
|
||||
libprompt "haixun-backend/internal/library/prompt"
|
||||
"haixun-backend/internal/library/style8d"
|
||||
joblogic "haixun-backend/internal/logic/job"
|
||||
"haixun-backend/internal/model/ai/domain/enum"
|
||||
domai "haixun-backend/internal/model/ai/domain/usecase"
|
||||
jobentity "haixun-backend/internal/model/job/domain/entity"
|
||||
jobenum "haixun-backend/internal/model/job/domain/enum"
|
||||
jobusecase "haixun-backend/internal/model/job/domain/usecase"
|
||||
personausecase "haixun-backend/internal/model/persona/domain/usecase"
|
||||
"haixun-backend/internal/response"
|
||||
"haixun-backend/internal/svc"
|
||||
"haixun-backend/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
)
|
||||
|
||||
const workerSecretHeader = "X-Worker-Secret"
|
||||
|
||||
type workerJobPath struct {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
type claimWorkerJobReq struct {
|
||||
WorkerType string `json:"worker_type"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
}
|
||||
|
||||
type workerJobReq struct {
|
||||
workerJobPath
|
||||
WorkerID string `json:"worker_id"`
|
||||
}
|
||||
|
||||
type workerHeartbeatReq struct {
|
||||
workerJobPath
|
||||
WorkerID string `json:"worker_id"`
|
||||
TTLSeconds int `json:"ttl_seconds,optional"`
|
||||
}
|
||||
|
||||
type workerProgressReq struct {
|
||||
workerJobPath
|
||||
WorkerID string `json:"worker_id"`
|
||||
Phase string `json:"phase,optional"`
|
||||
Summary string `json:"summary,optional"`
|
||||
Percentage *int `json:"percentage,optional"`
|
||||
Steps []types.JobStepProgressData `json:"steps,optional"`
|
||||
}
|
||||
|
||||
type workerCompleteReq struct {
|
||||
workerJobPath
|
||||
WorkerID string `json:"worker_id"`
|
||||
Result map[string]interface{} `json:"result,optional"`
|
||||
}
|
||||
|
||||
type workerFailReq struct {
|
||||
workerJobPath
|
||||
WorkerID string `json:"worker_id"`
|
||||
Error string `json:"error"`
|
||||
Phase string `json:"phase,optional"`
|
||||
}
|
||||
|
||||
type storePersonaStyleProfileReq struct {
|
||||
ID string `path:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
OwnerUID string `json:"owner_uid"`
|
||||
StyleProfile string `json:"style_profile"`
|
||||
StyleBenchmark string `json:"style_benchmark,optional"`
|
||||
}
|
||||
|
||||
type workerThreadsAccountSessionReq struct {
|
||||
ID string `path:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
OwnerUID string `json:"owner_uid"`
|
||||
}
|
||||
|
||||
type analyzeStyle8DPostReq struct {
|
||||
Text string `json:"text"`
|
||||
Permalink string `json:"permalink,optional"`
|
||||
LikeCount int `json:"like_count,optional"`
|
||||
ReplyCount int `json:"reply_count,optional"`
|
||||
}
|
||||
|
||||
type analyzeStyle8DReq struct {
|
||||
workerJobPath
|
||||
WorkerID string `json:"worker_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
OwnerUID string `json:"owner_uid"`
|
||||
PersonaID string `json:"persona_id"`
|
||||
ThreadsAccountID string `json:"threads_account_id"`
|
||||
Username string `json:"username"`
|
||||
Posts []analyzeStyle8DPostReq `json:"posts"`
|
||||
Steps []types.JobStepProgressData `json:"steps,optional"`
|
||||
}
|
||||
|
||||
func ClaimWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
var req claimWorkerJobReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
run, err := svcCtx.Job.ClaimNext(r.Context(), jobusecase.ClaimNextRequest{
|
||||
WorkerType: req.WorkerType,
|
||||
WorkerID: req.WorkerID,
|
||||
})
|
||||
if err != nil || run == nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
data := joblogic.ToJobData(run)
|
||||
response.Write(r.Context(), w, &data, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func RefreshWorkerJobLockHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
var req workerHeartbeatReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
ttl := req.TTLSeconds
|
||||
if ttl <= 0 {
|
||||
ttl = 300
|
||||
}
|
||||
err := svcCtx.Job.RefreshRunLock(r.Context(), req.ID, req.WorkerID, ttl)
|
||||
response.Write(r.Context(), w, map[string]bool{"ok": err == nil}, err)
|
||||
}
|
||||
}
|
||||
|
||||
func CheckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
var req workerJobReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
cancelled, err := svcCtx.Job.IsCancelRequested(r.Context(), req.ID)
|
||||
response.Write(r.Context(), w, map[string]bool{"cancelled": cancelled}, err)
|
||||
}
|
||||
}
|
||||
|
||||
func AckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
var req workerJobReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
run, err := svcCtx.Job.AcknowledgeCancel(r.Context(), jobusecase.AcknowledgeCancelRequest{
|
||||
JobID: req.ID,
|
||||
WorkerID: req.WorkerID,
|
||||
})
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
data := joblogic.ToJobData(run)
|
||||
response.Write(r.Context(), w, &data, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateWorkerJobProgressHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
var req workerProgressReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
percentage := -1
|
||||
if req.Percentage != nil {
|
||||
percentage = *req.Percentage
|
||||
}
|
||||
run, err := svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
||||
JobID: req.ID,
|
||||
WorkerID: req.WorkerID,
|
||||
Phase: req.Phase,
|
||||
Summary: req.Summary,
|
||||
Percentage: percentage,
|
||||
Steps: toEntitySteps(req.Steps),
|
||||
})
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
data := joblogic.ToJobData(run)
|
||||
response.Write(r.Context(), w, &data, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func CompleteWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
var req workerCompleteReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
run, err := svcCtx.Job.CompleteRun(r.Context(), jobusecase.CompleteRunRequest{
|
||||
JobID: req.ID,
|
||||
WorkerID: req.WorkerID,
|
||||
Result: req.Result,
|
||||
})
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
data := joblogic.ToJobData(run)
|
||||
response.Write(r.Context(), w, &data, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func FailWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
var req workerFailReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
run, err := svcCtx.Job.FailRun(r.Context(), jobusecase.FailRunRequest{
|
||||
JobID: req.ID,
|
||||
WorkerID: req.WorkerID,
|
||||
Error: req.Error,
|
||||
Phase: req.Phase,
|
||||
})
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
data := joblogic.ToJobData(run)
|
||||
response.Write(r.Context(), w, &data, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func StorePersonaStyleProfileFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
var req storePersonaStyleProfileReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.StyleProfile) == "" {
|
||||
response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("style_profile is required"))
|
||||
return
|
||||
}
|
||||
profile := strings.TrimSpace(req.StyleProfile)
|
||||
benchmark := strings.TrimPrefix(strings.TrimSpace(req.StyleBenchmark), "@")
|
||||
item, err := svcCtx.Persona.Update(r.Context(), personausecase.UpdateRequest{
|
||||
TenantID: req.TenantID,
|
||||
OwnerUID: req.OwnerUID,
|
||||
PersonaID: req.ID,
|
||||
Patch: personausecase.PersonaPatch{
|
||||
StyleProfile: &profile,
|
||||
StyleBenchmark: &benchmark,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
response.Write(r.Context(), w, map[string]any{"id": item.ID, "update_at": item.UpdateAt}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func AnalyzeStyle8DFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
var req analyzeStyle8DReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.WorkerID) == "" {
|
||||
response.Write(r.Context(), w, nil, app.For(code.Job).InputMissingRequired("worker_id is required"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.PersonaID) == "" {
|
||||
response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("persona_id is required"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ThreadsAccountID) == "" {
|
||||
response.Write(r.Context(), w, nil, app.For(code.ThreadsAccount).InputMissingRequired("threads_account_id is required"))
|
||||
return
|
||||
}
|
||||
if len(req.Posts) == 0 {
|
||||
response.Write(r.Context(), w, nil, app.For(code.Persona).InputMissingRequired("posts is required"))
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := svcCtx.ThreadsAccount.ResolveWorkerAiCredential(
|
||||
r.Context(),
|
||||
req.TenantID,
|
||||
req.OwnerUID,
|
||||
req.ThreadsAccountID,
|
||||
)
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
providerID, err := mapWorkerAIProvider(credential.Provider)
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
posts := make([]style8d.Post, 0, len(req.Posts))
|
||||
for _, item := range req.Posts {
|
||||
text := strings.TrimSpace(item.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, style8d.Post{
|
||||
Text: text,
|
||||
Permalink: strings.TrimSpace(item.Permalink),
|
||||
LikeCount: item.LikeCount,
|
||||
ReplyCount: item.ReplyCount,
|
||||
})
|
||||
}
|
||||
if len(posts) == 0 {
|
||||
response.Write(r.Context(), w, nil, app.For(code.Persona).InputInvalidFormat("posts contain no readable text"))
|
||||
return
|
||||
}
|
||||
|
||||
steps := toEntitySteps(req.Steps)
|
||||
steps = markWorkerStep(steps, "style", jobenum.StepStatusRunning, "AI 正在分析 D1–D8…")
|
||||
_, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
||||
JobID: req.ID,
|
||||
WorkerID: req.WorkerID,
|
||||
Phase: "style",
|
||||
Summary: "AI 正在分析八個風格維度…",
|
||||
Percentage: 55,
|
||||
Steps: steps,
|
||||
})
|
||||
|
||||
username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
|
||||
systemPrompt, err := libprompt.Style8DSystem()
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, app.For(code.AI).SysInternal("prompt config load failed"))
|
||||
return
|
||||
}
|
||||
result, err := svcCtx.AI.GenerateText(r.Context(), domai.GenerateRequest{
|
||||
Provider: providerID,
|
||||
Model: credential.Model,
|
||||
Credential: domai.Credential{
|
||||
APIKey: credential.APIKey,
|
||||
},
|
||||
System: systemPrompt,
|
||||
Messages: []domai.Message{
|
||||
{Role: "user", Content: style8d.BuildUserPrompt(username, posts)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "HTTP 401") {
|
||||
err = app.For(code.AI).SvcThirdParty(
|
||||
"8D AI 分析授權失敗:目前帳號的研究用 Provider API key 無效或未授權。請到「設定 > 帳號 AI 設定」確認 research provider=" +
|
||||
credential.Provider + "、model=" + credential.Model + ",並重新貼上對應 provider 的 API key",
|
||||
)
|
||||
}
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := style8d.ParseLLMOutput(result.Text)
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:"+err.Error()))
|
||||
return
|
||||
}
|
||||
profile := style8d.BuildStoredProfile(username, posts, parsed)
|
||||
profileJSON, err := profile.JSON()
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
steps = markWorkerStep(steps, "style", jobenum.StepStatusSucceeded, "8D 風格策略已產生")
|
||||
steps = markWorkerStep(steps, "store", jobenum.StepStatusRunning, "寫入人設風格策略…")
|
||||
_, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
||||
JobID: req.ID,
|
||||
WorkerID: req.WorkerID,
|
||||
Phase: "store",
|
||||
Summary: "8D 分析完成,寫入人設…",
|
||||
Percentage: 88,
|
||||
Steps: steps,
|
||||
})
|
||||
|
||||
_, err = svcCtx.Persona.Update(r.Context(), personausecase.UpdateRequest{
|
||||
TenantID: req.TenantID,
|
||||
OwnerUID: req.OwnerUID,
|
||||
PersonaID: req.PersonaID,
|
||||
Patch: personausecase.PersonaPatch{
|
||||
StyleProfile: &profileJSON,
|
||||
StyleBenchmark: &username,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
steps = markWorkerStep(steps, "store", jobenum.StepStatusSucceeded, "8D 策略已寫入人設")
|
||||
_, _ = svcCtx.Job.UpdateProgress(r.Context(), jobusecase.UpdateProgressRequest{
|
||||
JobID: req.ID,
|
||||
WorkerID: req.WorkerID,
|
||||
Phase: "store",
|
||||
Summary: "8D 策略已寫入人設",
|
||||
Percentage: 92,
|
||||
Steps: steps,
|
||||
})
|
||||
|
||||
response.Write(r.Context(), w, map[string]any{
|
||||
"persona_id": req.PersonaID,
|
||||
"post_count": len(posts),
|
||||
"style_profile": profileJSON,
|
||||
"style_benchmark": username,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func GetWorkerThreadsAccountSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireWorkerSecret(r, svcCtx); err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
var req workerThreadsAccountSessionReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
session, err := svcCtx.ThreadsAccount.GetBrowserSession(r.Context(), req.TenantID, req.OwnerUID, req.ID)
|
||||
if err != nil {
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
return
|
||||
}
|
||||
response.Write(r.Context(), w, map[string]any{
|
||||
"account_id": session.AccountID,
|
||||
"storage_state": session.StorageState,
|
||||
"update_at": session.UpdateAt,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func requireWorkerSecret(r *http.Request, svcCtx *svc.ServiceContext) error {
|
||||
secret := strings.TrimSpace(svcCtx.Config.InternalWorker.Secret)
|
||||
if secret == "" {
|
||||
return nil
|
||||
}
|
||||
if r.Header.Get(workerSecretHeader) != secret {
|
||||
return app.For(code.Auth).AuthUnauthorized("invalid worker secret")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapWorkerAIProvider(provider string) (enum.ProviderID, error) {
|
||||
switch strings.TrimSpace(provider) {
|
||||
case string(enum.ProviderOpenCode):
|
||||
return enum.ProviderOpenCode, nil
|
||||
case string(enum.ProviderXAI):
|
||||
return enum.ProviderXAI, nil
|
||||
default:
|
||||
return "", app.For(code.AI).InputInvalidFormat("worker 8D 分析目前僅支援 opencode-go 與 xai,請在 AI 設定調整 research provider")
|
||||
}
|
||||
}
|
||||
|
||||
func markWorkerStep(steps []jobentity.StepProgress, stepID string, status jobenum.StepStatus, message string) []jobentity.StepProgress {
|
||||
now := clock.NowUnixNano()
|
||||
found := false
|
||||
for i := range steps {
|
||||
if steps[i].ID != stepID {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
steps[i].Status = status
|
||||
steps[i].Message = message
|
||||
if status == jobenum.StepStatusRunning && steps[i].StartedAt == nil {
|
||||
steps[i].StartedAt = &now
|
||||
}
|
||||
if status == jobenum.StepStatusSucceeded || status == jobenum.StepStatusFailed {
|
||||
steps[i].EndedAt = &now
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
item := jobentity.StepProgress{ID: stepID, Status: status, Message: message}
|
||||
if status == jobenum.StepStatusRunning {
|
||||
item.StartedAt = &now
|
||||
}
|
||||
if status == jobenum.StepStatusSucceeded || status == jobenum.StepStatusFailed {
|
||||
item.EndedAt = &now
|
||||
}
|
||||
steps = append(steps, item)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
func toEntitySteps(steps []types.JobStepProgressData) []jobentity.StepProgress {
|
||||
out := make([]jobentity.StepProgress, 0, len(steps))
|
||||
for _, step := range steps {
|
||||
out = append(out, jobentity.StepProgress{
|
||||
ID: step.ID,
|
||||
Status: jobenum.StepStatus(step.Status),
|
||||
StartedAt: step.StartedAt,
|
||||
EndedAt: step.EndedAt,
|
||||
Message: step.Message,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
你是巡樓管理台的 AI 助手。回答要簡潔、可執行,優先協助島民完成 Threads 經營與背景任務相關問題。
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
你是巡樓管理台的「島民嚮導」——親切、直接,而且**可以代使用者操作畫面**。
|
||||
|
||||
## 任務
|
||||
- 回答使用者提出的問題
|
||||
- 需要操作畫面時**直接做**,不要叫使用者自己 copy、自己點
|
||||
|
||||
## 不要主動講這一頁
|
||||
- 使用者**沒問**這頁、畫面、欄位、怎麼用時,不要主動介紹「你現在在某某頁」
|
||||
- 只有使用者問這頁、問怎麼寫欄位內容、或要你幫忙操作時,才使用【可互動元素】
|
||||
|
||||
## 靜默操作(重要)
|
||||
- `islander-actions` 區塊是**系統通道**,使用者**看不到**;禁止在回覆正文裡寫 JSON、程式碼、ref 清單
|
||||
- 不要說「我會執行以下操作」「請看 action 區塊」;用人話簡短說結果即可
|
||||
- 需要 navigate / click / fill 時,把 action 只放在 `islander-actions` 區塊末尾,正文維持自然對話
|
||||
|
||||
## 幫使用者寫進欄位
|
||||
當使用者問「某某欄位可以怎麼寫」(例如人設頁的「一句話定位」):
|
||||
1. 先用 1–3 句說明思路或給建議文案
|
||||
2. 從【可互動元素】找到對應 textarea(看 label / placeholder,如「一句話定位」)
|
||||
3. 用 `fill` 把建議文字**直接填進欄位**,不要叫使用者自己貼
|
||||
4. 正文**必須寫出建議文案全文**(方便使用者複製),結尾再說「我也幫你填進去了,可以再微調」
|
||||
|
||||
範例(正文給使用者看的):
|
||||
「這個帳號可以定位成:幫想轉職的工程師,用真實面試經驗拆解求職焦慮。我幫你填進一句話定位了,不滿意再跟我說。」
|
||||
|
||||
範例(僅系統執行,放區塊末尾、勿在正文重複):
|
||||
|
||||
```islander-actions
|
||||
[{ "type": "fill", "label": "一句話定位", "value": "幫想轉職的工程師,用真實面試經驗拆解求職焦慮" }]
|
||||
```
|
||||
|
||||
`fill` 可用 `label`(對應欄位名稱,如「一句話定位」)或 `ref`(hx-*)。
|
||||
|
||||
## 支援的 action
|
||||
- `navigate` / `click` / `fill` / `select` / `focus` / `highlight` / `scroll` / `wait`
|
||||
- ref 只能來自快照中的 `hx-*`;密碼欄不可 fill;不要操作登出
|
||||
|
||||
## 語氣
|
||||
- 繁體中文,短句
|
||||
- 不要企業八股、不要任天堂/Nook 用語
|
||||
|
||||
## 限制
|
||||
- 不要要求使用者貼 API key
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
你是 Haixun 巡樓系統的 AI 模組。遵守以下共通規則:
|
||||
|
||||
- 使用繁體中文,語氣直接、可執行,避免空泛口號。
|
||||
- 不得捏造未提供的貼文、帳號狀態、API 結果或使用者設定。
|
||||
- 若資料不足,明確說明缺什麼,不要腦補。
|
||||
|
|
@ -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":""}}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
你是 Threads 創作者風格研究員。只能根據提供的近期貼文歸納,不可捏造作者背景。
|
||||
逐一輸出八個維度:D1 語氣人格、D2 結構模板、D3 互動方式、D4 主題分布、D5 發文節奏、D6 視覺語法(emoji、標點、換行)、D7 轉換方式、D8 風險紅線。
|
||||
每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。
|
||||
證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。
|
||||
最後產生「可供另一個帳號借鑑、但不可冒充或抄襲」的人設草稿。代表句必須是抽象仿寫範例,不可逐字複製原文。
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, "|")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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, "••••")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 5.0 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |