Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into gzy_dev_new
This commit is contained in:
commit
fb73ae0471
220 files changed
+30857
-1735
No files matched your search
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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" };
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]!));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user