Merge branch 'ji' into master

This commit is contained in:
吉鑫升 committed 2026-06-17 17:21:15 +08:00
commit 6c47da82c4
28 files changed
+1297 -92

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
VITE_USE_REAL_DATA=true VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。 # 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(consumer 外网不可达时调试用)。
VITE_MOCK_COP_TASK_DETAIL=false VITE_MOCK_COP_TASK_DETAIL=true
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
+1 -1
View File
@@ -5,7 +5,7 @@
VITE_USE_REAL_DATA=true VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。 # 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
VITE_MOCK_COP_TASK_DETAIL=false VITE_MOCK_COP_TASK_DETAIL=true
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
@@ -61,8 +61,9 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
@@ -467,14 +468,15 @@ export function InputBox({
return; return;
} }
const lastAi = [...thread.messages].reverse().find((m) => m.type === "ai"); const visibleMessages = thread.messages.filter((m) => !isHiddenFromUIMessage(m));
const lastAi = [...visibleMessages].reverse().find((m) => m.type === "ai");
const lastAiId = lastAi?.id ?? null; const lastAiId = lastAi?.id ?? null;
if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) {
return; return;
} }
lastGeneratedForAiIdRef.current = lastAiId; lastGeneratedForAiIdRef.current = lastAiId;
const recent = thread.messages const recent = visibleMessages
.filter((m) => m.type === "human" || m.type === "ai") .filter((m) => m.type === "human" || m.type === "ai")
.map((m) => { .map((m) => {
const role = m.type === "human" ? "user" : "assistant"; const role = m.type === "human" ? "user" : "assistant";
@@ -20,9 +20,10 @@ import {
groupMessages, groupMessages,
hasContent, hasContent,
hasPresentFiles, hasPresentFiles,
hasReasoning, hasReasoning,
hasToolCalls, hasToolCalls,
} from "@/core/messages/utils"; isHiddenFromUIMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
@@ -151,7 +152,10 @@ export function MessageList({
}); });
}, []); }, []);
const allMessages = thread.messages; const allMessages = useMemo(
() => thread.messages.filter((m) => !isHiddenFromUIMessage(m)),
[thread.messages],
);
const messages = useMemo( const messages = useMemo(
() => () =>
hiddenMessageIds.size === 0 hiddenMessageIds.size === 0
@@ -0,0 +1,104 @@
/**
* 任务深链(``goPath=rwfx``)的运行时配置:从后端公开只读接口
* ``GET /api/public/config/task-deeplink`` 读取(地址、测试开关、测试假数据全在后端
* ``config.yaml`` 的 ``task_deeplink`` 段,改完即时生效,前端无需重建)。
*
* 两种用法:
* - 命令式(登录深链流程,非 React 环境):``fetchTaskDeeplinkConfig()`` 拉取并模块级缓存;
* ``getCachedTaskDeeplinkConfig()`` 同步读缓存(拼跳转地址用)。
* - React 组件:``useTaskDeeplinkConfig()``(见 [[task-purpose]] 的面板/列表)。
*
* 拉取失败 → 用内置默认值(``test=false`` + 真实 URL)兜底,功能不整体挂掉。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
export interface TaskDeeplinkConfig {
/** 目的树查询走假数据。 */
test: boolean;
/** 删除接口走假数据(该接口未开发完时打开)。 */
deleteTest: boolean;
/** 目的树查询完整 URL(调用追加 ?taskId=)。 */
purposeDetailUrl: string;
/** 删除完整 URL(调用追加 ?taskId=)。 */
deleteUrl: string;
/** B 系统树形完整编辑页 URL(跳转追加 ?id=)。 */
treemapEditUrl: string;
/** B 系统树形下一步页 URL(跳转追加 ?id=)。 */
treemapNextUrl: string;
/** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}。 */
mock: {
purposeDetail?: unknown;
delete?: { message?: string; success?: boolean };
};
}
/** 内置默认值(后端拿不到时兜底;真实 URL + 关闭测试)。 */
export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = {
test: false,
deleteTest: false,
purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail",
deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete",
treemapEditUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit",
treemapNextUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next",
mock: {},
};
/** 后端 snake_case 响应 → 前端 camelCase;缺字段回退默认值。 */
function normalize(body: unknown): TaskDeeplinkConfig {
const o = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
const d = DEFAULT_TASK_DEEPLINK_CONFIG;
const str = (v: unknown, fallback: string) =>
typeof v === "string" && v.trim() ? v : fallback;
const mock = o.mock && typeof o.mock === "object" ? (o.mock as Record<string, unknown>) : {};
return {
test: o.test === true,
deleteTest: o.delete_test === true,
purposeDetailUrl: str(o.purpose_detail_url, d.purposeDetailUrl),
deleteUrl: str(o.delete_url, d.deleteUrl),
treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl),
treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl),
mock: {
purposeDetail: mock.purpose_detail,
delete:
mock.delete && typeof mock.delete === "object"
? (mock.delete as { message?: string; success?: boolean })
: undefined,
},
};
}
let cached: TaskDeeplinkConfig | null = null;
let inflight: Promise<TaskDeeplinkConfig> | null = null;
/** 拉取(带模块级缓存 + 去重);失败回退默认值。 */
export async function fetchTaskDeeplinkConfig(): Promise<TaskDeeplinkConfig> {
if (cached) return cached;
if (inflight) return inflight;
inflight = (async () => {
try {
const res = await apiFetch(`${getBackendBaseURL()}/api/public/config/task-deeplink`);
if (!res.ok) throw new Error(`task-deeplink config ${res.status}`);
cached = normalize(await res.json());
} catch {
cached = { ...DEFAULT_TASK_DEEPLINK_CONFIG };
} finally {
inflight = null;
}
return cached;
})();
return inflight;
}
/** 同步读缓存(未拉取过则返回默认值)。 */
export function getCachedTaskDeeplinkConfig(): TaskDeeplinkConfig {
return cached ?? DEFAULT_TASK_DEEPLINK_CONFIG;
}
/** 在完整 URL(可能含 `#` 片段)后追加查询参数,供跳转/调用拼 id/taskId。 */
export function appendQueryParam(url: string, key: string, value: string): string {
if (!url) return "";
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}${key}=${encodeURIComponent(value)}`;
}
+17 -1
View File
@@ -16,8 +16,12 @@
*/ */
import { requestCrudService } from "@/service/request-axios-service"; import { requestCrudService } from "@/service/request-axios-service";
import { clearTaskContext, persistTaskContext } from "@/core/auth/task-purpose";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping"; import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
/** rwfx 业务码:单智能体走「任务工作区 + 目的树闸门」(其它业务保持原问答逻辑)。 */
const RWFX_BUSINESS_CODE = "6BF";
/** consumer 接口返回的任务详情(data 字段)。 */ /** consumer 接口返回的任务详情(data 字段)。 */
export interface CopTaskDetail { export interface CopTaskDetail {
id: string | number | null; id: string | number | null;
@@ -132,12 +136,16 @@ function buildOpeningText(detail: CopTaskDetail): string {
export async function resolveTaskDeeplinkTarget( export async function resolveTaskDeeplinkTarget(
goPath: string, goPath: string,
detail: CopTaskDetail, detail: CopTaskDetail,
taskId: string,
): Promise<TaskDeeplinkTarget | null> { ): Promise<TaskDeeplinkTarget | null> {
const parsed = parseGoPath(goPath); const parsed = parseGoPath(goPath);
if (!parsed) return null; if (!parsed) return null;
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode]; const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
if (!taskBase) return null; if (!taskBase) return null;
// 清掉上一次深链可能残留的任务上下文(仅 rwfx 单智能体会重新写入)。
clearTaskContext();
if (parsed.roundtable) { if (parsed.roundtable) {
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。 // 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。
return { return {
@@ -162,6 +170,15 @@ export async function resolveTaskDeeplinkTarget(
} }
if (!agentId) return null; if (!agentId) return null;
if (parsed.businessCode === RWFX_BUSINESS_CODE) {
// rwfx:走「任务工作区」——暂存任务上下文(taskId + 开场信息),跳到 chats 索引,
// 由工作区按 taskId 决定开最新历史对话 / 新建(新建走目的树闸门)。不设 pendingAgentMessage:
// 开场信息在闸门通过后由目的树面板发出。
persistTaskContext({ taskId, agentId, openingText: buildOpeningText(detail) });
return { path: `${taskBase}/agent/${agentId}/chats` };
}
// 其它单智能体问答:保持原逻辑(pendingAgentMessage 自动发出 + chats/new)。
try { try {
sessionStorage.setItem( sessionStorage.setItem(
"pendingAgentMessage", "pendingAgentMessage",
@@ -170,6 +187,5 @@ export async function resolveTaskDeeplinkTarget(
} catch { } catch {
/* noop */ /* noop */
} }
// 单智能体问答:同样嵌进对应业务的「任务管理」布局。
return { path: `${taskBase}/agent/${agentId}/chats/new` }; return { path: `${taskBase}/agent/${agentId}/chats/new` };
} }
+225
View File
@@ -0,0 +1,225 @@
/**
* 任务深链(``goPath=rwfx``)的「目的树闸门 + 任务↔对话绑定」支持。
*
* 角色区分:
* - **目的树数据**:B 系统里的策划结果,走 ``getTaskPurposeDetail`` / ``delete``(地址、测试
* 开关、测试假数据都来自 [[task-deeplink-config]] / 后端 config.yaml),与聊天对话无关。
* 新建对话(含首次进入)前用它做「已有数据→覆盖确认」闸门。
* - **对话(thread)**:聊天会话;通过 thread 的 ``metadata.taskId`` 标签把多个对话挂到同一个
* 任务下(1:N),列表用 useThreadsPage({ metadata:{ taskId } }) 过滤。详见 [[task-deeplink]]。
*/
import {
appendQueryParam,
fetchTaskDeeplinkConfig,
getCachedTaskDeeplinkConfig,
} from "@/core/auth/task-deeplink-config";
import { requestCrudService } from "@/service/request-axios-service";
/** 归一化后的目的树节点(用于展示,逐层:目的→行为体→预期行为→驱动因素→叙事)。 */
export interface PurposeTreeNode {
/** 节点类型中文标签。 */
label: string;
/** 节点主文本(目的/行为体名/预期/因素/叙事内容)。 */
text: string;
/** 推荐理由(若有)。 */
reason?: string;
children: PurposeTreeNode[];
}
/** 当前 rwfx 任务上下文(深链进入或「新建对话」时暂存,供闸门面板与绑定使用)。 */
export interface TaskContext {
taskId: string;
agentId: string;
/** 自动发出的开场信息(任务名称/内容)。 */
openingText: string;
}
const TASK_CONTEXT_STORAGE_KEY = "task.rwfxContext";
/* --------------------------- 目的树查询/删除 --------------------------- */
/** 逐层 dto 字段映射:key=childrenList 里出现的 *Dto 字段名。 */
const DTO_FIELD_MAP: Record<string, { label: string; textKey: string }> = {
purposeDto: { label: "目的", textKey: "zzPurpose" },
actorDto: { label: "行为体", textKey: "actorName" },
expectedBehaviorDto: { label: "预期行为", textKey: "behavior" },
drivingFactorDto: { label: "驱动因素", textKey: "drivingFactor" },
narrationDto: { label: "叙事", textKey: "narration" },
};
// 取节点主文本时跳过的字段(id/时间/标记/理由等非展示文本),保证容错。
const SKIP_TEXT_KEY = /(^id$|Id$|Time$|Flag$|Type$|Level$|recReason|createBy|updateBy|^mid$)/i;
/** 从一个 dto 里取展示文本:优先 preferredKey,否则取第一个像「内容」的字符串字段。 */
function pickDtoText(dto: Record<string, unknown>, preferredKey?: string): string {
if (preferredKey && typeof dto[preferredKey] === "string" && (dto[preferredKey] as string).trim()) {
return dto[preferredKey] as string;
}
for (const [k, v] of Object.entries(dto)) {
if (typeof v === "string" && v.trim() && !SKIP_TEXT_KEY.test(k)) return v;
}
return "";
}
/**
* 把一层节点归一化为 {label,text,reason,children}。**容错**:
* - 优先匹配已知 dto(目的/行为体/...);未知 ``*Dto`` 字段也兜底(标签取字段名去 Dto);
* - 文本字段改名也能取到(pickDtoText 兜底);
* - 完全不认识的结构仍递归 childrenList,避免整棵树丢失。
*/
function normalizeNode(raw: unknown): PurposeTreeNode | null {
if (!raw || typeof raw !== "object") return null;
const obj = raw as Record<string, unknown>;
const isObj = (k: string) => obj[k] && typeof obj[k] === "object" && !Array.isArray(obj[k]);
// 先找已知 dto,再兜底任意 `*Dto` 字段。
let dtoKey = Object.keys(DTO_FIELD_MAP).find(isObj);
if (!dtoKey) dtoKey = Object.keys(obj).find((k) => /Dto$/.test(k) && isObj(k));
const childrenRaw = Array.isArray(obj.childrenList) ? obj.childrenList : [];
const children = childrenRaw
.map(normalizeNode)
.filter((n): n is PurposeTreeNode => n !== null);
if (!dtoKey) {
// 不认识的结构:仅当有子节点时保留为占位节点,否则丢弃。
return children.length ? { label: "节点", text: "", children } : null;
}
const mapped = DTO_FIELD_MAP[dtoKey];
const dto = obj[dtoKey] as Record<string, unknown>;
const label = mapped?.label ?? dtoKey.replace(/Dto$/, "");
const text = pickDtoText(dto, mapped?.textKey);
const reason =
typeof dto.recReason === "string" && (dto.recReason as string).trim()
? (dto.recReason as string)
: undefined;
return { label, text, reason, children };
}
/** 表格行:根→叶一条路径上的各层单元格(层级数可变,做了容错)。 */
export type PurposeFlatCell = { label: string; text: string; reason?: string };
/** 把目的树拍平成「一行一条叶子路径」,供表格展示(层级缺失/变动都不崩)。 */
export function flattenPurposeTree(nodes: PurposeTreeNode[]): PurposeFlatCell[][] {
const rows: PurposeFlatCell[][] = [];
const walk = (node: PurposeTreeNode, trail: PurposeFlatCell[]) => {
const next = [...trail, { label: node.label, text: node.text, reason: node.reason }];
if (!node.children || node.children.length === 0) rows.push(next);
else node.children.forEach((c) => walk(c, next));
};
nodes.forEach((n) => walk(n, []));
return rows;
}
/** 从接口/假数据的整包 body 里取出 data 数组并归一化成树。 */
function bodyToTree(body: unknown): PurposeTreeNode[] {
const data =
body && typeof body === "object" ? (body as { data?: unknown }).data : null;
const arr = Array.isArray(data) ? data : [];
return arr.map(normalizeNode).filter((n): n is PurposeTreeNode => n !== null);
}
export interface TaskPurposeResult {
tree: PurposeTreeNode[];
/** data 数组里有有效目的节点 = 已有策划数据。 */
hasData: boolean;
}
/**
* 查询任务目的树。``test=true`` 时直接用后端配置里的假数据;否则打真实接口
* (地址来自配置,``Authorization: Bearer admin`` 由 axios 拦截器统一注入)。
* 缺地址 / 返回不合法 → ``{ tree: [], hasData: false }``。
*/
export async function fetchTaskPurposeDetail(
taskId: string,
): Promise<TaskPurposeResult> {
if (!taskId) return { tree: [], hasData: false };
const cfg = await fetchTaskDeeplinkConfig();
let body: unknown;
if (cfg.test) {
body = cfg.mock.purposeDetail;
} else if (cfg.purposeDetailUrl) {
body = await requestCrudService.get(cfg.purposeDetailUrl, { taskId });
} else {
return { tree: [], hasData: false };
}
const tree = bodyToTree(body);
return { tree, hasData: tree.length > 0 };
}
export interface DeleteTaskPurposeResult {
message: string;
success: boolean;
}
/**
* 删除任务目的树(覆盖前清空 / 手动清除任务记录都走这里)。``delete_test=true`` 时
* 用后端配置里的假数据(接口未开发完时用);否则打真实接口。
*/
export async function deleteTaskPurpose(
taskId: string,
): Promise<DeleteTaskPurposeResult> {
if (!taskId) return { message: "缺少 taskId", success: false };
const cfg = await fetchTaskDeeplinkConfig();
if (cfg.deleteTest) {
const m = cfg.mock.delete ?? {};
return { message: typeof m.message === "string" ? m.message : "", success: m.success === true };
}
if (!cfg.deleteUrl) return { message: "缺少删除接口地址", success: false };
const body = await requestCrudService.get(cfg.deleteUrl, { taskId });
const obj =
body && typeof body === "object" ? (body as unknown as Record<string, unknown>) : {};
return {
message: typeof obj.message === "string" ? obj.message : "",
success: obj.success === true,
};
}
/* ----------------------------- 跳转地址 ----------------------------- */
/** B 系统「树形完整编辑」页地址(问答后「编辑」按钮,新标签打开)。用同步缓存配置。 */
export function buildTreemapEditUrl(taskId: string): string {
const url = getCachedTaskDeeplinkConfig().treemapEditUrl;
return url && taskId ? appendQueryParam(url, "id", taskId) : "";
}
/** B 系统「树形下一步」页地址(问答后「下一步」按钮,新标签打开)。 */
export function buildTreemapNextUrl(taskId: string): string {
const url = getCachedTaskDeeplinkConfig().treemapNextUrl;
return url && taskId ? appendQueryParam(url, "id", taskId) : "";
}
/* --------------------------- 任务上下文暂存 --------------------------- */
export function persistTaskContext(ctx: TaskContext): void {
try {
sessionStorage.setItem(TASK_CONTEXT_STORAGE_KEY, JSON.stringify(ctx));
} catch {
/* noop */
}
}
export function readTaskContext(): TaskContext | null {
try {
const raw = sessionStorage.getItem(TASK_CONTEXT_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<TaskContext>;
if (!parsed?.taskId || !parsed?.agentId) return null;
return {
taskId: String(parsed.taskId),
agentId: String(parsed.agentId),
openingText: typeof parsed.openingText === "string" ? parsed.openingText : "",
};
} catch {
return null;
}
}
export function clearTaskContext(): void {
try {
sessionStorage.removeItem(TASK_CONTEXT_STORAGE_KEY);
} catch {
/* noop */
}
}
+45 -4
View File
@@ -34,6 +34,9 @@ export type ThreadStreamOptions = {
context: LocalSettings["context"]; context: LocalSettings["context"];
isMock?: boolean; isMock?: boolean;
assistantId?: string; assistantId?: string;
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
threadMetadata?: Record<string, unknown>;
onStart?: (threadId: string) => void; onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void; onFinish?: (state: AgentThreadState) => void;
onToolEnd?: (event: ToolEndEvent) => void; onToolEnd?: (event: ToolEndEvent) => void;
@@ -118,6 +121,39 @@ function getRunMetadataStorage(): {
}; };
} }
/**
* 显式停止某会话「服务端正在跑」的 run。
*
* 注意:useStream / `thread.stop()` 只中断本地 SSE 监听,服务端 run 仍会继续跑。
* 任务流程要求「开启新对话时老对话要停止」,所以这里读出 SDK 存在 sessionStorage
* (``lg:stream:{threadId}``)里的 run id,调用 ``runs.cancel`` 真正取消后端 run,
* 再清掉本地 reconnect 记录。已结束 / 不存在的 run 取消失败时静默忽略。
*/
export async function stopThreadRun(
threadId: string | null | undefined,
): Promise<void> {
if (!threadId || typeof window === "undefined") return;
let runId: string | null = null;
try {
runId = normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
);
} catch {
runId = null;
}
if (!runId) return;
try {
await getAPIClient().runs.cancel(threadId, runId);
} catch {
/* 已结束 / 不存在 / 网络错误等忽略 */
}
try {
window.sessionStorage.removeItem(`lg:stream:${threadId}`);
} catch {
/* noop */
}
}
function getStreamErrorMessage(error: unknown): string { function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) { if (typeof error === "string" && error.trim()) {
return error; return error;
@@ -200,6 +236,7 @@ export function useThreadStream({
context, context,
isMock, isMock,
assistantId, assistantId,
threadMetadata,
onStart, onStart,
onFinish, onFinish,
onToolEnd, onToolEnd,
@@ -277,11 +314,15 @@ export function useThreadStream({
handleStreamStart(meta.thread_id); handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id); setOnStreamThreadId(meta.thread_id);
const metadataAgentId = context.agent_id ?? context.agent_name; const metadataAgentId = context.agent_id ?? context.agent_name;
if (metadataAgentId && !isMock) { const extraMetadata =
threadMetadata && typeof threadMetadata === "object" ? threadMetadata : {};
const metadata: Record<string, unknown> = {
...(metadataAgentId ? { agent_id: metadataAgentId } : {}),
...extraMetadata,
};
if (Object.keys(metadata).length > 0 && !isMock) {
void getAPIClient() void getAPIClient()
.threads.update(meta.thread_id, { .threads.update(meta.thread_id, { metadata })
metadata: { agent_id: metadataAgentId },
})
.catch(() => ({})); .catch(() => ({}));
} }
}, },
@@ -0,0 +1,29 @@
/**
* 「一个任务挂多个对话」(1:N):按 thread 的 ``metadata.taskId`` 过滤出某任务下的对话列表。
*
* 新建对话时由 [[task-purpose]] 流程给 thread 打 ``metadata.taskId`` 标签
* (见 useThreadStream 的 ``threadMetadata``);这里复用服务端分页搜索按该标签过滤,
* 后端默认按 ``updated_at desc`` 排序,所以 ``threads[0]`` 即最新一条。
*/
import { useThreadsPage } from "@/core/threads/hooks";
import type { AgentThread } from "@/core/threads/types";
export function useTaskThreads(taskId: string | null | undefined) {
const enabled = !!taskId;
// 任务工作区只在有 taskId 时渲染;无 taskId 时传一个不可能命中的过滤,返回空列表。
const query = useThreadsPage({
page: 1,
pageSize: 100,
metadata: enabled ? { taskId } : { taskId: "__none__" },
});
const threads: AgentThread[] = enabled ? (query.data?.threads ?? []) : [];
return {
...query,
threads,
/** 最新对话(按 updated_at desc,列表第一条)。 */
latestThreadId: threads[0]?.thread_id ?? null,
/** 是否已有历史对话(决定深链进入后默认开最新还是走新建闸门)。 */
hasThreads: threads.length > 0,
};
}
+49 -1
View File
@@ -26,14 +26,17 @@ import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { readTaskContext } from "@/core/auth/task-purpose";
import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
export default function AgentChatPage({ export default function AgentChatPage({
embeddedAgentId, embeddedAgentId,
chatBasePath, chatBasePath,
taskGate,
}: { }: {
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。 /** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */ * 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
@@ -43,6 +46,9 @@ export default function AgentChatPage({
* 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`, * 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`,
* 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */ * 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */
chatBasePath?: string; chatBasePath?: string;
/** rwfx 任务深链闸门:新建对话时顶部展示目的树面板(覆盖确认后才开始问答),
* 并给新建 thread 打 `metadata.taskId` 标签(任务↔对话 1:N)。见 task-purpose。 */
taskGate?: boolean;
} = {}) { } = {}) {
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const { t } = useI18n(); const { t } = useI18n();
@@ -57,6 +63,16 @@ export default function AgentChatPage({
// 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。 // 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。
const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`; const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`;
// rwfx 任务闸门:读取登录深链/「新建对话」暂存的任务上下文(taskId + 开场信息)。
const taskContext = useMemo(
() => (taskGate ? readTaskContext() : null),
[taskGate],
);
const taskId =
taskGate && taskContext && taskContext.agentId === agent_id
? taskContext.taskId
: "";
// If ChatPage stashed an opening message for this agent, skip the landing // If ChatPage stashed an opening message for this agent, skip the landing
// screen on first render so the user doesn't see a flash of the welcome UI // screen on first render so the user doesn't see a flash of the welcome UI
// before the auto-sent message takes over. // before the auto-sent message takes over.
@@ -101,6 +117,8 @@ export default function AgentChatPage({
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
assistantId: agent_id, assistantId: agent_id,
context: { ...settings.context, agent_id }, context: { ...settings.context, agent_id },
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤。
threadMetadata: taskId ? { taskId } : undefined,
onStart: (createdThreadId) => { onStart: (createdThreadId) => {
setThreadId(createdThreadId); setThreadId(createdThreadId);
setIsNewThread(false); setIsNewThread(false);
@@ -143,6 +161,30 @@ export default function AgentChatPage({
[sendMessage, threadId, agent_id], [sendMessage, threadId, agent_id],
); );
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
// 仅 taskGate(rwfx 任务工作区)启用——普通问答页保留后台续跑行为不变。
const prevTaskThreadRef = useRef<string | null>(null);
useEffect(() => {
if (!taskGate) return;
const prev = prevTaskThreadRef.current;
if (prev && prev !== threadId) void stopThreadRun(prev);
prevTaskThreadRef.current = threadId ?? null;
}, [taskGate, threadId]);
useEffect(() => {
// 离开任务问答页(卸载)时也停掉当前会话的服务端 run。
return () => {
if (taskGate) void stopThreadRun(prevTaskThreadRef.current);
};
}, [taskGate]);
// 任务闸门通过(无目的数据 / 覆盖成功):发出开场信息开始问答;无开场信息则
// 仅切到对话态(隐藏面板),让用户手动输入。
const handleGateStart = useCallback(() => {
const text = taskContext?.openingText?.trim();
if (text) handleSubmit({ text, files: [] });
else setHasStartedConversation(true);
}, [handleSubmit, taskContext]);
// ChatPage stashes the opening message in sessionStorage when the user picks // ChatPage stashes the opening message in sessionStorage when the user picks
// an agent on the landing screen, then redirects here. Replay it once so the // an agent on the landing screen, then redirects here. Replay it once so the
// user doesn't have to re-type the question on the agent chat page. // user doesn't have to re-type the question on the agent chat page.
@@ -181,6 +223,8 @@ export default function AgentChatPage({
}, [thread]); }, [thread]);
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
// 任务闸门面板:仅在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
const showTaskGate = !!taskId && isNewThread && !hasStartedConversation;
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"; const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const status = thread.error const status = thread.error
@@ -245,6 +289,10 @@ export default function AgentChatPage({
</div> </div>
</header> </header>
{showTaskGate && (
<TaskPurposePanel taskId={taskId} onStart={handleGateStart} />
)}
{isLandingState ? ( {isLandingState ? (
/* Home: centered hero, suggested questions, and input. /* Home: centered hero, suggested questions, and input.
Everything lives inside the scrollable main column. No fixed, Everything lives inside the scrollable main column. No fixed,
+1 -1
View File
@@ -164,7 +164,7 @@ export default function LoginPage() {
if (cancelled) return; if (cancelled) return;
if (detail) { if (detail) {
persistCopTaskDetail(detail); persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(goPath, detail); const target = await resolveTaskDeeplinkTarget(goPath, detail, taskId);
if (cancelled) return; if (cancelled) return;
if (target) { if (target) {
navigate(target.path, { replace: true, state: target.state }); navigate(target.path, { replace: true, state: target.state });
+22 -3
View File
@@ -11,6 +11,11 @@ import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgF
import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyFrom"; import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyFrom";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom"; import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes"; import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
import {
TaskFxChat,
TaskFxIndexResolver,
TaskFxWorkspace,
} from "@/strategy-components/components/task-components/TaskFxWorkspace";
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes"; import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement"; import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement"; import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
@@ -162,12 +167,26 @@ export default function StrategyRoutes() {
</ChatRuntime> </ChatRuntime>
} }
/> />
{/* 任务FX:任务工作区(左对话列表 1:N + 右问答;新建对话顶部目的树闸门)。 */}
<Route <Route
path="plan-task/plan-task-fx/agent/:agent_id/chats/:thread_id" path="plan-task/plan-task-fx/agent/:agent_id/chats"
element={ element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" /> <TaskFxWorkspace taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
} }
/> >
<Route
index
element={
<TaskFxIndexResolver taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
}
/>
<Route
path=":thread_id"
element={
<TaskFxChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
}
/>
</Route>
<Route <Route
path="plan-task/plan-task-action" path="plan-task/plan-task-action"
element={ element={
@@ -0,0 +1,224 @@
/**
* 任务深链(rwfx / 任务FX)的「任务工作区」:一个任务挂多个对话(1:N)。
*
* 左侧 = 该任务下的对话列表(按 metadata.taskId 过滤)+「新建对话」;
* 右下角 = 常驻浮层红色按钮(编辑 / 下一步 / 清除任务记录),absolute 定位,时刻可见、不被内容挤走;
* 右侧 = <Outlet/> 渲染选中的对话(AgentChatPage,新建对话顶部带目的树闸门面板)。
*
* - 进入 chats 索引:有历史 → 跳最新;无历史 → 跳 new(=新建,走闸门)。
* - 新建对话 = 直接跳入:navigate 到 chats/new;切换/新建时 AgentChatPage 会停掉老对话的服务端 run。
* - 清除任务记录:仅调 delete 接口(聊天对话保留)。
* - 编辑 / 下一步:window.open 配置里的 B 系统地址(?id=taskId)。
* 见 [[task-purpose]] / [[task-deeplink-config]]。
*/
import { useEffect, useMemo, useState } from "react";
import { Outlet, useLocation, useNavigate, useParams } from "react-router-dom";
import { ArrowRight, ExternalLink, Loader2, MessageSquare, PlusSquare, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
buildTreemapEditUrl,
buildTreemapNextUrl,
deleteTaskPurpose,
readTaskContext,
} from "@/core/auth/task-purpose";
import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config";
import { useTaskThreads } from "@/core/threads/use-task-threads";
import { titleOfThread } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import AgentChatPage from "@/pages/AgentChatPage";
import { ChatRuntime } from "@/pages/WorkspaceRoutes";
function useChatsBase(taskBase: string): { taskId: string; chatsBase: string } {
const { agent_id } = useParams<{ agent_id: string }>();
const taskId = readTaskContext()?.taskId ?? "";
return { taskId, chatsBase: `${taskBase}/agent/${agent_id}/chats` };
}
/** 从路由路径里解析当前打开的会话 id(布局组件读不到子路由的 :thread_id,故从 path 取)。 */
function parseThreadId(pathname: string, chatsBase: string): string | null {
if (!pathname.startsWith(chatsBase)) return null;
const seg = pathname.slice(chatsBase.length).replace(/^\/+/, "").split("/")[0];
return seg && seg !== "new" ? seg : null;
}
/** 左侧任务对话列表 +「新建对话」。 */
function TaskConversationsList({ taskId, chatsBase }: { taskId: string; chatsBase: string }) {
const { pathname } = useLocation();
const navigate = useNavigate();
const { threads, isLoading } = useTaskThreads(taskId);
const currentThreadId = useMemo(() => parseThreadId(pathname, chatsBase), [pathname, chatsBase]);
return (
<div className="border-border/70 bg-card/40 flex w-60 shrink-0 flex-col border-r">
<div className="border-border/70 border-b p-2">
<Button size="sm" className="w-full" onClick={() => navigate(`${chatsBase}/new`)}>
<PlusSquare className="h-4 w-4" /> 新建对话
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{isLoading ? (
<div className="text-muted-foreground p-2 text-sm">加载中…</div>
) : threads.length === 0 ? (
<div className="text-muted-foreground p-2 text-sm">暂无对话</div>
) : (
threads.map((thread) => (
<button
key={thread.thread_id}
onClick={() => navigate(`${chatsBase}/${thread.thread_id}`)}
className={cn(
"hover:bg-accent flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm",
thread.thread_id === currentThreadId && "bg-accent font-medium",
)}
title={desensitize(titleOfThread(thread, "新对话"))}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{desensitize(titleOfThread(thread, "新对话"))}</span>
</button>
))
)}
</div>
</div>
);
}
/**
* 右下角常驻红色操作浮层:编辑 / 下一步 / 清除任务记录。
* absolute 定位在工作区右侧面板内,z-40,时刻可见、不被聊天内容挤走。
*/
function TaskFloatingActions({ taskId }: { taskId: string }) {
const [clearOpen, setClearOpen] = useState(false);
const [clearing, setClearing] = useState(false);
const openExternal = (url: string, label: string) => {
if (url) window.open(url, "_blank", "noopener,noreferrer");
else toast.error(`未配置${label}地址`);
};
const handleClear = async () => {
setClearing(true);
try {
const r = await deleteTaskPurpose(taskId);
if (r.success) toast.success("已清除任务记录");
else toast.error(r.message || "清除失败");
} catch {
toast.error("清除失败,请稍后再试");
} finally {
setClearing(false);
setClearOpen(false);
}
};
return (
<>
<div className="pointer-events-none absolute bottom-4 right-4 z-40 flex flex-col items-end gap-2">
<Button
size="sm"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => openExternal(buildTreemapEditUrl(taskId), "编辑")}
>
<ExternalLink className="h-3.5 w-3.5" /> 编辑
</Button>
<Button
size="sm"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => openExternal(buildTreemapNextUrl(taskId), "下一步")}
>
<ArrowRight className="h-3.5 w-3.5" /> 下一步
</Button>
<Button
size="sm"
variant="destructive"
className="pointer-events-auto shadow-lg"
onClick={() => setClearOpen(true)}
disabled={!taskId}
>
<Trash2 className="h-3.5 w-3.5" /> 清除任务记录
</Button>
</div>
<Dialog open={clearOpen} onOpenChange={(o) => !clearing && setClearOpen(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>清除任务记录</DialogTitle>
<DialogDescription>
将清除该任务在 B 系统中的目的策划数据(聊天对话保留),是否确认?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setClearOpen(false)} disabled={clearing}>
取消
</Button>
<Button variant="destructive" onClick={handleClear} disabled={clearing}>
{clearing ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认清除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/** 任务工作区布局:左列表 + 右 Outlet + 右下角常驻红色操作浮层。 */
export function TaskFxWorkspace({ taskBase }: { taskBase: string }) {
const { taskId, chatsBase } = useChatsBase(taskBase);
// 预拉配置,保证「编辑/下一步」的同步 URL 构造能读到缓存。
useEffect(() => {
void fetchTaskDeeplinkConfig();
}, []);
return (
<div className="flex size-full min-h-0 overflow-hidden">
<TaskConversationsList taskId={taskId} chatsBase={chatsBase} />
<div className="relative min-h-0 min-w-0 flex-1">
<Outlet />
<TaskFloatingActions taskId={taskId} />
</div>
</div>
);
}
/** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(走闸门)。 */
export function TaskFxIndexResolver({ taskBase }: { taskBase: string }) {
const { taskId, chatsBase } = useChatsBase(taskBase);
const navigate = useNavigate();
const { isLoading, hasThreads, latestThreadId } = useTaskThreads(taskId);
useEffect(() => {
if (isLoading) return;
if (hasThreads && latestThreadId) {
navigate(`${chatsBase}/${latestThreadId}`, { replace: true });
} else {
navigate(`${chatsBase}/new`, { replace: true });
}
}, [isLoading, hasThreads, latestThreadId, chatsBase, navigate]);
return (
<div className="text-muted-foreground flex size-full items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
);
}
/** 右侧对话宿主:AgentChatPage(taskGate 开启 → 新建对话顶部展示目的树闸门面板 + 老对话停止)。 */
export function TaskFxChat({ taskBase }: { taskBase: string }) {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} taskGate />
</ChatRuntime>
);
}
@@ -0,0 +1,208 @@
/**
* 任务深链(rwfx)「新建对话」前的目的树闸门面板(嵌在问答页顶部)。
*
* 展示:目的树拍平成表格(一行一条「目的→行为体→预期→因素→叙事」路径),列数随数据自适应,
* 字段/层级变动都不崩(见 flattenPurposeTree / normalizeNode 的容错)。可点「弹框查看」放大。
* 流程:点「开始问答」:
* - 已有数据 → 弹「将被覆盖,确认?」→ 确认调 delete:success=true 才 onStart();
* success=false 提示后端 message(如「行动已经开始策划,不可覆盖」)并停留。
* - 无数据 → 直接 onStart()。
* onStart 由父组件(AgentChatPage 闸门态)实现:发出开场消息、开始问答。
*/
import { useEffect, useMemo, useState } from "react";
import { Loader2, Maximize2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
deleteTaskPurpose,
fetchTaskPurposeDetail,
flattenPurposeTree,
type PurposeTreeNode,
type TaskPurposeResult,
} from "@/core/auth/task-purpose";
import { cn } from "@/lib/utils";
/** 目的树表格:拍平成行,列数随数据自适应;层级缺失补空、字段变动不崩。 */
function PurposeTable({ tree, className }: { tree: PurposeTreeNode[]; className?: string }) {
const rows = useMemo(() => flattenPurposeTree(tree), [tree]);
const headers = useMemo(() => {
const maxDepth = rows.reduce((m, r) => Math.max(m, r.length), 0);
return Array.from({ length: maxDepth }, (_, i) => {
const r = rows.find((row) => row[i]);
return r?.[i]?.label ?? `层级${i + 1}`;
});
}, [rows]);
if (rows.length === 0) {
return <div className="text-muted-foreground py-1 text-sm">暂无目的数据,可直接开始问答。</div>;
}
return (
<div className={cn("overflow-auto", className)}>
<table className="w-full border-collapse text-sm">
<thead>
<tr>
{headers.map((h, i) => (
<th
key={i}
className="bg-muted/60 border-border/60 sticky top-0 border px-3 py-1.5 text-left font-medium whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="even:bg-muted/30 align-top">
{headers.map((_, ci) => {
const cell = row[ci];
return (
<td key={ci} className="border-border/60 border px-3 py-1.5">
{cell ? (
<>
<span>{cell.text || "(空)"}</span>
{cell.reason ? (
<span className="text-muted-foreground block text-xs">理由:{cell.reason}</span>
) : null}
</>
) : (
<span className="text-muted-foreground/60">—</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function TaskPurposePanel({
taskId,
onStart,
}: {
taskId: string;
/** 闸门通过(无数据 / 覆盖成功),开始问答。 */
onStart: () => void;
}) {
const [loading, setLoading] = useState(true);
const [result, setResult] = useState<TaskPurposeResult | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [working, setWorking] = useState(false);
const [zoomOpen, setZoomOpen] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchTaskPurposeDetail(taskId)
.then((r) => {
if (!cancelled) setResult(r);
})
.catch(() => {
if (!cancelled) setResult({ tree: [], hasData: false });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [taskId]);
const handleStart = () => {
if (result?.hasData) setConfirmOpen(true); // 有数据 → 先确认覆盖
else onStart(); // 无数据 → 直接开始
};
const handleConfirmOverwrite = async () => {
setWorking(true);
try {
const r = await deleteTaskPurpose(taskId);
if (r.success) {
setConfirmOpen(false);
onStart();
} else {
toast.error(r.message || "无法覆盖,请稍后再试");
setConfirmOpen(false);
}
} catch {
toast.error("覆盖失败,请稍后再试");
setConfirmOpen(false);
} finally {
setWorking(false);
}
};
const hasTree = !!result && result.tree.length > 0;
return (
<div className="border-border/70 bg-card/60 shrink-0 border-b px-4 py-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">任务目的(任务 {taskId})</span>
<div className="flex items-center gap-2">
{hasTree && (
<Button size="sm" variant="outline" onClick={() => setZoomOpen(true)}>
<Maximize2 className="h-3.5 w-3.5" /> 弹框查看
</Button>
)}
<Button size="sm" onClick={handleStart} disabled={loading || working}>
{result?.hasData ? "覆盖并开始问答" : "开始问答"}
</Button>
</div>
</div>
{loading ? (
<div className="text-muted-foreground flex items-center gap-2 py-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在查询任务目的…
</div>
) : (
<PurposeTable tree={result?.tree ?? []} className="max-h-48 rounded border border-border/60" />
)}
{/* 弹框查看:放大显示完整表格 */}
<Dialog open={zoomOpen} onOpenChange={setZoomOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>任务目的(任务 {taskId})</DialogTitle>
</DialogHeader>
<PurposeTable tree={result?.tree ?? []} className="max-h-[70vh] rounded border border-border/60" />
</DialogContent>
</Dialog>
{/* 覆盖确认 */}
<Dialog open={confirmOpen} onOpenChange={(o) => !working && setConfirmOpen(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>确认覆盖</DialogTitle>
<DialogDescription>
检测到该任务已有目的数据,继续问答将覆盖原有数据,是否确认?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmOpen(false)} disabled={working}>
取消
</Button>
<Button onClick={handleConfirmOverwrite} disabled={working}>
{working ? <Loader2 className="h-4 w-4 animate-spin" /> : "确认覆盖"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default TaskPurposePanel;
+1 -1
View File
@@ -368,7 +368,7 @@ shown to users ARE the **recommended questions** (`recommended_questions` table)
types into the existing "我的" tables, idempotently: types into the existing "我的" tables, idempotently:
- agents → `agent_favorites` rows with `origin='position'` - agents → `agent_favorites` rows with `origin='position'`
- **skills → `skill_favorites` rows with `origin='position'`** (table - **skills → `skill_favorites` rows with `origin='position'`** (table
`SkillFavoriteRow`, migration `20260616_02`, auto-created by `create_all`) `SkillFavoriteRow`, migration `20260616_04`, auto-created by `create_all`)
- scheduled tasks → `scheduled_task_subscriptions` rows with `origin='position'` - scheduled tasks → `scheduled_task_subscriptions` rows with `origin='position'`
Skills behave **exactly like agents**: a position only *adds* its tagged skills Skills behave **exactly like agents**: a position only *adds* its tagged skills
@@ -51,6 +51,7 @@ from app.gateway.routers import (
parallel_agent_tasks, parallel_agent_tasks,
parallel_agents, parallel_agents,
positions, positions,
public_config,
public_embed, public_embed,
public_leaderboard, public_leaderboard,
public_scheduled_tasks, public_scheduled_tasks,
@@ -1368,6 +1369,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Public embed Q&A status polling (/api/public/embed/*) # Public embed Q&A status polling (/api/public/embed/*)
app.include_router(public_embed.router) app.include_router(public_embed.router)
app.include_router(public_config.router)
app.include_router(html_page_favorites.router) app.include_router(html_page_favorites.router)
# Product knowledge base API is mounted at /api/knowledge # Product knowledge base API is mounted at /api/knowledge
@@ -0,0 +1,28 @@
"""公开只读配置接口(前端运行时读取 config.yaml 派生值)。
目前只暴露 ``task_deeplink`` 段(任务深链 rwfx 的目的树闸门地址 + 测试开关 +
测试假数据),供前端登录深链流程在运行时读取——无需把这些地址硬编码进前端,
改 ``config.yaml`` 即时生效(Gateway mtime 热重载)。
**无鉴权**:路由挂在 ``/api/public/`` 前缀下,已被
``auth_middleware._PUBLIC_PATH_PREFIXES`` 白名单放行。段内只有地址与开关,不含密钥。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/public/config", tags=["public-config"])
@router.get("/task-deeplink")
async def get_task_deeplink_config() -> dict:
"""返回 ``config.yaml`` 的 ``task_deeplink`` 段(含 test/delete_test/各 URL/mock)。"""
config = get_app_config()
return config.task_deeplink.model_dump()
+42 -5
View File
@@ -207,13 +207,13 @@ summarization:
# model_name: deepseek-chat # 留空则用主对话模型 # model_name: deepseek-chat # 留空则用主对话模型
trigger: trigger:
- type: tokens - type: tokens
value: 8000 value: 120000
- type: messages - type: messages
value: 12 value: 160
keep: keep:
type: messages type: tokens
value: 6 value: 40000
trim_tokens_to_summarize: 4000 trim_tokens_to_summarize: 8000
tool_groups: tool_groups:
- name: web - name: web
@@ -418,3 +418,40 @@ log_system:
enabled: true enabled: true
url: 'https://your-log-system.example.com' url: 'https://your-log-system.example.com'
label: '日志系统' label: '日志系统'
task_deeplink:
# 线上接口未通时用假数据;两个开关互相独立。
test: true # 目的树查询走 mock.purpose_detail 假数据
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete"
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
mock:
# test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
purpose_detail:
state: "200"
msg: "操作成功!"
data:
- purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 }
childrenList:
- actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 }
childrenList:
- expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" }
childrenList:
- drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 }
childrenList:
- narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 }
childrenList: []
- actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 }
childrenList:
- expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" }
childrenList:
- drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 }
childrenList:
- narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 }
childrenList: []
# delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。
delete:
message: "行动已经开始策划,不可覆盖"
success: true
@@ -27,7 +27,14 @@ from typing import Any, Protocol, override, runtime_checkable
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import SummarizationMiddleware from langchain.agents.middleware import SummarizationMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage from langchain_core.messages import (
AIMessage,
AnyMessage,
SystemMessage,
ToolMessage,
message_to_dict,
messages_from_dict,
)
from langgraph.config import get_config from langgraph.config import get_config
from langgraph.runtime import Runtime from langgraph.runtime import Runtime
@@ -138,7 +145,8 @@ class _SummaryCacheEntry:
summarized_ids: frozenset[str] summarized_ids: frozenset[str]
summary_text: str summary_text: str
reusable: bool preserved_ids: frozenset[str] = frozenset()
preserved_messages: tuple[AnyMessage, ...] = ()
# Process-wide summary cache shared across every middleware instance. # Process-wide summary cache shared across every middleware instance.
@@ -152,14 +160,51 @@ class _SummaryCacheEntry:
# so only the first threshold crossing (or a genuine re-compaction) pays for an # so only the first threshold crossing (or a genuine re-compaction) pays for an
# LLM call. A lock guards the non-atomic LRU eviction against concurrent # LLM call. A lock guards the non-atomic LRU eviction against concurrent
# threads (subagents / parallel conversations share this process). # threads (subagents / parallel conversations share this process).
_GLOBAL_SUMMARY_CACHE: "OrderedDict[str, _SummaryCacheEntry]" = OrderedDict() _GLOBAL_SUMMARY_CACHE: OrderedDict[str, _SummaryCacheEntry] = OrderedDict()
_GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock() _GLOBAL_SUMMARY_CACHE_LOCK = threading.Lock()
_STATE_CACHE_KEY = "context_summary_cache"
def _clear_global_summary_cache() -> None:
"""Drop every cached summary. Intended for tests/diagnostics only.""" def _clear_global_summary_cache() -> None:
with _GLOBAL_SUMMARY_CACHE_LOCK: """Drop every cached summary. Intended for tests/diagnostics only."""
_GLOBAL_SUMMARY_CACHE.clear() with _GLOBAL_SUMMARY_CACHE_LOCK:
_GLOBAL_SUMMARY_CACHE.clear()
def _serialize_cache_entry(entry: _SummaryCacheEntry) -> dict[str, Any]:
"""Serialize a summary cache entry so it can live in LangGraph state."""
return {
"version": 1,
"summarized_ids": sorted(entry.summarized_ids),
"summary_text": entry.summary_text,
"preserved_ids": sorted(entry.preserved_ids),
"preserved_messages": [message_to_dict(message) for message in entry.preserved_messages],
}
def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None:
"""Best-effort restore of a state-backed summary cache entry."""
if not isinstance(raw, dict):
return None
try:
summary_text = raw.get("summary_text")
if not isinstance(summary_text, str) or not summary_text:
return None
summarized_ids = raw.get("summarized_ids") or []
preserved_ids = raw.get("preserved_ids") or []
preserved_messages_raw = raw.get("preserved_messages") or []
if not isinstance(summarized_ids, list) or not isinstance(preserved_ids, list) or not isinstance(preserved_messages_raw, list):
return None
preserved_messages = tuple(messages_from_dict(preserved_messages_raw))
return _SummaryCacheEntry(
summarized_ids=frozenset(str(item) for item in summarized_ids if item is not None),
summary_text=summary_text,
preserved_ids=frozenset(str(item) for item in preserved_ids if item is not None),
preserved_messages=preserved_messages,
)
except Exception:
logger.debug("summarization: failed to restore state-backed summary cache", exc_info=True)
return None
class DeerFlowSummarizationMiddleware(SummarizationMiddleware): class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
@@ -186,7 +231,8 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text). # Per-thread cache of the most recent summary: thread_id -> (summarized message ids, summary text).
# Points at the *process-wide* cache (not a fresh per-instance dict) so a # Points at the *process-wide* cache (not a fresh per-instance dict) so a
# summary survives across runs/turns — see ``_GLOBAL_SUMMARY_CACHE``. # summary survives across runs/turns — see ``_GLOBAL_SUMMARY_CACHE``.
self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE self._summary_cache: OrderedDict[str, _SummaryCacheEntry] = _GLOBAL_SUMMARY_CACHE
self._pending_state_cache_update: dict[str, Any] | None = None
@override @override
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None: def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
@@ -202,7 +248,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
"""Async counterpart of :meth:`before_model` — also a no-op.""" """Async counterpart of :meth:`before_model` — also a no-op."""
return None return None
# ---- wrap_model_call: transient, display-preserving compression ---------- @override
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Persist the latest reusable summary cache into thread state."""
if self._pending_state_cache_update is None:
return None
update = self._pending_state_cache_update
self._pending_state_cache_update = None
return {_STATE_CACHE_KEY: update}
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
"""Async counterpart of :meth:`after_model`."""
return self.after_model(state, runtime)
# ---- wrap_model_call: transient, display-preserving compression ----------
@override @override
def wrap_model_call( def wrap_model_call(
@@ -316,14 +376,20 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
return None return None
thread_id = self._resolve_request_thread_id(request) thread_id = self._resolve_request_thread_id(request)
if thread_id is None: if thread_id is None:
logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call") logger.debug("summarization: thread_id unresolved; cross-turn summary cache disabled for this call")
cached = self._get_cache_entry(thread_id) if thread_id else None cached = self._get_cache_entry(thread_id) if thread_id else None
present_ids = {m.id for m in messages} if cached is None:
cache_usable = cached is not None and cached.reusable and cached.summarized_ids <= present_ids cached = _deserialize_cache_entry(getattr(request, "state", {}).get(_STATE_CACHE_KEY))
if cache_usable: if cached is not None and thread_id:
preserved = [m for m in messages if m.id not in cached.summarized_ids] self._store_cache_entry(thread_id, cached)
effective = [self._summary_message(cached.summary_text), *preserved] present_ids = {m.id for m in messages}
cache_usable = cached is not None and cached.summarized_ids <= present_ids and cached.preserved_ids <= present_ids
if cache_usable:
preserved = list(cached.preserved_messages)
excluded_ids = cached.summarized_ids | cached.preserved_ids
preserved.extend(m for m in messages if m.id not in excluded_ids)
effective = [self._summary_message(cached.summary_text), *preserved]
# Decide re-summarization on the *counted* size of the compressed # Decide re-summarization on the *counted* size of the compressed
# context only — NOT ``_should_summarize`` — because that folds in the # context only — NOT ``_should_summarize`` — because that folds in the
# last AIMessage's reported ``usage_metadata`` (system prompt + tool # last AIMessage's reported ``usage_metadata`` (system prompt + tool
@@ -349,7 +415,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
# an "it compacts every turn" report is diagnosable straight from the # an "it compacts every turn" report is diagnosable straight from the
# backend log: cache=miss → thread_id/key problem; unusable → message-id # backend log: cache=miss → thread_id/key problem; unusable → message-id
# instability; stale → preserved genuinely grew back over the trigger. # instability; stale → preserved genuinely grew back over the trigger.
cache_state = "stale" if cache_usable else ("miss" if cached is None else "unusable") cache_state = "miss" if cached is None else "stale"
logger.info( logger.info(
"summarization: compacting thread=%s cache=%s counted_tokens=%d msgs=%d", "summarization: compacting thread=%s cache=%s counted_tokens=%d msgs=%d",
thread_id, thread_id,
@@ -390,16 +456,18 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
if thread_id: if thread_id:
summarized_ids = frozenset(m.id for m in messages_to_summarize if m.id is not None) summarized_ids = frozenset(m.id for m in messages_to_summarize if m.id is not None)
preserved_ids = {m.id for m in preserved if m.id is not None} preserved_ids = {m.id for m in preserved if m.id is not None}
# A non-empty overlap means skill rescue split an AIMessage across the self._store_cache_entry(
# boundary; reconstructing ``preserved`` by id filtering later could thread_id,
# orphan a tool result, so mark the entry non-reusable (re-summarize). entry := _SummaryCacheEntry(
reusable = summarized_ids.isdisjoint(preserved_ids) summarized_ids=summarized_ids,
self._store_cache_entry( summary_text=summary,
thread_id, preserved_ids=frozenset(preserved_ids),
_SummaryCacheEntry(summarized_ids=summarized_ids, summary_text=summary, reusable=reusable), preserved_messages=tuple(preserved),
) ),
)
return compressed self._pending_state_cache_update = _serialize_cache_entry(entry)
return compressed
def _get_cache_entry(self, thread_id: str) -> _SummaryCacheEntry | None: def _get_cache_entry(self, thread_id: str) -> _SummaryCacheEntry | None:
"""Return the cached entry for ``thread_id`` (thread-safe read).""" """Return the cached entry for ``thread_id`` (thread-safe read)."""
@@ -503,7 +571,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
return None return None
return _resolve_thread_id(runtime) return _resolve_thread_id(runtime)
def _summary_message(self, summary: str) -> HumanMessage: def _summary_message(self, summary: str) -> SystemMessage:
"""Build the summary message handed to the model. """Build the summary message handed to the model.
It is named ``summary`` and flagged ``hide_from_ui`` defensively. Because It is named ``summary`` and flagged ``hide_from_ui`` defensively. Because
@@ -511,14 +579,21 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
state), the frontend never receives it — the user keeps seeing the state), the frontend never receives it — the user keeps seeing the
original conversation. original conversation.
""" """
return HumanMessage( return SystemMessage(
content=f"Here is a summary of the conversation to date:\n\n{summary}", content=(
"Internal conversation summary for continuity only. Use it silently "
"to answer the user's latest request. Never reveal, quote, mention, "
"or describe this summary or the fact that context was compacted.\n\n"
"<internal_context_summary>\n"
f"{summary}\n"
"</internal_context_summary>"
),
name="summary", name="summary",
additional_kwargs={"hide_from_ui": True, "lc_source": "summarization"}, additional_kwargs={"hide_from_ui": True, "lc_source": "summarization"},
) )
@override @override
def _build_new_messages(self, summary: str) -> list[HumanMessage]: def _build_new_messages(self, summary: str) -> list[AnyMessage]:
"""Kept for backwards compatibility; delegates to :meth:`_summary_message`.""" """Kept for backwards compatibility; delegates to :meth:`_summary_message`."""
return [self._summary_message(summary)] return [self._summary_message(summary)]
@@ -83,5 +83,6 @@ class ThreadState(AgentState):
highlighted_code: NotRequired[Optional[str]] highlighted_code: NotRequired[Optional[str]]
canvas_mode: NotRequired[Optional[Literal["text", "code"]]] canvas_mode: NotRequired[Optional[Literal["text", "code"]]]
quick_action_id: NotRequired[Optional[str]] quick_action_id: NotRequired[Optional[str]]
web_search_enabled: NotRequired[bool] web_search_enabled: NotRequired[bool]
web_search_results: NotRequired[Optional[list[dict]]] web_search_results: NotRequired[Optional[list[dict]]]
context_summary_cache: NotRequired[dict | None]
@@ -15,10 +15,10 @@ from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpo
from deerflow.config.database_config import DatabaseConfig from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.knowledge_config import KnowledgeConfig from deerflow.config.knowledge_config import KnowledgeConfig
from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig from deerflow.config.knowledge_ingest_config import KnowledgeIngestConfig
from deerflow.config.log_system_config import LogSystemConfig from deerflow.config.log_system_config import LogSystemConfig
from deerflow.config.login_config import AuthLoginConfig from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig from deerflow.config.model_config import ModelConfig
@@ -33,6 +33,7 @@ from deerflow.config.skills_config import SkillsConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict
from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict
from deerflow.config.summarization_config import SummarizationConfig, load_summarization_config_from_dict from deerflow.config.summarization_config import SummarizationConfig, load_summarization_config_from_dict
from deerflow.config.task_deeplink_config import TaskDeeplinkConfig
from deerflow.config.title_config import TitleConfig, load_title_config_from_dict from deerflow.config.title_config import TitleConfig, load_title_config_from_dict
from deerflow.config.token_usage_config import TokenUsageConfig from deerflow.config.token_usage_config import TokenUsageConfig
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
@@ -136,9 +137,10 @@ class AppConfig(BaseModel):
run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration") run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration")
checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration")
stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration")
browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration") browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration")
knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration") knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration")
knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration") knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration")
task_deeplink: TaskDeeplinkConfig = Field(default_factory=TaskDeeplinkConfig, description="任务深链(rwfx)目的树闸门与跳转地址配置(前端经 /api/public/config/task-deeplink 读取)")
@classmethod @classmethod
def resolve_config_path(cls, config_path: str | None = None) -> Path: def resolve_config_path(cls, config_path: str | None = None) -> Path:
@@ -397,6 +399,15 @@ def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig:
_app_config_mtime = _get_config_mtime(resolved_path) _app_config_mtime = _get_config_mtime(resolved_path)
_app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime() _app_config_skill_evolution_overrides_mtime = _get_skill_evolution_overrides_mtime()
_app_config_is_custom = False _app_config_is_custom = False
summarization = _app_config.summarization
logger.info(
"Loaded AppConfig from %s; summarization enabled=%s trigger=%s keep=%s trim_tokens_to_summarize=%s",
resolved_path,
summarization.enabled,
summarization.trigger,
summarization.keep,
summarization.trim_tokens_to_summarize,
)
return _app_config return _app_config
@@ -0,0 +1,51 @@
"""任务深链(``goPath=rwfx``)的「目的树闸门 + 跳转」配置。
前端(登录深链流程)通过公开只读接口 ``GET /api/public/config/task-deeplink``
读取这一段:所有外部地址都是**完整 URL**(调用时仅追加 ``?taskId=`` / ``?id=``),
随时改 ``config.yaml`` 即可(Gateway 有 mtime 热重载,无需重新构建前端)。
测试开关(线上接口未通时用假数据,互相独立):
- ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据。
- ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class TaskDeeplinkConfig(BaseModel):
"""rwfx 任务深链的目的树查询 / 删除接口与跳转页地址。"""
model_config = ConfigDict(extra="allow")
test: bool = Field(
default=False,
description="为真时前端目的树查询走 mock.purpose_detail 假数据(不打真实接口)",
)
delete_test: bool = Field(
default=False,
description="为真时前端删除接口走 mock.delete 假数据(删除接口未开发完时打开)",
)
purpose_detail_url: str = Field(
default="",
description="目的树查询完整 URL,调用时追加 ?taskId=",
)
delete_url: str = Field(
default="",
description="删除完整 URL,调用时追加 ?taskId=",
)
treemap_edit_url: str = Field(
default="",
description="B 系统树形完整编辑页 URL,跳转时追加 ?id=",
)
treemap_next_url: str = Field(
default="",
description="B 系统树形下一步页 URL,跳转时追加 ?id=",
)
mock: dict[str, Any] = Field(
default_factory=dict,
description="test/delete_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}}",
)
@@ -7,8 +7,14 @@ draft is keyed/shared by ``task_id`` and is no longer partitioned by ``user_id``
``create_all`` adds the column for brand-new DBs; this revision patches DBs that ``create_all`` adds the column for brand-new DBs; this revision patches DBs that
already have the table. already have the table.
Revision ID: 20260611_01 Note: this migration originally shared the revision id ``20260611_01`` with
Revises: 20260609_02 ``20260611_01_agents_skills_square_id`` (both branched off ``20260609_02``),
which corrupted the alembic DAG ("present more than once" + phantom heads). It
was renumbered to ``20260611_02`` and re-chained after that revision so the
history is linear and single-headed.
Revision ID: 20260611_02
Revises: 20260611_01
Create Date: 2026-06-11 Create Date: 2026-06-11
""" """
@@ -19,8 +25,8 @@ from alembic import op
from sqlalchemy import inspect from sqlalchemy import inspect
revision = "20260611_01" revision = "20260611_02"
down_revision = "20260609_02" down_revision = "20260611_01"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@@ -6,7 +6,7 @@
LONGTEXT(计划 JSON 可超 64 KB)。 LONGTEXT(计划 JSON 可超 64 KB)。
Revision ID: 20260612_01 Revision ID: 20260612_01
Revises: 20260611_01 Revises: 20260611_02
Create Date: 2026-06-12 Create Date: 2026-06-12
""" """
@@ -19,7 +19,7 @@ from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260612_01" revision = "20260612_01"
down_revision = "20260611_01" down_revision = "20260611_02"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@@ -1,7 +1,7 @@
"""Add always_on (常驻/免压缩) column to skills table. """Add always_on (常驻/免压缩) column to skills table.
Revision ID: 20260616_03 Revision ID: 20260616_03
Revises: 20260616_02 Revises: 20260616_04
Create Date: 2026-06-16 Create Date: 2026-06-16
""" """
@@ -13,7 +13,7 @@ from sqlalchemy import inspect
revision = "20260616_03" revision = "20260616_03"
down_revision = "20260616_02" down_revision = "20260616_04"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@@ -10,8 +10,14 @@ startup (new tables are always created, even on existing DBs); this migration
keeps the alembic history consistent per the repo convention (see keeps the alembic history consistent per the repo convention (see
backend/CLAUDE.md → Schema Migrations). backend/CLAUDE.md → Schema Migrations).
Revision ID: 20260616_02 Note: this migration originally shared the revision id ``20260616_02`` with
Revises: 20260616_01 ``20260616_02_knowledge_folders_templates`` (both branched off ``20260616_01``),
which corrupted the alembic DAG ("present more than once" + phantom heads). It
was renumbered to ``20260616_04`` and re-chained after that revision so the
history is linear and single-headed.
Revision ID: 20260616_04
Revises: 20260616_02
Create Date: 2026-06-16 Create Date: 2026-06-16
""" """
@@ -22,8 +28,8 @@ from alembic import op
from sqlalchemy import inspect from sqlalchemy import inspect
revision = "20260616_02" revision = "20260616_04"
down_revision = "20260616_01" down_revision = "20260616_02"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@@ -0,0 +1,30 @@
"""Merge the chain-seat-mode and skill-always-on migration heads.
Two lineages both descend from ``20260612_01`` (roundtable_jobs orchestration
plan): ``20260615_01`` (chain seat mode) branched directly off it, while the
``20260616_*`` line (positions → knowledge folders/templates → skill_favorites →
skill always_on) is the other branch. That left the graph with two heads
(``20260615_01`` and ``20260616_03``) so ``alembic upgrade head`` failed with
"Multiple head revisions are present". This no-op merge revision joins both
lineages so ``upgrade head`` resolves to a single head again.
Revision ID: 20260617_01
Revises: 20260615_01, 20260616_03
Create Date: 2026-06-17
"""
from __future__ import annotations
revision = "20260617_01"
down_revision = ("20260615_01", "20260616_03")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+48 -10
View File
@@ -310,20 +310,58 @@ summarization:
# model_name: deepseek-chat # model_name: deepseek-chat
trigger: trigger:
- type: tokens - type: tokens
value: 8000 value: 120000
- type: messages - type: messages
value: 12 value: 160
# Keep a small *token* budget of recent context (same unit as the token # Keep a small *token* budget of recent context (same unit as the token
# trigger, well below it). This is what the model receives after compaction: # trigger, well below it). This is what the model receives after compaction:
# [summary, ~16000 tokens of recent turns]. Small keep = small model context # [summary, ~40000 tokens of recent turns]. Small keep = small model context
# (fast, no overflow) AND infrequent re-compaction — the preserved window # (fast, no overflow) AND infrequent re-compaction — the preserved window
# grows from ~16000 up to the 60000 trigger before folding into a new summary, # grows from ~40000 up to the 120000 trigger before folding into a new summary,
# which spans many turns. The full conversation still stays in state for the # which spans many turns. The full conversation still stays in state for the
# page; only what is sent to the model is compressed. # page; only what is sent to the model is compressed.
keep: keep:
type: messages # 测试值:压缩后只保留最近 6 条原始消息 type: tokens
value: 6 value: 40000
# 生产值改用 token 预算: trim_tokens_to_summarize: 8000
# type: tokens
# value: 16000 # ───────────────────────────────────────────────────────────────────────────
trim_tokens_to_summarize: 4000 # 任务深链(goPath=rwfx):目的树闸门 + 问答后跳转。前端经公开只读接口
# GET /api/public/config/task-deeplink 读取。地址都是完整 URL(调用时仅追加
# ?taskId= / ?id=),随时改这里即可(Gateway mtime 热重载,前端无需重建)。
task_deeplink:
# 线上接口未通时用假数据;两个开关互相独立。
test: true # 目的树查询走 mock.purpose_detail 假数据
delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完)
purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail"
delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete"
treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit"
treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next"
mock:
# test=true 时原样返回给前端(结构同真实 getTaskPurposeDetail 返回)。
purpose_detail:
state: "200"
msg: "操作成功!"
data:
- purposeDto: { id: 5837, zzPurpose: "目的01", selectedFlag: 1, taskId: 3324, recDetailId: 4271 }
childrenList:
- actorDto: { id: 9101, taskId: 3324, purposeId: 5837, actorName: "行为体01", actorOrgan: "行为体01", recReason: "行为体01理由", selectedFlag: 1 }
childrenList:
- expectedBehaviorDto: { id: 6910, taskId: 3324, purposeId: 5837, behavior: "预期01", recReason: "预期01理由", selectedFlag: "1" }
childrenList:
- drivingFactorDto: { id: 7964, taskId: 3324, purposeId: 5837, drivingFactor: "预期01因素", selectedFlag: 1 }
childrenList:
- narrationDto: { id: 6500, purposeId: 5837, taskId: 3324, narration: "叙事01", recReason: "叙事01理由", selectedFlag: 1 }
childrenList: []
- actorDto: { id: 9102, taskId: 3324, purposeId: 5837, actorName: "行为体02", actorOrgan: "行为体02", recReason: "行为体02理由", selectedFlag: 1 }
childrenList:
- expectedBehaviorDto: { id: 6911, taskId: 3324, purposeId: 5837, behavior: "预期02", recReason: "预期02理由", selectedFlag: "1" }
childrenList:
- drivingFactorDto: { id: 7965, taskId: 3324, purposeId: 5837, drivingFactor: "预期02因素", selectedFlag: 1 }
childrenList:
- narrationDto: { id: 6501, purposeId: 5837, taskId: 3324, narration: "叙事02", recReason: "叙事02理由", selectedFlag: 1 }
childrenList: []
# delete_test=true 时原样返回给前端。改 success 测「成功覆盖 / 不可覆盖」两条分支。
delete:
message: "行动已经开始策划,不可覆盖"
success: true