feat(desktop): implement workspace foundation + desktop-client skeleton

Plan 0 (workspaces): add workspaces + workspace_memberships schema, extend
JWT/Actor/claims with primary_workspace_id, seed default workspace per tenant,
thread workspace_id through tenant monitoring quota.

Plan A (desktop skeleton): new Electron app (apps/desktop-client) with main/
preload/renderer, shared Vue component package (packages/ui-shared), and server
surface — desktop client registration + token rotation + heartbeat, SSE task
event stream, desktop accounts/tasks/content handlers, publish job endpoint,
and supporting repositories, services, sqlc queries, and migrations.

Hard cutover per plan: remove browser-extension monitoring callback endpoints,
stub legacy media API in admin-web, and delete monitoring_callback_handler.go.
This commit is contained in:
2026-04-19 14:18:20 +08:00
parent 98f9e95875
commit b16e9f0bd1
141 changed files with 21533 additions and 357 deletions
@@ -0,0 +1,780 @@
import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types";
import { ensureViewLoaded, normalizeText } from "./common";
import type { MonitorAdapter } from "./base";
const DOUBAO_APP_ID = "497858";
const DOUBAO_BOT_ID = "7338286299411103781";
const DOUBAO_CHAT_URL = "https://www.doubao.com/chat/completion";
const DOUBAO_REFERER = "https://www.doubao.com/chat/";
const SEARCH_RESULT_KEYS = new Set([
"references",
"reference_list",
"referenceList",
"reference_cards",
"referenceCards",
"reference_docs",
"referenceDocs",
"sources",
"source_list",
"sourceList",
"source_cards",
"sourceCards",
"search_results",
"searchResults",
"search_result_list",
"searchResultList",
"search_refs",
"searchRefs",
"web_results",
"webResults",
"grounding",
"grounding_results",
"groundingResults",
"browse_results",
"browseResults",
"browse_references",
"browseReferences",
]);
const CONTENT_CITATION_KEYS = new Set([
"citations",
"citation_list",
"citationList",
"content_citations",
"contentCitations",
"answer_citations",
"answerCitations",
]);
type DoubaoRuntimeState = {
fp: string;
ms_token: string;
device_id: string;
web_id: string;
tea_uuid: string;
};
type DoubaoStreamEvent = {
event: string;
payload: unknown;
};
type DoubaoStreamSummary = {
answer: string | null;
provider_request_id: string | null;
request_id: string | null;
conversation_id: string | null;
citations: MonitoringSourceItem[];
search_results: MonitoringSourceItem[];
event_count: number;
error_message: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeOptionalString(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function safeParseJSON(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
function getDirectString(record: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = normalizeOptionalString(record[key]);
if (value) {
return value;
}
}
return null;
}
function findFirstStringByKey(source: unknown, keys: string[], depth = 0): string | null {
if (depth > 8 || source == null) {
return null;
}
if (Array.isArray(source)) {
for (const item of source) {
const found = findFirstStringByKey(item, keys, depth + 1);
if (found) {
return found;
}
}
return null;
}
if (!isRecord(source)) {
return null;
}
for (const key of keys) {
const direct = normalizeOptionalString(source[key]);
if (direct) {
return direct;
}
}
for (const value of Object.values(source)) {
const found = findFirstStringByKey(value, keys, depth + 1);
if (found) {
return found;
}
}
return null;
}
function readNestedString(source: unknown, path: string[]): string | null {
let current: unknown = source;
for (const key of path) {
if (!isRecord(current) || !(key in current)) {
return null;
}
current = current[key];
}
return normalizeOptionalString(current);
}
function mergeAnswerText(current: string | null, nextFragment: string | null): string | null {
if (!nextFragment) {
return current;
}
if (!current) {
return nextFragment;
}
if (current === nextFragment || current.includes(nextFragment)) {
return current;
}
if (nextFragment.startsWith(current)) {
return nextFragment;
}
return `${current}${nextFragment}`;
}
function resolveSourceBucket(key: string): "search" | "citation" | null {
const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
if (!normalized) {
return null;
}
if (CONTENT_CITATION_KEYS.has(key) || normalized.includes("citation")) {
return "citation";
}
if (SEARCH_RESULT_KEYS.has(key) || normalized.includes("reference") || normalized.includes("source") || normalized.includes("search")) {
return "search";
}
return null;
}
function normalizeSourceUrl(value: string | null): string | null {
const input = normalizeText(value);
if (!input) {
return null;
}
try {
const url = new URL(input);
url.hash = "";
return url.toString();
} catch {
return input;
}
}
function buildSourceItem(input: unknown): MonitoringSourceItem | null {
if (typeof input === "string") {
const url = normalizeSourceUrl(input);
return url ? { url, normalized_url: url } : null;
}
if (!isRecord(input)) {
return null;
}
const url = normalizeSourceUrl(
getDirectString(input, [
"url",
"link",
"uri",
"href",
"cited_url",
"target_url",
"targetUrl",
"source_url",
"sourceUrl",
"page_url",
"pageUrl",
"jump_url",
"jumpUrl",
]),
);
if (!url) {
return null;
}
let host: string | null = null;
try {
host = new URL(url).hostname || null;
} catch {
host = null;
}
return {
url,
title: getDirectString(input, ["title", "name", "cited_title", "page_title", "display_title", "page_name"]),
site_name: getDirectString(input, ["site_name", "siteName", "source", "platform_name", "domain", "site", "host_name"]),
site_key: getDirectString(input, ["site_key", "siteKey", "domain_key"]),
normalized_url: url,
host,
resolution_status: getDirectString(input, ["resolution_status", "resolutionStatus"]),
resolution_confidence: getDirectString(input, ["resolution_confidence", "resolutionConfidence"]),
};
}
function dedupeSourceItems(items: MonitoringSourceItem[]): MonitoringSourceItem[] {
const keyed = new Map<string, MonitoringSourceItem>();
for (const item of items) {
const key = normalizeSourceUrl(item.normalized_url ?? item.url);
if (!key) {
continue;
}
const existing = keyed.get(key);
if (!existing) {
keyed.set(key, {
...item,
normalized_url: key,
});
continue;
}
keyed.set(key, {
...existing,
title: existing.title ?? item.title ?? null,
site_name: existing.site_name ?? item.site_name ?? null,
site_key: existing.site_key ?? item.site_key ?? null,
host: existing.host ?? item.host ?? null,
resolution_status: existing.resolution_status ?? item.resolution_status ?? null,
resolution_confidence: existing.resolution_confidence ?? item.resolution_confidence ?? null,
});
}
return Array.from(keyed.values());
}
function collectSources(
payload: unknown,
searchResults: MonitoringSourceItem[],
citations: MonitoringSourceItem[],
bucket: "search" | "citation" | null = null,
depth = 0,
): void {
if (depth > 12 || payload == null) {
return;
}
const push = (item: MonitoringSourceItem, targetBucket: "search" | "citation" | null) => {
if (targetBucket === "citation") {
citations.push(item);
return;
}
searchResults.push(item);
};
if (typeof payload === "string") {
if (!bucket) {
return;
}
const item = buildSourceItem(payload);
if (item) {
push(item, bucket);
}
return;
}
if (Array.isArray(payload)) {
for (const item of payload) {
collectSources(item, searchResults, citations, bucket, depth + 1);
}
return;
}
if (!isRecord(payload)) {
return;
}
const directSourceItem = buildSourceItem(payload);
if (directSourceItem && bucket) {
push(directSourceItem, bucket);
}
for (const [key, value] of Object.entries(payload)) {
const nextBucket = resolveSourceBucket(key) ?? bucket;
if (Array.isArray(value) || isRecord(value) || typeof value === "string") {
collectSources(value, searchResults, citations, nextBucket, depth + 1);
}
}
}
function collectAnswerFragments(payload: unknown, result: string[] = []): string[] {
if (payload == null) {
return result;
}
if (Array.isArray(payload)) {
for (const item of payload) {
collectAnswerFragments(item, result);
}
return result;
}
if (!isRecord(payload)) {
return result;
}
const textBlockText = readNestedString(payload, ["text_block", "text"]);
if (textBlockText) {
result.push(textBlockText);
}
const deltaText = normalizeOptionalString(payload.delta);
if (deltaText) {
result.push(deltaText);
}
const nestedDeltaText = readNestedString(payload, ["delta", "text"]);
if (nestedDeltaText) {
result.push(nestedDeltaText);
}
const assistantText =
(normalizeOptionalString(payload.role) === "assistant" ||
normalizeOptionalString(payload.message_type) === "assistant") &&
normalizeOptionalString(payload.text);
if (assistantText) {
result.push(assistantText);
}
for (const value of Object.values(payload)) {
if (Array.isArray(value) || isRecord(value)) {
collectAnswerFragments(value, result);
}
}
return result;
}
function parseSSERecord(record: string): DoubaoStreamEvent | null {
const lines = record.split(/\r?\n/);
let eventName = "message";
const dataLines: string[] = [];
for (const line of lines) {
if (!line || line.startsWith(":")) {
continue;
}
if (line.startsWith("event:")) {
eventName = line.slice(6).trim() || eventName;
continue;
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
}
}
if (!dataLines.length) {
return null;
}
return {
event: eventName,
payload: safeParseJSON(dataLines.join("\n")),
};
}
function formatDoubaoStreamError(payload: unknown): string {
const errorCode = findFirstStringByKey(payload, ["error_code", "code"]);
const errorMessage = findFirstStringByKey(payload, ["error_msg", "message", "detail"]);
const verifyScene = findFirstStringByKey(payload, ["verify_scene"]);
if (verifyScene) {
return `doubao verification required (${verifyScene})`;
}
if (errorCode && errorMessage) {
return `doubao stream error ${errorCode}: ${errorMessage}`;
}
if (errorCode) {
return `doubao stream error ${errorCode}`;
}
if (errorMessage) {
return `doubao stream error: ${errorMessage}`;
}
return "doubao stream error";
}
async function parseDoubaoStream(response: Response): Promise<DoubaoStreamSummary> {
const summary: DoubaoStreamSummary = {
answer: null,
provider_request_id: null,
request_id: null,
conversation_id: null,
citations: [],
search_results: [],
event_count: 0,
error_message: null,
};
const body = response.body;
if (!body) {
throw new Error("doubao response stream missing");
}
const decoder = new TextDecoder();
const reader = body.getReader();
let buffer = "";
const consumeRecord = (rawRecord: string) => {
const parsed = parseSSERecord(rawRecord);
if (!parsed) {
return;
}
summary.event_count += 1;
if (parsed.event.toUpperCase().includes("ERROR")) {
summary.error_message = formatDoubaoStreamError(parsed.payload);
return;
}
if (!summary.conversation_id) {
summary.conversation_id =
readNestedString(parsed.payload, ["ack_client_meta", "conversation_id"]) ??
readNestedString(parsed.payload, ["client_meta", "conversation_id"]) ??
findFirstStringByKey(parsed.payload, ["conversation_id"]);
}
if (!summary.provider_request_id) {
summary.provider_request_id = findFirstStringByKey(parsed.payload, ["log_id", "request_id", "req_id"]);
}
if (!summary.request_id) {
summary.request_id = findFirstStringByKey(parsed.payload, ["message_id", "local_message_id", "reply_id"]);
}
const fragments = collectAnswerFragments(parsed.payload);
for (const fragment of fragments) {
summary.answer = mergeAnswerText(summary.answer, fragment);
}
collectSources(parsed.payload, summary.search_results, summary.citations);
};
while (true) {
const { done, value } = await reader.read();
if (done) {
buffer += decoder.decode();
buffer = buffer.replace(/\r\n/g, "\n");
break;
}
buffer += decoder.decode(value, { stream: true });
buffer = buffer.replace(/\r\n/g, "\n");
let separatorIndex = buffer.indexOf("\n\n");
while (separatorIndex !== -1) {
const rawRecord = buffer.slice(0, separatorIndex).trim();
buffer = buffer.slice(separatorIndex + 2);
if (rawRecord) {
consumeRecord(rawRecord);
}
separatorIndex = buffer.indexOf("\n\n");
}
}
const tail = buffer.trim();
if (tail) {
consumeRecord(tail);
}
summary.citations = dedupeSourceItems(summary.citations);
summary.search_results = dedupeSourceItems(summary.search_results);
return summary;
}
function buildDoubaoRequestUrl(runtimeState: DoubaoRuntimeState): string {
const url = new URL(DOUBAO_CHAT_URL);
url.searchParams.set("aid", DOUBAO_APP_ID);
url.searchParams.set("device_platform", "web");
url.searchParams.set("device_id", runtimeState.device_id);
url.searchParams.set("web_id", runtimeState.web_id);
url.searchParams.set("tea_uuid", runtimeState.tea_uuid);
url.searchParams.set("msToken", runtimeState.ms_token);
url.searchParams.set("fp", runtimeState.fp);
return url.toString();
}
function buildDoubaoRequestBody(questionText: string, runtimeState: DoubaoRuntimeState): Record<string, unknown> {
return {
client_meta: {
local_conversation_id: `local_${Date.now()}${Math.floor(Math.random() * 1_000_000)}`,
conversation_id: "",
bot_id: DOUBAO_BOT_ID,
last_section_id: "",
last_message_index: null,
},
messages: [
{
local_message_id: crypto.randomUUID(),
content_block: [
{
block_type: 10000,
content: {
text_block: {
text: questionText,
icon_url: "",
icon_url_dark: "",
summary: "",
},
pc_event_block: "",
},
block_id: crypto.randomUUID(),
parent_id: "",
meta_info: [],
append_fields: [],
},
],
message_status: 0,
},
],
option: {
need_deep_think: 1,
need_create_conversation: true,
conversation_init_option: {
need_ack_conversation: true,
},
sse_recv_event_options: {
support_chunk_delta: true,
},
},
ext: {
use_deep_think: "1",
fp: runtimeState.fp,
conversation_init_option: JSON.stringify({
need_ack_conversation: true,
}),
commerce_credit_config_enable: "0",
sub_conv_firstmet_type: "1",
},
};
}
function parseRuntimeState(value: unknown): DoubaoRuntimeState | null {
if (!isRecord(value)) {
return null;
}
const fp = normalizeOptionalString(value.fp);
const msToken = normalizeOptionalString(value.ms_token);
const deviceID = normalizeOptionalString(value.device_id);
const webID = normalizeOptionalString(value.web_id);
const teaUUID = normalizeOptionalString(value.tea_uuid);
if (!fp || !msToken || !deviceID || !webID || !teaUUID) {
return null;
}
return {
fp,
ms_token: msToken,
device_id: deviceID,
web_id: webID,
tea_uuid: teaUUID,
};
}
async function loadDoubaoRuntimeState(context: Parameters<MonitorAdapter["query"]>[0]): Promise<DoubaoRuntimeState> {
context.reportProgress("doubao.bootstrap_view");
await ensureViewLoaded(context.view, DOUBAO_REFERER, context.signal);
context.reportProgress("doubao.read_runtime_state");
const state = await context.view.webContents.executeJavaScript(
`(() => {
try {
const safeParse = (value) => {
if (!value) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
};
const samanthaState = safeParse(window.localStorage.getItem("samantha_web_web_id"));
const teaState = safeParse(window.localStorage.getItem("__tea_cache_tokens_497858"));
const pick = (source, paths) => {
for (const path of paths) {
let current = source;
let ok = true;
for (const key of path) {
if (!current || typeof current !== "object" || !(key in current)) {
ok = false;
break;
}
current = current[key];
}
if (ok && typeof current === "string" && current.trim()) {
return current.trim();
}
}
return null;
};
const readCookie = (name) => {
const segments = document.cookie.split(";");
for (const segment of segments) {
const [rawName, ...rest] = segment.trim().split("=");
if (rawName === name) {
const joined = rest.join("=").trim();
return joined || null;
}
}
return null;
};
return {
fp: readCookie("s_v_web_id"),
ms_token: window.localStorage.getItem("xmst"),
device_id: pick(samanthaState, [["web_id"], ["device_id"], ["deviceId"], ["id"]]),
web_id: pick(teaState, [["web_id"], ["webId"], ["id"]]),
tea_uuid: pick(teaState, [["web_id"], ["tea_uuid"], ["teaUuid"], ["user_unique_id"], ["uuid"]]),
};
} catch (error) {
return {
error: error instanceof Error ? error.message : "runtime_state_read_failed",
};
}
})();`,
true,
);
const runtimeState = parseRuntimeState(state);
if (!runtimeState) {
throw new Error("doubao_runtime_state_missing");
}
return runtimeState;
}
function extractQuestionText(payload: Record<string, unknown>): string {
const candidates = [
payload.question_text,
payload.query,
payload.question,
payload.prompt,
payload.content,
];
for (const candidate of candidates) {
const text = normalizeOptionalString(candidate);
if (text) {
return text;
}
}
throw new Error("doubao_question_text_missing");
}
function toJsonSources(items: MonitoringSourceItem[]): JsonValue[] {
return items.map((item) => ({
url: item.url,
title: item.title ?? null,
site_name: item.site_name ?? null,
site_key: item.site_key ?? null,
normalized_url: item.normalized_url ?? null,
host: item.host ?? null,
resolution_status: item.resolution_status ?? null,
resolution_confidence: item.resolution_confidence ?? null,
}));
}
function buildAdapterError(
code: string,
message: string,
extras: Record<string, JsonValue> = {},
): Record<string, JsonValue> {
return {
code,
message,
...extras,
};
}
export const doubaoAdapter: MonitorAdapter = {
provider: "doubao",
async query(context, payload) {
const questionText = extractQuestionText(payload);
const runtimeState = await loadDoubaoRuntimeState(context);
const requestBody = buildDoubaoRequestBody(questionText, runtimeState);
context.reportProgress("doubao.query");
const response = await context.session.fetch(buildDoubaoRequestUrl(runtimeState), {
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
referer: DOUBAO_REFERER,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const bodyText = await response.text().catch(() => "");
const detail = normalizeText(bodyText)?.slice(0, 240);
return {
status: "failed",
summary: detail ? `豆包请求失败:${detail}` : `豆包请求失败(${response.status})。`,
error: buildAdapterError(
"doubao_request_failed",
detail || `doubao_request_failed_${response.status}`,
{ http_status: response.status },
),
};
}
context.reportProgress("doubao.parse_stream");
const streamSummary = await parseDoubaoStream(response);
if (streamSummary.error_message) {
return {
status: "failed",
summary: streamSummary.error_message,
error: buildAdapterError("doubao_stream_error", streamSummary.error_message),
};
}
if (!streamSummary.answer && !streamSummary.citations.length && !streamSummary.search_results.length) {
return {
status: "unknown",
summary: "豆包返回为空,已回写 unknown 等待后续对账。",
error: buildAdapterError("doubao_empty_response", "doubao returned no answer or sources"),
};
}
return {
status: "succeeded",
summary: "豆包监控任务执行成功。",
payload: {
platform: "doubao",
provider_model: "doubao-web-thinking",
provider_request_id: streamSummary.provider_request_id,
request_id: streamSummary.request_id ?? streamSummary.conversation_id,
conversation_id: streamSummary.conversation_id,
answer: streamSummary.answer,
citation_count: streamSummary.citations.length,
search_result_count: streamSummary.search_results.length,
event_count: streamSummary.event_count,
citations: toJsonSources(streamSummary.citations),
search_results: toJsonSources(streamSummary.search_results),
},
};
},
};