Merge remote-tracking branch 'origin/master' into ji
# Conflicts: # .gitignore # frontend-web/src/components/workspace/input-box.tsx # frontend-web/src/core/threads/types.ts # frontend-web/src/pages/ChatPage.tsx
This commit is contained in:
commit
c4fd9dd7da
233 files changed
+12524
-1163
No files matched your search
@@ -545,10 +545,13 @@ export const enUS: Translations = {
|
||||
filesSaveAction: "Save",
|
||||
filesCancelEditAction: "Cancel",
|
||||
filesDownloadAction: "Download",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
filesReadOnlyHint: "Built-in skills are read-only.",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
filesDeleteAction: "Delete",
|
||||
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
|
||||
filesDeleteSuccess: "File deleted",
|
||||
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
|
||||
viewDetailAction: "View skill details",
|
||||
stateSheet: {
|
||||
archivedTitle: "Archived skills",
|
||||
|
||||
@@ -475,10 +475,13 @@ export interface Translations {
|
||||
filesSaveAction: string;
|
||||
filesCancelEditAction: string;
|
||||
filesDownloadAction: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesDeleteAction: string;
|
||||
filesDeleteConfirm: string;
|
||||
filesDeleteSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
viewDetailAction: string;
|
||||
stateSheet: {
|
||||
archivedTitle: string;
|
||||
|
||||
@@ -524,8 +524,11 @@ export const zhCN: Translations = {
|
||||
filesDownloadAction: "下载",
|
||||
filesCopyPathAction: "复制路径",
|
||||
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesReadOnlyHint: "内置技能为只读,仅可查看。",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesDeleteAction: "删除",
|
||||
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
|
||||
filesDeleteSuccess: "文件已删除",
|
||||
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
|
||||
viewDetailAction: "查看技能详情",
|
||||
stateSheet: {
|
||||
archivedTitle: "归档的技能",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface ManagedModel {
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
description: string | null;
|
||||
use: string;
|
||||
model: string;
|
||||
base_url: string | null;
|
||||
request_timeout: number | null;
|
||||
max_retries: number | null;
|
||||
max_tokens: number | null;
|
||||
temperature: number | null;
|
||||
stream_usage: boolean | null;
|
||||
supports_thinking: boolean;
|
||||
supports_reasoning_effort: boolean;
|
||||
supports_vision: boolean;
|
||||
when_thinking_enabled: Record<string, unknown> | null;
|
||||
when_thinking_disabled: Record<string, unknown> | null;
|
||||
thinking: Record<string, unknown> | null;
|
||||
use_responses_api: boolean | null;
|
||||
output_version: string | null;
|
||||
extra_settings: Record<string, unknown>;
|
||||
api_key_configured: boolean;
|
||||
api_key_source: string | null;
|
||||
source: "config.yaml";
|
||||
}
|
||||
|
||||
export interface ManagedModelsResponse {
|
||||
models: ManagedModel[];
|
||||
config_path: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface ModelSavePayload {
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
description?: string | null;
|
||||
use: string;
|
||||
model: string;
|
||||
api_key?: string;
|
||||
clear_api_key?: boolean;
|
||||
base_url?: string | null;
|
||||
request_timeout?: number | null;
|
||||
max_retries?: number | null;
|
||||
max_tokens?: number | null;
|
||||
temperature?: number | null;
|
||||
stream_usage?: boolean | null;
|
||||
supports_thinking: boolean;
|
||||
supports_reasoning_effort: boolean;
|
||||
supports_vision: boolean;
|
||||
when_thinking_enabled?: Record<string, unknown> | null;
|
||||
when_thinking_disabled?: Record<string, unknown> | null;
|
||||
thinking?: Record<string, unknown> | null;
|
||||
use_responses_api?: boolean | null;
|
||||
output_version?: string | null;
|
||||
extra_settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ModelConnectionTestResult {
|
||||
success: boolean;
|
||||
latency_ms: number;
|
||||
message: string;
|
||||
preview?: string | null;
|
||||
}
|
||||
|
||||
async function readError(response: Response, fallback: string): Promise<never> {
|
||||
const payload = await response.json().catch(() => null) as { detail?: string } | null;
|
||||
throw new Error(payload?.detail || fallback);
|
||||
}
|
||||
|
||||
export async function loadManagedModels(): Promise<ManagedModelsResponse> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models`);
|
||||
if (!response.ok) return readError(response, "加载模型配置失败");
|
||||
return response.json() as Promise<ManagedModelsResponse>;
|
||||
}
|
||||
|
||||
export async function createManagedModel(payload: ModelSavePayload): Promise<ManagedModel> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) return readError(response, "创建模型失败");
|
||||
return response.json() as Promise<ManagedModel>;
|
||||
}
|
||||
|
||||
export async function updateManagedModel(name: string, payload: ModelSavePayload): Promise<ManagedModel> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) return readError(response, "保存模型配置失败");
|
||||
return response.json() as Promise<ManagedModel>;
|
||||
}
|
||||
|
||||
export async function deleteManagedModel(name: string): Promise<void> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) return readError(response, "删除模型失败");
|
||||
}
|
||||
|
||||
export async function testManagedModel(model: ModelSavePayload): Promise<ModelConnectionTestResult> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
if (!response.ok) return readError(response, "模型连接测试失败");
|
||||
return response.json() as Promise<ModelConnectionTestResult>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createManagedModel,
|
||||
deleteManagedModel,
|
||||
loadManagedModels,
|
||||
testManagedModel,
|
||||
updateManagedModel,
|
||||
type ModelSavePayload,
|
||||
} from "./api";
|
||||
|
||||
export const MANAGED_MODELS_QUERY_KEY = ["admin", "model-config"] as const;
|
||||
|
||||
export function useManagedModels() {
|
||||
return useQuery({
|
||||
queryKey: MANAGED_MODELS_QUERY_KEY,
|
||||
queryFn: loadManagedModels,
|
||||
staleTime: 10_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveManagedModel() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, payload, isNew }: { name: string; payload: ModelSavePayload; isNew: boolean }) =>
|
||||
isNew ? createManagedModel(payload) : updateManagedModel(name, payload),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: ["models"] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestManagedModel() {
|
||||
return useMutation({ mutationFn: testManagedModel });
|
||||
}
|
||||
|
||||
export function useDeleteManagedModel() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteManagedModel,
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: ["models"] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./api";
|
||||
export * from "./hooks";
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Model } from "./types";
|
||||
|
||||
export const SINGLE_ROUND_RAG_MODELS: Model[] = [
|
||||
{ id: "single-round-rag-openai3", name: "OpenAI3", model: "OpenAI3", display_name: "Qwen3-32B", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
{ id: "single-round-rag-openai4", name: "OpenAI4", model: "OpenAI4", display_name: "Qwen3-235B", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
{ id: "single-round-rag-openai5", name: "OpenAI5", model: "OpenAI5", display_name: "GLM-5", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
{ id: "single-round-rag-openai6", name: "OpenAI6", model: "OpenAI6", display_name: "Qwen3.5-27B", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
{ id: "single-round-rag-openai7", name: "OpenAI7", model: "OpenAI7", display_name: "Qwen3-8B", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
{ id: "single-round-rag-openai9", name: "OpenAI9", model: "OpenAI9", display_name: "四季7B", supports_thinking: true, execution_profile: "single_round_rag" },
|
||||
];
|
||||
|
||||
export function isSingleRoundRagProfile(
|
||||
context: unknown,
|
||||
): boolean {
|
||||
return (
|
||||
typeof context === "object" &&
|
||||
context !== null &&
|
||||
(context as { execution_profile?: unknown }).execution_profile ===
|
||||
"single_round_rag"
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,11 @@ export interface Model {
|
||||
model: string;
|
||||
display_name: string;
|
||||
description?: string | null;
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
}
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||
execution_profile?: "single_round_rag";
|
||||
}
|
||||
|
||||
export interface TokenUsageSettings {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -402,15 +402,26 @@ export async function getSkillContent(
|
||||
return response.json() as Promise<SkillContentResponse>;
|
||||
}
|
||||
|
||||
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to delete skill");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a built-in skill. The backend permits this only for administrators. */
|
||||
export async function deleteBuiltinSkill(skillName: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to delete built-in skill");
|
||||
}
|
||||
}
|
||||
|
||||
export interface SkillFileNode {
|
||||
name: string;
|
||||
@@ -479,7 +490,7 @@ export function getSkillFileDownloadURL(skillName: string, path: string): string
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveSkillFile(
|
||||
export async function saveSkillFile(
|
||||
skillName: string,
|
||||
path: string,
|
||||
content: string,
|
||||
@@ -494,9 +505,22 @@ export async function saveSkillFile(
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to save skill file");
|
||||
}
|
||||
return response.json() as Promise<SkillFileSaveResponse>;
|
||||
}
|
||||
}
|
||||
return response.json() as Promise<SkillFileSaveResponse>;
|
||||
}
|
||||
|
||||
export async function deleteSkillFile(
|
||||
skillName: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
const url =
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
|
||||
`?path=${encodeURIComponent(path)}`;
|
||||
const response = await apiFetch(url, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to delete skill file");
|
||||
}
|
||||
}
|
||||
|
||||
export interface SkillNameZhResponse {
|
||||
name: string;
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
import {
|
||||
deleteBuiltinSkill,
|
||||
deleteCustomSkill,
|
||||
deleteSkillFile,
|
||||
duplicateSkill,
|
||||
enableSkill,
|
||||
generateMissingSkillNameZh,
|
||||
@@ -318,7 +320,7 @@ export function useSkillFileContent(skillName: string | null, path: string | nul
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSkillFile() {
|
||||
export function useSaveSkillFile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -339,5 +341,33 @@ export function useSaveSkillFile() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSkillFile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
path,
|
||||
}: {
|
||||
skillName: string;
|
||||
path: string;
|
||||
}) => deleteSkillFile(skillName, path),
|
||||
onSuccess: (_data, { skillName, path }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
|
||||
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBuiltinSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteBuiltinSkill,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,10 +25,13 @@ import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
|
||||
import type { AgentThread, AgentThreadState } from "./types";
|
||||
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
data: unknown;
|
||||
};
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
data: unknown;
|
||||
toolCallId?: string;
|
||||
};
|
||||
|
||||
export type ToolStartEvent = ToolEndEvent;
|
||||
|
||||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
@@ -47,10 +50,11 @@ export type ThreadStreamOptions = {
|
||||
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
|
||||
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
||||
threadMetadata?: Record<string, unknown>;
|
||||
onStart?: (threadId: string) => void;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
onStart?: (threadId: string) => void;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
onToolStart?: (event: ToolStartEvent) => void;
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
@@ -447,10 +451,11 @@ export function useThreadStream({
|
||||
assistantId,
|
||||
runOptions,
|
||||
threadMetadata,
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
@@ -466,16 +471,17 @@ export function useThreadStream({
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
}, []);
|
||||
|
||||
const listeners = useRef({
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolEnd,
|
||||
});
|
||||
const listeners = useRef({
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
// Keep listeners ref updated with latest callbacks
|
||||
useEffect(() => {
|
||||
listeners.current = { onStart, onFinish, onToolEnd };
|
||||
}, [onStart, onFinish, onToolEnd]);
|
||||
useEffect(() => {
|
||||
listeners.current = { onStart, onFinish, onToolStart, onToolEnd };
|
||||
}, [onStart, onFinish, onToolStart, onToolEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedThreadId = threadId ?? null;
|
||||
@@ -545,12 +551,6 @@ export function useThreadStream({
|
||||
},
|
||||
onLangChainEvent(event) {
|
||||
markStreamActivity();
|
||||
if (event.event === "on_tool_end") {
|
||||
listeners.current.onToolEnd?.({
|
||||
name: event.name,
|
||||
data: event.data,
|
||||
});
|
||||
}
|
||||
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
|
||||
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
|
||||
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
|
||||
@@ -572,8 +572,32 @@ export function useThreadStream({
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// The SDK emits UI tool lifecycle events on the dedicated `tools` stream
|
||||
// mode. `events` is a separate LangChain trace channel; using it for UI
|
||||
// reactions can miss a tool that the conversation has already rendered.
|
||||
onToolEvent:
|
||||
onToolStart || onToolEnd
|
||||
? (event) => {
|
||||
markStreamActivity();
|
||||
if (event.event === "on_tool_start") {
|
||||
listeners.current.onToolStart?.({
|
||||
name: event.name,
|
||||
data: event.input,
|
||||
toolCallId: event.toolCallId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.event === "on_tool_end") {
|
||||
listeners.current.onToolEnd?.({
|
||||
name: event.name,
|
||||
data: event.output,
|
||||
toolCallId: event.toolCallId,
|
||||
});
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
onUpdateEvent(data) {
|
||||
markStreamActivity();
|
||||
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
||||
|
||||
@@ -23,11 +23,13 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
rag_mode_enabled?: boolean;
|
||||
/** Per-request switch for context compression (上下文压缩). Default off. */
|
||||
summarization_enabled?: boolean;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
|
||||
llmwiki_knowledge_base_ids?: string[];
|
||||
agent_id?: string;
|
||||
/** Direct knowledge-base RAG mode; this request never enters Lead Agent. */
|
||||
execution_profile?: "single_round_rag";
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
const STORAGE_PREFIX = "workspace.single-round-rag.";
|
||||
|
||||
type KnowledgeBaseChunk = {
|
||||
answer?: unknown;
|
||||
docs?: unknown;
|
||||
};
|
||||
|
||||
type RagSource = {
|
||||
id?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
content: string;
|
||||
source?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
raw: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Same JSON-object framing used by the established GeneralQA single-RAG flow. */
|
||||
function extractJsonObjects(raw: string): { objects: string[]; rest: string } {
|
||||
const objects: string[] = [];
|
||||
let depth = 0;
|
||||
let stringQuote: '"' | "'" | null = null;
|
||||
let escaped = false;
|
||||
let start = -1;
|
||||
for (let index = 0; index < raw.length; index += 1) {
|
||||
const char = raw[index]!;
|
||||
if (stringQuote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === stringQuote) stringQuote = null;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
stringQuote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
if (depth === 0) start = index;
|
||||
depth += 1;
|
||||
} else if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0 && start !== -1) {
|
||||
objects.push(raw.slice(start, index + 1));
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return depth > 0 && start !== -1
|
||||
? { objects, rest: raw.slice(start) }
|
||||
: { objects, rest: "" };
|
||||
}
|
||||
|
||||
/** Same `<think>` split semantics as GeneralQA, kept local to avoid loading its demo data. */
|
||||
function parseSingleRagStream(chunks: { answer: string }[]) {
|
||||
let thinkingMarkdown = "";
|
||||
let answerMarkdown = "";
|
||||
let inThink = false;
|
||||
let thinkingComplete = false;
|
||||
for (const { answer } of chunks) {
|
||||
let remaining = answer;
|
||||
while (remaining) {
|
||||
if (inThink) {
|
||||
const endIndex = remaining.indexOf("</think>");
|
||||
if (endIndex === -1) {
|
||||
thinkingMarkdown += remaining;
|
||||
remaining = "";
|
||||
} else {
|
||||
thinkingMarkdown += remaining.slice(0, endIndex);
|
||||
remaining = remaining.slice(endIndex + "</think>".length);
|
||||
inThink = false;
|
||||
thinkingComplete = true;
|
||||
}
|
||||
} else {
|
||||
const startIndex = remaining.indexOf("<think>");
|
||||
if (startIndex === -1) {
|
||||
answerMarkdown += remaining;
|
||||
remaining = "";
|
||||
} else {
|
||||
answerMarkdown += remaining.slice(0, startIndex);
|
||||
remaining = remaining.slice(startIndex + "<think>".length);
|
||||
inThink = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { thinkingMarkdown, answerMarkdown, thinkingComplete };
|
||||
}
|
||||
|
||||
function storageKey(threadId: string) {
|
||||
return `${STORAGE_PREFIX}${threadId}`;
|
||||
}
|
||||
|
||||
function readStoredMessages(threadId: string): Message[] {
|
||||
if (typeof window === "undefined" || !threadId) return [];
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey(threadId));
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? (parsed as Message[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredMessages(threadId: string, messages: Message[]) {
|
||||
if (typeof window === "undefined" || !threadId) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
|
||||
} catch {
|
||||
// Quota errors must never interrupt an already-running answer.
|
||||
}
|
||||
}
|
||||
|
||||
function parseLooseStringValue(rawValue: string): string {
|
||||
try {
|
||||
return JSON.parse(rawValue) as string;
|
||||
} catch {
|
||||
const value = rawValue.trim();
|
||||
if (value.startsWith("'") && value.endsWith("'")) {
|
||||
return value
|
||||
.slice(1, -1)
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\r/g, "\r")
|
||||
.replace(/\\t/g, "\t")
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\\\/g, "\\");
|
||||
}
|
||||
throw new Error(`无法解析知识库流片段:${rawValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk {
|
||||
try {
|
||||
return JSON.parse(jsonText) as KnowledgeBaseChunk;
|
||||
} catch {
|
||||
const objectText = jsonText.trim();
|
||||
const answerMatch = objectText.match(/^\{\s*answer\s*:\s*([\s\S]*)\s*\}$/);
|
||||
if (answerMatch) {
|
||||
return { answer: parseLooseStringValue(answerMatch[1]!.trim()) };
|
||||
}
|
||||
const docsMatch = objectText.match(/^\{\s*docs\s*:\s*([\s\S]*)\s*\}$/);
|
||||
if (docsMatch) {
|
||||
const rawValue = docsMatch[1]!.trim();
|
||||
try {
|
||||
return { docs: JSON.parse(rawValue) };
|
||||
} catch {
|
||||
return { docs: parseLooseStringValue(rawValue) };
|
||||
}
|
||||
}
|
||||
const normalizedText = objectText.replace(
|
||||
/([{,]\s*)(answer|docs)(\s*:)/g,
|
||||
'$1"$2"$3',
|
||||
);
|
||||
return JSON.parse(normalizedText) as KnowledgeBaseChunk;
|
||||
}
|
||||
}
|
||||
|
||||
function flattenDocs(value: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => flattenDocs(item));
|
||||
}
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const record = value as Record<string, unknown>;
|
||||
if ("docs" in record) return flattenDocs(record.docs);
|
||||
return [record];
|
||||
}
|
||||
|
||||
function normalizeSources(value: unknown): RagSource[] {
|
||||
return flattenDocs(value).map((item, index) => {
|
||||
const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`);
|
||||
const content = String(item.m_content ?? item.content ?? item.snippet ?? "");
|
||||
const source = item.source1 ?? item.source;
|
||||
const rawUrl = item.url ?? item.link ?? item.href;
|
||||
return {
|
||||
id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined,
|
||||
title,
|
||||
type: String(item.source1 ?? item.type ?? "知识库"),
|
||||
snippet: content,
|
||||
content,
|
||||
source: typeof source === "string" ? source : undefined,
|
||||
time: typeof item.m_publish === "string" ? item.m_publish : undefined,
|
||||
url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
|
||||
raw: item,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createId(prefix: string) {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown) {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function isSingleRoundRagMessage(message: Message): boolean {
|
||||
return message.additional_kwargs?.single_round_rag === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend-only adapter for the existing general-QA single-round endpoint.
|
||||
* It deliberately never submits a run to DeerFlow or mutates the Lead Agent.
|
||||
*/
|
||||
export function useSingleRoundRagMessages(threadId: string) {
|
||||
const [messages, setMessages] = useState<Message[]>(() => readStoredMessages(threadId));
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages(readStoredMessages(threadId));
|
||||
setError(null);
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||
|
||||
const updateMessages = useCallback(
|
||||
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
|
||||
setMessages((current) => {
|
||||
const next = updater(current);
|
||||
writeStoredMessages(targetThreadId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => controllerRef.current?.abort(), []);
|
||||
|
||||
const submit = useCallback(
|
||||
async ({
|
||||
targetThreadId,
|
||||
query,
|
||||
modelName,
|
||||
}: {
|
||||
targetThreadId: string;
|
||||
query: string;
|
||||
modelName: string;
|
||||
}) => {
|
||||
if (isLoading) return;
|
||||
const question = query.trim();
|
||||
if (!question) return;
|
||||
|
||||
const humanId = createId("single-rag-human");
|
||||
const assistantId = createId("single-rag-ai");
|
||||
const sourceId = createId("single-rag-sources");
|
||||
const clientTs = new Date().toISOString();
|
||||
const userMessage: Message = {
|
||||
id: humanId,
|
||||
type: "human",
|
||||
content: [{ type: "text", text: question }],
|
||||
additional_kwargs: { client_ts: clientTs, single_round_rag: true },
|
||||
};
|
||||
const initialAssistant: Message = {
|
||||
id: assistantId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
additional_kwargs: { single_round_rag: true },
|
||||
};
|
||||
updateMessages(targetThreadId, (current) => [...current, userMessage, initialAssistant]);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
const answerChunks: { answer: string }[] = [];
|
||||
let sources: RagSource[] = [];
|
||||
|
||||
const refreshAssistant = () => {
|
||||
const trace = parseSingleRagStream(answerChunks);
|
||||
updateMessages(targetThreadId, (current) => {
|
||||
const next = current.filter((message) => message.id !== sourceId);
|
||||
const assistantIndex = next.findIndex((message) => message.id === assistantId);
|
||||
if (assistantIndex < 0) return current;
|
||||
next[assistantIndex] = {
|
||||
...next[assistantIndex],
|
||||
content: trace.answerMarkdown,
|
||||
additional_kwargs: {
|
||||
...next[assistantIndex]!.additional_kwargs,
|
||||
single_round_rag: true,
|
||||
reasoning_content: trace.thinkingMarkdown || null,
|
||||
},
|
||||
} as Message;
|
||||
if (sources.length > 0) {
|
||||
next.splice(assistantIndex, 0, {
|
||||
id: sourceId,
|
||||
type: "tool",
|
||||
name: "knowledge_base_search",
|
||||
content: { results: sources },
|
||||
additional_kwargs: {
|
||||
hide_from_ui: true,
|
||||
single_round_rag: true,
|
||||
},
|
||||
} as unknown as Message);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const chatUrl = readRuntimeEnv("VITE_AIPI_CHAT_URL") || "";
|
||||
if (!chatUrl) {
|
||||
throw new Error("未配置 VITE_AIPI_CHAT_URL,无法发起单轮 RAG 问答。");
|
||||
}
|
||||
const response = await fetch(
|
||||
`${chatUrl.replace(/\/$/, "")}/chat/knowledge_base_chat_tiaoshi`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
query: question,
|
||||
knowledge_base_name: [],
|
||||
top_k: 5,
|
||||
score_threshold: 0.5,
|
||||
history: [],
|
||||
stream: true,
|
||||
model_name: modelName,
|
||||
temperature: 0.3,
|
||||
prompt_name: "default",
|
||||
local_doc_url: false,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`知识库服务请求失败(${response.status})`);
|
||||
if (!response.body) throw new Error("知识库服务未返回流式响应。");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const extracted = extractJsonObjects(buffer);
|
||||
buffer = extracted.rest;
|
||||
for (const jsonText of extracted.objects) {
|
||||
const chunk = parseKnowledgeBaseChunk(jsonText);
|
||||
if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer });
|
||||
if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs);
|
||||
refreshAssistant();
|
||||
}
|
||||
}
|
||||
} catch (caught) {
|
||||
if (!isAbortError(caught)) {
|
||||
const message = caught instanceof Error ? caught.message : "单轮 RAG 问答失败。";
|
||||
setError(message);
|
||||
updateMessages(targetThreadId, (current) =>
|
||||
current.map((item) =>
|
||||
item.id === assistantId
|
||||
? ({
|
||||
...item,
|
||||
content: answerChunks.length > 0 ? item.content : message,
|
||||
additional_kwargs: {
|
||||
...item.additional_kwargs,
|
||||
single_round_rag: true,
|
||||
},
|
||||
} as Message)
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (controllerRef.current === controller) controllerRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isLoading, updateMessages],
|
||||
);
|
||||
|
||||
return { messages, isLoading, error, submit, stop };
|
||||
}
|
||||
Reference in new issue
Block a user