256 lines
8.6 KiB
TypeScript
256 lines
8.6 KiB
TypeScript
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||
import { apiFetch } from "@/core/api/fetch-client";
|
||
import { getBackendBaseURL } from "@/core/config";
|
||
|
||
const API = () => `${getBackendBaseURL()}/api/enterprise-research`;
|
||
|
||
/**
|
||
* Enterprise research snapshots raw WeKnora evidence chunks. This is
|
||
* intentionally separate from LlmWikiSearchResult, whose public search shape
|
||
* now represents processed Wiki pages only.
|
||
*/
|
||
export interface EnterpriseResearchEvidence {
|
||
chunk_id: string;
|
||
content: string;
|
||
knowledge_id: string;
|
||
knowledge_base_id: string;
|
||
knowledge_base_name: string;
|
||
title: string;
|
||
filename: string;
|
||
score: number;
|
||
chunk_index: number;
|
||
url: string;
|
||
}
|
||
|
||
export type EnterpriseResearchTaskStatus = "draft" | "collecting" | "ready" | "partial" | "failed";
|
||
|
||
export interface EnterpriseResearchTask {
|
||
id: string;
|
||
subject: string;
|
||
focus: string;
|
||
template_id: string;
|
||
knowledge_base_ids: string[];
|
||
queries: string[];
|
||
selected_source_ids: string[];
|
||
sources: EnterpriseResearchEvidence[];
|
||
plan_markdown?: string | null;
|
||
report_markdown?: string | null;
|
||
report_summary?: string | null;
|
||
report_model_name?: string | null;
|
||
active_report_job_id?: string | null;
|
||
status: EnterpriseResearchTaskStatus;
|
||
warning?: string | null;
|
||
created_at?: string | null;
|
||
updated_at?: string | null;
|
||
}
|
||
|
||
export type EnterpriseResearchReportJobStatus = "queued" | "running" | "completed" | "failed" | "cancelled";
|
||
|
||
export interface EnterpriseResearchReportJob {
|
||
id: string;
|
||
task_id: string;
|
||
status: EnterpriseResearchReportJobStatus;
|
||
phase: string;
|
||
model_name?: string | null;
|
||
action: "initial" | "rewrite" | "followup";
|
||
instruction?: string | null;
|
||
style?: string | null;
|
||
target_length?: number | null;
|
||
original_report?: string | null;
|
||
report_markdown: string;
|
||
report_summary?: string | null;
|
||
error_message?: string | null;
|
||
created_at?: string | null;
|
||
updated_at?: string | null;
|
||
}
|
||
|
||
export type EnterpriseResearchReportEvent =
|
||
| { type: "snapshot"; job: EnterpriseResearchReportJob }
|
||
| { type: "phase"; phase: string; status: EnterpriseResearchReportJobStatus }
|
||
| { type: "report_delta"; delta: string }
|
||
| { type: "reasoning_delta"; delta: string }
|
||
| { type: "completed" | "failed" | "cancelled"; job: EnterpriseResearchReportJob | null }
|
||
| { type: "heartbeat"; status: EnterpriseResearchReportJobStatus; phase: string };
|
||
|
||
export interface CreateEnterpriseResearchTaskInput {
|
||
subject: string;
|
||
focus: string;
|
||
template_id: string;
|
||
knowledge_base_ids: string[];
|
||
queries: string[];
|
||
}
|
||
|
||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||
if (!response.ok) {
|
||
const payload = await response.json().catch(() => null as unknown);
|
||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||
? String((payload as { detail: unknown }).detail)
|
||
: `${context}失败(${response.status})`;
|
||
throw new Error(userFacingKnowledgeMessage(detail));
|
||
}
|
||
return response.json() as Promise<T>;
|
||
}
|
||
|
||
export async function listEnterpriseResearchTasks(limit = 20): Promise<EnterpriseResearchTask[]> {
|
||
const payload = await readJson<{ tasks?: EnterpriseResearchTask[] }>(
|
||
await apiFetch(`${API()}/tasks?limit=${encodeURIComponent(limit)}`),
|
||
"读取研究任务",
|
||
);
|
||
return payload.tasks ?? [];
|
||
}
|
||
|
||
export async function createEnterpriseResearchTask(input: CreateEnterpriseResearchTaskInput): Promise<EnterpriseResearchTask> {
|
||
return readJson<EnterpriseResearchTask>(
|
||
await apiFetch(`${API()}/tasks`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(input),
|
||
}),
|
||
"创建研究任务",
|
||
);
|
||
}
|
||
|
||
export async function collectEnterpriseResearchTask(taskId: string): Promise<EnterpriseResearchTask> {
|
||
return readJson<EnterpriseResearchTask>(
|
||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/collect`, { method: "POST" }),
|
||
"收集研究证据",
|
||
);
|
||
}
|
||
|
||
export async function createEnterpriseResearchPlanDraft(taskId: string): Promise<EnterpriseResearchTask> {
|
||
return readJson<EnterpriseResearchTask>(
|
||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/plan/draft`, { method: "POST" }),
|
||
"生成研究计划",
|
||
);
|
||
}
|
||
|
||
export async function createEnterpriseResearchAiPlan(
|
||
taskId: string,
|
||
input: { model_name?: string | null; instruction?: string } = {},
|
||
): Promise<EnterpriseResearchTask> {
|
||
return readJson<EnterpriseResearchTask>(
|
||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/plan/ai`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(input),
|
||
}),
|
||
"AI 优化研究计划",
|
||
);
|
||
}
|
||
|
||
export async function updateEnterpriseResearchPlan(
|
||
taskId: string,
|
||
input: { plan_markdown: string; selected_source_ids: string[] },
|
||
): Promise<EnterpriseResearchTask> {
|
||
return readJson<EnterpriseResearchTask>(
|
||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/plan`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(input),
|
||
}),
|
||
"保存研究计划",
|
||
);
|
||
}
|
||
|
||
export async function createEnterpriseResearchReportJob(
|
||
taskId: string,
|
||
input: {
|
||
model_name?: string | null;
|
||
action?: "initial" | "rewrite" | "followup";
|
||
instruction?: string;
|
||
style?: "formal" | "analytical" | "concise";
|
||
target_length?: number | null;
|
||
} = {},
|
||
): Promise<EnterpriseResearchReportJob> {
|
||
return readJson<EnterpriseResearchReportJob>(
|
||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(input),
|
||
}),
|
||
"创建报告写作任务",
|
||
);
|
||
}
|
||
|
||
export async function cancelEnterpriseResearchReportJob(
|
||
taskId: string,
|
||
jobId: string,
|
||
): Promise<EnterpriseResearchReportJob> {
|
||
return readJson<EnterpriseResearchReportJob>(
|
||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs/${encodeURIComponent(jobId)}/cancel`, {
|
||
method: "POST",
|
||
}),
|
||
"取消报告任务",
|
||
);
|
||
}
|
||
|
||
export async function restoreEnterpriseResearchReportVersion(
|
||
taskId: string,
|
||
jobId: string,
|
||
): Promise<EnterpriseResearchTask> {
|
||
return readJson<EnterpriseResearchTask>(
|
||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs/${encodeURIComponent(jobId)}/restore`, {
|
||
method: "POST",
|
||
}),
|
||
"恢复报告版本",
|
||
);
|
||
}
|
||
|
||
export async function listEnterpriseResearchReportJobs(taskId: string): Promise<EnterpriseResearchReportJob[]> {
|
||
const payload = await readJson<{ jobs?: EnterpriseResearchReportJob[] }>(
|
||
await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs`),
|
||
"读取报告写作任务",
|
||
);
|
||
return payload.jobs ?? [];
|
||
}
|
||
|
||
/** Parse the endpoint's standard SSE frames without introducing an EventSource auth gap. */
|
||
export async function streamEnterpriseResearchReportJob(
|
||
taskId: string,
|
||
jobId: string,
|
||
handlers: { onEvent: (event: EnterpriseResearchReportEvent) => void; signal?: AbortSignal },
|
||
): Promise<void> {
|
||
const response = await apiFetch(
|
||
`${API()}/tasks/${encodeURIComponent(taskId)}/report-jobs/${encodeURIComponent(jobId)}/stream`,
|
||
{ headers: { Accept: "text/event-stream" }, signal: handlers.signal },
|
||
);
|
||
if (!response.ok || !response.body) {
|
||
await readJson(response, "连接报告流");
|
||
return;
|
||
}
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let pending = "";
|
||
try {
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
pending += decoder.decode(value, { stream: true });
|
||
const frames = pending.split("\n\n");
|
||
pending = frames.pop() ?? "";
|
||
for (const frame of frames) {
|
||
const dataLine = frame.split("\n").find((line) => line.startsWith("data: "));
|
||
if (!dataLine) continue;
|
||
try {
|
||
handlers.onEvent(JSON.parse(dataLine.slice(6)) as EnterpriseResearchReportEvent);
|
||
} catch {
|
||
// One malformed frame must not discard an otherwise healthy stream.
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
reader.releaseLock();
|
||
}
|
||
}
|
||
|
||
export async function deleteEnterpriseResearchTask(taskId: string): Promise<void> {
|
||
const response = await apiFetch(`${API()}/tasks/${encodeURIComponent(taskId)}`, { method: "DELETE" });
|
||
if (!response.ok) {
|
||
const payload = await response.json().catch(() => null as unknown);
|
||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||
? String((payload as { detail: unknown }).detail)
|
||
: `删除研究任务失败(${response.status})`;
|
||
throw new Error(userFacingKnowledgeMessage(detail));
|
||
}
|
||
}
|