增强 Deep Research 可靠性,并接入 8BF 任务卡片与会商上下文。
修复 MySQL 读写分离下的持久化与 SSE 重连问题,报告完成前先落库;8BF 嵌入页支持 taskId 任务卡与独立意图 metadata;前端统一 createClientId 并更新 runtime 配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
47faa9bcdf
commit
771957429a
36 files changed
+1928
-190
No files matched your search
@@ -93,6 +93,12 @@ offline-backend-20260512/backend/tests/*
|
|||||||
# Keep the model configuration regression coverage versioned even though
|
# Keep the model configuration regression coverage versioned even though
|
||||||
# this deployment repository otherwise ignores newly added backend tests.
|
# this deployment repository otherwise ignores newly added backend tests.
|
||||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||||
|
# Deep-research reliability regression coverage.
|
||||||
|
!offline-backend-20260512/backend/tests/test_deep_research_persistence.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
|
||||||
|
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
|
||||||
deploy
|
deploy
|
||||||
.history
|
.history
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
|||||||
VITE_BRAND_WELCOME_DARK_URL: "",
|
VITE_BRAND_WELCOME_DARK_URL: "",
|
||||||
|
|
||||||
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
|
// DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。
|
||||||
// VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026",
|
// VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow",
|
||||||
|
|
||||||
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
|
// LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。
|
||||||
// VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api",
|
// VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api",
|
||||||
|
|
||||||
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
// Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。
|
||||||
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
VITE_CANVAS_LANGGRAPH_BASE_URL: "",
|
||||||
@@ -50,6 +50,10 @@ window.__CM_RUNTIME_CONFIG__ = {
|
|||||||
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
|
// CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。
|
||||||
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
|
VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
|
||||||
|
|
||||||
|
// 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项,
|
||||||
|
// 不影响原多智能体会商使用的任务详情接口。
|
||||||
|
VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm",
|
||||||
|
|
||||||
// 佳来模型对话接口。
|
// 佳来模型对话接口。
|
||||||
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
|
VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt",
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ window.__CM_RUNTIME_CONFIG__ = {
|
|||||||
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
|
VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000",
|
||||||
|
|
||||||
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
|
// Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。
|
||||||
VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer",
|
VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer",
|
||||||
|
|
||||||
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
|
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
|
||||||
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
|
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import type {
|
|||||||
CompactShortcutRouteParam,
|
CompactShortcutRouteParam,
|
||||||
} from "@/core/system-settings/api";
|
} from "@/core/system-settings/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
import {
|
import {
|
||||||
APP_THEME_COLORS,
|
APP_THEME_COLORS,
|
||||||
LOGIN_SOURCE_OPTIONS,
|
LOGIN_SOURCE_OPTIONS,
|
||||||
@@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [
|
|||||||
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
|
const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue";
|
||||||
|
|
||||||
function makeShortcutId() {
|
function makeShortcutId() {
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
return createClientId("shortcut");
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function newShortcutParam(): CompactShortcutRouteParam {
|
function newShortcutParam(): CompactShortcutRouteParam {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
customThemeTextContrast,
|
customThemeTextContrast,
|
||||||
type CustomThemeAdvancedKey,
|
type CustomThemeAdvancedKey,
|
||||||
} from "@/core/theme/custom-theme";
|
} from "@/core/theme/custom-theme";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const MAX_CUSTOM_THEMES = 12;
|
const MAX_CUSTOM_THEMES = 12;
|
||||||
@@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{
|
|||||||
];
|
];
|
||||||
|
|
||||||
function makeThemeId(): `custom-theme-${string}` {
|
function makeThemeId(): `custom-theme-${string}` {
|
||||||
const suffix =
|
const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase();
|
||||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
||||||
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase()
|
|
||||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
||||||
return `custom-theme-${suffix}`;
|
return `custom-theme-${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
DocumentRewriteStage,
|
DocumentRewriteStage,
|
||||||
} from "@/open-canvas/hooks/useDocumentRewrite";
|
} from "@/open-canvas/hooks/useDocumentRewrite";
|
||||||
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
|
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
|
|
||||||
export type DocumentRewritePresentation = {
|
export type DocumentRewritePresentation = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -74,10 +75,7 @@ const DocumentRewritePresentationContext =
|
|||||||
createContext<DocumentRewritePresentationContextValue | null>(null);
|
createContext<DocumentRewritePresentationContextValue | null>(null);
|
||||||
|
|
||||||
function createId() {
|
function createId() {
|
||||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
return createClientId("drw-ui");
|
||||||
return "drw-ui-" + crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentRewritePresentationProvider({
|
export function DocumentRewritePresentationProvider({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { runSentimentAgent, toAgUiMessages } from "./api";
|
import { runSentimentAgent, toAgUiMessages } from "./api";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
|
|
||||||
const STORAGE_PREFIX = "workspace.sentiment-agent.";
|
const STORAGE_PREFIX = "workspace.sentiment-agent.";
|
||||||
|
|
||||||
@@ -53,9 +54,9 @@ export function useSentimentAgentChat(threadId: string) {
|
|||||||
const question = query.trim();
|
const question = query.trim();
|
||||||
if (!question) return;
|
if (!question) return;
|
||||||
|
|
||||||
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
|
const assistantId = createClientId("sentiment-ai");
|
||||||
const userMessage: Message = {
|
const userMessage: Message = {
|
||||||
id: `sentiment-human-${crypto.randomUUID()}`,
|
id: createClientId("sentiment-human"),
|
||||||
type: "human",
|
type: "human",
|
||||||
content: [{ type: "text", text: question }],
|
content: [{ type: "text", text: question }],
|
||||||
additional_kwargs: {
|
additional_kwargs: {
|
||||||
@@ -115,7 +116,7 @@ export function useSentimentAgentChat(threadId: string) {
|
|||||||
try {
|
try {
|
||||||
await runSentimentAgent({
|
await runSentimentAgent({
|
||||||
threadId,
|
threadId,
|
||||||
runId: `run-${crypto.randomUUID()}`,
|
runId: createClientId("run"),
|
||||||
messages: history,
|
messages: history,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
handlers: {
|
handlers: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { readRuntimeEnv } from "@/env";
|
import { readRuntimeEnv } from "@/env";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
|
|
||||||
const STORAGE_PREFIX = "workspace.multi-round-rag.";
|
const STORAGE_PREFIX = "workspace.multi-round-rag.";
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ function writeStoredMessages(threadId: string, messages: Message[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createId(prefix: string) {
|
function createId(prefix: string) {
|
||||||
return `${prefix}-${crypto.randomUUID()}`;
|
return createClientId(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAbortError(error: unknown) {
|
function isAbortError(error: unknown) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { readRuntimeEnv } from "@/env";
|
import { readRuntimeEnv } from "@/env";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
|
|
||||||
const STORAGE_PREFIX = "workspace.single-round-rag.";
|
const STORAGE_PREFIX = "workspace.single-round-rag.";
|
||||||
|
|
||||||
@@ -277,7 +278,7 @@ function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[]
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createId(prefix: string) {
|
function createId(prefix: string) {
|
||||||
return `${prefix}-${crypto.randomUUID()}`;
|
return createClientId(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAbortError(error: unknown) {
|
function isAbortError(error: unknown) {
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { readRuntimeEnv } from "@/env";
|
||||||
|
|
||||||
|
export interface EightBfTask {
|
||||||
|
id: string;
|
||||||
|
externalTaskId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
direction: string;
|
||||||
|
overview: string;
|
||||||
|
priority: string;
|
||||||
|
taskPriority: string;
|
||||||
|
targetDirection: string;
|
||||||
|
taskStatus: string;
|
||||||
|
source: string;
|
||||||
|
raw: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EightBfDictionaryOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
alias?: string;
|
||||||
|
statusTone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EightBfTaskDictionaries {
|
||||||
|
priority: EightBfDictionaryOption[];
|
||||||
|
direction: EightBfDictionaryOption[];
|
||||||
|
status: EightBfDictionaryOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EightBfTaskContextPayload {
|
||||||
|
kind: "eightbf_roundtable_task";
|
||||||
|
usage: string;
|
||||||
|
task_id: string;
|
||||||
|
task: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
direction: string;
|
||||||
|
overview: string;
|
||||||
|
priority: string;
|
||||||
|
task_priority: string;
|
||||||
|
target_direction: string;
|
||||||
|
task_status: string;
|
||||||
|
};
|
||||||
|
task_tags: {
|
||||||
|
priority: string;
|
||||||
|
direction: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_DETAIL_PATH = "/task/{taskId}";
|
||||||
|
const DICTIONARY_TYPES = {
|
||||||
|
priority: "task_priority",
|
||||||
|
direction: "task_direction",
|
||||||
|
status: "task_status",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function externalApiBase(): string {
|
||||||
|
// The position-roundtable Vue app calls this task system through
|
||||||
|
// VITE_API_BASE_URL (usually the /zncm gateway), not DeerFlow's own API.
|
||||||
|
return (
|
||||||
|
readRuntimeEnv("VITE_8BF_TASK_API_BASE_URL")?.replace(/\/+$/, "") ||
|
||||||
|
readRuntimeEnv("VITE_API_BASE_URL")?.replace(/\/+$/, "") ||
|
||||||
|
getBackendBaseURL().replace(/\/+$/, "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function externalUrl(path: string): string {
|
||||||
|
if (/^https?:\/\//i.test(path)) return path;
|
||||||
|
return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
|
||||||
|
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
|
||||||
|
* 两套 token 的用途不同,不能直接复用 `apiFetch` 的默认鉴权头;否则
|
||||||
|
* 任务详情/字典接口可能返回 401,页面只会看到空任务卡。
|
||||||
|
*
|
||||||
|
* 这里仍保留 include credentials,与原 Vue 请求保持一致;同时不走
|
||||||
|
* `apiFetch` 的 401 自动清理逻辑,避免外部任务系统的鉴权失败误登出
|
||||||
|
* DeerFlow 当前用户。
|
||||||
|
*/
|
||||||
|
async function fetchExternalTaskSystem(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
if (!headers.has("Authorization")) {
|
||||||
|
headers.set("Authorization", "Bearer admin");
|
||||||
|
}
|
||||||
|
if (!headers.has("Accept")) {
|
||||||
|
headers.set("Accept", "application/json");
|
||||||
|
}
|
||||||
|
return fetch(externalUrl(path), {
|
||||||
|
...init,
|
||||||
|
credentials: init.credentials ?? "include",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstText(...values: unknown[]): string {
|
||||||
|
for (const value of values) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapData(payload: unknown): Record<string, unknown> {
|
||||||
|
let current: unknown = payload;
|
||||||
|
// Task services in different deployments wrap the object as either
|
||||||
|
// { data: task } or { data: { data: task } }.
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
if (!current || typeof current !== "object" || Array.isArray(current)) break;
|
||||||
|
const next = (current as { data?: unknown }).data;
|
||||||
|
if (!next || typeof next !== "object" || Array.isArray(next)) break;
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
return current && typeof current === "object" && !Array.isArray(current)
|
||||||
|
? (current as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEightBfTask(
|
||||||
|
raw: Record<string, unknown> | null | undefined,
|
||||||
|
fallbackTaskId = "",
|
||||||
|
): EightBfTask {
|
||||||
|
const source = raw ?? {};
|
||||||
|
const id = firstText(
|
||||||
|
source.id,
|
||||||
|
source.taskId,
|
||||||
|
source.externalTaskId,
|
||||||
|
source.external_task_id,
|
||||||
|
fallbackTaskId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
externalTaskId: firstText(source.externalTaskId, source.external_task_id, id),
|
||||||
|
title: firstText(source.title, source.taskName, source.task_name, source.name),
|
||||||
|
description: firstText(
|
||||||
|
source.description,
|
||||||
|
source.taskDescription,
|
||||||
|
source.task_description,
|
||||||
|
source.taskContent,
|
||||||
|
source.task_content,
|
||||||
|
source.content,
|
||||||
|
),
|
||||||
|
direction: firstText(
|
||||||
|
source.direction,
|
||||||
|
source.taskDirection,
|
||||||
|
source.task_direction,
|
||||||
|
),
|
||||||
|
overview: firstText(source.overview, source.summary),
|
||||||
|
priority: firstText(source.priority, source.urgency),
|
||||||
|
taskPriority: firstText(
|
||||||
|
source.taskPriority,
|
||||||
|
source.task_priority,
|
||||||
|
source.priority,
|
||||||
|
source.urgency,
|
||||||
|
),
|
||||||
|
targetDirection: firstText(
|
||||||
|
source.targetDirection,
|
||||||
|
source.target_direction,
|
||||||
|
source.direction,
|
||||||
|
source.taskDirection,
|
||||||
|
source.task_direction,
|
||||||
|
),
|
||||||
|
taskStatus: firstText(source.taskStatus, source.task_status, source.status),
|
||||||
|
source: "测试分析岗任务详情接口",
|
||||||
|
raw: source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dictionaryRows(payload: unknown): unknown[] {
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
if (payload && typeof payload === "object") {
|
||||||
|
const directData = (payload as { data?: unknown }).data;
|
||||||
|
if (Array.isArray(directData)) return directData;
|
||||||
|
if (directData && typeof directData === "object") {
|
||||||
|
const nested = directData as { rows?: unknown; list?: unknown };
|
||||||
|
if (Array.isArray(nested.rows)) return nested.rows;
|
||||||
|
if (Array.isArray(nested.list)) return nested.list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = unwrapData(payload);
|
||||||
|
if (Array.isArray(data.rows)) return data.rows;
|
||||||
|
if (Array.isArray(data.list)) return data.list;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDictionary(payload: unknown): EightBfDictionaryOption[] {
|
||||||
|
return dictionaryRows(payload)
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") return null;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const label = firstText(row.dictLabel, row.label);
|
||||||
|
const value = firstText(row.dictValue, row.dict_value, row.value);
|
||||||
|
if (!label || !value) return null;
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
alias: firstText(row.alias) || undefined,
|
||||||
|
statusTone: firstText(row.statusTone, row.status_tone) || undefined,
|
||||||
|
} as EightBfDictionaryOption;
|
||||||
|
})
|
||||||
|
.filter((item): item is EightBfDictionaryOption => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEightBfTask(taskId: string): Promise<EightBfTask> {
|
||||||
|
const encodedId = encodeURIComponent(taskId);
|
||||||
|
const path = TASK_DETAIL_PATH.replaceAll("{taskId}", encodedId);
|
||||||
|
const response = await fetchExternalTaskSystem(path, { method: "GET" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`任务详情接口请求失败(${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as unknown;
|
||||||
|
const task = normalizeEightBfTask(unwrapData(payload), taskId);
|
||||||
|
// Keep the external route id as the stable scope even if a deployment's
|
||||||
|
// response includes a legacy/internal numeric id.
|
||||||
|
return { ...task, id: taskId, externalTaskId: taskId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEightBfTaskDictionaries(): Promise<EightBfTaskDictionaries> {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
(Object.entries(DICTIONARY_TYPES) as Array<[
|
||||||
|
keyof EightBfTaskDictionaries,
|
||||||
|
string,
|
||||||
|
]>).map(async ([key, path]) => {
|
||||||
|
try {
|
||||||
|
const response = await fetchExternalTaskSystem(
|
||||||
|
`/system/dict/data/type/${path}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`字典接口请求失败(${response.status})`);
|
||||||
|
return [key, normalizeDictionary(await response.json())] as const;
|
||||||
|
} catch (error) {
|
||||||
|
// A dictionary failure must not hide the task card; the card falls back
|
||||||
|
// to the raw task codes and the detail request remains usable.
|
||||||
|
console.warn(`[8bf] ${key} 字典加载失败`, error);
|
||||||
|
return [key, []] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Object.fromEntries(entries) as EightBfTaskDictionaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dictionaryText(
|
||||||
|
options: EightBfDictionaryOption[] | undefined,
|
||||||
|
value: unknown,
|
||||||
|
preferredField: "label" | "alias" = "label",
|
||||||
|
): string {
|
||||||
|
const raw = firstText(value);
|
||||||
|
if (!raw) return "";
|
||||||
|
const match = options?.find((option) => option.value === raw);
|
||||||
|
return firstText(match?.[preferredField], match?.label, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEightBfTaskContext(
|
||||||
|
task: EightBfTask | null,
|
||||||
|
dictionaries: EightBfTaskDictionaries,
|
||||||
|
): EightBfTaskContextPayload | null {
|
||||||
|
if (!task?.id) return null;
|
||||||
|
return {
|
||||||
|
kind: "eightbf_roundtable_task",
|
||||||
|
usage:
|
||||||
|
"这是 8BF 页面左上角任务卡片中的任务信息。请直接基于这些信息理解用户问题,不要要求用户重复提供已经存在的任务名称、任务描述、任务方向或任务状态。",
|
||||||
|
task_id: task.id,
|
||||||
|
task: {
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
description: task.description,
|
||||||
|
direction: task.direction,
|
||||||
|
overview: task.overview,
|
||||||
|
priority: task.priority,
|
||||||
|
task_priority: task.taskPriority,
|
||||||
|
target_direction: task.targetDirection,
|
||||||
|
task_status: task.taskStatus,
|
||||||
|
},
|
||||||
|
task_tags: {
|
||||||
|
priority: dictionaryText(dictionaries.priority, task.taskPriority),
|
||||||
|
direction: dictionaryText(dictionaries.direction, task.targetDirection, "alias"),
|
||||||
|
status: dictionaryText(dictionaries.status, task.taskStatus),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Clipboard, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
dictionaryText,
|
||||||
|
type EightBfDictionaryOption,
|
||||||
|
type EightBfTask,
|
||||||
|
} from "@/eightbf-roundtable/api/task-context";
|
||||||
|
|
||||||
|
interface EightBfTaskCardProps {
|
||||||
|
task: EightBfTask | null;
|
||||||
|
dictionaries: {
|
||||||
|
priority: EightBfDictionaryOption[];
|
||||||
|
direction: EightBfDictionaryOption[];
|
||||||
|
status: EightBfDictionaryOption[];
|
||||||
|
};
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EightBfTaskCard({
|
||||||
|
task,
|
||||||
|
dictionaries,
|
||||||
|
loading = false,
|
||||||
|
error,
|
||||||
|
}: EightBfTaskCardProps) {
|
||||||
|
const priority = dictionaryText(dictionaries.priority, task?.taskPriority);
|
||||||
|
const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias");
|
||||||
|
const status = dictionaryText(dictionaries.status, task?.taskStatus);
|
||||||
|
const tags = [priority, direction, status].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="eightbf-roundtable__task-card" aria-labelledby="eightbf-task-card-title">
|
||||||
|
<div className="eightbf-roundtable__task-card-heading">
|
||||||
|
<span className="eightbf-roundtable__task-card-icon" aria-hidden="true">
|
||||||
|
<Clipboard className="h-3.5 w-3.5" strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
<h2 id="eightbf-task-card-title">当前任务</h2>
|
||||||
|
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-label="正在加载任务" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="eightbf-roundtable__task-card-body">
|
||||||
|
<p className="eightbf-roundtable__task-card-title">
|
||||||
|
{task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")}
|
||||||
|
</p>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="eightbf-roundtable__task-card-tags" aria-label="任务标签">
|
||||||
|
{tags.map((tag) => <span key={tag}>{tag}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className={cn("eightbf-roundtable__task-card-description", !task?.description && "is-empty")}>
|
||||||
|
{task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")}
|
||||||
|
</p>
|
||||||
|
{error && <p className="eightbf-roundtable__task-card-error" role="status">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Loader2, Save, Settings2, X } from "lucide-react";
|
import { Loader2, Save, Settings2, X } from "lucide-react";
|
||||||
|
|
||||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||||
|
import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard";
|
||||||
|
import {
|
||||||
|
buildEightBfTaskContext,
|
||||||
|
fetchEightBfTask,
|
||||||
|
fetchEightBfTaskDictionaries,
|
||||||
|
normalizeEightBfTask,
|
||||||
|
type EightBfTask,
|
||||||
|
type EightBfTaskDictionaries,
|
||||||
|
} from "@/eightbf-roundtable/api/task-context";
|
||||||
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
|
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
|
||||||
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
|
||||||
import { listChains } from "@/roundtable-planning/api/chains";
|
import { listChains } from "@/roundtable-planning/api/chains";
|
||||||
@@ -26,10 +36,28 @@ const BUSINESS_CODE = "8BF";
|
|||||||
*/
|
*/
|
||||||
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
|
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
|
||||||
const auth = getStoredAuth();
|
const auth = getStoredAuth();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const taskId = useMemo(
|
||||||
|
() => searchParams.get("taskId")?.trim() || "",
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
|
// 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
|
||||||
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
|
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
|
||||||
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
|
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
|
||||||
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
|
const storageTaskId = useMemo(
|
||||||
|
() => `8bf:${auth?.user_id || "anonymous"}:${taskId || "anonymous"}`,
|
||||||
|
[auth?.user_id, taskId],
|
||||||
|
);
|
||||||
|
const [task, setTask] = useState<EightBfTask | null>(() => (
|
||||||
|
taskId ? normalizeEightBfTask({}, taskId) : null
|
||||||
|
));
|
||||||
|
const [taskDictionaries, setTaskDictionaries] = useState<EightBfTaskDictionaries>({
|
||||||
|
priority: [],
|
||||||
|
direction: [],
|
||||||
|
status: [],
|
||||||
|
});
|
||||||
|
const [taskLoading, setTaskLoading] = useState(Boolean(taskId));
|
||||||
|
const [taskError, setTaskError] = useState<string | null>(null);
|
||||||
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
|
const [mapping, setMapping] = useState<BusinessMapping | null>(null);
|
||||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -41,6 +69,53 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
|
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
|
||||||
[chains, mapping?.chainId],
|
[chains, mapping?.chainId],
|
||||||
);
|
);
|
||||||
|
const taskContext = useMemo(
|
||||||
|
() => buildEightBfTaskContext(task, taskDictionaries),
|
||||||
|
[task, taskDictionaries],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId) {
|
||||||
|
setTask(null);
|
||||||
|
setTaskError(null);
|
||||||
|
setTaskLoading(false);
|
||||||
|
setTaskDictionaries({ priority: [], direction: [], status: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setTaskLoading(true);
|
||||||
|
setTaskError(null);
|
||||||
|
setTask(normalizeEightBfTask({}, taskId));
|
||||||
|
let pendingRequests = 2;
|
||||||
|
const markRequestFinished = () => {
|
||||||
|
pendingRequests -= 1;
|
||||||
|
if (!cancelled && pendingRequests === 0) setTaskLoading(false);
|
||||||
|
};
|
||||||
|
// Keep detail and dictionary requests independent: a missing dictionary
|
||||||
|
// must not prevent the task card or task context from being available.
|
||||||
|
void fetchEightBfTask(taskId)
|
||||||
|
.then((nextTask) => {
|
||||||
|
if (!cancelled) setTask(nextTask);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTaskError(error instanceof Error ? error.message : "任务详情加载失败");
|
||||||
|
})
|
||||||
|
.finally(markRequestFinished);
|
||||||
|
void fetchEightBfTaskDictionaries()
|
||||||
|
.then((nextDictionaries) => {
|
||||||
|
if (!cancelled) setTaskDictionaries(nextDictionaries);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// fetchEightBfTaskDictionaries already degrades each dictionary to an
|
||||||
|
// empty list; this guard protects future adapter changes.
|
||||||
|
})
|
||||||
|
.finally(markRequestFinished);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [taskId]);
|
||||||
|
|
||||||
const loadConfiguration = useCallback(async () => {
|
const loadConfiguration = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -162,6 +237,16 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
|
|||||||
lockedChainId={mapping?.chainId ?? null}
|
lockedChainId={mapping?.chainId ?? null}
|
||||||
lockBusinessChain
|
lockBusinessChain
|
||||||
storageTaskId={storageTaskId}
|
storageTaskId={storageTaskId}
|
||||||
|
taskContext={taskContext as Record<string, unknown> | null}
|
||||||
|
entryRailTaskId={taskId || null}
|
||||||
|
entryRailTaskCardSlot={taskId ? (
|
||||||
|
<EightBfTaskCard
|
||||||
|
task={task}
|
||||||
|
dictionaries={taskDictionaries}
|
||||||
|
loading={taskLoading}
|
||||||
|
error={taskError}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
toolbarSlot={toolbarSlot}
|
toolbarSlot={toolbarSlot}
|
||||||
entryRailBusinessChainSlot={
|
entryRailBusinessChainSlot={
|
||||||
<PositionBusinessChainFlow
|
<PositionBusinessChainFlow
|
||||||
|
|||||||
@@ -27,4 +27,100 @@
|
|||||||
min-height: 460px;
|
min-height: 460px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 8BF 外部 taskId:左上任务卡。仅挂在 8BF 根节点下,避免改动原会商任务卡。 */
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid #d9e5ef;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(145deg, #ffffff 0%, #f8fbff 100%);
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: 0 4px 20px rgb(15 23 42 / 3%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card-body {
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card-title {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card-tags span {
|
||||||
|
border: 1px solid #c7e6df;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #0f766e;
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card-description {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin: 9px 0 0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.65;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #94a3b8; }
|
||||||
|
.eightbf-roundtable .eightbf-roundtable__task-card-error { margin: 8px 0 0; color: #b91c1c; font-size: 10px; line-height: 1.5; }
|
||||||
|
|
||||||
|
.dark .eightbf-roundtable .eightbf-roundtable__task-card {
|
||||||
|
border-color: rgb(92 156 219 / 45%);
|
||||||
|
background: linear-gradient(145deg, #0b315d 0%, #08264b 100%);
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading,
|
||||||
|
.dark .eightbf-roundtable .eightbf-roundtable__task-card-title { color: #f1f7ff; }
|
||||||
|
.dark .eightbf-roundtable .eightbf-roundtable__task-card-heading > svg:last-child { color: #65d6c6; }
|
||||||
|
.dark .eightbf-roundtable .eightbf-roundtable__task-card-tags span { border-color: rgb(101 214 198 / 40%); background: rgb(15 118 110 / 24%); color: #9ef0df; }
|
||||||
|
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description { color: #c3d4e8; }
|
||||||
|
.dark .eightbf-roundtable .eightbf-roundtable__task-card-description.is-empty { color: #8fa8c5; }
|
||||||
|
.dark .eightbf-roundtable .eightbf-roundtable__task-card-error { color: #fca5a5; }
|
||||||
|
|
||||||
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
|
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Generate a client-only id without assuming a secure browser context.
|
||||||
|
*
|
||||||
|
* `crypto.randomUUID()` is unavailable in several browsers when the app is
|
||||||
|
* opened directly through an `http://<public-ip>:<port>` address. Request and
|
||||||
|
* UI ids need uniqueness, not a security token, so a timestamp/random fallback
|
||||||
|
* keeps those flows usable until the deployment is served over HTTPS.
|
||||||
|
*/
|
||||||
|
export function createClientId(prefix?: string): string {
|
||||||
|
const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined;
|
||||||
|
let id: string | undefined;
|
||||||
|
|
||||||
|
if (typeof cryptoApi?.randomUUID === "function") {
|
||||||
|
id = cryptoApi.randomUUID();
|
||||||
|
} else if (typeof cryptoApi?.getRandomValues === "function") {
|
||||||
|
try {
|
||||||
|
const values = new Uint32Array(4);
|
||||||
|
cryptoApi.getRandomValues(values);
|
||||||
|
id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
|
||||||
|
} catch {
|
||||||
|
// Some restricted browser contexts expose crypto but reject random data.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
||||||
|
return prefix ? `${prefix}-${id}` : id;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useAgentSelector } from "@/core/agents";
|
import { useAgentSelector } from "@/core/agents";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
import {
|
import {
|
||||||
type DomainAgentMapping,
|
type DomainAgentMapping,
|
||||||
normalizeDomainInput,
|
normalizeDomainInput,
|
||||||
@@ -20,10 +21,7 @@ import {
|
|||||||
|
|
||||||
function makeRow(): DomainAgentMapping {
|
function makeRow(): DomainAgentMapping {
|
||||||
return {
|
return {
|
||||||
id:
|
id: createClientId(),
|
||||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
||||||
? crypto.randomUUID()
|
|
||||||
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
||||||
domain: "",
|
domain: "",
|
||||||
agentId: "",
|
agentId: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
|
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
|
||||||
@@ -147,10 +148,7 @@ const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
|
|||||||
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
|
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
|
||||||
*/
|
*/
|
||||||
function newCommandId(): string {
|
function newCommandId(): string {
|
||||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
return createClientId().replace(/-/g, "");
|
||||||
return crypto.randomUUID().replace(/-/g, "");
|
|
||||||
}
|
|
||||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type IntentSummary,
|
type IntentSummary,
|
||||||
} from "@/roundtable-planning/api/intent";
|
} from "@/roundtable-planning/api/intent";
|
||||||
import { uploadFiles } from "@/core/uploads/api";
|
import { uploadFiles } from "@/core/uploads/api";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
import {
|
import {
|
||||||
clarificationKey,
|
clarificationKey,
|
||||||
formatIntentClarificationAnswer,
|
formatIntentClarificationAnswer,
|
||||||
@@ -449,9 +450,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
|||||||
|
|
||||||
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
|
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
|
||||||
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
|
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
|
||||||
const clientTurnId =
|
const clientTurnId = createClientId();
|
||||||
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
|
|
||||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
||||||
|
|
||||||
const res = await streamIntent({
|
const res = await streamIntent({
|
||||||
threadId: intentThreadId,
|
threadId: intentThreadId,
|
||||||
|
|||||||
@@ -298,6 +298,12 @@ export default function RoundtablePlanningPage({
|
|||||||
toolbarSlot,
|
toolbarSlot,
|
||||||
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
|
/** 独立业务页在第一步左上角替换默认业务链选择区的内容。 */
|
||||||
entryRailBusinessChainSlot,
|
entryRailBusinessChainSlot,
|
||||||
|
/** 独立业务页在第一步左上角提供自己的任务卡片;不触发原深链任务接口。 */
|
||||||
|
entryRailTaskCardSlot,
|
||||||
|
/** 独立业务页传给意图智能体的隐藏任务上下文。 */
|
||||||
|
taskContext,
|
||||||
|
/** 独立业务页的任务作用域,仅用于左栏记录筛选,不改变原深链逻辑。 */
|
||||||
|
entryRailTaskId,
|
||||||
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
/** 独立业务页的根节点样式类,避免修改原会商样式。 */
|
||||||
rootClassName,
|
rootClassName,
|
||||||
}: {
|
}: {
|
||||||
@@ -307,6 +313,9 @@ export default function RoundtablePlanningPage({
|
|||||||
storageTaskId?: string | null;
|
storageTaskId?: string | null;
|
||||||
toolbarSlot?: ReactNode;
|
toolbarSlot?: ReactNode;
|
||||||
entryRailBusinessChainSlot?: ReactNode;
|
entryRailBusinessChainSlot?: ReactNode;
|
||||||
|
entryRailTaskCardSlot?: ReactNode;
|
||||||
|
taskContext?: Record<string, unknown> | null;
|
||||||
|
entryRailTaskId?: string | null;
|
||||||
rootClassName?: string;
|
rootClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -315,6 +324,10 @@ export default function RoundtablePlanningPage({
|
|||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
|
||||||
|
useEffect(() => {
|
||||||
|
taskContextRef.current = taskContext ?? null;
|
||||||
|
}, [taskContext]);
|
||||||
/** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */
|
/** 无界(wujie)嵌入抽屉:隐藏业务链条入口,设置按钮改作手动回传交付。 */
|
||||||
const inWujieEmbed = isWujieEnv();
|
const inWujieEmbed = isWujieEnv();
|
||||||
|
|
||||||
@@ -542,6 +555,38 @@ export default function RoundtablePlanningPage({
|
|||||||
}>({ intentReady: null, interactiveMessages: [] });
|
}>({ intentReady: null, interactiveMessages: [] });
|
||||||
|
|
||||||
const buildInitialIntent = useCallback((): string => {
|
const buildInitialIntent = useCallback((): string => {
|
||||||
|
const task = taskContextRef.current?.task as Record<string, unknown> | null | undefined;
|
||||||
|
const taskTags = taskContextRef.current?.task_tags as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const taskValue = (value: unknown) =>
|
||||||
|
value === undefined || value === null ? "" : String(value).trim();
|
||||||
|
const taskLines = task
|
||||||
|
? [
|
||||||
|
["任务 ID", taskValue(task.id ?? taskContextRef.current?.task_id)],
|
||||||
|
["任务名称", taskValue(task.title)],
|
||||||
|
["任务描述", taskValue(task.description)],
|
||||||
|
["任务概览", taskValue(task.overview)],
|
||||||
|
["任务方向", taskValue(task.direction ?? task.target_direction)],
|
||||||
|
["优先级", taskValue(task.task_priority ?? task.priority)],
|
||||||
|
["任务状态", taskValue(task.task_status)],
|
||||||
|
["优先级标签", taskValue(taskTags?.priority)],
|
||||||
|
["方向标签", taskValue(taskTags?.direction)],
|
||||||
|
["状态标签", taskValue(taskTags?.status)],
|
||||||
|
]
|
||||||
|
.filter(([, value]) => value)
|
||||||
|
.map(([label, value]) => `${label}: ${value}`)
|
||||||
|
: [];
|
||||||
|
const taskCardContext = taskLines.length
|
||||||
|
? [
|
||||||
|
"【8BF 任务卡片上下文】",
|
||||||
|
...taskLines,
|
||||||
|
"请把以上任务卡片信息作为本轮研讨的既有背景,不要让用户重复提供。",
|
||||||
|
"",
|
||||||
|
].join("\n")
|
||||||
|
: "";
|
||||||
|
const withTaskCardContext = (value: string) =>
|
||||||
|
taskCardContext ? `${taskCardContext}${value}` : value;
|
||||||
const { intentReady, interactiveMessages } = step1StateRef.current;
|
const { intentReady, interactiveMessages } = step1StateRef.current;
|
||||||
if (intentReady) {
|
if (intentReady) {
|
||||||
const parts: string[] = [intentReady.objective];
|
const parts: string[] = [intentReady.objective];
|
||||||
@@ -559,17 +604,17 @@ export default function RoundtablePlanningPage({
|
|||||||
parts.push(
|
parts.push(
|
||||||
"用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。",
|
"用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。",
|
||||||
);
|
);
|
||||||
return parts.join("\n");
|
return withTaskCardContext(parts.join("\n"));
|
||||||
}
|
}
|
||||||
const userTurns = interactiveMessages
|
const userTurns = interactiveMessages
|
||||||
.filter((m) => m.sender === "user")
|
.filter((m) => m.sender === "user")
|
||||||
.map((m) => m.content?.trim())
|
.map((m) => m.content?.trim())
|
||||||
.filter((s): s is string => !!s);
|
.filter((s): s is string => !!s);
|
||||||
if (userTurns.length === 0) {
|
if (userTurns.length === 0) {
|
||||||
return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。";
|
return withTaskCardContext("用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。");
|
||||||
}
|
}
|
||||||
const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n");
|
const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n");
|
||||||
return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`;
|
return withTaskCardContext(`${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const step2 = useStep2Orchestration({
|
const step2 = useStep2Orchestration({
|
||||||
@@ -593,6 +638,7 @@ export default function RoundtablePlanningPage({
|
|||||||
currentStep,
|
currentStep,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
|
taskContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
|
// 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。
|
||||||
@@ -2176,12 +2222,13 @@ export default function RoundtablePlanningPage({
|
|||||||
onSelectChain={handleSelectChainFromSidebar}
|
onSelectChain={handleSelectChainFromSidebar}
|
||||||
onOpenDraft={drafts.handleLoadDraft}
|
onOpenDraft={drafts.handleLoadDraft}
|
||||||
activeDraftId={drafts.currentDraftId}
|
activeDraftId={drafts.currentDraftId}
|
||||||
entryRailTaskId={recordTaskIdOf(prefillRef.current)}
|
entryRailTaskId={entryRailTaskId ?? recordTaskIdOf(prefillRef.current)}
|
||||||
historyTaskId={draftStorageTaskId}
|
historyTaskId={draftStorageTaskId}
|
||||||
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
|
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
|
||||||
lockedChainId={lockedChainId}
|
lockedChainId={lockedChainId}
|
||||||
lockBusinessChain={lockBusinessChain}
|
lockBusinessChain={lockBusinessChain}
|
||||||
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
|
entryRailBusinessChainSlot={entryRailBusinessChainSlot}
|
||||||
|
entryRailTaskCardSlot={entryRailTaskCardSlot}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MIDDLE VIEWPORT */}
|
{/* MIDDLE VIEWPORT */}
|
||||||
@@ -2743,6 +2790,8 @@ interface LeftSidebarProps {
|
|||||||
lockedChainId?: string | null;
|
lockedChainId?: string | null;
|
||||||
/** 第一阶段替换默认业务链选择区的自定义内容。 */
|
/** 第一阶段替换默认业务链选择区的自定义内容。 */
|
||||||
entryRailBusinessChainSlot?: ReactNode;
|
entryRailBusinessChainSlot?: ReactNode;
|
||||||
|
/** 第一阶段由独立业务页提供的自定义任务卡片。 */
|
||||||
|
entryRailTaskCardSlot?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step2SandboxSidebarSync({
|
function Step2SandboxSidebarSync({
|
||||||
@@ -2794,6 +2843,7 @@ function LeftSidebar({
|
|||||||
lockBusinessChain,
|
lockBusinessChain,
|
||||||
lockedChainId,
|
lockedChainId,
|
||||||
entryRailBusinessChainSlot,
|
entryRailBusinessChainSlot,
|
||||||
|
entryRailTaskCardSlot,
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
|
||||||
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
|
||||||
@@ -2937,6 +2987,7 @@ function LeftSidebar({
|
|||||||
lockBusinessChain={lockBusinessChain}
|
lockBusinessChain={lockBusinessChain}
|
||||||
lockedChainId={lockedChainId ?? null}
|
lockedChainId={lockedChainId ?? null}
|
||||||
businessChainSlot={entryRailBusinessChainSlot}
|
businessChainSlot={entryRailBusinessChainSlot}
|
||||||
|
taskCardSlot={entryRailTaskCardSlot}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -2992,6 +3043,7 @@ function RoundtableEntryRail({
|
|||||||
lockedChainId,
|
lockedChainId,
|
||||||
lockBusinessChain = false,
|
lockBusinessChain = false,
|
||||||
businessChainSlot,
|
businessChainSlot,
|
||||||
|
taskCardSlot,
|
||||||
}: {
|
}: {
|
||||||
selectedChainId: string | null;
|
selectedChainId: string | null;
|
||||||
onSelectChain: (chain: BusinessChain) => void;
|
onSelectChain: (chain: BusinessChain) => void;
|
||||||
@@ -3007,6 +3059,7 @@ function RoundtableEntryRail({
|
|||||||
lockedChainId?: string | null;
|
lockedChainId?: string | null;
|
||||||
lockBusinessChain?: boolean;
|
lockBusinessChain?: boolean;
|
||||||
businessChainSlot?: ReactNode;
|
businessChainSlot?: ReactNode;
|
||||||
|
taskCardSlot?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||||
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
|
const [chainSectionExpanded, setChainSectionExpanded] = useState(true);
|
||||||
@@ -3093,7 +3146,12 @@ function RoundtableEntryRail({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
|
{/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
|
||||||
{deeplink ? (
|
{taskCardSlot ? (
|
||||||
|
<>
|
||||||
|
{taskCardSlot}
|
||||||
|
{lockBusinessChain && businessChainSlot}
|
||||||
|
</>
|
||||||
|
) : deeplink ? (
|
||||||
isXdfx ? (
|
isXdfx ? (
|
||||||
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
|
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
|
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
import type {
|
import type {
|
||||||
DocumentRewriteSummary,
|
DocumentRewriteSummary,
|
||||||
DocumentRewriteStreamEvent,
|
DocumentRewriteStreamEvent,
|
||||||
@@ -355,7 +356,7 @@ export async function startJob(
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
request_id: opts.requestId ?? crypto.randomUUID(),
|
request_id: opts.requestId ?? createClientId(),
|
||||||
entry: opts.entry ?? "legacy",
|
entry: opts.entry ?? "legacy",
|
||||||
config: opts.config ?? null,
|
config: opts.config ?? null,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1147,6 +1147,10 @@ Automatic conversation summarization when approaching token limits:
|
|||||||
the config flag. `config.summarization.enabled` now defaults to **false**.
|
the config flag. `config.summarization.enabled` now defaults to **false**.
|
||||||
- `config.yaml` `summarization` still supplies the **tuning**: trigger types
|
- `config.yaml` `summarization` still supplies the **tuning**: trigger types
|
||||||
(tokens, messages, or fraction of max input) and the keep policy.
|
(tokens, messages, or fraction of max input) and the keep policy.
|
||||||
|
- A compressed request must retain the latest non-empty `HumanMessage`, even
|
||||||
|
when it has already been folded into the hidden summary. This keeps strict
|
||||||
|
OpenAI-compatible gateways from rejecting a tool-continuation call with
|
||||||
|
`No user query found in message`.
|
||||||
- Keeps recent messages while summarizing older ones.
|
- Keeps recent messages while summarizing older ones.
|
||||||
|
|
||||||
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
|
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
|
||||||
@@ -1284,6 +1288,31 @@ The per-user Deep Research concurrency guard counts only session statuses
|
|||||||
consume a slot; otherwise saved drafts make `POST /sessions/{id}/jobs` return
|
consume a slot; otherwise saved drafts make `POST /sessions/{id}/jobs` return
|
||||||
429 permanently. Regression: `test_saved_draft_sessions_do_not_consume_research_concurrency`.
|
429 permanently. Regression: `test_saved_draft_sessions_do_not_consume_research_concurrency`.
|
||||||
|
|
||||||
|
`POST /api/deep-research/sessions/{id}/jobs` is retry-safe under MySQL
|
||||||
|
read/write splitting. Deep Research session/job/event/source/message writes
|
||||||
|
must serialise their changed row while the write transaction is still open
|
||||||
|
(`flush` for inserts, select-after-update **before** commit), never through a
|
||||||
|
post-commit `session.refresh()` or a fresh pooled read. Replica lag otherwise
|
||||||
|
turns a committed job into a misleading 500, can make dispatcher `claim_job()`
|
||||||
|
lose its snapshot, or can fail report/follow-up persistence after the model has
|
||||||
|
already completed. Always nudge the dispatcher for both a newly created job and
|
||||||
|
an idempotently reused queued job; otherwise a retry after such an interrupted
|
||||||
|
response returns the old job but leaves it waiting for the periodic scan. The
|
||||||
|
SSE replay generator must catch temporary event/job-store reads, emit a
|
||||||
|
recoverable warning, and keep the connection open so the next poll can repair
|
||||||
|
the cursor instead of surfacing an ASGI `ExceptionGroup`. Regressions:
|
||||||
|
`test_deep_research_writes_never_depend_on_post_commit_refresh`,
|
||||||
|
`test_job_create_never_reads_back_after_commit`,
|
||||||
|
`test_retry_of_existing_queued_job_nudges_dispatcher`, and
|
||||||
|
`test_stream_stays_open_when_durable_replay_has_a_transient_failure`.
|
||||||
|
|
||||||
|
At ordinary report completion, persist and confirm the session's
|
||||||
|
`report_markdown` projection **before** marking the Job `completed`. Retry a
|
||||||
|
short transient projection failure; if it cannot be confirmed, let the job
|
||||||
|
become failed rather than reporting a report that vanishes on refresh.
|
||||||
|
`test_report_is_persisted_before_job_is_marked_completed` and
|
||||||
|
`test_report_projection_failure_never_marks_job_completed` cover the ordering.
|
||||||
|
|
||||||
The chat-collection frontend must keep report-writing progress in the existing
|
The chat-collection frontend must keep report-writing progress in the existing
|
||||||
workspace `MessageList` / `MessageGroup` tool-step renderer: inject only
|
workspace `MessageList` / `MessageGroup` tool-step renderer: inject only
|
||||||
virtual `deep_research_progress` tool calls for the durable phases, keep the
|
virtual `deep_research_progress` tool calls for the durable phases, keep the
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
|||||||
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
||||||
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||||
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
||||||
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
|
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
|
||||||
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
|
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
|
||||||
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
|
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
|
||||||
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
|
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ WORKER_ID = f"w-{os.getpid()}-{_uuid.uuid4().hex[:8]}"
|
|||||||
_LEASE_TTL_SECONDS = 60
|
_LEASE_TTL_SECONDS = 60
|
||||||
_HEARTBEAT_INTERVAL = 20.0
|
_HEARTBEAT_INTERVAL = 20.0
|
||||||
_CANCEL_POLL_INTERVAL = 5.0
|
_CANCEL_POLL_INTERVAL = 5.0
|
||||||
|
_REPORT_PROJECTION_RETRY_ATTEMPTS = 3
|
||||||
|
_REPORT_PROJECTION_RETRY_DELAY_SECONDS = 0.5
|
||||||
|
|
||||||
# Phase → progress% mapping for the job timeline.
|
# Phase → progress% mapping for the job timeline.
|
||||||
_PHASE_PROGRESS: dict[str, int] = {
|
_PHASE_PROGRESS: dict[str, int] = {
|
||||||
@@ -1372,7 +1374,17 @@ class DeepResearchJobExecutor:
|
|||||||
async def _mark_completed(
|
async def _mark_completed(
|
||||||
self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult
|
self, job_id: str, session_id: str, lease_owner: str | None, result: DeepResearchResult
|
||||||
) -> None:
|
) -> None:
|
||||||
# Job row → completed.
|
# Persist the report projection before exposing a terminal completed
|
||||||
|
# job. The session row is the durable report itself; marking the job
|
||||||
|
# complete first and suppressing a session-write failure can otherwise
|
||||||
|
# make a successfully generated report disappear after refresh.
|
||||||
|
await self._persist_completed_report_projection(
|
||||||
|
job_id=job_id,
|
||||||
|
session_id=session_id,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Job row → completed only after the report is durable.
|
||||||
await self._job_store.update_progress(
|
await self._job_store.update_progress(
|
||||||
job_id,
|
job_id,
|
||||||
lease_owner=lease_owner,
|
lease_owner=lease_owner,
|
||||||
@@ -1385,26 +1397,62 @@ class DeepResearchJobExecutor:
|
|||||||
"plan": result.plan,
|
"plan": result.plan,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Session row → completed + report.
|
|
||||||
source_count = len(result.source_ids)
|
|
||||||
with suppress(Exception): # noqa: BLE001
|
|
||||||
await self._session_store.update(
|
|
||||||
session_id,
|
|
||||||
status="completed",
|
|
||||||
report_markdown=result.report_markdown,
|
|
||||||
report_html=result.report_html,
|
|
||||||
plan_snapshot=result.plan,
|
|
||||||
# Keep the terminal job id alongside model usage. The
|
|
||||||
# frontend uses it only to replay the persisted research trace
|
|
||||||
# after a refresh; it does not affect billing or job state.
|
|
||||||
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
|
|
||||||
source_count=source_count,
|
|
||||||
active_job_id=None,
|
|
||||||
)
|
|
||||||
# Emit a terminal event for SSE consumers.
|
# Emit a terminal event for SSE consumers.
|
||||||
with suppress(Exception): # noqa: BLE001
|
with suppress(Exception): # noqa: BLE001
|
||||||
await self._emit_terminal(job_id, session_id, "job_completed")
|
await self._emit_terminal(job_id, session_id, "job_completed")
|
||||||
|
|
||||||
|
async def _persist_completed_report_projection(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
job_id: str,
|
||||||
|
session_id: str,
|
||||||
|
result: DeepResearchResult,
|
||||||
|
) -> None:
|
||||||
|
"""Durably save the report before publishing job completion.
|
||||||
|
|
||||||
|
A write can succeed while a short-lived MySQL proxy/readback hiccup
|
||||||
|
makes its caller fail. Retry the full owner-scoped update a few times;
|
||||||
|
if it still cannot be confirmed, propagate the error so ``_run`` marks
|
||||||
|
the job failed instead of advertising a report that cannot be reopened.
|
||||||
|
"""
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(1, _REPORT_PROJECTION_RETRY_ATTEMPTS + 1):
|
||||||
|
try:
|
||||||
|
updated = await self._session_store.update(
|
||||||
|
session_id,
|
||||||
|
status="completed",
|
||||||
|
report_markdown=result.report_markdown,
|
||||||
|
report_html=result.report_html,
|
||||||
|
plan_snapshot=result.plan,
|
||||||
|
# Keep the terminal job id alongside model usage. The
|
||||||
|
# frontend uses it only to replay the persisted research
|
||||||
|
# trace after a refresh; it does not affect billing.
|
||||||
|
usage_snapshot={**(result.usage or {}), "lastJobId": job_id},
|
||||||
|
source_count=len(result.source_ids),
|
||||||
|
active_job_id=None,
|
||||||
|
)
|
||||||
|
if updated is not None:
|
||||||
|
return
|
||||||
|
last_error = RuntimeError(f"Deep Research session {session_id} no longer exists")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last_error = exc
|
||||||
|
|
||||||
|
if attempt < _REPORT_PROJECTION_RETRY_ATTEMPTS:
|
||||||
|
logger.warning(
|
||||||
|
"deep-research report projection failed for %s (attempt %s/%s); retrying",
|
||||||
|
job_id,
|
||||||
|
attempt,
|
||||||
|
_REPORT_PROJECTION_RETRY_ATTEMPTS,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(_REPORT_PROJECTION_RETRY_DELAY_SECONDS * attempt)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"研究报告未能保存到会话 {session_id},任务不会标记为完成"
|
||||||
|
) from last_error
|
||||||
|
|
||||||
async def _mark_awaiting(
|
async def _mark_awaiting(
|
||||||
self, job_id: str, session_id: str, pause: ResearchAwaitingInput
|
self, job_id: str, session_id: str, pause: ResearchAwaitingInput
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -639,17 +639,30 @@ async def start_job(session_id: str, body: StartJobRequest, request: Request) ->
|
|||||||
input_snapshot=input_snapshot,
|
input_snapshot=input_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update session status.
|
# The durable job is the source of truth once it has been committed. A
|
||||||
await session_store.update(
|
# transient failure updating the display projection must not turn that
|
||||||
session_id,
|
# successful write into a misleading 500 response or leave it undispatched.
|
||||||
user_id=user_id,
|
# The executor re-projects the terminal status when it completes.
|
||||||
status="running",
|
try:
|
||||||
active_job_id=job["id"],
|
await session_store.update(
|
||||||
)
|
session_id,
|
||||||
|
user_id=user_id,
|
||||||
|
status="running",
|
||||||
|
active_job_id=job["id"],
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception(
|
||||||
|
"deep-research job %s was queued but its session running projection could not be updated",
|
||||||
|
job["id"],
|
||||||
|
)
|
||||||
|
|
||||||
# Nudge the dispatcher to claim immediately.
|
# Nudge the dispatcher even for an idempotent retry. If an earlier request
|
||||||
|
# committed the job but failed before this point (for example a transient
|
||||||
|
# MySQL read-after-write failure), the retry receives the existing queued
|
||||||
|
# job. Restricting the nudge to ``created`` would leave that durable job
|
||||||
|
# queued until the next periodic scan and make the UI appear stuck.
|
||||||
dispatcher = _get_dispatcher(request)
|
dispatcher = _get_dispatcher(request)
|
||||||
if dispatcher is not None and created:
|
if dispatcher is not None:
|
||||||
dispatcher.nudge()
|
dispatcher.nudge()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -810,36 +823,68 @@ async def stream_job(
|
|||||||
if loop.time() >= next_durable_poll:
|
if loop.time() >= next_durable_poll:
|
||||||
# Durable replay repairs a missed live delta after
|
# Durable replay repairs a missed live delta after
|
||||||
# reconnect or when another worker owned the job.
|
# reconnect or when another worker owned the job.
|
||||||
events = await event_store.list_after(job_id, after=cursor, limit=200)
|
try:
|
||||||
for ev in events:
|
events = await event_store.list_after(job_id, after=cursor, limit=200)
|
||||||
payload = {
|
for ev in events:
|
||||||
"seq": ev["seq"],
|
payload = {
|
||||||
"sessionId": session_id,
|
"seq": ev["seq"],
|
||||||
"jobId": job_id,
|
"sessionId": session_id,
|
||||||
"type": ev["event_type"],
|
"jobId": job_id,
|
||||||
"phase": ev.get("phase", "initializing"),
|
"type": ev["event_type"],
|
||||||
"timestamp": _iso(ev.get("created_at")),
|
"phase": ev.get("phase", "initializing"),
|
||||||
"payload": ev.get("payload") or {},
|
"timestamp": _iso(ev.get("created_at")),
|
||||||
}
|
"payload": ev.get("payload") or {},
|
||||||
yield format_sse("progress", payload, event_id=str(ev["seq"]))
|
}
|
||||||
cursor = max(cursor, ev["seq"])
|
yield format_sse("progress", payload, event_id=str(ev["seq"]))
|
||||||
current = await job_store.get_unscoped(job_id)
|
cursor = max(cursor, ev["seq"])
|
||||||
if current and current.get("status") in TERMINAL_STATUSES:
|
current = await job_store.get_unscoped(job_id)
|
||||||
|
if current and current.get("status") in TERMINAL_STATUSES:
|
||||||
|
yield format_sse(
|
||||||
|
"progress",
|
||||||
|
{
|
||||||
|
"seq": cursor + 1,
|
||||||
|
"sessionId": session_id,
|
||||||
|
"jobId": job_id,
|
||||||
|
"type": "job_status",
|
||||||
|
"phase": current.get("phase", "done"),
|
||||||
|
"timestamp": _iso(datetime.now(UTC)),
|
||||||
|
"payload": {"status": current["status"], "progress": current.get("progress", 0)},
|
||||||
|
},
|
||||||
|
event_id=str(cursor + 1),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
next_durable_poll = loop.time() + _POLL_INTERVAL_S
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
# Do not let one read-replica / transient DB failure
|
||||||
|
# abort the ASGI generator (which surfaces as an
|
||||||
|
# ExceptionGroup and leaves the page stuck). Live
|
||||||
|
# frames can still reach an attached browser, and the
|
||||||
|
# next durable poll repairs the cursor on recovery.
|
||||||
|
logger.warning(
|
||||||
|
"deep-research SSE durable replay temporarily failed for job %s; keeping stream open",
|
||||||
|
job_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
yield format_sse(
|
yield format_sse(
|
||||||
"progress",
|
"progress",
|
||||||
{
|
{
|
||||||
"seq": cursor + 1,
|
"seq": 0,
|
||||||
|
"liveId": f"durable-replay-retry:{job_id}:{int(loop.time() * 1000)}",
|
||||||
"sessionId": session_id,
|
"sessionId": session_id,
|
||||||
"jobId": job_id,
|
"jobId": job_id,
|
||||||
"type": "job_status",
|
"type": "warning",
|
||||||
"phase": current.get("phase", "done"),
|
"phase": "initializing",
|
||||||
"timestamp": _iso(datetime.now(UTC)),
|
"timestamp": _iso(datetime.now(UTC)),
|
||||||
"payload": {"status": current["status"], "progress": current.get("progress", 0)},
|
"payload": {
|
||||||
|
"code": "durable_replay_temporarily_unavailable",
|
||||||
|
"message": "进度同步暂时不可用,任务仍在继续;正在自动重试。",
|
||||||
|
"recoverable": True,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
event_id=str(cursor + 1),
|
|
||||||
)
|
)
|
||||||
return
|
next_durable_poll = loop.time() + max(_POLL_INTERVAL_S, 3.0)
|
||||||
next_durable_poll = loop.time() + _POLL_INTERVAL_S
|
|
||||||
if live_queue is None:
|
if live_queue is None:
|
||||||
await asyncio.sleep(max(0.01, next_durable_poll - loop.time()))
|
await asyncio.sleep(max(0.01, next_durable_poll - loop.time()))
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -195,17 +195,22 @@ def _intent_thread_metadata(
|
|||||||
if not task_id:
|
if not task_id:
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
metadata.update(
|
metadata["taskId"] = task_id
|
||||||
{
|
# 8BF 使用通用意图接口但有明确的上下文 kind;它不能被岗位会商的
|
||||||
"taskId": task_id,
|
# 会话索引误认为岗位任务,否则两个页面的历史记录会串在一起。
|
||||||
"position_roundtable_task_id": task_id,
|
# 其它历史调用保持原有 metadata 形状,避免影响既有会商恢复逻辑。
|
||||||
"position_task_id": f"{task_id}:intelligence",
|
is_eightbf_context = task_context.get("kind") == "eightbf_roundtable_task"
|
||||||
"position_id": "intelligence",
|
if intent_agent_id == POSITION_INTENT_AGENT_ID or not is_eightbf_context:
|
||||||
"position_roundtable_phase": "intent",
|
metadata.update(
|
||||||
}
|
{
|
||||||
)
|
"position_roundtable_task_id": task_id,
|
||||||
if intent_agent_id == POSITION_INTENT_AGENT_ID:
|
"position_task_id": f"{task_id}:intelligence",
|
||||||
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
|
"position_id": "intelligence",
|
||||||
|
"position_roundtable_phase": "intent",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if intent_agent_id == POSITION_INTENT_AGENT_ID:
|
||||||
|
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+81
-28
@@ -48,6 +48,11 @@ class PersistingEventSink:
|
|||||||
self._session_id = session_id
|
self._session_id = session_id
|
||||||
self._job_id = job_id
|
self._job_id = job_id
|
||||||
self._on_event = on_event
|
self._on_event = on_event
|
||||||
|
# A durable event row normally owns the sequence. When the event
|
||||||
|
# database is temporarily unavailable, use distinct negative sequence
|
||||||
|
# numbers for the live-only path so clients can render every milestone
|
||||||
|
# without advancing their durable ``after`` cursor.
|
||||||
|
self._fallback_seq = 0
|
||||||
|
|
||||||
async def emit(
|
async def emit(
|
||||||
self,
|
self,
|
||||||
@@ -56,26 +61,66 @@ class PersistingEventSink:
|
|||||||
phase: ResearchPhase = "initializing",
|
phase: ResearchPhase = "initializing",
|
||||||
payload: dict | None = None,
|
payload: dict | None = None,
|
||||||
) -> DeepResearchEvent:
|
) -> DeepResearchEvent:
|
||||||
row = await self._repo.append(
|
event_payload = payload or {}
|
||||||
session_id=self._session_id,
|
try:
|
||||||
job_id=self._job_id,
|
row = await self._repo.append(
|
||||||
event_type=type,
|
session_id=self._session_id,
|
||||||
phase=phase,
|
job_id=self._job_id,
|
||||||
payload=payload or {},
|
event_type=type,
|
||||||
)
|
phase=phase,
|
||||||
# Reconstruct the envelope from the persisted row (seq is now assigned).
|
payload=event_payload,
|
||||||
event = DeepResearchEvent(
|
)
|
||||||
seq=int(row.get("seq", 0)),
|
# Reconstruct the envelope from the persisted row (seq is now assigned).
|
||||||
session_id=self._session_id,
|
event = DeepResearchEvent(
|
||||||
job_id=self._job_id,
|
seq=int(row.get("seq", 0)),
|
||||||
type=row.get("event_type", type),
|
session_id=self._session_id,
|
||||||
phase=row.get("phase", phase),
|
job_id=self._job_id,
|
||||||
payload=row.get("payload") or payload or {},
|
type=row.get("event_type", type),
|
||||||
)
|
phase=row.get("phase", phase),
|
||||||
if self._on_event is not None:
|
payload=row.get("payload") or event_payload,
|
||||||
await self._on_event(event.to_sse_dict())
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
# Progress persistence must not turn a recoverable database blip
|
||||||
|
# into a failed report. The job/status stores still record terminal
|
||||||
|
# state; this live-only frame lets an attached UI stay responsive
|
||||||
|
# until durable replay becomes available again after reconnect.
|
||||||
|
self._fallback_seq -= 1
|
||||||
|
logger.exception(
|
||||||
|
"deep-research event persistence failed; continuing job with a live-only event (job=%s, type=%s, phase=%s)",
|
||||||
|
self._job_id,
|
||||||
|
type,
|
||||||
|
phase,
|
||||||
|
)
|
||||||
|
event = DeepResearchEvent(
|
||||||
|
seq=self._fallback_seq,
|
||||||
|
session_id=self._session_id,
|
||||||
|
job_id=self._job_id,
|
||||||
|
type=type,
|
||||||
|
phase=phase,
|
||||||
|
payload={**event_payload, "eventPersistenceDegraded": True},
|
||||||
|
)
|
||||||
|
await self._publish_event(event)
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
async def _publish_event(self, event: DeepResearchEvent) -> None:
|
||||||
|
"""Push a persisted or live-only event without risking the worker."""
|
||||||
|
if self._on_event is None:
|
||||||
|
return
|
||||||
|
wire = event.to_sse_dict()
|
||||||
|
if event.seq < 0:
|
||||||
|
wire["liveId"] = f"event-persistence-fallback:{self._job_id}:{abs(event.seq)}"
|
||||||
|
try:
|
||||||
|
await self._on_event(wire)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
# A browser disconnect or a live-hub issue must not abort report
|
||||||
|
# generation; persisted rows will still be replayable on reconnect.
|
||||||
|
logger.warning(
|
||||||
|
"deep-research live event publish failed; job continues (job=%s, seq=%s)",
|
||||||
|
self._job_id,
|
||||||
|
event.seq,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
async def publish_live(
|
async def publish_live(
|
||||||
self,
|
self,
|
||||||
type: str, # noqa: A002
|
type: str, # noqa: A002
|
||||||
@@ -85,16 +130,24 @@ class PersistingEventSink:
|
|||||||
) -> None:
|
) -> None:
|
||||||
if self._on_event is None:
|
if self._on_event is None:
|
||||||
return
|
return
|
||||||
await self._on_event(
|
try:
|
||||||
{
|
await self._on_event(
|
||||||
"seq": 0,
|
{
|
||||||
"sessionId": self._session_id,
|
"seq": 0,
|
||||||
"jobId": self._job_id,
|
"sessionId": self._session_id,
|
||||||
"type": type,
|
"jobId": self._job_id,
|
||||||
"phase": phase,
|
"type": type,
|
||||||
"payload": payload or {},
|
"phase": phase,
|
||||||
}
|
"payload": payload or {},
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.warning(
|
||||||
|
"deep-research live frame publish failed; job continues (job=%s, type=%s)",
|
||||||
|
self._job_id,
|
||||||
|
type,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NullEventSink:
|
class NullEventSink:
|
||||||
|
|||||||
+52
-2
@@ -30,6 +30,7 @@ from langchain.agents.middleware.types import ModelCallResult, ModelRequest, Mod
|
|||||||
from langchain_core.messages import (
|
from langchain_core.messages import (
|
||||||
AIMessage,
|
AIMessage,
|
||||||
AnyMessage,
|
AnyMessage,
|
||||||
|
HumanMessage,
|
||||||
SystemMessage,
|
SystemMessage,
|
||||||
ToolMessage,
|
ToolMessage,
|
||||||
message_to_dict,
|
message_to_dict,
|
||||||
@@ -207,6 +208,54 @@ def _deserialize_cache_entry(raw: Any) -> _SummaryCacheEntry | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_nonempty_human_message(message: AnyMessage) -> bool:
|
||||||
|
"""Whether a message is a real, non-empty user turn.
|
||||||
|
|
||||||
|
Some OpenAI-compatible gateways reject a completion request that contains
|
||||||
|
only ``system`` / ``assistant`` / ``tool`` roles. That can legitimately
|
||||||
|
happen after compaction: the active model call is continuing a tool turn,
|
||||||
|
while the user request that started the turn has already moved into the
|
||||||
|
internal summary.
|
||||||
|
"""
|
||||||
|
if not isinstance(message, HumanMessage):
|
||||||
|
return False
|
||||||
|
content = message.content
|
||||||
|
if isinstance(content, str):
|
||||||
|
return bool(content.strip())
|
||||||
|
if isinstance(content, list):
|
||||||
|
return any(bool(block.strip()) if isinstance(block, str) else bool((block.get("text") or block.get("content") or "").strip()) if isinstance(block, dict) else False for block in content)
|
||||||
|
return bool(str(content).strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_latest_human_message(
|
||||||
|
source_messages: list[AnyMessage],
|
||||||
|
compressed_messages: list[AnyMessage],
|
||||||
|
) -> list[AnyMessage]:
|
||||||
|
"""Keep the latest real user request visible to strict chat gateways.
|
||||||
|
|
||||||
|
The message is inserted after leading system messages, so it neither
|
||||||
|
exposes the internal summary to the UI nor splits an AI tool-call from its
|
||||||
|
following ToolMessages. When there was no user turn in the source at all,
|
||||||
|
do not fabricate one: that is an upstream caller bug and should remain
|
||||||
|
diagnosable rather than silently changing the task.
|
||||||
|
"""
|
||||||
|
if any(_has_nonempty_human_message(message) for message in compressed_messages):
|
||||||
|
return compressed_messages
|
||||||
|
|
||||||
|
latest_human = next(
|
||||||
|
(message for message in reversed(source_messages) if _has_nonempty_human_message(message)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if latest_human is None:
|
||||||
|
logger.warning("summarization: compressed model request has no user message and source history contains none; passing it through unchanged")
|
||||||
|
return compressed_messages
|
||||||
|
|
||||||
|
insert_at = 0
|
||||||
|
while insert_at < len(compressed_messages) and isinstance(compressed_messages[insert_at], SystemMessage):
|
||||||
|
insert_at += 1
|
||||||
|
return [*compressed_messages[:insert_at], latest_human, *compressed_messages[insert_at:]]
|
||||||
|
|
||||||
|
|
||||||
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||||
"""Summarization middleware with pre-compression hook dispatch and skill rescue."""
|
"""Summarization middleware with pre-compression hook dispatch and skill rescue."""
|
||||||
|
|
||||||
@@ -390,6 +439,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
excluded_ids = cached.summarized_ids | cached.preserved_ids
|
excluded_ids = cached.summarized_ids | cached.preserved_ids
|
||||||
preserved.extend(m for m in messages if m.id not in excluded_ids)
|
preserved.extend(m for m in messages if m.id not in excluded_ids)
|
||||||
effective = [self._summary_message(cached.summary_text), *preserved]
|
effective = [self._summary_message(cached.summary_text), *preserved]
|
||||||
|
effective = _ensure_latest_human_message(messages, effective)
|
||||||
# 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
|
||||||
@@ -400,8 +450,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
# Refresh LRU recency and reuse without an LLM call.
|
# Refresh LRU recency and reuse without an LLM call.
|
||||||
self._touch_cache_entry(thread_id)
|
self._touch_cache_entry(thread_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
"summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary "
|
"summarization: 上下文压缩(复用缓存摘要,无 LLM 调用) context compaction reusing cached summary thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d",
|
||||||
"thread=%s full_tokens=%d compressed_tokens=%d preserved_msgs=%d",
|
|
||||||
thread_id,
|
thread_id,
|
||||||
self.token_counter(messages),
|
self.token_counter(messages),
|
||||||
self.token_counter(effective),
|
self.token_counter(effective),
|
||||||
@@ -452,6 +501,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
|||||||
) -> list[AnyMessage]:
|
) -> list[AnyMessage]:
|
||||||
"""Assemble the compressed message list and update the per-thread cache."""
|
"""Assemble the compressed message list and update the per-thread cache."""
|
||||||
compressed = [self._summary_message(summary), *preserved]
|
compressed = [self._summary_message(summary), *preserved]
|
||||||
|
compressed = _ensure_latest_human_message([*messages_to_summarize, *preserved], compressed)
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
+16
-35
@@ -68,13 +68,7 @@ class DeepResearchEventRepository:
|
|||||||
"""
|
"""
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
current_max = (
|
current_max = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar() or 0
|
||||||
await session.execute(
|
|
||||||
select(func.max(DeepResearchEventRow.seq)).where(
|
|
||||||
DeepResearchEventRow.job_id == job_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar() or 0
|
|
||||||
next_seq = int(current_max) + 1
|
next_seq = int(current_max) + 1
|
||||||
row = DeepResearchEventRow(
|
row = DeepResearchEventRow(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -86,49 +80,38 @@ class DeepResearchEventRepository:
|
|||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
try:
|
try:
|
||||||
|
# Do not issue ``session.refresh(row)`` after commit. On a
|
||||||
|
# MySQL read/write-splitting deployment that read can land
|
||||||
|
# on a lagging replica, which cannot see the just-committed
|
||||||
|
# event and raises ``Could not refresh instance``. Flush
|
||||||
|
# assigns generated primary-key/default values on the writer
|
||||||
|
# connection, so the plain dict is complete before commit.
|
||||||
|
await session.flush()
|
||||||
|
result = self._row_to_dict(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
return result
|
||||||
return self._row_to_dict(row)
|
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
continue # retry with recomputed max
|
continue # retry with recomputed max
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})")
|
||||||
f"deep_research_events.append: seq collision persisted after {max_retries} retries (job={job_id})"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def list_after(
|
async def list_after(self, job_id: str, *, after: int = 0, limit: int = 200) -> list[dict[str, Any]]:
|
||||||
self, job_id: str, *, after: int = 0, limit: int = 200
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Events with ``seq > after`` in ascending order (SSE replay, §15.3)."""
|
"""Events with ``seq > after`` in ascending order (SSE replay, §15.3)."""
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
stmt = (
|
stmt = select(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id).where(DeepResearchEventRow.seq > after).order_by(DeepResearchEventRow.seq.asc()).limit(limit)
|
||||||
select(DeepResearchEventRow)
|
|
||||||
.where(DeepResearchEventRow.job_id == job_id)
|
|
||||||
.where(DeepResearchEventRow.seq > after)
|
|
||||||
.order_by(DeepResearchEventRow.seq.asc())
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
rows = (await session.execute(stmt)).scalars().all()
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
return [self._row_to_dict(r) for r in rows]
|
return [self._row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
async def last_seq(self, job_id: str) -> int:
|
async def last_seq(self, job_id: str) -> int:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
mx = (
|
mx = (await session.execute(select(func.max(DeepResearchEventRow.seq)).where(DeepResearchEventRow.job_id == job_id))).scalar()
|
||||||
await session.execute(
|
|
||||||
select(func.max(DeepResearchEventRow.seq)).where(
|
|
||||||
DeepResearchEventRow.job_id == job_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar()
|
|
||||||
return int(mx or 0)
|
return int(mx or 0)
|
||||||
|
|
||||||
async def delete_by_job(self, job_id: str) -> int:
|
async def delete_by_job(self, job_id: str) -> int:
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id))
|
||||||
delete(DeepResearchEventRow).where(DeepResearchEventRow.job_id == job_id)
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
|
|
||||||
@@ -136,8 +119,6 @@ class DeepResearchEventRepository:
|
|||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id))
|
||||||
delete(DeepResearchEventRow).where(DeepResearchEventRow.session_id == session_id)
|
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return result.rowcount
|
return result.rowcount
|
||||||
+26
-12
@@ -113,9 +113,18 @@ class DeepResearchJobRepository:
|
|||||||
phase=phase,
|
phase=phase,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
|
# Do not issue ``session.refresh(row)`` after commit. A
|
||||||
|
# MySQL read/write-splitting proxy can route that read to
|
||||||
|
# a lagging replica, even though the INSERT has already
|
||||||
|
# committed on the writer. The caller would receive 500
|
||||||
|
# while an invisible queued job remains in the database.
|
||||||
|
# ``flush`` materializes ORM defaults on the writer, so we
|
||||||
|
# can return a complete, durable snapshot without a
|
||||||
|
# replica-dependent readback.
|
||||||
|
await session.flush()
|
||||||
|
result = self._row_to_dict(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
return result, True
|
||||||
return self._row_to_dict(row), True
|
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# Unique-index conflict → an active job already exists.
|
# Unique-index conflict → an active job already exists.
|
||||||
pass
|
pass
|
||||||
@@ -230,13 +239,14 @@ class DeepResearchJobRepository:
|
|||||||
)
|
)
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
return None
|
return None
|
||||||
await session.commit()
|
|
||||||
row = (
|
row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return self._row_to_dict(row) if row else None
|
snapshot = self._row_to_dict(row) if row else None
|
||||||
|
await session.commit()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
# ── heartbeat ───────────────────────────────────────────────────────────
|
# ── heartbeat ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -351,13 +361,14 @@ class DeepResearchJobRepository:
|
|||||||
)
|
)
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
return None
|
return None
|
||||||
await session.commit()
|
|
||||||
row = (
|
row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return self._row_to_dict(row) if row else None
|
snapshot = self._row_to_dict(row) if row else None
|
||||||
|
await session.commit()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
# ── two-phase cancel ────────────────────────────────────────────────────
|
# ── two-phase cancel ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -385,13 +396,14 @@ class DeepResearchJobRepository:
|
|||||||
)
|
)
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
return None
|
return None
|
||||||
await session.commit()
|
|
||||||
row = (
|
row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return self._row_to_dict(row) if row else None
|
snapshot = self._row_to_dict(row) if row else None
|
||||||
|
await session.commit()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
async def finalize_cancel(
|
async def finalize_cancel(
|
||||||
self, job_id: str, *, lease_owner: str | None = None
|
self, job_id: str, *, lease_owner: str | None = None
|
||||||
@@ -419,13 +431,14 @@ class DeepResearchJobRepository:
|
|||||||
)
|
)
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
return None
|
return None
|
||||||
await session.commit()
|
|
||||||
row = (
|
row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return self._row_to_dict(row) if row else None
|
snapshot = self._row_to_dict(row) if row else None
|
||||||
|
await session.commit()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
# ── resume after human-review interrupt ─────────────────────────────────
|
# ── resume after human-review interrupt ─────────────────────────────────
|
||||||
|
|
||||||
@@ -460,13 +473,14 @@ class DeepResearchJobRepository:
|
|||||||
)
|
)
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
return None
|
return None
|
||||||
await session.commit()
|
|
||||||
row = (
|
row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
select(DeepResearchJobRow).where(DeepResearchJobRow.id == job_id)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return self._row_to_dict(row) if row else None
|
snapshot = self._row_to_dict(row) if row else None
|
||||||
|
await session.commit()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
# ── cleanup ─────────────────────────────────────────────────────────────
|
# ── cleanup ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -74,9 +74,13 @@ class DeepResearchMessageRepository:
|
|||||||
seq=int(current_max) + 1,
|
seq=int(current_max) + 1,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
|
# Keep the successful append independent of a later replica read.
|
||||||
|
# The explicit id/seq and Python-side defaults are available after
|
||||||
|
# flush, before the transaction releases its writer connection.
|
||||||
|
await session.flush()
|
||||||
|
snapshot = self._row_to_dict(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
return snapshot
|
||||||
return self._row_to_dict(row)
|
|
||||||
|
|
||||||
async def list_by_session(
|
async def list_by_session(
|
||||||
self, session_id: str, *, user_id: str | _AutoSentinel = AUTO, limit: int = 100
|
self, session_id: str, *, user_id: str | _AutoSentinel = AUTO, limit: int = 100
|
||||||
@@ -139,10 +143,11 @@ class DeepResearchMessageRepository:
|
|||||||
if not result.rowcount:
|
if not result.rowcount:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
return None
|
return None
|
||||||
await session.commit()
|
|
||||||
stmt = select(DeepResearchMessageRow).where(*where)
|
stmt = select(DeepResearchMessageRow).where(*where)
|
||||||
row = (await session.execute(stmt)).scalar_one_or_none()
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
return self._row_to_dict(row) if row is not None else None
|
snapshot = self._row_to_dict(row) if row is not None else None
|
||||||
|
await session.commit()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
async def delete_by_session(self, session_id: str) -> int:
|
async def delete_by_session(self, session_id: str) -> int:
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|||||||
+12
-6
@@ -85,9 +85,13 @@ class DeepResearchSessionRepository:
|
|||||||
runtime_thread_id=runtime_thread_id,
|
runtime_thread_id=runtime_thread_id,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
|
# New sessions are a control-plane write. Avoid a post-commit
|
||||||
|
# refresh because MySQL read/write-splitting can make a just
|
||||||
|
# created session look absent on a lagging replica.
|
||||||
|
await session.flush()
|
||||||
|
snapshot = self._row_to_dict(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
return snapshot
|
||||||
return self._row_to_dict(row)
|
|
||||||
|
|
||||||
# ── read ────────────────────────────────────────────────────────────────
|
# ── read ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -187,13 +191,14 @@ class DeepResearchSessionRepository:
|
|||||||
)
|
)
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
return None
|
return None
|
||||||
await session.commit()
|
|
||||||
row = (
|
row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
|
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return self._row_to_dict(row) if row else None
|
snapshot = self._row_to_dict(row) if row else None
|
||||||
|
await session.commit()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
async def replace_report_if_unchanged(
|
async def replace_report_if_unchanged(
|
||||||
self,
|
self,
|
||||||
@@ -231,13 +236,14 @@ class DeepResearchSessionRepository:
|
|||||||
)
|
)
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
return None
|
return None
|
||||||
await session.commit()
|
|
||||||
row = (
|
row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
|
select(DeepResearchSessionRow).where(DeepResearchSessionRow.id == session_id)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return self._row_to_dict(row) if row else None
|
snapshot = self._row_to_dict(row) if row else None
|
||||||
|
await session.commit()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
# ── delete ──────────────────────────────────────────────────────────────
|
# ── delete ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -23,6 +23,13 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
|||||||
|
|
||||||
class DeepResearchSourceRow(Base):
|
class DeepResearchSourceRow(Base):
|
||||||
__tablename__ = "deep_research_sources"
|
__tablename__ = "deep_research_sources"
|
||||||
|
__table_args__ = (
|
||||||
|
# MySQL requires a prefix length when indexing TEXT columns. The
|
||||||
|
# column remains unbounded so long URLs are preserved; the prefix is
|
||||||
|
# only an index implementation detail and equality filters still
|
||||||
|
# compare the complete URL value.
|
||||||
|
sa.Index("ix_deep_research_sources_url", "url", mysql_length=512),
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
@@ -32,7 +39,7 @@ class DeepResearchSourceRow(Base):
|
|||||||
# The query that first surfaced this material.
|
# The query that first surfaced this material.
|
||||||
first_seen_query: Mapped[str | None] = mapped_column(Text, nullable=True)
|
first_seen_query: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
title: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
title: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
url: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
raw_content: Mapped[str] = mapped_column(PortableLongText, nullable=False, default="")
|
raw_content: Mapped[str] = mapped_column(PortableLongText, nullable=False, default="")
|
||||||
snippet: Mapped[str | None] = mapped_column(Text, nullable=True)
|
snippet: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
source: Mapped[str] = mapped_column(String(256), nullable=False, default="")
|
source: Mapped[str] = mapped_column(String(256), nullable=False, default="")
|
||||||
|
|||||||
+10
-4
@@ -37,9 +37,14 @@ class DeepResearchSourceRepository:
|
|||||||
row = DeepResearchSourceRow(session_id=session_id, job_id=job_id, **fields)
|
row = DeepResearchSourceRow(session_id=session_id, job_id=job_id, **fields)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
try:
|
try:
|
||||||
|
# The source was produced on the worker's write path. Build
|
||||||
|
# its response on that same connection so a replica-lag read
|
||||||
|
# cannot turn a successfully collected source into a failed
|
||||||
|
# research run.
|
||||||
|
await session.flush()
|
||||||
|
snapshot = self._row_to_dict(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
return snapshot
|
||||||
return self._row_to_dict(row)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
# Fallback: find existing by rec_uuid or url.
|
# Fallback: find existing by rec_uuid or url.
|
||||||
@@ -147,13 +152,14 @@ class DeepResearchSourceRepository:
|
|||||||
)
|
)
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
return None
|
return None
|
||||||
await session.commit()
|
|
||||||
row = (
|
row = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(DeepResearchSourceRow).where(DeepResearchSourceRow.id == source_id)
|
select(DeepResearchSourceRow).where(DeepResearchSourceRow.id == source_id)
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return self._row_to_dict(row) if row else None
|
snapshot = self._row_to_dict(row) if row else None
|
||||||
|
await session.commit()
|
||||||
|
return snapshot
|
||||||
|
|
||||||
async def delete_by_session(self, session_id: str) -> int:
|
async def delete_by_session(self, session_id: str) -> int:
|
||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Fault-tolerance coverage for Deep Research progress events."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.agents.deep_research.adapters.event_sink import PersistingEventSink
|
||||||
|
|
||||||
|
|
||||||
|
class _UnavailableEventRepository:
|
||||||
|
async def append(self, **kwargs): # noqa: ANN003
|
||||||
|
raise RuntimeError("database temporarily unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_persistence_failure_keeps_job_live_and_emits_unique_fallback_event():
|
||||||
|
published: list[dict] = []
|
||||||
|
|
||||||
|
async def publish(event: dict) -> None:
|
||||||
|
published.append(event)
|
||||||
|
|
||||||
|
sink = PersistingEventSink(
|
||||||
|
_UnavailableEventRepository(),
|
||||||
|
session_id="drs_1",
|
||||||
|
job_id="drj_1",
|
||||||
|
on_event=publish,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = await sink.emit("phase_changed", phase="planning", payload={"to": "planning"})
|
||||||
|
second = await sink.emit("queries_planned", phase="collecting", payload={"queries": ["AI"]})
|
||||||
|
|
||||||
|
assert (first.seq, second.seq) == (-1, -2)
|
||||||
|
assert first.payload["eventPersistenceDegraded"] is True
|
||||||
|
assert published[0]["liveId"] == "event-persistence-fallback:drj_1:1"
|
||||||
|
assert published[1]["liveId"] == "event-persistence-fallback:drj_1:2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_publish_failure_does_not_fail_event_delivery():
|
||||||
|
class _EventRepository:
|
||||||
|
async def append(self, **kwargs): # noqa: ANN003
|
||||||
|
return {
|
||||||
|
"seq": 1,
|
||||||
|
"event_type": kwargs["event_type"],
|
||||||
|
"phase": kwargs["phase"],
|
||||||
|
"payload": kwargs["payload"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def broken_publisher(event: dict) -> None: # noqa: ARG001
|
||||||
|
raise RuntimeError("browser disconnected")
|
||||||
|
|
||||||
|
sink = PersistingEventSink(
|
||||||
|
_EventRepository(),
|
||||||
|
session_id="drs_1",
|
||||||
|
job_id="drj_1",
|
||||||
|
on_event=broken_publisher,
|
||||||
|
)
|
||||||
|
|
||||||
|
event = await sink.emit("phase_changed", phase="planning")
|
||||||
|
|
||||||
|
assert event.seq == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_document_rewrite_milestone_is_a_supported_durable_event():
|
||||||
|
"""Guard the artifact-rewrite job path against EventType regressions."""
|
||||||
|
|
||||||
|
class _EventRepository:
|
||||||
|
async def append(self, **kwargs): # noqa: ANN003
|
||||||
|
return {
|
||||||
|
"seq": 1,
|
||||||
|
"event_type": kwargs["event_type"],
|
||||||
|
"phase": kwargs["phase"],
|
||||||
|
"payload": kwargs["payload"],
|
||||||
|
}
|
||||||
|
|
||||||
|
sink = PersistingEventSink(_EventRepository(), session_id="drs_1", job_id="drj_1")
|
||||||
|
|
||||||
|
event = await sink.emit(
|
||||||
|
"document_rewrite",
|
||||||
|
phase="planning",
|
||||||
|
payload={"type": "requirements_started"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.type == "document_rewrite"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Failure-ordering regression coverage for final Deep Research report writes."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.gateway import deep_research_job_executor as executor_module
|
||||||
|
from app.gateway.deep_research_job_executor import DeepResearchJobExecutor
|
||||||
|
from deerflow.agents.deep_research.types import DeepResearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class _JobStore:
|
||||||
|
def __init__(self, session_store) -> None:
|
||||||
|
self._session_store = session_store
|
||||||
|
self.progress_calls: list[dict] = []
|
||||||
|
|
||||||
|
async def update_progress(self, job_id: str, **fields): # noqa: ARG002
|
||||||
|
assert self._session_store.report_updates, "report must be persisted before job completion"
|
||||||
|
self.progress_calls.append(fields)
|
||||||
|
return {"id": job_id, **fields}
|
||||||
|
|
||||||
|
|
||||||
|
class _SessionStore:
|
||||||
|
def __init__(self, *, available: bool = True) -> None:
|
||||||
|
self.available = available
|
||||||
|
self.report_updates: list[dict] = []
|
||||||
|
|
||||||
|
async def update(self, session_id: str, **fields): # noqa: ARG002
|
||||||
|
if not self.available:
|
||||||
|
raise RuntimeError("temporary session database failure")
|
||||||
|
self.report_updates.append(fields)
|
||||||
|
return {"id": session_id, **fields}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_report_is_persisted_before_job_is_marked_completed():
|
||||||
|
sessions = _SessionStore()
|
||||||
|
jobs = _JobStore(sessions)
|
||||||
|
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
|
||||||
|
|
||||||
|
await runner._mark_completed(
|
||||||
|
"drj_1",
|
||||||
|
"drs_1",
|
||||||
|
"worker_1",
|
||||||
|
DeepResearchResult(report_markdown="# 报告", source_ids=["src_1"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sessions.report_updates[0]["report_markdown"] == "# 报告"
|
||||||
|
assert jobs.progress_calls[0]["status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_report_projection_failure_never_marks_job_completed(monkeypatch):
|
||||||
|
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_ATTEMPTS", 2)
|
||||||
|
monkeypatch.setattr(executor_module, "_REPORT_PROJECTION_RETRY_DELAY_SECONDS", 0)
|
||||||
|
sessions = _SessionStore(available=False)
|
||||||
|
jobs = _JobStore(sessions)
|
||||||
|
runner = DeepResearchJobExecutor(jobs, sessions, event_store=None)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="研究报告未能保存"):
|
||||||
|
await runner._mark_completed(
|
||||||
|
"drj_1",
|
||||||
|
"drs_1",
|
||||||
|
"worker_1",
|
||||||
|
DeepResearchResult(report_markdown="# 报告"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert jobs.progress_calls == []
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
"""Persistence tests for deep-research sessions, jobs, events, sources, messages.
|
||||||
|
|
||||||
|
Uses in-memory SQLite + aiosqlite, mirroring ``test_ai_writing_session_repo``.
|
||||||
|
Focuses on the durable-job mechanics that are load-bearing for correctness:
|
||||||
|
|
||||||
|
- Session CRUD + user isolation.
|
||||||
|
- Job ``try_create_or_get_active`` idempotency (unique dedupe key).
|
||||||
|
- Job lease claim / renew / release (atomic CAS).
|
||||||
|
- Two-phase cancel (``request_cancel`` → ``finalize_cancel``).
|
||||||
|
- Event seq monotonicity + ``list_after`` replay (SSE reconnect correctness).
|
||||||
|
|
||||||
|
Pattern: a plain ``async def _make_repos()`` helper (not a fixture) — matches
|
||||||
|
the project convention for async DB setup in pytest-asyncio strict mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_repos():
|
||||||
|
"""Create an in-memory SQLite repo set with all deep-research tables."""
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.deep_research_events.model import DeepResearchEventRow # noqa: F401
|
||||||
|
from deerflow.persistence.deep_research_events.sql import DeepResearchEventRepository
|
||||||
|
from deerflow.persistence.deep_research_jobs.model import DeepResearchJobRow # noqa: F401
|
||||||
|
from deerflow.persistence.deep_research_jobs.sql import DeepResearchJobRepository
|
||||||
|
from deerflow.persistence.deep_research_messages.model import DeepResearchMessageRow # noqa: F401
|
||||||
|
from deerflow.persistence.deep_research_messages.sql import DeepResearchMessageRepository
|
||||||
|
from deerflow.persistence.deep_research_sessions.model import DeepResearchSessionRow # noqa: F401
|
||||||
|
from deerflow.persistence.deep_research_sessions.sql import DeepResearchSessionRepository
|
||||||
|
from deerflow.persistence.deep_research_sources.model import DeepResearchSourceRow # noqa: F401
|
||||||
|
from deerflow.persistence.deep_research_sources.sql import DeepResearchSourceRepository
|
||||||
|
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
return {
|
||||||
|
"sessions": DeepResearchSessionRepository(sf),
|
||||||
|
"jobs": DeepResearchJobRepository(sf),
|
||||||
|
"events": DeepResearchEventRepository(sf),
|
||||||
|
"sources": DeepResearchSourceRepository(sf),
|
||||||
|
"messages": DeepResearchMessageRepository(sf),
|
||||||
|
"_engine": engine,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── sessions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_create_and_get():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
created = await repos["sessions"].create(id="drs_1", user_id="u1", title="Test", query="AI research", mode="deep")
|
||||||
|
assert created["id"] == "drs_1"
|
||||||
|
assert created["status"] == "draft"
|
||||||
|
|
||||||
|
got = await repos["sessions"].get("drs_1", user_id="u1")
|
||||||
|
assert got is not None
|
||||||
|
assert got["query"] == "AI research"
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deep_research_writes_never_depend_on_post_commit_refresh(monkeypatch):
|
||||||
|
"""Control-plane writes must survive a MySQL replica-lag readback."""
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
|
||||||
|
raise AssertionError("Deep Research writes must not call session.refresh")
|
||||||
|
|
||||||
|
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
session = await repos["sessions"].create(
|
||||||
|
id="drs_1", user_id="u1", title="Test", query="AI research"
|
||||||
|
)
|
||||||
|
assert session["id"] == "drs_1"
|
||||||
|
assert await repos["sessions"].update("drs_1", user_id="u1", status="running")
|
||||||
|
|
||||||
|
job, created = await repos["jobs"].try_create_or_get_active(
|
||||||
|
id="drj_1", session_id="drs_1", user_id="u1"
|
||||||
|
)
|
||||||
|
assert created is True
|
||||||
|
assert job["id"] == "drj_1"
|
||||||
|
|
||||||
|
source = await repos["sources"].upsert(
|
||||||
|
session_id="drs_1",
|
||||||
|
id="src_1",
|
||||||
|
user_id="u1",
|
||||||
|
title="A",
|
||||||
|
raw_content="source content",
|
||||||
|
source="web",
|
||||||
|
content_hash="hash_1",
|
||||||
|
)
|
||||||
|
assert source["id"] == "src_1"
|
||||||
|
|
||||||
|
message = await repos["messages"].append(
|
||||||
|
id="msg_1",
|
||||||
|
session_id="drs_1",
|
||||||
|
user_id="u1",
|
||||||
|
role="assistant",
|
||||||
|
content="已开始撰写",
|
||||||
|
)
|
||||||
|
assert message["id"] == "msg_1"
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_user_isolation():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["sessions"].create(id="drs_1", user_id="u1", query="q1")
|
||||||
|
assert await repos["sessions"].get("drs_1", user_id="u2") is None
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_list_strips_heavy_columns():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["sessions"].create(id="drs_1", user_id="u1", query="q", config_snapshot={"mode": "deep"})
|
||||||
|
items = await repos["sessions"].list_by_user(user_id="u1")
|
||||||
|
assert len(items) == 1
|
||||||
|
assert "report_markdown" not in items[0]
|
||||||
|
assert "config_snapshot" not in items[0]
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_update_and_delete():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["sessions"].create(id="drs_1", user_id="u1", query="q")
|
||||||
|
updated = await repos["sessions"].update("drs_1", user_id="u1", title="New Title")
|
||||||
|
assert updated["title"] == "New Title"
|
||||||
|
assert await repos["sessions"].delete("drs_1", user_id="u1") is True
|
||||||
|
assert await repos["sessions"].get("drs_1", user_id="u1") is None
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_count_active_by_user():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["sessions"].create(id="drs_1", user_id="u1", query="q", status="running")
|
||||||
|
await repos["sessions"].create(id="drs_2", user_id="u1", query="q", status="completed")
|
||||||
|
count = await repos["sessions"].count_active_by_user(user_id="u1")
|
||||||
|
assert count == 1
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── jobs: idempotent create ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_try_create_returns_created_true():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
job, created = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1")
|
||||||
|
assert created is True
|
||||||
|
assert job["status"] == "queued"
|
||||||
|
assert job["active_dedupe_key"] is not None
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_create_never_reads_back_after_commit(monkeypatch):
|
||||||
|
"""A committed job must not fail when a MySQL replica briefly lags."""
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
|
||||||
|
raise AssertionError("DeepResearchJobRepository must not call session.refresh")
|
||||||
|
|
||||||
|
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
job, created = await repos["jobs"].try_create_or_get_active(
|
||||||
|
id="drj_1",
|
||||||
|
session_id="drs_1",
|
||||||
|
user_id="u1",
|
||||||
|
request_id="req_1",
|
||||||
|
)
|
||||||
|
assert created is True
|
||||||
|
assert job["id"] == "drj_1"
|
||||||
|
assert job["status"] == "queued"
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_try_create_idempotent_returns_existing():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
job1, created1 = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1", request_id="req_1")
|
||||||
|
job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1", request_id="req_2")
|
||||||
|
assert created1 is True
|
||||||
|
assert created2 is False
|
||||||
|
assert job2["id"] == job1["id"]
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_dedupe_released_on_terminal():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
job, _ = await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||||
|
assert job["active_dedupe_key"] is not None
|
||||||
|
await repos["jobs"].update_progress("drj_1", lease_owner=None, user_id="u1", status="completed")
|
||||||
|
job2, created2 = await repos["jobs"].try_create_or_get_active(id="drj_2", session_id="drs_1", user_id="u1")
|
||||||
|
assert created2 is True
|
||||||
|
assert job2["id"] == "drj_2"
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── jobs: lease mechanics ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_claim_succeeds_for_queued():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||||
|
lease_until = datetime.now(UTC) + timedelta(seconds=60)
|
||||||
|
claimed = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
|
||||||
|
assert claimed is not None
|
||||||
|
assert claimed["status"] == "running"
|
||||||
|
assert claimed["lease_owner"] == "w1"
|
||||||
|
assert claimed["attempt"] == 1
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatcher_claims_queued_job_and_starts_report_worker():
|
||||||
|
"""The claimed snapshot must reach the executor before the job can run."""
|
||||||
|
from app.gateway.deep_research_job_dispatcher import DeepResearchJobDispatcher
|
||||||
|
|
||||||
|
class _Executor:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.started: list[tuple[dict, str, str | None]] = []
|
||||||
|
|
||||||
|
def start_job(self, snapshot: dict, *, job_id: str, lease_owner: str | None) -> bool:
|
||||||
|
self.started.append((snapshot, job_id, lease_owner))
|
||||||
|
return True
|
||||||
|
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["jobs"].try_create_or_get_active(
|
||||||
|
id="drj_1",
|
||||||
|
session_id="drs_1",
|
||||||
|
user_id="u1",
|
||||||
|
input_snapshot={"query": "测试报告"},
|
||||||
|
)
|
||||||
|
runner = _Executor()
|
||||||
|
dispatcher = DeepResearchJobDispatcher(repos["jobs"], runner, worker_id="worker_1")
|
||||||
|
|
||||||
|
started = await dispatcher.dispatch_once()
|
||||||
|
|
||||||
|
assert started == 1
|
||||||
|
assert runner.started == [({"query": "测试报告"}, "drj_1", "worker_1")]
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_claim_fails_if_already_claimed():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||||
|
lease_until = datetime.now(UTC) + timedelta(seconds=60)
|
||||||
|
first = await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
|
||||||
|
assert first is not None
|
||||||
|
second = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=lease_until)
|
||||||
|
assert second is None
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_renew_lease_only_by_owner():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||||
|
lease_until = datetime.now(UTC) + timedelta(seconds=60)
|
||||||
|
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
|
||||||
|
assert await repos["jobs"].renew_lease("drj_1", lease_owner="w1", lease_until=lease_until) is True
|
||||||
|
assert await repos["jobs"].renew_lease("drj_1", lease_owner="w2", lease_until=lease_until) is False
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_expired_lease_reclaimable():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||||
|
expired = datetime.now(UTC) - timedelta(seconds=10)
|
||||||
|
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=expired)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
claimable = await repos["jobs"].list_claimable(now=now)
|
||||||
|
assert "drj_1" in claimable
|
||||||
|
reclaimed = await repos["jobs"].claim_job("drj_1", lease_owner="w2", lease_until=now + timedelta(seconds=60))
|
||||||
|
assert reclaimed is not None
|
||||||
|
assert reclaimed["lease_owner"] == "w2"
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── jobs: two-phase cancel ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_cancel_two_phase():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||||
|
lease_until = datetime.now(UTC) + timedelta(seconds=60)
|
||||||
|
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
|
||||||
|
|
||||||
|
requested = await repos["jobs"].request_cancel("drj_1", user_id="u1")
|
||||||
|
assert requested is not None
|
||||||
|
assert requested["cancel_requested"] is True
|
||||||
|
|
||||||
|
finalized = await repos["jobs"].finalize_cancel("drj_1")
|
||||||
|
assert finalized is not None
|
||||||
|
assert finalized["status"] == "cancelled"
|
||||||
|
assert finalized["active_dedupe_key"] is None
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_cancel_idempotent():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||||
|
first = await repos["jobs"].request_cancel("drj_1", user_id="u1")
|
||||||
|
assert first is not None
|
||||||
|
await repos["jobs"].finalize_cancel("drj_1")
|
||||||
|
second = await repos["jobs"].request_cancel("drj_1", user_id="u1")
|
||||||
|
assert second is None
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── jobs: progress update with lease gate ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_job_update_progress_lease_gated():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["jobs"].try_create_or_get_active(id="drj_1", session_id="drs_1", user_id="u1")
|
||||||
|
lease_until = datetime.now(UTC) + timedelta(seconds=60)
|
||||||
|
await repos["jobs"].claim_job("drj_1", lease_owner="w1", lease_until=lease_until)
|
||||||
|
|
||||||
|
updated = await repos["jobs"].update_progress("drj_1", lease_owner="w1", phase="collecting", progress=50)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated["phase"] == "collecting"
|
||||||
|
assert updated["progress"] == 50
|
||||||
|
|
||||||
|
rejected = await repos["jobs"].update_progress("drj_1", lease_owner="w2", phase="writing")
|
||||||
|
assert rejected is None
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── events: monotonic seq + replay ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_seq_monotonic():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
repo = repos["events"]
|
||||||
|
e1 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="phase_changed", payload={"to": "planning"})
|
||||||
|
e2 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s1"})
|
||||||
|
e3 = await repo.append(session_id="drs_1", job_id="drj_1", event_type="source_added", payload={"id": "s2"})
|
||||||
|
assert e1["seq"] == 1
|
||||||
|
assert e2["seq"] == 2
|
||||||
|
assert e3["seq"] == 3
|
||||||
|
assert await repo.last_seq("drj_1") == 3
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_append_never_reads_back_after_commit(monkeypatch):
|
||||||
|
"""Event persistence must work when a post-commit read hits a lagging replica."""
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
async def fail_refresh(self, instance, *args, **kwargs): # noqa: ANN001, ARG001
|
||||||
|
raise AssertionError("DeepResearchEventRepository must not call session.refresh")
|
||||||
|
|
||||||
|
monkeypatch.setattr(AsyncSession, "refresh", fail_refresh)
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
event = await repos["events"].append(
|
||||||
|
session_id="drs_1",
|
||||||
|
job_id="drj_1",
|
||||||
|
event_type="phase_changed",
|
||||||
|
payload={"to": "planning"},
|
||||||
|
)
|
||||||
|
assert event["id"] is not None
|
||||||
|
assert event["seq"] == 1
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_list_after_replay():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
repo = repos["events"]
|
||||||
|
for i in range(5):
|
||||||
|
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat", payload={"i": i})
|
||||||
|
replayed = await repo.list_after("drj_1", after=2)
|
||||||
|
assert len(replayed) == 3
|
||||||
|
assert [e["seq"] for e in replayed] == [3, 4, 5]
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_payload_round_trip():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
repo = repos["events"]
|
||||||
|
await repo.append(
|
||||||
|
session_id="drs_1",
|
||||||
|
job_id="drj_1",
|
||||||
|
event_type="plan_created",
|
||||||
|
payload={"subtopics": ["AI", "ML"], "nested": {"a": 1}},
|
||||||
|
)
|
||||||
|
events = await repo.list_after("drj_1", after=0)
|
||||||
|
assert events[0]["payload"]["subtopics"] == ["AI", "ML"]
|
||||||
|
assert events[0]["payload"]["nested"]["a"] == 1
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_event_seq_independent_per_job():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
repo = repos["events"]
|
||||||
|
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat")
|
||||||
|
await repo.append(session_id="drs_1", job_id="drj_1", event_type="heartbeat")
|
||||||
|
await repo.append(session_id="drs_1", job_id="drj_2", event_type="heartbeat")
|
||||||
|
assert await repo.last_seq("drj_1") == 2
|
||||||
|
assert await repo.last_seq("drj_2") == 1
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── sources ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_upsert_and_list():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["sources"].upsert(
|
||||||
|
session_id="drs_1",
|
||||||
|
id="src_1",
|
||||||
|
user_id="u1",
|
||||||
|
title="Source A",
|
||||||
|
url="https://example.com/a",
|
||||||
|
raw_content="content A",
|
||||||
|
source="web",
|
||||||
|
source_type="web",
|
||||||
|
content_hash="hash_a",
|
||||||
|
)
|
||||||
|
items = await repos["sources"].list_by_session("drs_1", user_id="u1")
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["title"] == "Source A"
|
||||||
|
assert "raw_content" not in items[0]
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_set_selected():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["sources"].upsert(
|
||||||
|
session_id="drs_1",
|
||||||
|
id="src_1",
|
||||||
|
user_id="u1",
|
||||||
|
title="A",
|
||||||
|
raw_content="c",
|
||||||
|
source="web",
|
||||||
|
content_hash="h",
|
||||||
|
)
|
||||||
|
updated = await repos["sources"].set_selected("src_1", selected=True, reason="relevant", citation_key="1", user_id="u1")
|
||||||
|
assert updated["selected"] is True
|
||||||
|
assert updated["citation_key"] == "1"
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── messages ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_message_append_and_list():
|
||||||
|
repos = await _make_repos()
|
||||||
|
try:
|
||||||
|
await repos["messages"].append(id="msg_1", session_id="drs_1", user_id="u1", role="user", content="question?")
|
||||||
|
await repos["messages"].append(
|
||||||
|
id="msg_2",
|
||||||
|
session_id="drs_1",
|
||||||
|
user_id="u1",
|
||||||
|
role="assistant",
|
||||||
|
content="answer",
|
||||||
|
citation_source_ids=["src_1"],
|
||||||
|
)
|
||||||
|
msgs = await repos["messages"].list_by_session("drs_1", user_id="u1")
|
||||||
|
assert len(msgs) == 2
|
||||||
|
assert msgs[0]["role"] == "user"
|
||||||
|
assert msgs[1]["citation_source_ids"] == ["src_1"]
|
||||||
|
finally:
|
||||||
|
await repos["_engine"].dispose()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Regression coverage for idempotent Deep Research job submission."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.gateway.routers import deep_research
|
||||||
|
|
||||||
|
|
||||||
|
class _SessionStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.updates: list[dict] = []
|
||||||
|
|
||||||
|
async def get(self, session_id: str, *, user_id: str): # noqa: ARG002
|
||||||
|
return {
|
||||||
|
"id": session_id,
|
||||||
|
"query": "测试主题",
|
||||||
|
"title": "测试主题",
|
||||||
|
"config_snapshot": {"mode": "basic"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def count_active_by_user(self, *, user_id: str) -> int: # noqa: ARG002
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
|
||||||
|
self.updates.append(fields)
|
||||||
|
return {"id": session_id, **fields}
|
||||||
|
|
||||||
|
|
||||||
|
class _JobStore:
|
||||||
|
async def get_active_for_session(self, session_id: str, *, user_id: str): # noqa: ARG002
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
|
||||||
|
# Simulate a retry after an earlier request committed the job but did
|
||||||
|
# not finish responding to the browser.
|
||||||
|
return {"id": "drj_existing", "status": "queued"}, False
|
||||||
|
|
||||||
|
|
||||||
|
class _Dispatcher:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.nudges = 0
|
||||||
|
|
||||||
|
def nudge(self) -> None:
|
||||||
|
self.nudges += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _request(*, sessions, jobs, dispatcher):
|
||||||
|
return SimpleNamespace(
|
||||||
|
app=SimpleNamespace(
|
||||||
|
state=SimpleNamespace(
|
||||||
|
deep_research_session_store=sessions,
|
||||||
|
deep_research_job_store=jobs,
|
||||||
|
deep_research_dispatcher=dispatcher,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_of_existing_queued_job_nudges_dispatcher(monkeypatch):
|
||||||
|
"""An idempotent retry must recover a queued-but-not-yet-dispatched job."""
|
||||||
|
|
||||||
|
async def fake_require_user(request): # noqa: ANN001
|
||||||
|
return "u1"
|
||||||
|
|
||||||
|
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
|
||||||
|
sessions = _SessionStore()
|
||||||
|
dispatcher = _Dispatcher()
|
||||||
|
request = _request(sessions=sessions, jobs=_JobStore(), dispatcher=dispatcher)
|
||||||
|
|
||||||
|
response = await deep_research.start_job(
|
||||||
|
"drs_1",
|
||||||
|
deep_research.StartJobRequest(entry="legacy"),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response == {
|
||||||
|
"jobId": "drj_existing",
|
||||||
|
"sessionId": "drs_1",
|
||||||
|
"status": "queued",
|
||||||
|
"reused": True,
|
||||||
|
}
|
||||||
|
assert dispatcher.nudges == 1
|
||||||
|
assert sessions.updates == [{"status": "running", "active_job_id": "drj_existing"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_queued_job_still_starts_when_session_projection_temporarily_fails(monkeypatch):
|
||||||
|
"""A committed job is more important than its non-authoritative UI projection."""
|
||||||
|
|
||||||
|
async def fake_require_user(request): # noqa: ANN001
|
||||||
|
return "u1"
|
||||||
|
|
||||||
|
class _ProjectionFailureSessionStore(_SessionStore):
|
||||||
|
async def update(self, session_id: str, *, user_id: str, **fields): # noqa: ARG002
|
||||||
|
raise RuntimeError("temporary database readback failure")
|
||||||
|
|
||||||
|
class _NewJobStore(_JobStore):
|
||||||
|
async def try_create_or_get_active(self, **kwargs): # noqa: ANN003
|
||||||
|
return {"id": "drj_new", "status": "queued"}, True
|
||||||
|
|
||||||
|
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
|
||||||
|
dispatcher = _Dispatcher()
|
||||||
|
response = await deep_research.start_job(
|
||||||
|
"drs_1",
|
||||||
|
deep_research.StartJobRequest(entry="legacy"),
|
||||||
|
_request(
|
||||||
|
sessions=_ProjectionFailureSessionStore(),
|
||||||
|
jobs=_NewJobStore(),
|
||||||
|
dispatcher=dispatcher,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response["jobId"] == "drj_new"
|
||||||
|
assert response["reused"] is False
|
||||||
|
assert dispatcher.nudges == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_stays_open_when_durable_replay_has_a_transient_failure(monkeypatch):
|
||||||
|
"""A stream DB blip must yield a recoverable warning, not an ASGI error group."""
|
||||||
|
|
||||||
|
async def fake_require_user(request): # noqa: ANN001
|
||||||
|
return "u1"
|
||||||
|
|
||||||
|
class _StreamJobStore:
|
||||||
|
async def get(self, job_id: str, *, user_id: str): # noqa: ARG002
|
||||||
|
return {"id": job_id, "session_id": "drs_1"}
|
||||||
|
|
||||||
|
async def get_unscoped(self, job_id: str): # noqa: ARG002
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _UnavailableEventStore:
|
||||||
|
async def list_after(self, *args, **kwargs): # noqa: ANN002, ANN003
|
||||||
|
raise RuntimeError("temporary replica failure")
|
||||||
|
|
||||||
|
async def is_connected() -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(deep_research, "_require_user", fake_require_user)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
app=SimpleNamespace(
|
||||||
|
state=SimpleNamespace(
|
||||||
|
deep_research_job_store=_StreamJobStore(),
|
||||||
|
deep_research_event_store=_UnavailableEventStore(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
is_disconnected=is_connected,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await deep_research.stream_job("drj_1", request)
|
||||||
|
chunk = await anext(response.body_iterator)
|
||||||
|
await response.body_iterator.aclose()
|
||||||
|
|
||||||
|
assert "durable_replay_temporarily_unavailable" in chunk
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Regression coverage for provider-safe transient context compression."""
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.summarization_middleware import _ensure_latest_human_message
|
||||||
|
|
||||||
|
|
||||||
|
def test_compression_restores_latest_human_turn_when_only_tool_tail_remains():
|
||||||
|
user = HumanMessage(content="请分析这份材料", id="user-1")
|
||||||
|
source = [
|
||||||
|
user,
|
||||||
|
AIMessage(content="", id="ai-1"),
|
||||||
|
ToolMessage(content="检索结果", tool_call_id="call-1", id="tool-1"),
|
||||||
|
]
|
||||||
|
compressed = [SystemMessage(content="internal summary"), *source[1:]]
|
||||||
|
|
||||||
|
result = _ensure_latest_human_message(source, compressed)
|
||||||
|
|
||||||
|
assert result[0].type == "system"
|
||||||
|
assert result[1] is user
|
||||||
|
assert [message.type for message in result[2:]] == ["ai", "tool"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compression_does_not_duplicate_human_turn_already_in_tail():
|
||||||
|
user = HumanMessage(content="继续", id="user-1")
|
||||||
|
compressed = [SystemMessage(content="internal summary"), user]
|
||||||
|
|
||||||
|
result = _ensure_latest_human_message([user], compressed)
|
||||||
|
|
||||||
|
assert result == compressed
|
||||||
Reference in new issue
Block a user