代码提交

This commit is contained in:
董宏凯 committed 2026-08-15 12:51:58 +08:00
1 parent 500dab29ab
commit 70da7c5649
59 files changed
+12108 -148

No files matched your search

@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -172,6 +172,18 @@ export function WorkspaceNavChatList({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/deep-research")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/deep-research">
<Telescope />
<span>深度研究</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/planning")}
@@ -35,6 +35,7 @@ import {
Settings,
Sparkles,
Target,
Telescope,
Trophy,
Users,
} from "lucide-react";
@@ -92,6 +93,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: FileText,
path: "/page/canvas/ai-writing-chat",
},
{
id: "qa-deep-research",
label: "深度研究",
icon: Telescope,
path: "/page/strategy/qa/deep-research",
},
],
},
{
File diff suppressed because it is too large. Load diff
@@ -24,6 +24,7 @@ import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanni
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
import EightBfRoundtablePage from "@/eightbf-roundtable/pages/EightBfRoundtablePage";
import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import DeepResearchPage from "./DeepResearchPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
@@ -113,6 +114,7 @@ export default function StrategyRoutes() {
</ChatRuntime>
}
/>
<Route path="qa/deep-research" element={<DeepResearchPage />} />
{/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
<Route
@@ -57,6 +57,7 @@ import PositionRolesPage from './PositionRolesPage';
import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import DeepResearchPage from './DeepResearchPage';
import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
@@ -521,6 +522,16 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="deep-research"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<DeepResearchPage />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route
path="studio/notebooks"
element={
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,639 @@
"use client";
/**
* Deep Research API client.
*
* Mirrors the patterns in sensitive-words.ts (CRUD + React Query hooks)
* and roundtable-jobs.ts (SSE job-progress streaming via apiFetch + getReader).
*
* Backend prefix: /api/deep-research
*/
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
// ── base URL ────────────────────────────────────────────────────────────────
const BASE = () => `${getBackendBaseURL()}/api/deep-research`;
// ── types ───────────────────────────────────────────────────────────────────
export type ResearchMode = "basic" | "quick" | "detailed" | "deep" | "multi_agent";
export type ResearchTone = "objective" | "formal" | "analytical" | "persuasive";
export type SessionStatus =
| "draft"
| "running"
| "awaiting_input"
| "completed"
| "failed"
| "cancelled";
export type JobStatus =
| "queued"
| "running"
| "awaiting_input"
| "completed"
| "failed"
| "cancelled";
export interface DeepResearchSession {
id: string;
userId?: string;
title: string;
query: string;
mode: ResearchMode;
status: SessionStatus;
config?: Record<string, unknown>;
plan?: Record<string, unknown>;
reportMarkdown?: string;
reportHtml?: string;
sourceCount: number;
usage?: Record<string, unknown>;
activeJobId?: string;
error?: string;
createdAt?: string;
updatedAt?: string;
}
export interface DeepResearchJob {
id: string;
sessionId: string;
status: JobStatus;
phase: string;
progress: number;
currentQuery?: string;
errorCode?: string;
errorMessage?: string;
createdAt?: string;
updatedAt?: string;
}
export interface DeepResearchSource {
id: string;
sessionId: string;
title: string;
url?: string;
snippet?: string;
source: string;
sourceType: string;
selected: boolean;
citationKey?: string;
publishedAt?: string;
relevanceScore?: number;
rawContent?: string;
}
export interface DeepResearchMessage {
id: string;
sessionId: string;
role: "user" | "assistant";
content: string;
citationSourceIds: string[];
usage?: Record<string, unknown>;
createdAt?: string;
}
export interface ProgressEvent {
seq: number;
/** Local live-stream identifier. Durable replay events use their seq instead. */
liveId?: string;
sessionId: string;
jobId: string;
type: string;
phase: string;
timestamp?: string;
payload: Record<string, unknown>;
}
export interface Capabilities {
modes: ResearchMode[];
materialChannels: string[];
exportFormats: string[];
imageGeneration?: {
available: boolean;
maxImages: number;
};
maxActiveJobsPerUser: number;
}
export const TERMINAL_JOB_STATUSES: JobStatus[] = [
"completed",
"failed",
"cancelled",
];
export function isTerminalJobStatus(status: JobStatus): boolean {
return TERMINAL_JOB_STATUSES.includes(status);
}
// ── helpers ─────────────────────────────────────────────────────────────────
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
let detail = `${ctx} failed (${res.status})`;
try {
const body = await res.json();
detail = body.detail || detail;
} catch {
// ignore parse error
}
throw new Error(detail);
}
return res.json() as Promise<T>;
}
type WireRecord = Record<string, unknown>;
function pick<T>(row: WireRecord, camel: string, snake: string): T | undefined {
return (row[camel] ?? row[snake]) as T | undefined;
}
function asRecord(value: unknown): WireRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as WireRecord) : {};
}
function asItems(value: unknown): WireRecord[] {
return Array.isArray(value) ? value.map(asRecord) : [];
}
/**
* Gateway Pydantic models currently serialize snake_case field names, while
* React follows the rest of this frontend's camelCase convention. Keep that
* boundary here and accept camelCase too, so a future API alias change is not
* a breaking UI change.
*/
function normalizeSession(row: WireRecord): DeepResearchSession {
return {
id: String(row.id ?? ""),
userId: pick<string>(row, "userId", "user_id"),
title: String(row.title ?? ""),
query: String(row.query ?? ""),
mode: (row.mode ?? "basic") as ResearchMode,
status: (row.status ?? "draft") as SessionStatus,
config: row.config as Record<string, unknown> | undefined,
plan: row.plan as Record<string, unknown> | undefined,
reportMarkdown: pick<string>(row, "reportMarkdown", "report_markdown"),
reportHtml: pick<string>(row, "reportHtml", "report_html"),
sourceCount: Number(pick<number>(row, "sourceCount", "source_count") ?? 0),
usage: (pick(row, "usage", "usage_snapshot") ?? undefined) as Record<string, unknown> | undefined,
activeJobId: pick<string>(row, "activeJobId", "active_job_id"),
error: typeof row.error === "string" ? row.error : undefined,
createdAt: pick<string>(row, "createdAt", "created_at"),
updatedAt: pick<string>(row, "updatedAt", "updated_at"),
};
}
function normalizeJob(row: WireRecord): DeepResearchJob {
return {
id: String(row.id ?? ""),
sessionId: String(pick<string>(row, "sessionId", "session_id") ?? ""),
status: (row.status ?? "queued") as JobStatus,
phase: String(row.phase ?? "initializing"),
progress: Number(row.progress ?? 0),
currentQuery: pick<string>(row, "currentQuery", "current_query"),
errorCode: pick<string>(row, "errorCode", "error_code"),
errorMessage: pick<string>(row, "errorMessage", "error_message"),
createdAt: pick<string>(row, "createdAt", "created_at"),
updatedAt: pick<string>(row, "updatedAt", "updated_at"),
};
}
function normalizeSource(row: WireRecord): DeepResearchSource {
return {
id: String(row.id ?? ""),
sessionId: String(pick<string>(row, "sessionId", "session_id") ?? ""),
title: String(row.title ?? ""),
url: typeof row.url === "string" ? row.url : undefined,
snippet: typeof row.snippet === "string" ? row.snippet : undefined,
source: String(row.source ?? ""),
sourceType: String(pick<string>(row, "sourceType", "source_type") ?? "other"),
selected: Boolean(row.selected),
citationKey: pick<string>(row, "citationKey", "citation_key"),
publishedAt: pick<string>(row, "publishedAt", "published_at"),
relevanceScore: Number(pick<number>(row, "relevanceScore", "relevance_score") ?? 0) || undefined,
rawContent: pick<string>(row, "rawContent", "raw_content"),
};
}
function normalizeMessage(row: WireRecord): DeepResearchMessage {
return {
id: String(row.id ?? ""),
sessionId: String(pick<string>(row, "sessionId", "session_id") ?? ""),
role: row.role === "user" ? "user" : "assistant",
content: String(row.content ?? ""),
citationSourceIds: Array.isArray(pick(row, "citationSourceIds", "citation_source_ids"))
? (pick<string[]>(row, "citationSourceIds", "citation_source_ids") ?? []).map(String)
: [],
usage: (pick(row, "usage", "usage_snapshot") ?? undefined) as Record<string, unknown> | undefined,
createdAt: pick<string>(row, "createdAt", "created_at"),
};
}
// ── session CRUD ─────────────────────────────────────────────────────────────
export async function createSession(
query: string,
opts?: { title?: string; config?: Record<string, unknown> },
): Promise<DeepResearchSession> {
const res = await apiFetch(BASE() + "/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, title: opts?.title ?? "", config: opts?.config ?? {} }),
});
return normalizeSession(await readJson<WireRecord>(res, "createSession"));
}
export async function listSessions(
params?: { limit?: number; offset?: number; status?: string },
): Promise<{ items: DeepResearchSession[]; total: number; limit: number; offset: number }> {
const qs = new URLSearchParams();
if (params?.limit) qs.set("limit", String(params.limit));
if (params?.offset) qs.set("offset", String(params.offset));
if (params?.status) qs.set("status", params.status);
const res = await apiFetch(`${BASE()}/sessions?${qs}`, { method: "GET" });
const data = await readJson<WireRecord>(res, "listSessions");
return {
items: asItems(data.items).map(normalizeSession),
total: Number(data.total ?? 0),
limit: Number(data.limit ?? params?.limit ?? 20),
offset: Number(data.offset ?? params?.offset ?? 0),
};
}
export async function getSession(id: string): Promise<DeepResearchSession> {
const res = await apiFetch(`${BASE()}/sessions/${id}`, { method: "GET" });
return normalizeSession(await readJson<WireRecord>(res, "getSession"));
}
export async function updateSession(
id: string,
patch: { title?: string },
): Promise<DeepResearchSession> {
const res = await apiFetch(`${BASE()}/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
return normalizeSession(await readJson<WireRecord>(res, "updateSession"));
}
export async function deleteSession(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/sessions/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) {
throw new Error(`deleteSession failed (${res.status})`);
}
}
// ── job lifecycle ─────────────────────────────────────────────────────────────
export async function startJob(
sessionId: string,
requestId?: string,
): Promise<{ jobId: string; sessionId: string; status: string; reused: boolean }> {
const res = await apiFetch(`${BASE()}/sessions/${sessionId}/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ request_id: requestId ?? crypto.randomUUID() }),
});
return readJson(res, "startJob");
}
export async function getJob(jobId: string): Promise<DeepResearchJob> {
const res = await apiFetch(`${BASE()}/jobs/${jobId}`, { method: "GET" });
return normalizeJob(await readJson<WireRecord>(res, "getJob"));
}
export async function cancelJob(
jobId: string,
): Promise<{ jobId: string; status: string }> {
const res = await apiFetch(`${BASE()}/jobs/${jobId}/cancel`, { method: "POST" });
return readJson(res, "cancelJob");
}
export async function resumeJob(
jobId: string,
action: "approve" | "revise" | "cancel",
payload: Record<string, unknown> = {},
): Promise<{ jobId: string; status: string }> {
const res = await apiFetch(`${BASE()}/jobs/${jobId}/resume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, payload }),
});
return readJson(res, "resumeJob");
}
// ── SSE streaming ────────────────────────────────────────────────────────────
export interface StreamJobHandlers {
onEvent: (event: ProgressEvent) => void;
onTerminal?: (status: string) => void;
onError?: (detail: string) => void;
}
/**
* Stream job progress events via SSE.
*
* Returns an unsubscribe function (call on unmount / job change).
* Uses ?after=<seq> cursor for reconnect-safe replay.
*/
export function streamJob(
jobId: string,
handlers: StreamJobHandlers,
afterSeq = 0,
): () => void {
const controller = new AbortController();
void (async () => {
try {
const res = await apiFetch(
`${BASE()}/jobs/${encodeURIComponent(jobId)}/stream?after=${afterSeq}`,
{ method: "GET", signal: controller.signal },
);
if (!res.ok || !res.body) {
handlers.onError?.(`stream failed (${res.status})`);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Normalize CRLF so the parser works with both the Gateway's SSE
// formatter and proxies that rewrite line endings.
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
// SSE frames are separated by double-newlines; each frame has
// `event: <name>` and `data: <json>` lines.
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const dataLine = frame
.split("\n")
.find((l) => l.startsWith("data:"));
if (!dataLine) continue;
const json = dataLine.slice("data:".length).trim();
if (!json) continue;
let event: ProgressEvent;
try {
event = JSON.parse(json);
} catch {
continue;
}
handlers.onEvent(event);
// Check for terminal status in the payload.
const payloadStatus = event.payload?.status as string | undefined;
if (
event.type === "job_status" &&
payloadStatus &&
isTerminalJobStatus(payloadStatus as JobStatus)
) {
handlers.onTerminal?.(payloadStatus);
}
}
}
} catch (err) {
if (!controller.signal.aborted) {
handlers.onError?.(err instanceof Error ? err.message : String(err));
}
}
})();
return () => controller.abort();
}
// ── sources ──────────────────────────────────────────────────────────────────
export async function listSources(
sessionId: string,
params?: { selected?: boolean; limit?: number; offset?: number },
): Promise<{ items: DeepResearchSource[]; total: number }> {
const qs = new URLSearchParams();
if (params?.selected !== undefined) qs.set("selected", String(params.selected));
if (params?.limit) qs.set("limit", String(params.limit));
if (params?.offset) qs.set("offset", String(params.offset));
const res = await apiFetch(`${BASE()}/sessions/${sessionId}/sources?${qs}`, {
method: "GET",
});
const data = await readJson<WireRecord>(res, "listSources");
return {
items: asItems(data.items).map(normalizeSource),
total: Number(data.total ?? 0),
};
}
export async function getSource(
sessionId: string,
sourceId: string,
): Promise<DeepResearchSource> {
const res = await apiFetch(
`${BASE()}/sessions/${sessionId}/sources/${sourceId}`,
{ method: "GET" },
);
return normalizeSource(await readJson<WireRecord>(res, "getSource"));
}
/**
* Update the evidence admission set used by future report follow-up answers.
* This deliberately does not rewrite an already completed report.
*/
export async function updateSourceSelection(
sessionId: string,
sourceId: string,
selected: boolean,
reason?: string,
): Promise<DeepResearchSource> {
const res = await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/sources/${encodeURIComponent(sourceId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selected, ...(reason?.trim() ? { reason: reason.trim() } : {}) }),
},
);
return normalizeSource(await readJson<WireRecord>(res, "updateSourceSelection"));
}
// ── report follow-up chat ───────────────────────────────────────────────────
export async function listResearchMessages(sessionId: string): Promise<DeepResearchMessage[]> {
const res = await apiFetch(`${BASE()}/sessions/${sessionId}/messages`, { method: "GET" });
const data = await readJson<WireRecord>(res, "listResearchMessages");
return asItems(data.items).map(normalizeMessage);
}
export async function sendResearchMessage(
sessionId: string,
message: string,
): Promise<{ user: DeepResearchMessage; assistant: DeepResearchMessage }> {
const res = await apiFetch(`${BASE()}/sessions/${sessionId}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, allow_new_research: false }),
});
const data = await readJson<WireRecord>(res, "sendResearchMessage");
return {
user: normalizeMessage(asRecord(data.user)),
assistant: normalizeMessage(asRecord(data.assistant)),
};
}
// ── capabilities ─────────────────────────────────────────────────────────────
export async function getCapabilities(): Promise<Capabilities> {
const res = await apiFetch(BASE() + "/capabilities", { method: "GET" });
return readJson(res, "getCapabilities");
}
// ── export ───────────────────────────────────────────────────────────────────
export async function exportReport(
sessionId: string,
format: "markdown" | "html",
): Promise<{ format: string; content: string; sessionId: string }> {
const res = await apiFetch(`${BASE()}/sessions/${sessionId}/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ format }),
});
return readJson(res, "exportReport");
}
// ── React Query hooks ────────────────────────────────────────────────────────
export const DEEP_RESEARCH_SESSIONS_KEY = "deep-research-sessions";
export const DEEP_RESEARCH_SESSION_KEY = "deep-research-session";
export const DEEP_RESEARCH_SOURCES_KEY = "deep-research-sources";
export const DEEP_RESEARCH_MESSAGES_KEY = "deep-research-messages";
export const DEEP_RESEARCH_CAPABILITIES_KEY = "deep-research-capabilities";
export function useResearchSessions(limit = 50) {
return useQuery({
queryKey: [DEEP_RESEARCH_SESSIONS_KEY, limit],
queryFn: () => listSessions({ limit }),
refetchOnWindowFocus: false,
staleTime: 5_000,
});
}
export function useResearchSession(id: string | null) {
return useQuery({
queryKey: [DEEP_RESEARCH_SESSION_KEY, id],
queryFn: () => getSession(id!),
enabled: !!id,
refetchOnWindowFocus: false,
});
}
export function useResearchSources(sessionId: string | null) {
return useQuery({
queryKey: [DEEP_RESEARCH_SOURCES_KEY, sessionId],
queryFn: () => listSources(sessionId!, { limit: 200 }),
enabled: !!sessionId,
refetchOnWindowFocus: false,
});
}
export function useResearchMessages(sessionId: string | null) {
return useQuery({
queryKey: [DEEP_RESEARCH_MESSAGES_KEY, sessionId],
queryFn: () => listResearchMessages(sessionId!),
enabled: !!sessionId,
refetchOnWindowFocus: false,
});
}
export function useCapabilities() {
return useQuery({
queryKey: [DEEP_RESEARCH_CAPABILITIES_KEY],
queryFn: getCapabilities,
refetchOnWindowFocus: false,
staleTime: 300_000,
});
}
export function useResearchActions() {
const queryClient = useQueryClient();
return {
create: useMutation({
mutationFn: (params: { query: string; title?: string; config?: Record<string, unknown> }) =>
createSession(params.query, { title: params.title, config: params.config }),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] }),
}),
startJob: useMutation({
mutationFn: (sessionId: string) => startJob(sessionId),
onSuccess: (_result, sessionId) => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY, sessionId] });
},
}),
cancelJob: useMutation({
mutationFn: (jobId: string) => cancelJob(jobId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY] });
},
}),
resumeJob: useMutation({
mutationFn: ({
jobId,
action,
payload,
}: {
jobId: string;
action: "approve" | "revise" | "cancel";
payload?: Record<string, unknown>;
}) => resumeJob(jobId, action, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY] });
},
}),
delete: useMutation({
mutationFn: (sessionId: string) => deleteSession(sessionId),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] }),
}),
updateTitle: useMutation({
mutationFn: ({ id, title }: { id: string; title: string }) =>
updateSession(id, { title }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SESSION_KEY] });
},
}),
sendChat: useMutation({
mutationFn: ({ sessionId, message }: { sessionId: string; message: string }) =>
sendResearchMessage(sessionId, message),
onSuccess: (_result, { sessionId }) => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_MESSAGES_KEY, sessionId] });
},
}),
setSourceSelected: useMutation({
mutationFn: ({
sessionId,
sourceId,
selected,
reason,
}: {
sessionId: string;
sourceId: string;
selected: boolean;
reason?: string;
}) => updateSourceSelection(sessionId, sourceId, selected, reason),
onSuccess: (_result, { sessionId }) => {
queryClient.invalidateQueries({ queryKey: [DEEP_RESEARCH_SOURCES_KEY, sessionId] });
},
}),
};
}