This commit is contained in:
Rocky Kwok committed 2026-07-27 10:06:33 +08:00
commit fb73ae0471
220 files changed
+30857 -1735

No files matched your search

+9 -6
View File
@@ -150,12 +150,15 @@ export function useAllAgentsAdmin(enabled: boolean) {
return { agents: data ?? [], isLoading, error };
}
export function useAgent(id: string | null | undefined) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: !!id,
});
export function useAgent(
id: string | null | undefined,
{ enabled = true }: { enabled?: boolean } = {},
) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: enabled && !!id,
});
return { agent: data ?? null, isLoading, error };
}
+26 -6
View File
@@ -19,12 +19,32 @@ export async function loadArtifactContent({
let enhancedFilepath = filepath;
if (filepath.endsWith(".skill")) {
enhancedFilepath = filepath + "/SKILL.md";
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
const response = await apiFetch(url);
const text = await response.text();
return { content: text, url };
}
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
const response = await apiFetch(url);
const text = await response.text();
if (!response.ok) {
let detail = text.trim();
try {
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
const parsedDetail =
typeof parsed.detail === "string"
? parsed.detail
: typeof parsed.message === "string"
? parsed.message
: "";
if (parsedDetail) {
detail = parsedDetail;
}
} catch {
// Keep the raw response text as the diagnostic detail.
}
throw new Error(
`Failed to load artifact (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
return { content: text, url };
}
export function loadArtifactContentFromToolCall({
url: urlString,
+22 -20
View File
@@ -1,12 +1,12 @@
import { getBackendBaseURL } from "../config";
import type { AgentThread } from "../threads";
export function urlOfArtifact({
filepath,
threadId,
download = false,
isMock = false,
publicShareCode,
export function urlOfArtifact({
filepath,
threadId,
download = false,
isMock = false,
publicShareCode,
}: {
filepath: string;
threadId: string;
@@ -16,22 +16,24 @@ export function urlOfArtifact({
}) {
// A public read-only share has no auth-gated thread route — its referenced
// files are served by the unauthenticated public-share endpoint instead.
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
}
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
}
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
}
export function extractArtifactsFromThread(thread: AgentThread) {
return thread.values.artifacts ?? [];
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
const normalized = absolutePath.startsWith("/") ? absolutePath : `/${absolutePath}`;
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}`;
}
+38 -207
View File
@@ -1,101 +1,19 @@
/**
* 大屏绘制智能体「独立页面」的**输入 JSON** 适配 seam。
* 大屏绘制智能体的任务输入适配层。
*
* 设计意图:深链 `?taskId=` 模式下,后端提供「研判情报报告」接口
* GET `{VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=`
* 返回该任务的多类情报内容;本页把它**提取成干净的结构化 JSON** 交给智能体分析
* → 产出 report-json → 绘制大屏(react / html)。
*
* 接口返回结构:`{ state, msg, data: [{ id, taskId, ..., contentJson, categoryType }] }`,
* 其中 `contentJson` 是**被转义的 JSON 字符串**,`categoryType` 形如「敌情关键事件 JSON」。
* 本模块 `extractSqReport` 把每条的 `contentJson` 反序列化、按 `categoryType` 归类,
* 拼成 `{ taskId, categories: { 敌情关键事件: [...], 决心建议: {...}, ... } }` 喂给智能体。
*
* ⚠️ 真实接口现在还**请求不到**:通过 `VITE_MOCK_SQ_REPORT=true`(见 `utils/request.ts`)
* 走后端给的样例假数据(`sq-report-mock.json`),机制与 cop-task-detail 假数据一致。
* 联调通过后把该 env 置 false 即走真实接口;接口/数据若有变只需改 `extractSqReport`。
* 报告详情由 Gateway 的 `/api/task-reports/by-task/{taskId}` 提供,返回格式
* 与原 `selectSqReport` 一致:`{state, msg, data}`。每条 `contentJson` 是
* JSON 字符串,本模块将其按 `categoryType` 解析为智能体和大屏页面可直接使用的
* 结构化输入。
*/
import { requestCrudService } from "@/service/request-axios-service";
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
import { readRuntimeEnv, readRuntimeFlag } from "@/env";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
import { getTaskReport } from "@/strategy-components/api/task-reports";
import { readRuntimeFlag } from "@/env";
import { SQ_REPORT_MOCK_RESPONSE } from "@/strategy-components/components/task-components/sq-report-mock-data";
/** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */
/** 保留给既有调用方的 sessionStorage key;报告本身不再使用 mock 缓存。 */
export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson";
/**
* 🧪 演示用模拟业务 JSON —— JSON 接口未就绪、也没有登录任务详情时的兜底输入。
* 让大屏页可立即跑通:智能体分析这份数据 → report-json → 渲染大屏。
* ⚠️ 接口就绪后删除本常量及其兜底引用。
*/
export const DEMO_DASHBOARD_INPUT = {
taskId: "DEMO-涉台舆情",
taskName: "2026 第一季度涉台舆情态势研判",
taskContent:
"汇总境内外多平台涉台舆情数据,研判热点叙事走势、关键行为体立场与传播渠道分布,识别风险并给出处置建议。",
overview:
"本季涉台舆情整体平稳但局部波动,'两岸交流''和平发展'类正向叙事占比上升,少数账号借军事议题制造紧张;境外智库与媒体联动放大特定框架。",
period: "2026-01-01 ~ 2026-03-31",
kpis: {
总信息量: 1284000,
日均增速: "12%",
正向占比: "58%",
负向占比: "19%",
识别风险点: 7,
覆盖平台: 12,
},
channels: [
{ platform: "微博", share: 34 },
{ platform: "境外社媒(X/FB)", share: 27 },
{ platform: "短视频", share: 18 },
{ platform: "新闻门户", share: 13 },
{ platform: "论坛/贴吧", share: 8 },
],
narratives: [
{ theme: "两岸交流与融合发展", sentiment: "正向", volume: 42, trend: "up" },
{ theme: "和平稳定·避战", sentiment: "正向", volume: 26, trend: "up" },
{ theme: "军事演训与安全", sentiment: "中性偏负", volume: 21, trend: "flat" },
{ theme: "外部势力介入", sentiment: "负向", volume: 11, trend: "down" },
],
actors: [
{ name: "主流官媒", stance: "正面引导", influence: 88, note: "议程设置与权威发布" },
{ name: "岛内民间团体", stance: "中性多元", influence: 64, note: "交流合作类内容活跃" },
{ name: "境外智库", stance: "负面框架", influence: 57, note: "联动媒体放大紧张叙事" },
{ name: "自媒体大V", stance: "立场分化", influence: 49, note: "情绪化传播、易被借势" },
],
risks: [
{ label: "军事议题被恶意剪辑放大", level: "high" },
{ label: "境外智库联动制造紧张框架", level: "high" },
{ label: "个别大V情绪化带节奏", level: "mid" },
{ label: "短视频谣言二次传播", level: "mid" },
{ label: "节假日期间信息量骤增", level: "low" },
],
timeline: [
{ phase: "数据汇聚", desc: "12 平台多源采集与清洗去重" },
{ phase: "叙事识别", desc: "聚类提炼核心叙事与情感倾向" },
{ phase: "行为体画像", desc: "关键账号立场与影响力建模" },
{ phase: "风险研判", desc: "识别高危议题与传播链路" },
{ phase: "处置建议", desc: "分级响应与正向引导预案" },
],
weeklyVolume: [
{ week: "W1", value: 86 },
{ week: "W2", value: 92 },
{ week: "W3", value: 110 },
{ week: "W4", value: 134 },
{ week: "W5", value: 121 },
{ week: "W6", value: 158 },
],
};
/** 「研判情报报告」接口完整地址:`{VITE_AIPI_CONSUMER_URL}/cop/selectSqReport`。 */
function sqReportUrl(): string {
const value = readRuntimeEnv("VITE_AIPI_CONSUMER_URL");
const base = typeof value === "string" ? value.replace(/\/+$/, "") : "";
return base ? `${base}/cop/selectSqReport` : "";
}
/** 把单条 `contentJson` 反序列化为对象/数组/原值(已是对象则原样返回)。 */
function parseContentJson(raw: unknown): unknown {
if (typeof raw !== "string") return raw ?? null;
const text = raw.trim();
@@ -103,11 +21,11 @@ function parseContentJson(raw: unknown): unknown {
try {
return JSON.parse(text);
} catch {
return text; // 解析失败就把原始字符串透传给智能体,让它自行理解
// 非 JSON 字符串仍透传给智能体,避免因单条异常让整份报告不可用。
return text;
}
}
/** 去掉 categoryType 末尾的「 JSON」后缀,作为归类键(如「敌情关键事件 JSON」→「敌情关键事件」)。 */
function categoryKey(categoryType: unknown, index: number): string {
const raw = typeof categoryType === "string" ? categoryType.trim() : "";
const cleaned = raw.replace(/\s*JSON\s*$/i, "").trim();
@@ -115,157 +33,70 @@ function categoryKey(categoryType: unknown, index: number): string {
}
/**
* 把 selectSqReport 的整包响应**提取**成喂给智能体的干净结构化输入:
* `{ taskId, source, categories: { [类目]: 反序列化后的内容 } }`
* 返回 null 表示无可用内容(调用方据此回退/不开跑)。
* 将接口 envelope 提取为 `{ taskId, source, categories }`。
* 返回 null 表示该任务尚未保存可用的报告内容。
*/
export function extractSqReport(body: unknown, taskId: string): unknown | null {
const data = (body && typeof body === "object" ? (body as { data?: unknown }).data : null) as
| Array<Record<string, unknown>>
| undefined;
if (!Array.isArray(data) || data.length === 0) return null;
const categories: Record<string, unknown> = {};
data.forEach((item, index) => {
const content = parseContentJson(item?.contentJson);
if (content == null) return;
let key = categoryKey(item?.categoryType, index);
if (key in categories) key = `${key}(${index + 1})`; // 同名类目去重保留
if (key in categories) key = `${key}(${index + 1})`;
categories[key] = content;
});
if (Object.keys(categories).length === 0) return null;
return { taskId, source: "cop/selectSqReport", categories };
return { taskId, source: "api/task-reports", categories };
}
/**
* 输入 JSON 缓存**仅供开发(mock)使用**:`VITE_MOCK_SQ_REPORT==='true'` 时才读/写缓存,
* 避免开发反复请求样例假数据。真实接口模式(false)下不读不写缓存,**始终拉取真实数据**。
*/
function inputCacheEnabled(): boolean {
return readRuntimeFlag("VITE_MOCK_SQ_REPORT");
}
/** 读取暂存的输入 JSON(仅 mock 开发模式生效,真实模式恒返回 null 以强制重新拉取)。 */
/** 兼容旧调用方:报告由接口实时读取,不再在浏览器中缓存。 */
export function readDashboardInputJson(): unknown | null {
if (!inputCacheEnabled()) return null;
try {
const raw = sessionStorage.getItem(DASHBOARD_INPUT_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
return null;
}
/** 暂存输入 JSON(仅 mock 开发模式生效;隐私模式/quota 异常静默失败)。 */
export function persistDashboardInputJson(data: unknown): void {
if (!inputCacheEnabled()) return;
try {
sessionStorage.setItem(DASHBOARD_INPUT_STORAGE_KEY, JSON.stringify(data));
} catch {
/* noop */
}
/** 兼容旧调用方:报告数据只由后端接口维护。 */
export function persistDashboardInputJson(_data: unknown): void {
// noop
}
/** 用登录暂存的任务详情拼一份占位输入(接口未就绪时的回退)。 */
function placeholderFromCopTask(taskId: string): unknown | null {
const detail = readCopTaskDetail();
if (!detail) return taskId ? { taskId } : null;
return {
taskId,
taskName: detail.taskName,
taskContent: detail.taskContent,
overview: detail.overview,
taskDirection: detail.taskDirection,
_note: "占位输入(真实业务 JSON 接口未就绪)",
};
}
/**
* 取本页的输入 JSON:**先请求 selectSqReport,待其返回后再交给模型抽取**。
*
* - 真实接口模式(`VITE_MOCK_SQ_REPORT=false`):不读缓存、不兜底假数据——
* **只用接口真实返回的内容**;请求失败或返回空一律返回 `null`(页面据此提示「请求失败/无数据」,
* 绝不拿占位/演示数据去喂模型,避免「没等接口就抽取假数据」)。
* - mock 开发模式(true):走本地样例假数据(见 utils/request.ts)并缓存避免反复请求;
* 仅此模式在接口不可用时回退 copTaskDetail 占位 / DEMO 演示数据,保证开发能跑通。
*/
/** 查询并提取当前任务的报告详情;失败或空数据均返回 null。 */
export async function fetchDashboardInputJson(
taskId: string,
opts?: { forceRefresh?: boolean },
_opts?: { forceRefresh?: boolean },
): Promise<unknown | null> {
const mock = inputCacheEnabled(); // true=mock/开发;false=真实接口
// forceRefresh(重新分析/修改时):跳过缓存,强制重新请求接口拿**最新数据**(数据可能已变化)。
const cached = opts?.forceRefresh ? null : readDashboardInputJson(); // 真实模式恒 null(缓存仅开发用)
if (cached) return cached;
const url = sqReportUrl();
if (url && taskId) {
try {
// service 拦截器统一注入鉴权并返回 response.data(整包 body)。
// await 在此:必须**等接口返回**后才有 body 可 extract → 再由上层启动模型抽取。
const body = await requestCrudService.get(url, { taskId });
const extracted = extractSqReport(body, taskId);
if (extracted) {
persistDashboardInputJson(extracted); // 真实模式 no-op
return extracted;
}
} catch {
/* 请求失败:真实模式返回 null(见下),mock 模式落占位/DEMO */
}
if (!taskId) return null;
// 开发演示模式使用与 Gateway 接口同形的本地样例;这样不依赖报告库是否已为
// 当前深链 taskId 写入数据,同时真实/生产环境仍始终从 Gateway 读取。
if (readRuntimeFlag("VITE_MOCK_SQ_REPORT")) {
return extractSqReport(
{
...SQ_REPORT_MOCK_RESPONSE,
data: SQ_REPORT_MOCK_RESPONSE.data.map((item) => ({ ...item, taskId })),
},
taskId,
);
}
// 真实接口模式:**不兜底假数据**。失败/空 → null,让页面提示而不是拿假数据喂模型。
if (!mock) return null;
// 以下兜底**仅 mock/开发模式**:copTaskDetail 占位 / DEMO 演示数据,保证开发能看效果。
const placeholder = placeholderFromCopTask(taskId);
const detail = readCopTaskDetail();
const resolved = detail ? placeholder : { ...DEMO_DASHBOARD_INPUT, taskId: taskId || DEMO_DASHBOARD_INPUT.taskId };
if (resolved) persistDashboardInputJson(resolved);
return resolved;
const body = await getTaskReport(taskId);
return extractSqReport(body, taskId);
}
/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */
/**
* 「3q(研判情报报告)是否完成」状态:
* - ``complete`` = selectSqReport 数组里有有效内容;
* - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成);
* - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。
*/
export type SqReportStatus = "complete" | "incomplete" | "unknown";
export interface SqReportCheckResult {
status: SqReportStatus;
/** 提取后的 3q 研判情报报告;仅 status=complete 时有值。 */
report?: unknown;
}
/**
* rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport``
* 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。
* - 有可用内容 → ``complete``(正常);
* - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx);
* - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。
*
* **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时):
* - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求**
* (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]);
* - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。
*/
/** rwfx 进入前检查:任务已有可解析报告则 complete,否则 incomplete。 */
export async function fetchSqReportCheck(taskId: string): Promise<SqReportCheckResult> {
if (!taskId) return { status: "unknown" };
const cfg = await fetchTaskDeeplinkConfig();
// test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。
if (cfg.test) {
const report = extractSqReport(cfg.mock.sqReport, taskId);
return report ? { status: "complete", report } : { status: "incomplete" };
}
// 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。
const url = sqReportUrl();
if (!url) return { status: "unknown" };
try {
const body = await requestCrudService.get(url, { taskId });
const report = extractSqReport(body, taskId);
const report = await fetchDashboardInputJson(taskId);
return report ? { status: "complete", report } : { status: "incomplete" };
} catch {
return { status: "unknown" };
+25 -7
View File
@@ -3,23 +3,33 @@ import fallbackLogoLight from "@/assets/img/brand-logo-light.png";
import fallbackLogoLightblue from "@/assets/img/brand-logo-lightblue.png";
import { readRuntimeEnv } from "@/env";
export type BrandTheme = "light" | "lightblue" | "dark";
export type BrandTheme = "light" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow" | "dark";
type BrandAssetTheme = BrandTheme | `custom-theme-${string}`;
const headerFallbackAssets: Record<BrandTheme, string> = {
light: fallbackLogoLight,
lightblue: fallbackLogoLightblue,
lightgreen: fallbackLogoLight,
iceblue: fallbackLogoLightblue,
lightyellow: fallbackLogoLight,
dark: fallbackLogoDark,
};
const headerRuntimeKeys: Record<BrandTheme, string> = {
light: "VITE_BRAND_LOGO_LIGHT_URL",
lightblue: "VITE_BRAND_LOGO_LIGHTBLUE_URL",
lightgreen: "VITE_BRAND_LOGO_LIGHTGREEN_URL",
iceblue: "VITE_BRAND_LOGO_ICEBLUE_URL",
lightyellow: "VITE_BRAND_LOGO_LIGHTYELLOW_URL",
dark: "VITE_BRAND_LOGO_DARK_URL",
};
const welcomeRuntimeKeys: Record<BrandTheme, string> = {
light: "VITE_BRAND_WELCOME_LIGHT_URL",
lightblue: "VITE_BRAND_WELCOME_LIGHTBLUE_URL",
lightgreen: "VITE_BRAND_WELCOME_LIGHTGREEN_URL",
iceblue: "VITE_BRAND_WELCOME_ICEBLUE_URL",
lightyellow: "VITE_BRAND_WELCOME_LIGHTYELLOW_URL",
dark: "VITE_BRAND_WELCOME_DARK_URL",
};
@@ -52,18 +62,26 @@ function readRuntimeBrandAsset(key: string): string | undefined {
}
}
export function getHeaderBrandAsset(theme: BrandTheme): string {
return readRuntimeBrandAsset(headerRuntimeKeys[theme]) ?? headerFallbackAssets[theme];
function resolveBrandTheme(theme: BrandAssetTheme): BrandTheme {
if (!theme.startsWith("custom-theme-")) return theme as BrandTheme;
return typeof document !== "undefined" && document.documentElement.getAttribute("theme-mode") === "dark"
? "dark"
: "light";
}
export function getHeaderBrandFallback(theme: BrandTheme): string {
return headerFallbackAssets[theme];
export function getHeaderBrandAsset(theme: BrandAssetTheme): string {
const resolved = resolveBrandTheme(theme);
return readRuntimeBrandAsset(headerRuntimeKeys[resolved]) ?? headerFallbackAssets[resolved];
}
export function getHeaderBrandFallback(theme: BrandAssetTheme): string {
return headerFallbackAssets[resolveBrandTheme(theme)];
}
/**
* Welcome content deliberately has no bundled image fallback. An empty
* runtime URL keeps the original CM text + gradient treatment.
*/
export function getWelcomeBrandAsset(theme: BrandTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[theme]);
export function getWelcomeBrandAsset(theme: BrandAssetTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[resolveBrandTheme(theme)]);
}
@@ -145,6 +145,10 @@ export function isChromeHidden(): boolean {
return s?.embed === true || s?.hideChrome === true;
}
export function isHideChromeActive(): boolean {
return readEmbedSession()?.hideChrome === true;
}
/**
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
+8 -2
View File
@@ -448,11 +448,17 @@ export const enUS: Translations = {
system: "System",
light: "Light",
dark: "Dark",
lightblue: "Light Blue",
lightblue: "Light Blue",
lightgreen: "Green",
iceblue: "Glacier Blue",
lightyellow: "Pale Yellow",
systemDescription: "Match the operating system preference automatically.",
lightDescription: "Bright palette with higher contrast for daytime.",
darkDescription: "Dim palette that reduces glare for focus.",
lightblueDescription: "A crisp light palette built around blue.",
lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around WeKnora green.",
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
languageTitle: "Language",
languageDescription: "Switch between languages.",
},
+8 -2
View File
@@ -383,11 +383,17 @@ export interface Translations {
system: string;
light: string;
dark: string;
lightblue: string;
lightblue: string;
lightgreen: string;
iceblue: string;
lightyellow: string;
systemDescription: string;
lightDescription: string;
darkDescription: string;
lightblueDescription: string;
lightblueDescription: string;
lightgreenDescription: string;
iceblueDescription: string;
lightyellowDescription: string;
languageTitle: string;
languageDescription: string;
};
+8 -2
View File
@@ -429,11 +429,17 @@ export const zhCN: Translations = {
system: "系统",
light: "浅色",
dark: "深色",
lightblue: "浅蓝",
lightblue: "浅蓝",
lightgreen: "绿色",
iceblue: "冰川蓝",
lightyellow: "浅黄色",
systemDescription: "自动跟随系统主题。",
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言",
languageDescription: "在不同语言之间切换。",
},
@@ -65,6 +65,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// {
// id: "position-roundtable",
// label: "岗位会商",
// icon: Briefcase,
// path: "/page/strategy/qa/position-roundtable",
// },
{
id: "dashboard-agent",
label: "大屏绘制",
@@ -80,6 +86,12 @@ export const sidebarMenuItems: MenuItem[] = [
},
{ id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
{
id: "qa-research-chat",
label: "课题研究(对话版)",
icon: FileText,
path: "/page/canvas/ai-writing-chat",
},
],
},
{
+36 -1
View File
@@ -276,7 +276,40 @@ export async function updatePublishSquares(
return data;
}
export type ThemeMode = "light" | "dark" | "lightblue";
export const BUILTIN_THEME_IDS = [
"light",
"dark",
"lightblue",
"lightgreen",
"iceblue",
"lightyellow",
] as const;
export type BuiltinThemeId = (typeof BUILTIN_THEME_IDS)[number];
export type ThemeMode = BuiltinThemeId | `custom-theme-${string}`;
export type CustomThemeBaseMode = "light" | "dark";
export interface CustomThemeColorOverrides {
button_hover?: string | null;
selected_background?: string | null;
border?: string | null;
muted?: string | null;
muted_text?: string | null;
destructive?: string | null;
brand_title?: string | null;
primary_foreground?: string | null;
}
export interface CustomThemePreset {
id: `custom-theme-${string}`;
name: string;
primary: string;
text_color?: string | null;
mode: CustomThemeBaseMode;
overrides?: CustomThemeColorOverrides;
enabled: boolean;
sort_order: number;
}
export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme";
@@ -304,6 +337,7 @@ export interface AppearanceSettings {
default_agent_view: ViewMode;
default_layout_mode: LayoutMode;
compact_shortcut_buttons: CompactShortcutButton[];
custom_themes: CustomThemePreset[];
}
export interface AppearanceUpdate {
@@ -312,6 +346,7 @@ export interface AppearanceUpdate {
default_agent_view?: ViewMode;
default_layout_mode?: LayoutMode;
compact_shortcut_buttons?: CompactShortcutButton[];
custom_themes?: CustomThemePreset[];
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
@@ -111,10 +111,13 @@ export function useUpdatePublishSquares() {
});
}
export function useAppearanceSettings() {
export function useAppearanceSettings(
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: APPEARANCE_KEY,
queryFn: () => loadAppearanceSettings(),
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
// The provider runs even on the login page (where the user has no auth)
+284
View File
@@ -0,0 +1,284 @@
import type {
CustomThemeColorOverrides,
CustomThemePreset,
} from "@/core/system-settings/api";
export const CUSTOM_THEME_PREFIX = "custom-theme-";
type Rgb = { r: number; g: number; b: number };
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
const BLACK: Rgb = { r: 0, g: 0, b: 0 };
export const CUSTOM_THEME_ADVANCED_KEYS = [
"button_hover",
"selected_background",
"border",
"muted",
"muted_text",
"destructive",
"brand_title",
"primary_foreground",
] as const;
export type CustomThemeAdvancedKey = (typeof CUSTOM_THEME_ADVANCED_KEYS)[number];
export type CustomThemeTokenInput = Pick<
CustomThemePreset,
"primary" | "mode"
> &
Partial<Pick<CustomThemePreset, "text_color" | "overrides">>;
export function isCustomThemeId(value: string | null | undefined): value is `custom-theme-${string}` {
return typeof value === "string" && value.startsWith(CUSTOM_THEME_PREFIX);
}
export function normalizeHexColor(value: string, fallback = "#0062D9"): string {
const normalized = value.trim().toUpperCase();
return HEX_COLOR.test(normalized) ? normalized : fallback;
}
function optionalHexColor(value: string | null | undefined): string | undefined {
if (!value?.trim() || !HEX_COLOR.test(value.trim())) return undefined;
return value.trim().toUpperCase();
}
function toRgb(value: string): Rgb {
const hex = normalizeHexColor(value).slice(1);
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
};
}
function toHex({ r, g, b }: Rgb): string {
const channel = (value: number) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
return `#${channel(r)}${channel(g)}${channel(b)}`.toUpperCase();
}
function mix(from: Rgb, to: Rgb, amount: number): Rgb {
const ratio = Math.min(1, Math.max(0, amount));
return {
r: from.r + (to.r - from.r) * ratio,
g: from.g + (to.g - from.g) * ratio,
b: from.b + (to.b - from.b) * ratio,
};
}
function relativeLuminance({ r, g, b }: Rgb): number {
const linear = [r, g, b].map((channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!;
}
function contrastRatio(a: Rgb, b: Rgb): number {
const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (lighter! + 0.05) / (darker! + 0.05);
}
function readableForeground(background: Rgb): string {
return contrastRatio(background, BLACK) >= contrastRatio(background, WHITE) ? "#172033" : "#FFFFFF";
}
function ensureContrast(color: Rgb, background: Rgb, toward: Rgb, minimum = 4.5): Rgb {
let candidate = color;
for (let step = 1; step <= 10 && contrastRatio(candidate, background) < minimum; step += 1) {
candidate = mix(candidate, toward, 0.18);
}
return candidate;
}
function rgbChannels(color: Rgb): string {
return `${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)}`;
}
/**
* Produce the semantic CSS tokens consumed by Tailwind/shadcn and TDesign.
* Inputs are restricted to a safe six-digit primary colour, optional primary
* button text colour, a small set of interaction overrides and a light/dark surface
* family, so callers never interpolate arbitrary CSS values.
*/
export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<string, string> {
const primary = toRgb(theme.primary);
const isDark = theme.mode === "dark";
const overrides: CustomThemeColorOverrides = theme.overrides ?? {};
const background = isDark ? mix({ r: 13, g: 19, b: 32 }, primary, 0.12) : mix(WHITE, primary, 0.035);
const card = isDark ? mix({ r: 18, g: 27, b: 44 }, primary, 0.12) : WHITE;
// Normal page copy stays automatic so selecting white for a teal/blue
// action does not accidentally make a light-page's body text white.
const preferredForeground = toRgb(isDark ? "#F4F7FB" : "#202A3A");
const foregroundRgb = ensureContrast(
preferredForeground,
background,
toRgb(readableForeground(background)),
);
const foreground = toHex(foregroundRgb);
const defaultMutedForeground = toRgb(isDark ? "#AAB4C2" : "#667085");
const mutedForegroundRgb = optionalHexColor(overrides.muted_text)
? ensureContrast(
toRgb(overrides.muted_text!),
background,
toRgb(readableForeground(background)),
3,
)
: defaultMutedForeground;
const mutedForeground = toHex(mutedForegroundRgb);
const defaultAccent = isDark ? mix(background, primary, 0.3) : mix(WHITE, primary, 0.14);
const accent = optionalHexColor(overrides.selected_background)
? toRgb(overrides.selected_background!)
: defaultAccent;
const accentHover = isDark ? mix(accent, primary, 0.2) : mix(accent, primary, 0.14);
const defaultBorder = isDark ? mix(background, WHITE, 0.16) : mix(WHITE, primary, 0.28);
const border = optionalHexColor(overrides.border) ? toRgb(overrides.border!) : defaultBorder;
const input = isDark ? mix(background, WHITE, 0.22) : mix(WHITE, primary, 0.38);
const action = ensureContrast(
mix(primary, isDark ? WHITE : BLACK, isDark ? 0.2 : 0.3),
isDark ? background : WHITE,
isDark ? WHITE : BLACK,
);
const actionHover = isDark ? mix(action, WHITE, 0.12) : mix(action, BLACK, 0.12);
const actionActive = isDark ? mix(action, WHITE, 0.22) : mix(action, BLACK, 0.22);
const defaultPrimaryHover = isDark ? mix(primary, WHITE, 0.1) : mix(primary, BLACK, 0.1);
const primaryHover = optionalHexColor(overrides.button_hover)
? toRgb(overrides.button_hover!)
: defaultPrimaryHover;
const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2);
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
const brandTitle = optionalHexColor(overrides.brand_title) ?? foreground;
const defaultSecondary = isDark ? mix(background, WHITE, 0.08) : mix(WHITE, primary, 0.075);
const secondary = optionalHexColor(overrides.muted) ? toRgb(overrides.muted!) : defaultSecondary;
const sidebar = isDark ? mix(background, BLACK, 0.08) : mix(WHITE, primary, 0.018);
const ring = rgbChannels(primary);
const destructive = optionalHexColor(overrides.destructive) ?? (isDark ? "#FF777B" : "#CF4A4A");
const primaryHex = toHex(primary);
const actionHex = toHex(action);
const actionHoverHex = toHex(actionHover);
const actionActiveHex = toHex(actionActive);
const accentHex = toHex(accent);
const accentHoverHex = toHex(accentHover);
const backgroundHex = toHex(background);
const cardHex = toHex(card);
const borderHex = toHex(border);
const inputHex = toHex(input);
const secondaryHex = toHex(secondary);
const primaryHoverHex = toHex(primaryHover);
const primaryActiveHex = toHex(primaryActive);
const sidebarHex = toHex(sidebar);
return {
"--workspace-brand": primaryHex,
"--workspace-brand-foreground": primaryForeground,
"--brand-200": accentHex,
"--brand-900": actionHex,
"--brand-950": actionActiveHex,
"--theme-button-hover": primaryHoverHex,
"--theme-selected-background": accentHex,
"--background": backgroundHex,
"--foreground": foreground,
"--card": cardHex,
"--card-foreground": foreground,
"--popover": cardHex,
"--popover-foreground": foreground,
"--primary": primaryHex,
"--primary-foreground": primaryForeground,
"--button-foreground": buttonForeground,
"--brand-title-foreground": brandTitle,
"--secondary": secondaryHex,
"--secondary-foreground": actionHex,
"--muted": secondaryHex,
"--muted-foreground": mutedForeground,
"--accent": accentHex,
"--accent-foreground": actionHex,
"--destructive": destructive,
"--border": borderHex,
"--input": inputHex,
"--ring": `rgb(${ring} / 0.48)`,
"--chart-1": actionHex,
"--chart-2": toHex(mix(primary, { r: 20, g: 145, b: 150 }, 0.45)),
"--chart-3": toHex(mix(primary, { r: 108, g: 96, b: 175 }, 0.45)),
"--chart-4": toHex(mix(primary, { r: 220, g: 137, b: 54 }, 0.45)),
"--chart-5": toHex(mix(primary, { r: 57, g: 139, b: 110 }, 0.45)),
"--sidebar": sidebarHex,
"--sidebar-foreground": foreground,
"--sidebar-primary": primaryHex,
"--sidebar-primary-foreground": primaryForeground,
"--sidebar-accent": accentHex,
"--sidebar-accent-foreground": actionHex,
"--sidebar-border": borderHex,
"--sidebar-ring": `rgb(${ring} / 0.48)`,
"--legacy-accent-rgb": rgbChannels(accent),
"--legacy-background-rgb": rgbChannels(background),
"--legacy-border-rgb": rgbChannels(border),
"--legacy-brand-200-rgb": rgbChannels(accent),
"--legacy-card-rgb": rgbChannels(card),
"--legacy-destructive-rgb": rgbChannels(toRgb(destructive)),
"--legacy-foreground-rgb": rgbChannels(foregroundRgb),
"--legacy-input-rgb": rgbChannels(input),
"--legacy-muted-rgb": rgbChannels(secondary),
"--legacy-muted-foreground-rgb": rgbChannels(mutedForegroundRgb),
"--legacy-primary-rgb": rgbChannels(primary),
"--legacy-primary-foreground-rgb": rgbChannels(toRgb(primaryForeground)),
"--legacy-ring-rgb": ring,
"--legacy-secondary-rgb": rgbChannels(secondary),
"--legacy-sidebar-accent-rgb": rgbChannels(accent),
"--legacy-sidebar-border-rgb": rgbChannels(border),
"--legacy-sidebar-foreground-rgb": rgbChannels(foregroundRgb),
"--legacy-workspace-brand-rgb": rgbChannels(primary),
"--td-brand-color-1": backgroundHex,
"--td-brand-color-2": secondaryHex,
"--td-brand-color-3": accentHex,
"--td-brand-color-4": accentHoverHex,
"--td-brand-color-5": primaryHex,
"--td-brand-color-6": primaryHex,
"--td-brand-color-7": actionHex,
"--td-brand-color-8": actionHoverHex,
"--td-brand-color-9": actionActiveHex,
"--td-brand-color-10": actionActiveHex,
"--td-brand-color": primaryHex,
"--td-brand-color-hover": primaryHoverHex,
"--td-brand-color-focus": accentHex,
"--td-brand-color-active": primaryActiveHex,
"--td-brand-color-disabled": accentHoverHex,
"--td-brand-color-light": accentHex,
"--td-brand-color-light-hover": accentHoverHex,
"--td-text-color-primary": foreground,
"--td-text-color-secondary": mutedForeground,
"--td-text-color-brand": actionHex,
"--td-text-color-link": actionHex,
"--td-text-color-anti": buttonForeground,
"--td-bg-color-page": backgroundHex,
"--td-bg-color-container": cardHex,
"--td-bg-color-container-hover": secondaryHex,
"--td-bg-color-container-active": accentHex,
"--td-bg-color-secondarycontainer": secondaryHex,
"--td-bg-color-secondarycontainer-hover": accentHex,
"--td-bg-color-secondarycontainer-active": accentHoverHex,
"--td-border-level-1-color": borderHex,
"--td-component-stroke": borderHex,
"--td-border-level-2-color": inputHex,
"--td-component-border": inputHex,
};
}
/** Tokens produced from the automatic palette only, used by the editor to
* show which advanced values are currently inheriting from the system. */
export function getCustomThemeAutomaticTokens(
theme: Pick<CustomThemePreset, "primary" | "mode">,
): Record<string, string> {
return getCustomThemeTokens(theme);
}
export function customThemeContrast(primary: string): number {
const color = toRgb(primary);
return Math.max(contrastRatio(color, BLACK), contrastRatio(color, WHITE));
}
export function customThemeTextContrast(theme: CustomThemeTokenInput): number {
const tokens = getCustomThemeTokens(theme);
return contrastRatio(toRgb(tokens["--button-foreground"]!), toRgb(tokens["--primary"]!));
}
+332 -72
View File
@@ -30,11 +30,20 @@ export type ToolEndEvent = {
data: unknown;
};
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
context: LocalSettings["context"];
isMock?: boolean;
assistantId?: string;
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
context: LocalSettings["context"];
isMock?: boolean;
assistantId?: string;
/**
* Per-page run behaviour. Most chats keep the SDK defaults; long-running
* workspace flows can opt into a resumable stream without changing global
* chat semantics.
*/
runOptions?: {
onDisconnect?: "cancel" | "continue";
multitaskStrategy?: "reject" | "rollback" | "interrupt" | "enqueue";
};
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
threadMetadata?: Record<string, unknown>;
@@ -45,11 +54,45 @@ export type ThreadStreamOptions = {
type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>;
/**
* Extra human-context messages submitted before the visible user message.
* They are persisted with `hide_from_ui` so the model receives rich business
* context without dumping long prompts into the conversation surface.
*/
hiddenContext?:
| string
| {
text: string;
name?: string;
additionalKwargs?: Record<string, unknown>;
}
| Array<{
text: string;
name?: string;
additionalKwargs?: Record<string, unknown>;
}>;
};
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
title_provisional?: boolean;
};
function normalizeHiddenContextMessages(options?: SendMessageOptions) {
const value = options?.hiddenContext;
if (!value) return [];
const items =
typeof value === "string"
? [{ text: value }]
: Array.isArray(value)
? value
: [value];
return items
.map((item) => ({
...item,
text: item.text.trim(),
}))
.filter((item) => item.text.length > 0);
}
function normalizeStoredRunId(runId: string | null): string | null {
if (!runId) {
@@ -182,6 +225,22 @@ function getStreamErrorMessage(error: unknown): string {
return "Request failed.";
}
const TERMINAL_RUN_STATUSES = new Set([
"success",
"error",
"timeout",
"interrupted",
]);
// A completed server run normally sends a terminal SSE event. In local
// development, however, a Vite hot update, a proxy disconnect, or a brief
// network interruption can make the browser miss that final event while the
// backend continues (and successfully completes) the run. Do not probe on
// every render: only treat the stream as suspicious after it has been quiet
// for a while, then reconcile it from the authoritative run/state endpoints.
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000;
async function fetchThreadTitleRefreshState(
threadId: string,
signal?: AbortSignal,
@@ -384,9 +443,10 @@ function lastHumanFromChatModelInput(input: unknown): string {
export function useThreadStream({
threadId,
context,
isMock,
assistantId,
threadMetadata,
isMock,
assistantId,
runOptions,
threadMetadata,
onStart,
onFinish,
onToolEnd,
@@ -397,8 +457,14 @@ export function useThreadStream({
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders,
// and to allow access to the current thread id in onUpdateEvent
const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false);
const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false);
const lastStreamActivityAtRef = useRef(0);
const streamReconciliationInFlightRef = useRef(false);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
}, []);
const listeners = useRef({
onStart,
@@ -430,13 +496,14 @@ export function useThreadStream({
}
}, []);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
_handleOnStart(_threadId);
},
[_handleOnStart],
);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
markStreamActivity();
_handleOnStart(_threadId);
},
[_handleOnStart, markStreamActivity],
);
const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask();
@@ -460,8 +527,8 @@ export function useThreadStream({
? () => runMetadataStorageRef.current!
: false,
fetchStateHistory: { limit: 1 },
onCreated(meta) {
handleStreamStart(meta.thread_id);
onCreated(meta) {
handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id);
const metadataAgentId = context.agent_id ?? context.agent_name;
const extraMetadata =
@@ -475,9 +542,10 @@ export function useThreadStream({
.threads.update(meta.thread_id, { metadata })
.catch(() => ({}));
}
},
onLangChainEvent(event) {
if (event.event === "on_tool_end") {
},
onLangChainEvent(event) {
markStreamActivity();
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.data,
@@ -505,9 +573,10 @@ export function useThreadStream({
/* 上报绝不影响问答 */
}
}
},
onUpdateEvent(data) {
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
},
onUpdateEvent(data) {
markStreamActivity();
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
data || {},
);
for (const update of updates) {
@@ -528,9 +597,10 @@ export function useThreadStream({
);
}
}
},
onCustomEvent(event: unknown) {
if (
},
onCustomEvent(event: unknown) {
markStreamActivity();
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
@@ -574,9 +644,10 @@ export function useThreadStream({
const e = event as { type: "context_compacting"; message: string };
toast(e.message);
}
},
onError(error) {
setOptimisticMessages([]);
},
onError(error) {
markStreamActivity();
setOptimisticMessages([]);
const message = getStreamErrorMessage(error);
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
@@ -635,10 +706,11 @@ export function useThreadStream({
}
toast.error(message);
},
onFinish(state) {
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
onFinish(state) {
markStreamActivity();
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
});
// Optimistic messages shown before the server stream responds
@@ -661,8 +733,97 @@ export function useThreadStream({
// Keep a ref to the latest `thread` so the threadId-change effect can stop
// an in-flight stream without re-running on every render that produces a
// new `thread` identity.
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
useEffect(() => {
if (!thread.isLoading) {
// A normal terminal event reached the browser. A later run may use the
// same thread, so it must be eligible for recovery again.
streamReconciliationInFlightRef.current = false;
return;
}
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (!activeThreadId || typeof window === "undefined") {
return;
}
let disposed = false;
let refreshTimer: number | null = null;
const reconcileCompletedRun = async () => {
if (
disposed ||
streamReconciliationInFlightRef.current ||
Date.now() - lastStreamActivityAtRef.current < STREAM_RECONCILIATION_IDLE_MS
) {
return;
}
const storage = runMetadataStorageRef.current;
const runId = storage?.getItem(`lg:stream:${activeThreadId}`);
if (!runId) return;
streamReconciliationInFlightRef.current = true;
try {
const client = getAPIClient(isMock);
const run = await client.runs.get(activeThreadId, runId);
if (disposed || !TERMINAL_RUN_STATUSES.has(run.status)) {
return;
}
// The server is already terminal, so the existing SSE listener cannot
// receive any more useful events. Load the checkpoint directly before
// resetting the SDK stream; this preserves the normal onFinish side
// effects (e.g. marking a position delivery complete) even when the
// browser missed the terminal event.
const finalState = await client.threads.getState(activeThreadId);
if (disposed || !threadStreamRef.current.isLoading) {
return;
}
storage?.removeItem(`lg:stream:${activeThreadId}`);
if (run.status === "success") {
listeners.current.onFinish?.(finalState.values as AgentThreadState);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} else {
toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。");
}
// Changing the controlled thread id clears only the local SDK stream
// (it does not cancel the already-terminal backend run). Switching it
// back immediately makes useStream fetch the persisted messages, so
// the page exits the false loading state without a manual refresh.
setOnStreamThreadId(null);
refreshTimer = window.setTimeout(() => {
if (!disposed && threadIdRef.current === activeThreadId) {
setOnStreamThreadId(activeThreadId);
}
}, 0);
} catch {
// A transient status lookup failure must not disrupt a real stream;
// the next polling interval will try again if it is still quiet.
} finally {
if (!disposed) {
streamReconciliationInFlightRef.current = false;
}
}
};
const interval = window.setInterval(
() => void reconcileCompletedRun(),
STREAM_RECONCILIATION_POLL_MS,
);
return () => {
disposed = true;
window.clearInterval(interval);
if (refreshTimer !== null) {
window.clearTimeout(refreshTimer);
}
};
}, [isMock, onStreamThreadId, queryClient, thread.isLoading]);
// Reset thread-local pending UI state when the caller actually switches AWAY
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
@@ -975,32 +1136,51 @@ export function useThreadStream({
...extraContext,
};
await thread.submit(
{
messages: [
{
type: "human",
content: [
{
type: "text",
text,
},
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
},
],
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
const hiddenContextMessages = normalizeHiddenContextMessages(options);
const submittedMessages: Message[] = [
...hiddenContextMessages.map((item) => ({
type: "human" as const,
...(item.name ? { name: item.name } : {}),
content: [
{
type: "text" as const,
text: item.text,
},
],
additional_kwargs: {
...item.additionalKwargs,
hide_from_ui: true,
},
})),
{
type: "human" as const,
content: [
{
type: "text" as const,
text,
},
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
},
];
await thread.submit(
{
messages: submittedMessages,
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
onDisconnect: runOptions?.onDisconnect,
multitaskStrategy: runOptions?.multitaskStrategy,
config: {
recursion_limit: 1000,
},
context: {
@@ -1055,7 +1235,7 @@ export function useThreadStream({
sendInFlightRef.current = false;
}
},
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient],
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions],
);
// Merge thread with optimistic messages for display
@@ -1330,8 +1510,8 @@ export function useDeleteThread() {
* `MessageList`) so the removal shows immediately; a reload then reads the
* persisted, already-trimmed state.
*/
export function useDeleteMessage() {
return useMutation({
export function useDeleteMessage() {
return useMutation({
mutationFn: async ({
threadId,
messageId,
@@ -1361,12 +1541,92 @@ export function useDeleteMessage() {
// Fall back to the requested id so the caller can always hide something.
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
},
});
}
/**
* Merge metadata into a conversation's record via the Gateway
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
});
}
/**
* Clear all messages from a thread checkpoint. The caller should immediately
* hide the returned ids locally because the active stream cache may still hold
* the old message objects until a reload/refetch reads the trimmed checkpoint.
*/
export function useClearThreadMessages() {
return useMutation({
mutationFn: async ({
threadId,
}: {
threadId: string;
}): Promise<string[]> => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/messages`,
{
method: "DELETE",
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "清空对话失败。" }));
throw new Error(error.detail ?? "清空对话失败。");
}
const body = (await response.json().catch(() => ({}))) as {
deleted_ids?: string[];
};
return body.deleted_ids ?? [];
},
});
}
/**
* Persist an in-place message edit in the latest thread checkpoint.
*
* This is opt-in UI behaviour: most chat pages keep their existing
* "edit question and resubmit" affordance, while position roundtable can pass
* this mutation down to save the original message content itself.
*/
export function useEditMessage() {
return useMutation({
mutationFn: async ({
threadId,
messageId,
content,
}: {
threadId: string;
messageId: string;
content: string;
}): Promise<Message> => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/messages/${encodeURIComponent(messageId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "消息保存失败。" }));
throw new Error(error.detail ?? "消息保存失败。");
}
const body = (await response.json().catch(() => ({}))) as {
message?: Message;
};
return body.message ?? ({ id: messageId, type: "human", content } as Message);
},
});
}
/**
* Merge metadata into a conversation's record via the Gateway
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
* single user, so these live directly on the thread metadata (no join table).
* Updates the `["threads", "search"]` cache optimistically so the UI reflects