# Conflicts:
#	frontend-web/src/App.tsx
#	frontend-web/src/components/workspace/agents/agent-detail-panel.tsx
#	frontend-web/src/components/workspace/agents/agent-gallery.tsx
#	frontend-web/src/components/workspace/appearance-controls.tsx
#	frontend-web/src/components/workspace/compact-welcome.tsx
#	frontend-web/src/components/workspace/input-box.tsx
#	frontend-web/src/components/workspace/settings/appearance-settings-page.tsx
#	frontend-web/src/components/workspace/settings/skill-settings-page.tsx
#	frontend-web/src/components/workspace/skill-display-sources.tsx
#	frontend-web/src/components/workspace/workspace-sidebar.tsx
#	frontend-web/src/contexts/ThemeContext.tsx
#	frontend-web/src/core/embed/embed-session.ts
#	frontend-web/src/core/i18n/locales/en-US.ts
#	frontend-web/src/core/i18n/locales/types.ts
#	frontend-web/src/core/i18n/locales/zh-CN.ts
#	frontend-web/src/core/system-settings/api.ts
#	frontend-web/src/env.ts
#	frontend-web/src/pages/ChatPage.tsx
#	frontend-web/src/pages/LightAppIframeView.tsx
#	frontend-web/src/pages/LightAppListPage.tsx
#	frontend-web/src/pages/LightAppNoteQAPage.tsx
#	frontend-web/src/pages/NotebookAIWritingPage.tsx
#	frontend-web/src/pages/PageRoutes.tsx
#	frontend-web/src/pages/WorkspaceRoutes.tsx
#	frontend-web/src/pages/light-app-management.css
#	frontend-web/src/pages/light-app-pages.css
#	frontend-web/src/strategy-components/lib/light-app.ts
#	frontend-web/src/styles/globals.css
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/README.md
#	offline-backend-20260512/backend/app/gateway/routers/system_settings.py
#	offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py
This commit is contained in:
fengxiangdebaozi committed 2026-08-18 10:06:34 +08:00
commit e0b6e1cd19
600 files changed
+83188 -3340

No files matched your search

+9 -6
View File
@@ -150,12 +150,15 @@ export function useAllAgentsAdmin(enabled: boolean) {
return { agents: data ?? [], isLoading, error };
}
export function useAgent(id: string | null | undefined) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: !!id,
});
export function useAgent(
id: string | null | undefined,
{ enabled = true }: { enabled?: boolean } = {},
) {
const { data, isLoading, error } = useQuery({
queryKey: ["agents", id],
queryFn: () => getAgent(id!),
enabled: enabled && !!id,
});
return { agent: data ?? null, isLoading, error };
}
+8 -4
View File
@@ -61,7 +61,9 @@ export interface Agent {
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
seat_skill_directive?: boolean | null;
seat_skill_directive?: boolean | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -96,7 +98,8 @@ export interface CreateAgentRequest {
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
export interface UpdateAgentRequest {
@@ -113,5 +116,6 @@ export interface UpdateAgentRequest {
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
}
seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
}
+26 -6
View File
@@ -19,12 +19,32 @@ export async function loadArtifactContent({
let enhancedFilepath = filepath;
if (filepath.endsWith(".skill")) {
enhancedFilepath = filepath + "/SKILL.md";
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
const response = await apiFetch(url);
const text = await response.text();
return { content: text, url };
}
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
const response = await apiFetch(url);
const text = await response.text();
if (!response.ok) {
let detail = text.trim();
try {
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
const parsedDetail =
typeof parsed.detail === "string"
? parsed.detail
: typeof parsed.message === "string"
? parsed.message
: "";
if (parsedDetail) {
detail = parsedDetail;
}
} catch {
// Keep the raw response text as the diagnostic detail.
}
throw new Error(
`Failed to load artifact (${response.status})${detail ? `: ${detail}` : ""}`,
);
}
return { content: text, url };
}
export function loadArtifactContentFromToolCall({
url: urlString,
@@ -0,0 +1,68 @@
export type StreamingArtifactLanguage = "markdown" | "html" | "mermaid";
export function normalizeStreamingArtifactPath(path: string): string {
let normalized = path.trim();
const first = normalized[0];
const last = normalized.at(-1);
if (
normalized.length >= 2 &&
((first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === "`" && last === "`"))
) {
normalized = normalized.slice(1, -1).trim();
}
return normalized;
}
export function hasCompleteArtifactExtension(path: string): boolean {
const pathname = normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
// JSON tool arguments commonly stream `report.m` before `report.md`.
// Treating the partial suffix as final makes the sandbox permanently TEXT.
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
export function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
return "未命名文件.html";
}
return "未命名文件.md";
}
export function languageOfStreamingArtifact(
path: string,
): StreamingArtifactLanguage | null {
const pathname = normalizeStreamingArtifactPath(path)
.split(/[?#]/, 1)[0]
?.toLowerCase();
if (pathname?.endsWith(".md") || pathname?.endsWith(".markdown")) {
return "markdown";
}
if (pathname?.endsWith(".html") || pathname?.endsWith(".htm")) {
return "html";
}
if (pathname?.endsWith(".mmd") || pathname?.endsWith(".mermaid")) {
return "mermaid";
}
return null;
}
export function buildWriteFileVirtualUrl({
path,
messageId,
toolCallId,
}: {
path: string;
messageId: string;
toolCallId: string;
}): string {
const language = languageOfStreamingArtifact(path);
const languageQuery = language ? `&language=${language}` : "";
return new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}${languageQuery}`,
).toString();
}
+22 -20
View File
@@ -1,12 +1,12 @@
import { getBackendBaseURL } from "../config";
import type { AgentThread } from "../threads";
export function urlOfArtifact({
filepath,
threadId,
download = false,
isMock = false,
publicShareCode,
export function urlOfArtifact({
filepath,
threadId,
download = false,
isMock = false,
publicShareCode,
}: {
filepath: string;
threadId: string;
@@ -16,22 +16,24 @@ export function urlOfArtifact({
}) {
// A public read-only share has no auth-gated thread route — its referenced
// files are served by the unauthenticated public-share endpoint instead.
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
}
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
}
if (publicShareCode) {
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
}
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
if (isMock) {
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
}
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}${download ? "?download=true" : ""}`;
}
export function extractArtifactsFromThread(thread: AgentThread) {
return thread.values.artifacts ?? [];
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${absolutePath}`;
}
export function resolveArtifactURL(absolutePath: string, threadId: string) {
const normalized = absolutePath.startsWith("/") ? absolutePath : `/${absolutePath}`;
return `${getBackendBaseURL()}/api/threads/${threadId}/artifacts${normalized}`;
}
+58
View File
@@ -45,6 +45,64 @@ export function getStoredAuth(): LoginByUsernameResponse | null {
}
}
function normalizeIdentityPart(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function readUserInfoIdentity(): string | null {
let info: Record<string, unknown> | null = null;
try {
const raw = authStorageGet("userInfo");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
info = parsed as Record<string, unknown>;
}
}
} catch {
info = null;
}
if (!info) return null;
const candidates = [
["yUserId", info.yUserId],
["userName", info.userName],
["userId", info.userId],
] as const;
for (const [prefix, value] of candidates) {
const normalized = normalizeIdentityPart(value);
if (normalized) return `${prefix}:${normalized}`;
}
return null;
}
/**
* A stable cross-tab identity key for "did the account really change?" checks.
*
* Some external login paths can re-issue a backend ``user_id`` while resolving
* to the same business account. Prefer business-facing identifiers
* (email/username/userInfo) and keep ``user_id`` only as the final fallback.
*/
export function getAccountIdentityKey(
auth: LoginByUsernameResponse | null = getStoredAuth(),
): string | null {
if (!auth) return null;
const email = normalizeIdentityPart(auth.email);
if (email) return `email:${email}`;
const username = normalizeIdentityPart(auth.username);
if (username) return `username:${username}`;
const userInfoIdentity = readUserInfoIdentity();
if (userInfoIdentity) return `userInfo:${userInfoIdentity}`;
const userId = normalizeIdentityPart(auth.user_id);
return userId ? `user_id:${userId}` : null;
}
export function setStoredAuth(auth: LoginByUsernameResponse) {
if (!isBrowser()) {
return;
@@ -0,0 +1,62 @@
import { readRuntimeEnv } from "@/env";
/**
* Short-link route map maintained by the frontend.
*
* Add a new entry here when an external system needs a short ``routeKey``.
* Targets must remain internal SPA routes; LoginPage validates them again
* before navigating. The optional display settings are converted into the
* existing route parameters on the destination URL.
*/
export interface RouteKeyMapping {
/** Internal SPA route to open after sign-in. */
path: string;
/** Add `hideChrome=1` to hide the global header and page sidebar. */
hideChrome?: boolean;
/** Add the existing `theme` route parameter and apply it during sign-in. */
theme?: string;
}
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, RouteKeyMapping>> = {
a: { path: "/page/canvas/ai-writing" },
"8bf": {
path: "/page/strategy/qa/8bf-roundtable",
hideChrome: true,
theme: "dark-blue",
},
};
/** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */
export const ROUTE_KEY_PARAM = "routeKey";
/**
* Optional frontend-configured username-login password.
*
* This is intentionally an environment/runtime value rather than a literal in
* source code. It is used only with a mapped ``routeKey`` link. When it is
* empty, LoginPage falls back to the URL's existing ``password`` parameter.
*/
export function getRouteKeyLoginPassword(): string {
return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? "";
}
export function resolveRouteKeyMapping(
routeKey: string | null | undefined,
): RouteKeyMapping | undefined {
const key = routeKey?.trim();
if (!key) return undefined;
return ROUTE_KEY_MAPPINGS[key];
}
export function resolveRouteKey(routeKey: string | null | undefined): string {
const mapping = resolveRouteKeyMapping(routeKey);
if (!mapping) return "";
const [path, ownQuery = ""] = mapping.path.split("?");
const query = new URLSearchParams(ownQuery);
if (mapping.hideChrome) query.set("hideChrome", "1");
if (mapping.theme) query.set("theme", mapping.theme);
const serializedQuery = query.toString();
return serializedQuery ? `${path}?${serializedQuery}` : path;
}
+8 -8
View File
@@ -3,15 +3,15 @@
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { getAccountIdentityKey } from "@/core/auth";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth";
const SETTLE_DELAY_MS = 1200;
const IDENTITY_TOAST_ID = "auth-identity-changed";
function readUserId(): string | null {
return getStoredAuth()?.user_id ?? null;
function readIdentityKey(): string | null {
return getAccountIdentityKey();
}
/**
@@ -19,7 +19,7 @@ function readUserId(): string | null {
* tabs and lets the user decide when to refresh on account switches.
*/
export function AuthSyncWatcher() {
const lastUserIdRef = useRef<string | null>(readUserId());
const lastIdentityRef = useRef<string | null>(readIdentityKey());
useEffect(() => {
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
@@ -29,14 +29,14 @@ export function AuthSyncWatcher() {
let settleTimer: ReturnType<typeof setTimeout> | null = null;
function evaluateSettledIdentity() {
const nextUserId = readUserId();
if (nextUserId === lastUserIdRef.current) {
const nextIdentity = readIdentityKey();
if (nextIdentity === lastIdentityRef.current) {
return;
}
lastUserIdRef.current = nextUserId;
lastIdentityRef.current = nextIdentity;
if (nextUserId === null) {
if (nextIdentity === null) {
return;
}
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import {
AUTH_STATE_CHANGED_EVENT,
getAccountDisplayName,
getAccountIdentityKey,
getStoredAuth,
} from "@/core/auth";
import { isEmbedActive } from "@/core/embed/embed-session";
@@ -36,11 +37,12 @@ export interface LiveAccount {
* 账号时给出提示 —— 否则会出现「用 A 账号创建却归到 B 账号名下」。
*/
export function useLiveAccount(): LiveAccount {
const mountRef = useRef<{ id: string | null; name: string } | null>(null);
const mountRef = useRef<{ id: string | null; key: string | null; name: string } | null>(null);
if (mountRef.current === null) {
const auth = getStoredAuth();
mountRef.current = {
id: auth?.user_id ?? null,
key: getAccountIdentityKey(auth),
name: getAccountDisplayName(auth),
};
}
@@ -50,9 +52,10 @@ export function useLiveAccount(): LiveAccount {
function refresh() {
const auth = getStoredAuth();
const id = auth?.user_id ?? null;
const key = getAccountIdentityKey(auth);
const name = getAccountDisplayName(auth);
setLive((prev) =>
prev.id === id && prev.name === name ? prev : { id, name },
prev.id === id && prev.key === key && prev.name === name ? prev : { id, key, name },
);
}
// 嵌入(iframe)态鉴权走按标签页的 sessionStorage、不跨标签,收不到 storage 事件
@@ -83,6 +86,9 @@ export function useLiveAccount(): LiveAccount {
displayName: live.name,
mountUserId: mount.id,
mountDisplayName: mount.name,
switched: live.id !== null && mount.id !== null && live.id !== mount.id,
switched:
live.key !== null && mount.key !== null
? live.key !== mount.key
: live.id !== null && mount.id !== null && live.id !== mount.id,
};
}
@@ -0,0 +1,87 @@
import fallbackLogoDark from "@/assets/img/brand-logo-dark.png";
import fallbackLogoLight from "@/assets/img/brand-logo-light.png";
import fallbackLogoLightblue from "@/assets/img/brand-logo-lightblue.png";
import { readRuntimeEnv } from "@/env";
export type BrandTheme = "light" | "lightblue" | "lightgreen" | "iceblue" | "lightyellow" | "dark";
type BrandAssetTheme = BrandTheme | `custom-theme-${string}`;
const headerFallbackAssets: Record<BrandTheme, string> = {
light: fallbackLogoLight,
lightblue: fallbackLogoLightblue,
lightgreen: fallbackLogoLight,
iceblue: fallbackLogoLightblue,
lightyellow: fallbackLogoLight,
dark: fallbackLogoDark,
};
const headerRuntimeKeys: Record<BrandTheme, string> = {
light: "VITE_BRAND_LOGO_LIGHT_URL",
lightblue: "VITE_BRAND_LOGO_LIGHTBLUE_URL",
lightgreen: "VITE_BRAND_LOGO_LIGHTGREEN_URL",
iceblue: "VITE_BRAND_LOGO_ICEBLUE_URL",
lightyellow: "VITE_BRAND_LOGO_LIGHTYELLOW_URL",
dark: "VITE_BRAND_LOGO_DARK_URL",
};
const welcomeRuntimeKeys: Record<BrandTheme, string> = {
light: "VITE_BRAND_WELCOME_LIGHT_URL",
lightblue: "VITE_BRAND_WELCOME_LIGHTBLUE_URL",
lightgreen: "VITE_BRAND_WELCOME_LIGHTGREEN_URL",
iceblue: "VITE_BRAND_WELCOME_ICEBLUE_URL",
lightyellow: "VITE_BRAND_WELCOME_LIGHTYELLOW_URL",
dark: "VITE_BRAND_WELCOME_DARK_URL",
};
function runtimeConfigBaseUrl(): string | undefined {
if (typeof document === "undefined") {
return undefined;
}
return document.querySelector<HTMLScriptElement>("script[data-runtime-config]")?.src;
}
/**
* Resolves an optional post-build brand override from runtime-config.js.
* Relative paths are anchored to runtime-config.js, which keeps the URL valid
* when the whole SPA is mounted below an Nginx location prefix.
*/
function readRuntimeBrandAsset(key: string): string | undefined {
const configuredUrl = readRuntimeEnv(key);
if (!configuredUrl) {
return undefined;
}
try {
const baseUrl =
runtimeConfigBaseUrl() ??
(typeof window !== "undefined" ? window.location.href : undefined);
return baseUrl ? new URL(configuredUrl, baseUrl).href : configuredUrl;
} catch {
return configuredUrl;
}
}
function resolveBrandTheme(theme: BrandAssetTheme): BrandTheme {
if (!theme.startsWith("custom-theme-")) return theme as BrandTheme;
return typeof document !== "undefined" && document.documentElement.getAttribute("theme-mode") === "dark"
? "dark"
: "light";
}
export function getHeaderBrandAsset(theme: BrandAssetTheme): string {
const resolved = resolveBrandTheme(theme);
return readRuntimeBrandAsset(headerRuntimeKeys[resolved]) ?? headerFallbackAssets[resolved];
}
export function getHeaderBrandFallback(theme: BrandAssetTheme): string {
return headerFallbackAssets[resolveBrandTheme(theme)];
}
/**
* Welcome content deliberately has no bundled image fallback. An empty
* runtime URL keeps the original CM text + gradient treatment.
*/
export function getWelcomeBrandAsset(theme: BrandAssetTheme): string | undefined {
return readRuntimeBrandAsset(welcomeRuntimeKeys[resolveBrandTheme(theme)]);
}
+23 -9
View File
@@ -13,19 +13,33 @@ export function buildIntranetSourceUrl(recUuid?: string | null): string {
return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`;
}
function getBaseOrigin() {
function getBaseOrigin() {
if (typeof window !== "undefined") {
return window.location.origin;
}
// Fallback for SSR
return "http://localhost:2026";
}
export function getBackendBaseURL() {
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
return new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin())
.toString()
.replace(/\/+$/, "");
return "http://localhost:2026";
}
function alignLoopbackHostname(url: URL): URL {
if (typeof window === "undefined") return url;
const loopbackHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
if (
loopbackHosts.has(url.hostname) &&
loopbackHosts.has(window.location.hostname)
) {
url.hostname = window.location.hostname;
}
return url;
}
export function getBackendBaseURL() {
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
return alignLoopbackHostname(
new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin()),
)
.toString()
.replace(/\/+$/, "");
} else {
return "";
}
@@ -0,0 +1,166 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type {
DocumentRewriteStage,
} from "@/open-canvas/hooks/useDocumentRewrite";
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
export type DocumentRewritePresentation = {
id: string;
threadId: string;
source: {
kind: "artifact" | "deep_research_report";
id: string;
path?: string;
};
fileName: string;
instruction: string;
style: string;
modelName: string | null;
stage: DocumentRewriteStage;
originalCharCount: number;
writtenCharCount: number;
/** Short model-generated plan, streamed while it understands the request. */
requirementsPlan: string;
warnings: string[];
summary: DocumentRewriteSummary | null;
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
originalContent: string;
rewrittenContent: string | null;
versionId: string | null;
/** Inverse snapshot created by a safe undo; enables a one-click reapply. */
redoVersionId: string | null;
revertedAt: string | null;
error: string | null;
createdAt: string;
updatedAt: string;
};
type CreatePresentationInput = Omit<
DocumentRewritePresentation,
| "id"
| "stage"
| "originalCharCount"
| "writtenCharCount"
| "requirementsPlan"
| "warnings"
| "summary"
| "originalContent"
| "rewrittenContent"
| "versionId"
| "redoVersionId"
| "revertedAt"
| "error"
| "createdAt"
| "updatedAt"
> & {
originalContent?: string;
rewrittenContent?: string | null;
requirementsPlan?: string;
};
type DocumentRewritePresentationContextValue = {
create: (input: CreatePresentationInput) => string;
update: (
id: string,
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
) => void;
forThread: (threadId: string) => DocumentRewritePresentation[];
};
const DocumentRewritePresentationContext =
createContext<DocumentRewritePresentationContextValue | null>(null);
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return "drw-ui-" + crypto.randomUUID();
}
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
}
export function DocumentRewritePresentationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [presentations, setPresentations] = useState<DocumentRewritePresentation[]>([]);
const create = useCallback((input: CreatePresentationInput) => {
const id = createId();
const now = new Date().toISOString();
setPresentations((previous) => [
...previous.slice(-29),
{
...input,
id,
stage: "snapshot",
originalCharCount: 0,
writtenCharCount: 0,
requirementsPlan: input.requirementsPlan ?? "",
warnings: [],
summary: null,
originalContent: input.originalContent ?? "",
rewrittenContent: input.rewrittenContent ?? null,
versionId: null,
redoVersionId: null,
revertedAt: null,
error: null,
createdAt: now,
updatedAt: now,
},
]);
return id;
}, []);
const update = useCallback(
(
id: string,
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
) => {
setPresentations((previous) => {
const index = previous.findIndex((item) => item.id === id);
if (index < 0) return previous;
const current = previous[index];
const changed = Object.entries(patch).some(
([key, value]) => current[key as keyof DocumentRewritePresentation] !== value,
);
// Do not create a new context value for an identical update. Stream
// subscribers can legitimately receive an already-hydrated snapshot;
// treating it as a state change makes effect-based synchronisation
// vulnerable to update loops.
if (!changed) return previous;
const next = [...previous];
next[index] = { ...current, ...patch, updatedAt: new Date().toISOString() };
return next;
});
},
[],
);
const value = useMemo<DocumentRewritePresentationContextValue>(
() => ({
create,
update,
forThread: (threadId) =>
presentations.filter((item) => item.threadId === threadId),
}),
[create, presentations, update],
);
return (
<DocumentRewritePresentationContext.Provider value={value}>
{children}
</DocumentRewritePresentationContext.Provider>
);
}
export function useDocumentRewritePresentation() {
return useContext(DocumentRewritePresentationContext);
}
/** Browser event consumed by the open sandbox after an in-feed undo succeeds. */
export const DOCUMENT_REWRITE_UNDONE_EVENT = "deerflow:document-rewrite-undone";
+49 -22
View File
@@ -1,3 +1,7 @@
import { resolveEmbedThemeMode } from "./theme-mode.mjs";
export { resolveEmbedThemeMode } from "./theme-mode.mjs";
/**
* 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载三个相互独立的能力,多由 URL
* 路由参数开启(独立会话 `isolate` 另可由「外观」设置里的开关在运行时切换,见 `setIsolatedSession`),
@@ -27,11 +31,9 @@
* 正常页标签页里既无这些参数也无这份会话 → 所有 `isXxx()` 恒为 false,完全不受影响。
*/
import type { ThemeType } from "@/contexts/ThemeContext";
const EMBED_SESSION_KEY = "embed.session";
export type EmbedTheme = ThemeType;
export type EmbedTheme = "light" | "dark";
/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */
export interface EmbedSession {
@@ -41,6 +43,8 @@ export interface EmbedSession {
theme: EmbedTheme;
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
hideJump: boolean;
/** Only hide the global Header while retaining PageSidebar and normal app behavior. */
hideHeader: boolean;
/**
* 仅去全局 chrome(Header 导航栏 + PageSidebar 侧边栏),**不**强制主题、**不**隔离鉴权。
* 区别于 `embed`:嵌入态会额外强制主题 + 走 sessionStorage 隔离登录身份;`hideChrome` 只想要
@@ -77,6 +81,11 @@ export function readHideJumpParam(search: string): FlagParam {
return readFlagParam(search, "hideJump");
}
/** Read the URL's `hideHeader` three-state flag (hide only the global Header). */
export function readHideHeaderParam(search: string): FlagParam {
return readFlagParam(search, "hideHeader");
}
/** 读 URL 里的 `hideChrome` 三态(仅去全局导航栏 + 侧边栏)。 */
export function readHideChromeParam(search: string): FlagParam {
return readFlagParam(search, "hideChrome");
@@ -87,18 +96,6 @@ export function readIsolateParam(search: string): FlagParam {
return readFlagParam(search, "isolate");
}
/**
* 把外部传入的主题色映射成离散明/暗。与 `resolveThemeFromQuery` 同源约定(dark-blue → 深色),
* 另外宽松接受 `dark` / 中文「深色/深蓝」直传;其余(含空)一律浅色。
*/
export function resolveEmbedThemeMode(raw: string | null | undefined): EmbedTheme {
const v = (raw ?? "").trim().toLowerCase();
if (v === "dark" || v === "dark-blue" || v === "深色" || v === "深蓝") return "dark";
if (v === "blue" || v === "lightblue" || v === "light-blue" || v === "浅蓝") return "lightblue";
if (v === "green" || v === "lightgreen" || v === "light-green" || v === "绿色") return "lightgreen";
return "light";
}
/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */
export function readEmbedThemeParam(search: string): string {
return (toSearchParams(search).get("theme") ?? "").trim();
@@ -112,11 +109,9 @@ export function readEmbedSession(): EmbedSession | null {
const p = JSON.parse(raw) as Partial<EmbedSession>;
return {
embed: p.embed === true,
theme:
p.theme === "dark" || p.theme === "lightblue" || p.theme === "lightgreen"
? p.theme
: "light",
theme: p.theme === "dark" ? "dark" : "light",
hideJump: p.hideJump === true,
hideHeader: p.hideHeader === true,
hideChrome: p.hideChrome === true,
isolate: p.isolate === true,
};
@@ -128,7 +123,7 @@ export function readEmbedSession(): EmbedSession | null {
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
function writeEmbedSession(next: EmbedSession): void {
try {
if (!next.embed && !next.hideJump && !next.hideChrome && !next.isolate) {
if (!next.embed && !next.hideJump && !next.hideHeader && !next.hideChrome && !next.isolate) {
sessionStorage.removeItem(EMBED_SESSION_KEY);
return;
}
@@ -148,6 +143,16 @@ export function isJumpBarHidden(): boolean {
return readEmbedSession()?.hideJump === true;
}
/**
* Whether this tab should hide its global Header.
* `embed` and `hideChrome` also hide the Header, while `hideHeader` alone
* deliberately leaves PageSidebar visible.
*/
export function isHeaderHidden(): boolean {
const session = readEmbedSession();
return session?.embed === true || session?.hideChrome === true || session?.hideHeader === true;
}
/**
* 本标签页是否要去全局 chrome(Header 导航栏 + PageSidebar 侧边栏)。
* `embed` 已隐含去 chrome,故这里取「embed || hideChrome」之并——任一置位都隐藏。
@@ -158,6 +163,10 @@ export function isChromeHidden(): boolean {
return s?.embed === true || s?.hideChrome === true;
}
export function isHideChromeActive(): boolean {
return readEmbedSession()?.hideChrome === true;
}
/**
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
@@ -175,7 +184,14 @@ export function isIsolatedSession(): boolean {
export function syncEmbedSessionFromSearch(search: string): void {
const cur =
readEmbedSession() ??
{ embed: false, theme: "light" as EmbedTheme, hideJump: false, hideChrome: false, isolate: false };
{
embed: false,
theme: "light" as EmbedTheme,
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
};
const next: EmbedSession = { ...cur };
const embedParam = readEmbedParam(search);
@@ -190,6 +206,10 @@ export function syncEmbedSessionFromSearch(search: string): void {
if (hideJumpParam === "on") next.hideJump = true;
else if (hideJumpParam === "off") next.hideJump = false;
const hideHeaderParam = readHideHeaderParam(search);
if (hideHeaderParam === "on") next.hideHeader = true;
else if (hideHeaderParam === "off") next.hideHeader = false;
const hideChromeParam = readHideChromeParam(search);
if (hideChromeParam === "on") next.hideChrome = true;
else if (hideChromeParam === "off") next.hideChrome = false;
@@ -241,6 +261,13 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
export function setIsolatedSession(on: boolean): void {
const cur =
readEmbedSession() ??
{ embed: false, theme: "light" as EmbedTheme, hideJump: false, hideChrome: false, isolate: false };
{
embed: false,
theme: "light" as EmbedTheme,
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
};
writeEmbedSession({ ...cur, isolate: on });
}
@@ -0,0 +1,24 @@
/**
* Whether the application is currently hosted by another page.
*
* This intentionally covers both a native `<iframe>` and Wujie's child-app
* runtime. Accessing `window.top` is safe across origins for identity
* comparison, but the guard keeps the login/route bootstrap resilient to
* restrictive browser hosts.
*/
export function isEmbeddedHost(): boolean {
if (typeof window === "undefined") return false;
const hostWindow = window as Window & {
__POWERED_BY_WUJIE__?: boolean;
};
if (hostWindow.__POWERED_BY_WUJIE__) return true;
try {
return window.self !== window.top;
} catch {
// Some sandboxed or cross-origin hosts deny top-window access. They are
// still embedded contexts and should use the same safe entry route.
return true;
}
}
@@ -0,0 +1,19 @@
/**
* @typedef {"light" | "dark"} EmbedThemeMode
*/
/**
* Map external theme names to the two themes supported by iframe surfaces.
*
* @param {string | null | undefined} rawTheme
* @returns {EmbedThemeMode}
*/
export function resolveEmbedThemeMode(rawTheme) {
const theme = (rawTheme ?? "").trim().toLowerCase();
return theme === "dark" ||
theme === "dark-blue" ||
theme === "深色" ||
theme === "深蓝"
? "dark"
: "light";
}
+15 -8
View File
@@ -448,13 +448,17 @@ export const enUS: Translations = {
system: "System",
light: "Light",
dark: "Dark",
lightblue: "Light Blue",
lightgreen: "Green",
lightblue: "Light Blue",
lightgreen: "Green",
iceblue: "Glacier Blue",
lightyellow: "Pale Yellow",
systemDescription: "Match the operating system preference automatically.",
lightDescription: "Bright palette with higher contrast for daytime.",
darkDescription: "Dim palette that reduces glare for focus.",
lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around WeKnora green.",
lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around WeKnora green.",
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
languageTitle: "Language",
languageDescription: "Switch between languages.",
},
@@ -541,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",
+15 -8
View File
@@ -383,13 +383,17 @@ export interface Translations {
system: string;
light: string;
dark: string;
lightblue: string;
lightgreen: string;
lightblue: string;
lightgreen: string;
iceblue: string;
lightyellow: string;
systemDescription: string;
lightDescription: string;
darkDescription: string;
lightblueDescription: string;
lightgreenDescription: string;
lightblueDescription: string;
lightgreenDescription: string;
iceblueDescription: string;
lightyellowDescription: string;
languageTitle: string;
languageDescription: string;
};
@@ -471,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;
+13 -6
View File
@@ -429,13 +429,17 @@ export const zhCN: Translations = {
system: "系统",
light: "浅色",
dark: "深色",
lightblue: "浅蓝",
lightgreen: "绿色",
lightblue: "浅蓝",
lightgreen: "绿色",
iceblue: "冰川蓝",
lightyellow: "浅黄色",
systemDescription: "自动跟随系统主题。",
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言",
languageDescription: "在不同语言之间切换。",
},
@@ -520,8 +524,11 @@ export const zhCN: Translations = {
filesDownloadAction: "下载",
filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存",
filesReadOnlyHint: "内置技能为只读,仅可查看。",
filesSaveSuccess: "文件已保存",
filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
viewDetailAction: "查看技能详情",
stateSheet: {
archivedTitle: "归档的技能",
@@ -0,0 +1,66 @@
import type { Message } from "@langchain/langgraph-sdk";
import { textOfMessage } from "@/core/threads/utils";
import { createLlmWikiConversationDeposit } from "@/strategy-components/api/llmwiki";
function isHidden(message: Message): boolean {
const extra = (message as { additional_kwargs?: Record<string, unknown> })
.additional_kwargs;
return extra?.hide_from_ui === true;
}
function messageText(message: Message | undefined): string {
return (message ? textOfMessage(message) : "")?.trim() ?? "";
}
function hasText(message: Message | undefined): message is Message {
return !!message && messageText(message).length > 0;
}
export function queueLlmWikiConversationDeposit({
threadId,
messages,
assistantId,
knowledgeBaseIds,
}: {
threadId: string | null | undefined;
messages: Message[] | undefined;
assistantId?: string | null;
knowledgeBaseIds?: string[];
}) {
if (!threadId || !messages?.length) {
return;
}
const visible = messages.filter((message) => !isHidden(message));
const assistantIndex = [...visible]
.map((message, index) => ({ message, index }))
.reverse()
.find(({ message }) => message.type === "ai" && hasText(message))?.index;
if (assistantIndex === undefined) {
return;
}
const assistantMessage = visible[assistantIndex];
const humanMessage = [...visible.slice(0, assistantIndex)]
.reverse()
.find((message) => message.type === "human" && hasText(message));
if (!assistantMessage?.id || !humanMessage) {
return;
}
const question = messageText(humanMessage);
const answer = messageText(assistantMessage);
if (!question || !answer) {
return;
}
void createLlmWikiConversationDeposit({
thread_id: threadId,
question,
answer,
human_message_id: humanMessage.id ?? null,
assistant_message_id: assistantMessage.id,
assistant_id: assistantId ?? null,
knowledge_base_ids: knowledgeBaseIds ?? [],
}).catch((error) => {
console.warn("Failed to queue LLMWiki conversation deposit", error);
});
}
@@ -0,0 +1,125 @@
import { readRuntimeEnv } from "@/env";
export type WeKnoraSourceTarget = {
knowledgeBaseId: string;
chunkId?: string;
documentId?: string;
wikiSlug?: string;
tab?: "documents" | "wiki" | "graph";
};
export type KnowledgeSourceLike = {
url?: string | null;
recUuid?: string | null;
rec_uuid?: string | null;
knowledge_base_id?: string | null;
knowledgeBaseId?: string | null;
mapping_id?: string | null;
mappingId?: string | null;
chunk_id?: string | null;
chunkId?: string | null;
knowledge_id?: string | null;
knowledgeId?: string | null;
document_id?: string | null;
documentId?: string | null;
wiki_slug?: string | null;
wikiSlug?: string | null;
slug?: string | null;
};
export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget | null {
if (!url?.startsWith("/weknora-source-preview?")) {
return null;
}
const queryIndex = url.indexOf("?");
const params = new URLSearchParams(url.slice(queryIndex + 1));
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
const chunkId = params.get("chunk_id")?.trim();
const documentId =
params.get("document_id")?.trim() ||
params.get("knowledge_id")?.trim() ||
"";
const wikiSlug =
params.get("wiki_slug")?.trim() ||
params.get("wikiSlug")?.trim() ||
params.get("slug")?.trim() ||
"";
return knowledgeBaseId && chunkId
? {
knowledgeBaseId,
chunkId,
documentId: documentId || undefined,
wikiSlug: wikiSlug || undefined,
tab: wikiSlug ? "wiki" : "documents",
}
: null;
}
export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnoraSourceTarget | null {
const fromPreview = parseWeKnoraPreviewUrl(source.url);
const knowledgeBaseId =
source.knowledge_base_id ||
source.knowledgeBaseId ||
source.mapping_id ||
source.mappingId ||
fromPreview?.knowledgeBaseId ||
"";
if (!knowledgeBaseId) return null;
const wikiSlug = source.wiki_slug || source.wikiSlug || source.slug || fromPreview?.wikiSlug || "";
const documentId =
source.document_id ||
source.documentId ||
source.knowledge_id ||
source.knowledgeId ||
fromPreview?.documentId ||
"";
const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || "";
return {
knowledgeBaseId,
chunkId: chunkId || undefined,
documentId: documentId || undefined,
wikiSlug: wikiSlug || undefined,
tab: wikiSlug ? "wiki" : "documents",
};
}
export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string {
const params = new URLSearchParams();
if (target.tab) params.set("tab", target.tab);
if (target.chunkId) params.set("sourceChunkId", target.chunkId);
if (target.documentId) params.set("documentId", target.documentId);
if (target.wikiSlug) params.set("wikiSlug", target.wikiSlug);
const query = params.toString();
return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`;
}
export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string {
const recUuid = source.recUuid || source.rec_uuid || source.url || "";
if (!recUuid) return "";
if (/^https?:\/\//i.test(recUuid)) return recUuid;
const base = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL");
if (base) {
return `${base.replace(/\/+$/, "")}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recUuid)}`;
}
return "";
}
export function openKnowledgeSource(source: KnowledgeSourceLike): boolean {
const weknoraTarget = resolveWeKnoraSourceTarget(source);
const href = weknoraTarget
? buildWeKnoraSourceIframeUrl(weknoraTarget)
: buildLegacyKnowledgeSourceUrl(source);
if (!href) return false;
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
export function normalizeMalformedWeKnoraHashRoute(): boolean {
if (typeof window === "undefined") return false;
const { pathname, search, hash } = window.location;
if (!pathname.startsWith("/page/workspace/knowledge/")) return false;
if (hash && !hash.startsWith("#/")) return false;
window.location.replace(`/#${pathname}${search}`);
return true;
}
+25 -1
View File
@@ -22,6 +22,8 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -32,6 +34,7 @@ type MessageGroup =
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
| AssistantSetupWritingApprovalGroup
| AssistantResearchApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>(
@@ -54,7 +57,8 @@ export function groupMessages<T>(
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval" &&
last.type !== "assistant:setup-writing-approval"
last.type !== "assistant:setup-writing-approval" &&
last.type !== "assistant:research-approval"
) {
return last;
}
@@ -114,6 +118,18 @@ export function groupMessages<T>(
}
if (message.type === "ai") {
// Deep-research interaction card: virtual ai messages injected by the
// workbench (additional_kwargs.setup_research_approval) render through
// the host-provided slot. Writing phases use ordinary virtual tool calls
// and completed summaries remain normal assistant messages.
if (hasResearchApproval(message)) {
groups.push({
id: message.id,
type: "assistant:research-approval",
messages: [message],
});
continue;
}
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
@@ -386,6 +402,14 @@ export function isSetupWritingApprovalToolMessage(message: Message) {
);
}
export function hasResearchApproval(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.setup_research_approval === "object" &&
message.additional_kwargs.setup_research_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
@@ -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,36 @@
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"
);
}
export function isMultiRoundRagProfile(
context: unknown,
): boolean {
return (
typeof context === "object" &&
context !== null &&
(context as { execution_profile?: unknown }).execution_profile ===
"multi_round_rag"
);
}
export function isFrontendRagProfile(context: unknown): boolean {
return isSingleRoundRagProfile(context) || isMultiRoundRagProfile(context);
}
+5 -3
View File
@@ -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" | "multi_round_rag";
}
export interface TokenUsageSettings {
enabled: boolean;
@@ -2,8 +2,19 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
buildStandardChatRedirectPath,
buildFullChatNavigationPath,
buildEmbeddedIframeRedirectPath,
buildChatsIframeRedirectPath,
buildChatsIframeThreadPath,
hasIframePresentationOverride,
isExplicitBrainyIframeMode,
isChatsIframeWorkspacePath,
resolveChatsIframeEntryRedirect,
resolvePostLoginRedirectPath,
resolveForcedThemeForPath,
resolveStandardChatFallback,
shouldUseChatsIframeProtocol,
} from "./chats-iframe-route.ts";
test("matches only workspace chats_iframe routes", () => {
@@ -31,3 +42,277 @@ test("forces light theme for landing and chats_iframe routes only", () => {
);
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
});
test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
assert.equal(isExplicitBrainyIframeMode(""), false);
assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false);
assert.equal(isExplicitBrainyIframeMode("?chatMode=full"), false);
assert.equal(isExplicitBrainyIframeMode("?chatMode=iframe"), true);
assert.equal(isExplicitBrainyIframeMode("chatMode=IFRAME"), true);
});
test("keeps bare from=iframe on the original dedicated iframe protocol", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe",
),
null,
);
assert.equal(shouldUseChatsIframeProtocol("?from=iframe"), true);
assert.equal(hasIframePresentationOverride("?from=iframe"), false);
});
test("uses the dedicated AgentSelector route for a bare embedded host", () => {
assert.equal(shouldUseChatsIframeProtocol("", true), true);
assert.equal(
buildEmbeddedIframeRedirectPath("?ticket=secret&theme=dark-blue"),
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"",
true,
),
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/thread-123",
"",
true,
),
"/page/workspace/chats_iframe/thread-123?from=iframe",
);
assert.equal(shouldUseChatsIframeProtocol("?chatMode=full", true), false);
assert.equal(shouldUseChatsIframeProtocol("?hideChrome=1", true), false);
assert.equal(
buildStandardChatRedirectPath("?hideChrome=1"),
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
});
test("routes legacy iframe links with standard presentation flags to full chat", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&hideHeader=1",
),
"/page/workspace/chats/new?hideHeader=1&chatMode=full",
);
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&hideChrome=1",
),
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&embed=1&theme=dark-blue",
),
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
assert.equal(shouldUseChatsIframeProtocol("?from=iframe&hideChrome=1"), false);
assert.equal(hasIframePresentationOverride("?from=iframe&embed=1"), true);
});
test("redirects chats_iframe routes without the iframe source to standard full chat", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/thread-123",
"?hideChrome=1",
),
"/page/workspace/chats/thread-123?hideChrome=1&chatMode=full",
);
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?chatMode=iframe",
),
null,
);
assert.equal(
resolveStandardChatFallback("/page/workspace/chats/new", ""),
null,
);
});
test("honors the dark-blue theme query on chats_iframe routes", () => {
assert.equal(
resolveForcedThemeForPath(
"/page/workspace/chats_iframe/new",
"?from=iframe&theme=dark-blue",
),
"dark",
);
assert.equal(
resolveForcedThemeForPath(
"/page/workspace/chats_iframe/new",
"?from=iframe&theme=red",
),
"light",
);
});
test("forces the iframe theme on the login route without changing normal login", () => {
assert.equal(
resolveForcedThemeForPath(
"/login",
"?from=iframe&theme=dark-blue",
),
"dark",
);
assert.equal(
resolveForcedThemeForPath(
"/login/guest",
"?from=iframe&theme=dark-blue",
),
"dark",
);
assert.equal(
resolveForcedThemeForPath("/login", "?theme=dark-blue"),
undefined,
);
assert.equal(
resolveForcedThemeForPath(
"/page/workspace/chats/new",
"?from=iframe&theme=dark-blue",
),
undefined,
);
assert.equal(
resolveForcedThemeForPath(
"/login",
"?from=iframe&hideChrome=1&theme=dark-blue",
),
undefined,
);
});
test("does not add chatMode=iframe to the legacy post-login redirect", () => {
assert.equal(
buildChatsIframeRedirectPath("?from=iframe&theme=dark-blue"),
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
buildChatsIframeRedirectPath("?from=iframe"),
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
buildChatsIframeRedirectPath(
"?from=iframe&theme=dark-blue&ticket=secret&authToken=secret",
),
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
});
test("uses full chat after login when a legacy iframe link sets presentation", () => {
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&theme=dark-blue&yUserId=user-1",
"/page/workspace/chats/new",
),
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&hideChrome=1&theme=dark-blue",
"/page/workspace/chats/new",
),
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&embed=1&theme=dark-blue",
"/page/workspace/chats/new",
),
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
assert.equal(
resolvePostLoginRedirectPath("?yUserId=user-1", "/page/workspace/chats/new"),
"/page/workspace/chats/new",
);
});
test("preserves iframe theme when a new chat becomes a thread", () => {
assert.equal(
buildChatsIframeThreadPath(
"/page/workspace",
"thread-123",
"?from=iframe&theme=dark-blue&ticket=secret",
),
"/page/workspace/chats_iframe/thread-123?from=iframe&theme=dark-blue",
);
});
test("normalizes direct regular-chat legacy links according to the same split", () => {
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"?from=iframe",
),
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/thread-123",
"?from=iframe&hideChrome=1",
),
"/page/workspace/chats/thread-123?hideChrome=1&chatMode=full",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"?from=iframe&embed=1&theme=dark-blue",
),
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
});
test("builds a standard full-chat URL without leaking the legacy marker", () => {
assert.equal(
buildStandardChatRedirectPath(
"?from=iframe&hideHeader=1&ticket=secret",
),
"/page/workspace/chats/new?hideHeader=1&chatMode=full",
);
});
test("keeps full-chat presentation flags when the sidebar opens an agent or thread", () => {
assert.equal(
buildFullChatNavigationPath(
"/page/workspace/chats/new?agent=agent-1",
"?hideChrome=1&chatMode=full",
),
"/page/workspace/chats/new?agent=agent-1&hideChrome=1&chatMode=full",
);
assert.equal(
buildFullChatNavigationPath(
"/page/workspace/chats/thread-123",
"?embed=1&theme=dark-blue&chatMode=full",
),
"/page/workspace/chats/thread-123?embed=1&theme=dark-blue&chatMode=full",
);
assert.equal(
buildFullChatNavigationPath("/page/workspace/chats/new", ""),
"/page/workspace/chats/new",
);
});
test("preserves explicit Brainy iframe mode and theme across redirects", () => {
assert.equal(
buildChatsIframeRedirectPath("?chatMode=iframe&theme=dark-blue&ticket=secret"),
"/page/workspace/chats_iframe/new?chatMode=iframe&theme=dark-blue",
);
assert.equal(
buildChatsIframeThreadPath(
"/page/workspace",
"thread-123",
"?chatMode=iframe&theme=dark-blue",
),
"/page/workspace/chats_iframe/thread-123?chatMode=iframe&theme=dark-blue",
);
});
@@ -1,16 +1,341 @@
import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
const IFRAME_LOGIN_SOURCE = "iframe";
const STANDARD_CHATS_PATH = "/page/workspace/chats";
function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
}
/** Keep the accepted URL booleans aligned with embed-session.ts. */
function isEnabledPresentationParam(search: string, key: string): boolean {
const value = toSearchParams(search).get(key)?.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
export function isChatsIframeLogin(search: string): boolean {
return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE;
}
function isExplicitFullChatMode(search: string): boolean {
return (
toSearchParams(search).get("chatMode")?.trim().toLowerCase() === "full"
);
}
/**
* A bare login URL embedded by an external host has no query parameter to
* survive the login redirect. Add the legacy source marker before building
* the private chat URL so refreshes and subsequent thread navigation retain
* the selected dedicated iframe workspace.
*/
export function buildEmbeddedIframeRedirectPath(search: string): string {
const source = toSearchParams(search);
if (!source.has("from")) {
source.set("from", IFRAME_LOGIN_SOURCE);
}
return buildChatsIframeRedirectPath(source.toString());
}
/**
* `from=iframe` predates the regular embedded-chat presentation flags. Keep
* the two concerns separate: this only answers whether the caller explicitly
* asked PageLayout to alter global chrome, not whether it is an iframe page.
*/
export function hasIframePresentationOverride(search: string): boolean {
return (
isEnabledPresentationParam(search, "hideHeader") ||
isEnabledPresentationParam(search, "hideChrome") ||
isEnabledPresentationParam(search, "embed")
);
}
/**
* The private `chats_iframe` transport is the legacy default for
* `from=iframe`. An explicit `chatMode=iframe` always wins, while a caller
* that also supplies one of the standard presentation flags deliberately uses
* the ordinary full chat workspace instead.
*/
export function shouldUseChatsIframeProtocol(
search: string,
embeddedHost = false,
): boolean {
return (
!hasIframePresentationOverride(search) &&
(isExplicitBrainyIframeMode(search) ||
isChatsIframeLogin(search) ||
// The historical bare iframe integration enters `/login/admin` without
// any route parameters. Treat that embedded context as the legacy
// dedicated AgentSelector page, unless the caller explicitly selected
// the standard full-chat mode.
(embeddedHost && !isExplicitFullChatMode(search)))
);
}
/**
* Build the regular full-chat destination for a legacy iframe entry that has
* opted into a standard presentation mode. Keeping the display flags in the
* address bar makes direct links and refreshes deterministic; `from` is
* intentionally omitted so the legacy protocol cannot take over again.
*/
export function buildStandardChatRedirectPath(
search: string,
fallbackPath = `${STANDARD_CHATS_PATH}/new`,
): string {
const source = toSearchParams(search);
const [pathname, existingQuery = ""] = fallbackPath.split("?");
const target = new URLSearchParams(existingQuery);
for (const key of ["hideHeader", "hideChrome", "embed"] as const) {
const value = source.get(key)?.trim();
if (value && !target.has(key)) {
target.set(key, value);
}
}
// `theme` is meaningful only for embed mode, but preserving it here keeps a
// refreshed embedded page on the same forced theme before session storage is
// restored.
const theme = source.get("theme")?.trim();
if (theme && target.get("embed")) {
target.set("theme", theme);
}
target.set("chatMode", "full");
const query = target.toString();
return query ? `${pathname}?${query}` : pathname;
}
/**
* Keep an explicitly selected standard-chat presentation while navigating
* between `/chats/new` and a concrete chat thread. Without this, a click in
* the full-chat sidebar drops `hideChrome=1&chatMode=full`; an iframe host
* then looks like the legacy bare integration and is incorrectly redirected
* to the private AgentSelector workspace.
*
* This deliberately whitelists presentation flags through
* `buildStandardChatRedirectPath` instead of copying arbitrary login/query
* values into user-facing chat URLs.
*/
export function buildFullChatNavigationPath(
destinationPath: string,
search: string,
): string {
return hasIframePresentationOverride(search) || isExplicitFullChatMode(search)
? buildStandardChatRedirectPath(search, destinationPath)
: destinationPath;
}
/**
* Preserve only the presentation parameters consumed by the iframe workspace.
* Login credentials must never be copied into the post-login address bar.
*/
export function buildChatsIframeRedirectPath(search: string): string {
const source = toSearchParams(search);
const target = new URLSearchParams();
if (isChatsIframeLogin(search)) {
target.set("from", IFRAME_LOGIN_SOURCE);
}
const requestedMode = source.get("chatMode")?.trim();
if (
requestedMode?.toLowerCase() === "iframe" ||
(!isChatsIframeLogin(search) && requestedMode)
) {
target.set("chatMode", requestedMode);
}
const theme = source.get("theme")?.trim();
if (theme) {
target.set("theme", theme);
}
const query = target.toString();
return query ? `${CHATS_IFRAME_PATH}?${query}` : CHATS_IFRAME_PATH;
}
export function buildChatsIframeThreadPath(
routeBase: string,
threadId: string,
search: string,
): string {
const base = routeBase.endsWith("/") ? routeBase.slice(0, -1) : routeBase;
const query = buildChatsIframeRedirectPath(search).split("?")[1];
const path = `${base}/chats_iframe/${encodeURIComponent(threadId)}`;
return query ? `${path}?${query}` : path;
}
export function resolvePostLoginRedirectPath(
search: string,
fallbackPath: string,
): string {
if (shouldUseChatsIframeProtocol(search)) {
return buildChatsIframeRedirectPath(search);
}
return isChatsIframeLogin(search) && hasIframePresentationOverride(search)
? buildStandardChatRedirectPath(search, fallbackPath)
: fallbackPath;
}
export function isChatsIframeWorkspacePath(pathname: string): boolean {
return (
pathname === "/page/workspace/chats_iframe/new" ||
pathname === CHATS_IFRAME_PATH ||
pathname.startsWith("/page/workspace/chats_iframe/")
);
}
/**
* A `from=iframe` link can arrive directly at a regular chat URL instead of
* passing through LoginPage. Normalize both entry paths to the same dedicated
* protocol workspace, including an existing thread id when one is supplied.
*/
export function resolveChatsIframeEntryRedirect(
pathname: string,
search: string,
embeddedHost = false,
): string | null {
const isStandardChatPath =
pathname === STANDARD_CHATS_PATH ||
pathname === `${STANDARD_CHATS_PATH}/` ||
pathname.startsWith(`${STANDARD_CHATS_PATH}/`);
// Standard presentation flags deliberately bypass the private iframe
// protocol. Normalize direct legacy URLs once so the result stays on the
// normal full-chat workspace and retains its agent/history sidebar.
if (
isChatsIframeLogin(search) &&
hasIframePresentationOverride(search) &&
!isExplicitBrainyIframeMode(search)
) {
return isStandardChatPath
? buildStandardChatRedirectPath(search, pathname)
: null;
}
const inferredEmbeddedProtocol =
embeddedHost &&
!hasIframePresentationOverride(search) &&
!isExplicitFullChatMode(search) &&
!isChatsIframeLogin(search) &&
!isExplicitBrainyIframeMode(search);
const protocolSearch = inferredEmbeddedProtocol
? `?${new URLSearchParams({
...Object.fromEntries(toSearchParams(search)),
from: IFRAME_LOGIN_SOURCE,
}).toString()}`
: search;
const requestsIframeProtocol = shouldUseChatsIframeProtocol(
protocolSearch,
embeddedHost,
);
if (!requestsIframeProtocol) {
return null;
}
if (pathname === STANDARD_CHATS_PATH || pathname === `${STANDARD_CHATS_PATH}/`) {
return buildChatsIframeRedirectPath(protocolSearch);
}
if (!pathname.startsWith(`${STANDARD_CHATS_PATH}/`)) {
return null;
}
const routeSuffix = pathname.slice(`${STANDARD_CHATS_PATH}/`.length);
if (!routeSuffix || routeSuffix.includes("/")) {
return null;
}
if (routeSuffix === "new") {
return buildChatsIframeRedirectPath(protocolSearch);
}
try {
return buildChatsIframeThreadPath(
"/page/workspace",
decodeURIComponent(routeSuffix),
protocolSearch,
);
} catch {
return null;
}
}
/**
* The lightweight iframe page is a private transport for the Brainy
* postMessage protocol, not a general-purpose embedded chat page.
*
* `from=iframe` is the legacy equivalent of the explicit mode. Ordinary
* iframe/Wujie embedding without either marker remains standard chat.
*/
export function isExplicitBrainyIframeMode(search: string): boolean {
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
return (
new URLSearchParams(normalizedSearch)
.get("chatMode")
?.trim()
.toLowerCase() === "iframe"
);
}
/**
* Convert an accidental/legacy `chats_iframe` URL to the equivalent standard
* chat URL. Existing thread ids and unrelated query parameters are retained.
*/
export function resolveStandardChatFallback(
pathname: string,
search: string,
): string | null {
if (!isChatsIframeWorkspacePath(pathname)) {
return null;
}
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
const params = new URLSearchParams(normalizedSearch);
const routePrefix = "/page/workspace/chats_iframe/";
const routeSuffix = pathname.slice(routePrefix.length);
const targetPath =
routeSuffix && routeSuffix !== "new"
? `/page/workspace/chats/${routeSuffix}`
: "/page/workspace/chats/new";
if (
isChatsIframeLogin(search) &&
hasIframePresentationOverride(search) &&
!isExplicitBrainyIframeMode(search)
) {
return buildStandardChatRedirectPath(search, targetPath);
}
if (shouldUseChatsIframeProtocol(search)) {
// The legacy marker itself is enough. Do not add chatMode=iframe here: it
// is an implementation detail and adding it caused a self-redirect loop.
return null;
}
params.delete("from");
params.set("chatMode", "full");
const targetSearch = params.toString();
return targetSearch ? `${targetPath}?${targetSearch}` : targetPath;
}
export function resolveForcedThemeForPath(
pathname: string,
): "light" | undefined {
if (pathname === "/landing" || isChatsIframeWorkspacePath(pathname)) {
search = "",
): "light" | "dark" | undefined {
if (pathname === "/landing") {
return "light";
}
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
if (
isChatsIframeWorkspacePath(pathname) ||
(isLoginPath && shouldUseChatsIframeProtocol(search))
) {
const rawTheme = toSearchParams(search).get("theme")?.trim();
return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light";
}
return undefined;
}
@@ -35,6 +35,7 @@ import {
Settings,
Sparkles,
Target,
Telescope,
Trophy,
Users,
} from "lucide-react";
@@ -65,6 +66,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
// {
// id: "position-roundtable",
// label: "岗位会商",
// icon: Briefcase,
// path: "/page/strategy/qa/position-roundtable",
// },
{
id: "dashboard-agent",
label: "大屏绘制",
@@ -86,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",
},
],
},
{
@@ -264,6 +277,13 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/positions",
adminOnly: true,
},
{
id: "mgmt-position-roles",
label: "岗位角色配置",
icon: Briefcase,
path: "/page/strategy/admin/position-roles",
adminOnly: true,
},
{
id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理",
@@ -297,6 +317,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: BookOpen,
path: "/page/workspace/knowledge",
},
{
id: "resource-markdown-docx-editor",
label: "Markdown 转 Word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
{
id: "resource-knowledge-base",
label: "知识库资源管理",
@@ -187,4 +187,4 @@ test("unmapped incoming url resets auto-selected agent back to default when ther
forceDefaultAssistant: true,
},
);
});
});
@@ -0,0 +1,19 @@
import { useMemo } from "react";
import { desensitize, makeDesensitizer } from "@/lib/desensitize";
import { useSensitiveWords, wordsToMap } from "@/strategy-components/api/sensitive-words";
/**
* 返回用于界面静态文本的脱敏函数。
*
* 与导航菜单使用同一份敏感词查询:后台存在配置时以后台为准,未配置时回退到内置种子。
* Hook 订阅 React Query 缓存,管理员保存敏感词后,使用它的导航/欢迎区会自动重新渲染。
*/
export function useDisplayDesensitizer(): (text: string) => string {
const { data: words = [] } = useSensitiveWords();
return useMemo(
() => (words.length > 0 ? makeDesensitizer(wordsToMap(words)) : desensitize),
[words],
);
}
@@ -0,0 +1,192 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
buildSentimentAgentHeaders,
readSentimentAgentEndpoint,
} from "./config";
/**
* 舆情分析智能体走的是外部 AG-UI 协议网关(POST,SSE 返回):
* 请求体为 { threadId, runId, state, tools, context, forwardedProps, messages },
* 返回为 `data: {"type": ...}` 的 SSE 事件流:
* RUN_STARTED / THINKING_TEXT_MESSAGE_START|CONTENT|END /
* TEXT_MESSAGE_START|CONTENT|END / RUN_FINISHED(另有 RUN_ERROR 等异常事件)。
*/
/** AG-UI 协议消息(请求体里的 messages 数组元素)。 */
export interface AgUiMessage {
role: "user" | "assistant";
id: string;
content: string;
}
export interface SentimentAgentRunHandlers {
/** 推理(思考)文本开始:reasoning 消息流开始。 */
onReasoningStart?: (messageId: string) => void;
onReasoningDelta?: (messageId: string, delta: string) => void;
/** 正文回答开始:answer 消息流开始。 */
onAnswerStart?: (messageId: string) => void;
onAnswerDelta?: (messageId: string, delta: string) => void;
}
function textOfMessage(message: Message): string {
if (typeof message.content === "string") return message.content;
if (!Array.isArray(message.content)) return "";
return message.content
.map((part) => {
if (typeof part === "string") return part;
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("");
}
/** 把本地会话消息转成 AG-UI 请求的历史消息(过滤空内容)。 */
export function toAgUiMessages(messages: Message[]): AgUiMessage[] {
return messages
.filter((message) => message.type === "human" || message.type === "ai")
.map((message) => ({
role: message.type === "human" ? ("user" as const) : ("assistant" as const),
id: message.id ?? "",
content: textOfMessage(message),
}))
.filter((message) => message.content.trim().length > 0);
}
type SseEvent = {
event?: string;
data: string;
};
/** 按 SSE 规范切帧:事件以空行分隔,`data:` 行可能跨多行、帧可能跨 chunk。 */
function parseSseFrames(buffer: string): { frames: SseEvent[]; rest: string } {
const normalized = buffer.replace(/\r\n/g, "\n");
const parts = normalized.split("\n\n");
const rest = parts.pop() ?? "";
const frames: SseEvent[] = [];
for (const part of parts) {
if (!part.trim()) continue;
const dataLines: string[] = [];
let eventName: string | undefined;
for (const line of part.split("\n")) {
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).replace(/^ /, ""));
} else if (line.startsWith("event:")) {
eventName = line.slice(6).replace(/^ /, "");
}
}
if (dataLines.length > 0) {
frames.push({ event: eventName, data: dataLines.join("\n") });
}
}
return { frames, rest };
}
export interface RunSentimentAgentOptions {
threadId: string;
runId: string;
messages: AgUiMessage[];
signal: AbortSignal;
handlers: SentimentAgentRunHandlers;
}
/**
* 发起一次舆情分析流式问答。resolve 于流正常结束(RUN_FINISHED 或连接关闭);
* 网络错误 / 非 2xx / RUN_ERROR 事件都会 reject(AbortError 由调用方自行忽略)。
*/
export async function runSentimentAgent(
options: RunSentimentAgentOptions,
): Promise<void> {
const { threadId, runId, messages, signal, handlers } = options;
const { apiUrl } = readSentimentAgentEndpoint();
if (!apiUrl) {
throw new Error("未配置舆情分析接口地址(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。");
}
const response = await fetch(apiUrl, {
method: "POST",
headers: buildSentimentAgentHeaders(),
signal,
body: JSON.stringify({
threadId,
runId,
state: {},
tools: [],
context: [],
forwardedProps: {},
messages,
}),
});
if (!response.ok) {
throw new Error(`舆情分析服务请求失败(HTTP ${response.status})。`);
}
if (!response.body) {
throw new Error("舆情分析服务未返回流式响应。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const dispatch = (payload: Record<string, unknown>) => {
// 样例里思考事件使用 message_id,而正文事件使用 messageId;两种 AG-UI
// 字段形式都兼容,避免后端升级/不同事件类型造成流式状态断开。
const messageId = String(payload.message_id ?? payload.messageId ?? "");
const delta = typeof payload.delta === "string" ? payload.delta : "";
switch (payload.type) {
case "THINKING_TEXT_MESSAGE_START":
handlers.onReasoningStart?.(messageId);
break;
case "THINKING_TEXT_MESSAGE_CONTENT":
handlers.onReasoningDelta?.(messageId, delta);
break;
case "TEXT_MESSAGE_START":
handlers.onAnswerStart?.(messageId);
break;
case "TEXT_MESSAGE_CONTENT":
handlers.onAnswerDelta?.(messageId, delta);
break;
case "RUN_ERROR": {
const message =
typeof payload.message === "string" && payload.message
? payload.message
: "舆情分析服务返回错误。";
throw new Error(message);
}
default:
// RUN_STARTED / *_END / RUN_FINISHED / TOOL_* 等事件不需要驱动 UI。
break;
}
};
const consumeFrames = (frames: SseEvent[]) => {
for (const frame of frames) {
let payload: Record<string, unknown>;
try {
payload = JSON.parse(frame.data) as Record<string, unknown>;
} catch {
continue; // 解析不了的脏帧直接跳过,不断流。
}
// RUN_ERROR 会在 dispatch 里抛出业务错误,正常往外传播。
dispatch(payload);
}
};
while (true) {
const { done, value } = await reader.read();
if (done) {
// 某些网关会在最后一帧后直接关闭连接而不补 SSE 的空行分隔符。流已
// 结束时剩余缓冲区就是完整帧,补上分隔符后仍要消费,避免丢最后几个字。
buffer += decoder.decode();
if (buffer.trim()) {
const { frames } = parseSseFrames(`${buffer}\n\n`);
consumeFrames(frames);
}
break;
}
buffer += decoder.decode(value, { stream: true });
const { frames, rest } = parseSseFrames(buffer);
buffer = rest;
consumeFrames(frames);
}
}
@@ -0,0 +1,59 @@
import { readRuntimeEnv } from "@/env";
/**
* 舆情分析智能体(前端直连的虚拟智能体)的接入配置。
* 接口地址与 Basic Auth 凭据都来自 public/runtime-config.js,部署后可直接改
* dist/runtime-config.js 切换环境,无需重新打包。
*/
/** 该虚拟智能体在路由里的固定 id(/agents/{id}/chats/...)。 */
export const SENTIMENT_AGENT_ID = "sentiment-analysis";
export interface SentimentAgentEndpoint {
apiUrl: string;
authUsername: string;
authPassword: string;
}
export function readSentimentAgentEndpoint(): SentimentAgentEndpoint {
return {
apiUrl: readRuntimeEnv("VITE_SENTIMENT_AGENT_API_URL") ?? "",
authUsername: readRuntimeEnv("VITE_SENTIMENT_AGENT_AUTH_USERNAME") ?? "",
authPassword: readRuntimeEnv("VITE_SENTIMENT_AGENT_AUTH_PASSWORD") ?? "",
};
}
export function isSentimentAgentConfigured(): boolean {
return !!readSentimentAgentEndpoint().apiUrl;
}
/**
* 是否开放舆情分析虚拟智能体。未配置时保持展示,避免旧部署升级后入口意外消失;
* 只有运行时配置显式写为 "false" 才关闭。
*/
export function isSentimentAgentVisible(): boolean {
return readRuntimeEnv("VITE_SENTIMENT_AGENT_VISIBLE") !== "false";
}
function toBase64(value: string): string {
// 凭据按 UTF-8 字节做 Basic Auth base64,避免非 ASCII 字符导致 btoa 抛错。
const bytes = new TextEncoder().encode(value);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/** 组装请求头:Basic Auth(用户名/密码都配置了才带 Authorization)。 */
export function buildSentimentAgentHeaders(): Record<string, string> {
const { authUsername, authPassword } = readSentimentAgentEndpoint();
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
};
if (authUsername && authPassword) {
headers.Authorization = `Basic ${toBase64(`${authUsername}:${authPassword}`)}`;
}
return headers;
}
@@ -0,0 +1,55 @@
/**
* 舆情分析智能体的展示信息(名称 / 简介)。
* 这是一个前端虚拟智能体(不落库后端 /api/agents),名称与简介保存在
* localStorage,可在智能体管理页的编辑弹窗里随时修改。
*/
export interface SentimentAgentProfile {
name: string;
description: string;
}
const STORAGE_KEY = "deerflow.sentiment-agent.profile";
/** 名称 / 简介 未自定义时的默认文案。 */
export const DEFAULT_SENTIMENT_AGENT_PROFILE: SentimentAgentProfile = {
name: "舆情分析",
description:
"面向舆情领域的态势问答助手:围绕指定话题或事件,自动梳理舆情脉络、研判发展态势、归纳各方观点与热点走向,输出结构化的分析结论,为舆情应对与决策提供参考。",
};
export function readSentimentAgentProfile(): SentimentAgentProfile {
if (typeof window === "undefined") return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
}
const record = parsed as Record<string, unknown>;
const name =
typeof record.name === "string" && record.name.trim()
? record.name.trim()
: DEFAULT_SENTIMENT_AGENT_PROFILE.name;
const description =
typeof record.description === "string"
? record.description.trim()
: DEFAULT_SENTIMENT_AGENT_PROFILE.description;
return { name, description };
} catch {
return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
}
}
export function saveSentimentAgentProfile(profile: SentimentAgentProfile): void {
if (typeof window === "undefined") return;
const name = profile.name.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.name;
const description =
profile.description.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.description;
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ name, description } satisfies SentimentAgentProfile),
);
// 编辑弹窗可能挂在管理页 / 对话页两处,广播一下让同页签的其他挂载点即时刷新。
window.dispatchEvent(new CustomEvent("sentiment-agent-profile-changed"));
}
@@ -0,0 +1,187 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { runSentimentAgent, toAgUiMessages } from "./api";
const STORAGE_PREFIX = "workspace.sentiment-agent.";
/**
* 舆情分析智能体的前端会话状态:消息只存在当前标签页的 sessionStorage,
* 不创建后端 thread、不进 LangGraph —— 所有问答都直连外部 AG-UI 网关。
* 整体结构参照 use-single-round-rag 的「合成 LangGraph 消息 + 本地持久化」模式。
*/
export function useSentimentAgentChat(threadId: string) {
const [messages, setMessages] = useState<Message[]>(() =>
readStoredMessages(threadId),
);
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
// 卸载 / 切会话时中断进行中的流。
useEffect(() => () => controllerRef.current?.abort(), []);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const commitMessages = useCallback(
(updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(threadId, next);
return next;
});
},
[threadId],
);
const submit = useCallback(
async ({ query }: { query: string }) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
const userMessage: Message = {
id: `sentiment-human-${crypto.randomUUID()}`,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: {
client_ts: new Date().toISOString(),
sentiment_agent: true,
},
};
const initialAssistant: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: { sentiment_agent: true },
};
// AG-UI 历史 = 之前已完成的轮次 + 本轮提问。
const history = [
...toAgUiMessages(messagesRef.current),
{ role: "user" as const, id: userMessage.id!, content: question },
];
commitMessages((current) => [...current, userMessage, initialAssistant]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
// 思考 / 正文两个缓冲区 + rAF 合帧刷新:SSE 一轮几千个 delta,
// 直接逐条 setState 会造成大量无效渲染。
let reasoning = "";
let answer = "";
let flushScheduled = false;
const flush = () => {
flushScheduled = false;
commitMessages((current) =>
current.map((message) =>
message.id === assistantId
? ({
...message,
content: answer,
additional_kwargs: {
...message.additional_kwargs,
sentiment_agent: true,
reasoning_content: reasoning || null,
},
} as Message)
: message,
),
);
};
const scheduleFlush = () => {
if (flushScheduled) return;
flushScheduled = true;
requestAnimationFrame(flush);
};
try {
await runSentimentAgent({
threadId,
runId: `run-${crypto.randomUUID()}`,
messages: history,
signal: controller.signal,
handlers: {
onReasoningDelta: (_messageId, delta) => {
reasoning += delta;
scheduleFlush();
},
onAnswerDelta: (_messageId, delta) => {
answer += delta;
scheduleFlush();
},
},
});
flush();
} catch (caught) {
flush();
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "舆情分析问答失败。";
setError(message);
// 一个字都没回答出来时,把错误写进气泡,用户能看到失败原因。
if (!answer) {
commitMessages((current) =>
current.map((item) =>
item.id === assistantId
? ({ ...item, content: message } as Message)
: item,
),
);
}
}
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[commitMessages, isLoading, threadId],
);
return { messages, isLoading, error, submit, stop };
}
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 {
// 配额异常不能打断进行中的回答。
}
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
@@ -71,6 +71,12 @@ test("keeps half-width syntax working", () => {
assert.equal(out.includes(citation(3)), true);
});
test("converts double-bracket RAG references", () => {
const out = applyInlineCitations("RAG answer [[1]] and [[2]].", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
test("accepts spaced and full-width square brackets", () => {
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
+31 -7
View File
@@ -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;
+35 -5
View File
@@ -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"] });
},
});
}
+42 -1
View File
@@ -276,7 +276,42 @@ export async function updatePublishSquares(
return data;
}
export type ThemeMode = "light" | "dark" | "lightblue" | "lightgreen";
export const BUILTIN_THEME_IDS = [
"light",
"dark",
"lightblue",
"lightgreen",
"iceblue",
"lightyellow",
] as const;
export type BuiltinThemeId = (typeof BUILTIN_THEME_IDS)[number];
export type ThemeMode = BuiltinThemeId | `custom-theme-${string}`;
export type CustomThemeBaseMode = "light" | "dark";
export interface CustomThemeColorOverrides {
button_hover?: string | null;
navigation_gradient_start?: string | null;
navigation_gradient_end?: string | null;
selected_background?: string | null;
border?: string | null;
muted?: string | null;
muted_text?: string | null;
destructive?: string | null;
brand_title?: string | null;
primary_foreground?: string | null;
}
export interface CustomThemePreset {
id: `custom-theme-${string}`;
name: string;
primary: string;
text_color?: string | null;
mode: CustomThemeBaseMode;
overrides?: CustomThemeColorOverrides;
enabled: boolean;
sort_order: number;
}
export type ViewMode = "card" | "list";
export type LayoutMode = "full" | "compact";
export type CompactShortcutParamType = "login" | "theme";
@@ -304,6 +339,7 @@ export interface AppearanceSettings {
default_agent_view: ViewMode;
default_layout_mode: LayoutMode;
compact_shortcut_buttons: CompactShortcutButton[];
custom_themes: CustomThemePreset[];
}
export interface AppearanceUpdate {
@@ -312,6 +348,7 @@ export interface AppearanceUpdate {
default_agent_view?: ViewMode;
default_layout_mode?: LayoutMode;
compact_shortcut_buttons?: CompactShortcutButton[];
custom_themes?: CustomThemePreset[];
}
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
@@ -469,12 +506,16 @@ export interface ModelConcurrencySettings {
enabled: boolean;
default_limit: number;
model_limits: Record<string, number>;
user_default_limit: number;
user_model_limits: Record<string, number>;
}
export interface ModelConcurrencyUpdate {
enabled?: boolean;
default_limit?: number;
model_limits?: Record<string, number>;
user_default_limit?: number;
user_model_limits?: Record<string, number>;
}
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
@@ -111,10 +111,13 @@ export function useUpdatePublishSquares() {
});
}
export function useAppearanceSettings() {
export function useAppearanceSettings(
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery({
queryKey: APPEARANCE_KEY,
queryFn: () => loadAppearanceSettings(),
enabled,
staleTime: 60_000,
refetchOnWindowFocus: false,
// The provider runs even on the login page (where the user has no auth)
+303
View File
@@ -0,0 +1,303 @@
import type {
CustomThemeColorOverrides,
CustomThemePreset,
} from "@/core/system-settings/api";
export const CUSTOM_THEME_PREFIX = "custom-theme-";
type Rgb = { r: number; g: number; b: number };
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
const WHITE: Rgb = { r: 255, g: 255, b: 255 };
const BLACK: Rgb = { r: 0, g: 0, b: 0 };
export const CUSTOM_THEME_ADVANCED_KEYS = [
"button_hover",
"navigation_gradient_start",
"navigation_gradient_end",
"selected_background",
"border",
"muted",
"muted_text",
"destructive",
"brand_title",
"primary_foreground",
] as const;
export type CustomThemeAdvancedKey = (typeof CUSTOM_THEME_ADVANCED_KEYS)[number];
export type CustomThemeTokenInput = Pick<
CustomThemePreset,
"primary" | "mode"
> &
Partial<Pick<CustomThemePreset, "text_color" | "overrides">>;
export function isCustomThemeId(value: string | null | undefined): value is `custom-theme-${string}` {
return typeof value === "string" && value.startsWith(CUSTOM_THEME_PREFIX);
}
export function normalizeHexColor(value: string, fallback = "#0062D9"): string {
const normalized = value.trim().toUpperCase();
return HEX_COLOR.test(normalized) ? normalized : fallback;
}
function optionalHexColor(value: string | null | undefined): string | undefined {
if (!value?.trim() || !HEX_COLOR.test(value.trim())) return undefined;
return value.trim().toUpperCase();
}
function toRgb(value: string): Rgb {
const hex = normalizeHexColor(value).slice(1);
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
};
}
function toHex({ r, g, b }: Rgb): string {
const channel = (value: number) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
return `#${channel(r)}${channel(g)}${channel(b)}`.toUpperCase();
}
function mix(from: Rgb, to: Rgb, amount: number): Rgb {
const ratio = Math.min(1, Math.max(0, amount));
return {
r: from.r + (to.r - from.r) * ratio,
g: from.g + (to.g - from.g) * ratio,
b: from.b + (to.b - from.b) * ratio,
};
}
function relativeLuminance({ r, g, b }: Rgb): number {
const linear = [r, g, b].map((channel) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!;
}
function contrastRatio(a: Rgb, b: Rgb): number {
const [lighter, darker] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (lighter! + 0.05) / (darker! + 0.05);
}
function readableForeground(background: Rgb): string {
return contrastRatio(background, BLACK) >= contrastRatio(background, WHITE) ? "#172033" : "#FFFFFF";
}
function ensureContrast(color: Rgb, background: Rgb, toward: Rgb, minimum = 4.5): Rgb {
let candidate = color;
for (let step = 1; step <= 10 && contrastRatio(candidate, background) < minimum; step += 1) {
candidate = mix(candidate, toward, 0.18);
}
return candidate;
}
function rgbChannels(color: Rgb): string {
return `${Math.round(color.r)} ${Math.round(color.g)} ${Math.round(color.b)}`;
}
/**
* Produce the semantic CSS tokens consumed by Tailwind/shadcn and TDesign.
* Inputs are restricted to a safe six-digit primary colour, optional primary
* button text colour, a small set of interaction overrides and a light/dark surface
* family, so callers never interpolate arbitrary CSS values.
*/
export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<string, string> {
const primary = toRgb(theme.primary);
const isDark = theme.mode === "dark";
const overrides: CustomThemeColorOverrides = theme.overrides ?? {};
const background = isDark ? mix({ r: 13, g: 19, b: 32 }, primary, 0.12) : mix(WHITE, primary, 0.035);
const card = isDark ? mix({ r: 18, g: 27, b: 44 }, primary, 0.12) : WHITE;
// Normal page copy stays automatic so selecting white for a teal/blue
// action does not accidentally make a light-page's body text white.
const preferredForeground = toRgb(isDark ? "#F4F7FB" : "#202A3A");
const foregroundRgb = ensureContrast(
preferredForeground,
background,
toRgb(readableForeground(background)),
);
const foreground = toHex(foregroundRgb);
const defaultMutedForeground = toRgb(isDark ? "#AAB4C2" : "#667085");
const mutedForegroundRgb = optionalHexColor(overrides.muted_text)
? ensureContrast(
toRgb(overrides.muted_text!),
background,
toRgb(readableForeground(background)),
3,
)
: defaultMutedForeground;
const mutedForeground = toHex(mutedForegroundRgb);
const defaultAccent = isDark ? mix(background, primary, 0.3) : mix(WHITE, primary, 0.14);
const accent = optionalHexColor(overrides.selected_background)
? toRgb(overrides.selected_background!)
: defaultAccent;
const accentHover = isDark ? mix(accent, primary, 0.2) : mix(accent, primary, 0.14);
const defaultBorder = isDark ? mix(background, WHITE, 0.16) : mix(WHITE, primary, 0.28);
const border = optionalHexColor(overrides.border) ? toRgb(overrides.border!) : defaultBorder;
const input = isDark ? mix(background, WHITE, 0.22) : mix(WHITE, primary, 0.38);
const action = ensureContrast(
mix(primary, isDark ? WHITE : BLACK, isDark ? 0.2 : 0.3),
isDark ? background : WHITE,
isDark ? WHITE : BLACK,
);
const actionHover = isDark ? mix(action, WHITE, 0.12) : mix(action, BLACK, 0.12);
const actionActive = isDark ? mix(action, WHITE, 0.22) : mix(action, BLACK, 0.22);
const defaultPrimaryHover = isDark ? mix(primary, WHITE, 0.1) : mix(primary, BLACK, 0.1);
const primaryHover = optionalHexColor(overrides.button_hover)
? toRgb(overrides.button_hover!)
: defaultPrimaryHover;
// Keep the same left → main-colour → right treatment used by the built-in
// themes. These two values remain automatic unless the administrator opens
// “更多配色” and explicitly overrides one of them.
const defaultNavigationGradientStart = mix(primary, BLACK, isDark ? 0.46 : 0.3);
const defaultNavigationGradientEnd = mix(primary, WHITE, isDark ? 0.14 : 0.42);
const navigationGradientStart = optionalHexColor(overrides.navigation_gradient_start)
? toRgb(overrides.navigation_gradient_start!)
: defaultNavigationGradientStart;
const navigationGradientEnd = optionalHexColor(overrides.navigation_gradient_end)
? toRgb(overrides.navigation_gradient_end!)
: defaultNavigationGradientEnd;
const navigationForeground = readableForeground(mix(navigationGradientStart, navigationGradientEnd, 0.5));
const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2);
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
const brandTitle = optionalHexColor(overrides.brand_title) ?? foreground;
const defaultSecondary = isDark ? mix(background, WHITE, 0.08) : mix(WHITE, primary, 0.075);
const secondary = optionalHexColor(overrides.muted) ? toRgb(overrides.muted!) : defaultSecondary;
const sidebar = isDark ? mix(background, BLACK, 0.08) : mix(WHITE, primary, 0.018);
const ring = rgbChannels(primary);
const destructive = optionalHexColor(overrides.destructive) ?? (isDark ? "#FF777B" : "#CF4A4A");
const primaryHex = toHex(primary);
const actionHex = toHex(action);
const actionHoverHex = toHex(actionHover);
const actionActiveHex = toHex(actionActive);
const accentHex = toHex(accent);
const accentHoverHex = toHex(accentHover);
const backgroundHex = toHex(background);
const cardHex = toHex(card);
const borderHex = toHex(border);
const inputHex = toHex(input);
const secondaryHex = toHex(secondary);
const primaryHoverHex = toHex(primaryHover);
const primaryActiveHex = toHex(primaryActive);
const navigationGradientStartHex = toHex(navigationGradientStart);
const navigationGradientEndHex = toHex(navigationGradientEnd);
const sidebarHex = toHex(sidebar);
return {
"--workspace-brand": primaryHex,
"--workspace-brand-foreground": primaryForeground,
"--brand-200": accentHex,
"--brand-900": actionHex,
"--brand-950": actionActiveHex,
"--theme-button-hover": primaryHoverHex,
"--navigation-gradient-start": navigationGradientStartHex,
"--navigation-gradient-end": navigationGradientEndHex,
"--navigation-foreground": navigationForeground,
"--theme-selected-background": accentHex,
"--background": backgroundHex,
"--foreground": foreground,
"--card": cardHex,
"--card-foreground": foreground,
"--popover": cardHex,
"--popover-foreground": foreground,
"--primary": primaryHex,
"--primary-foreground": primaryForeground,
"--button-foreground": buttonForeground,
"--brand-title-foreground": brandTitle,
"--secondary": secondaryHex,
"--secondary-foreground": actionHex,
"--muted": secondaryHex,
"--muted-foreground": mutedForeground,
"--accent": accentHex,
"--accent-foreground": actionHex,
"--destructive": destructive,
"--border": borderHex,
"--input": inputHex,
"--ring": `rgb(${ring} / 0.48)`,
"--chart-1": actionHex,
"--chart-2": toHex(mix(primary, { r: 20, g: 145, b: 150 }, 0.45)),
"--chart-3": toHex(mix(primary, { r: 108, g: 96, b: 175 }, 0.45)),
"--chart-4": toHex(mix(primary, { r: 220, g: 137, b: 54 }, 0.45)),
"--chart-5": toHex(mix(primary, { r: 57, g: 139, b: 110 }, 0.45)),
"--sidebar": sidebarHex,
"--sidebar-foreground": foreground,
"--sidebar-primary": primaryHex,
"--sidebar-primary-foreground": primaryForeground,
"--sidebar-accent": accentHex,
"--sidebar-accent-foreground": actionHex,
"--sidebar-border": borderHex,
"--sidebar-ring": `rgb(${ring} / 0.48)`,
"--legacy-accent-rgb": rgbChannels(accent),
"--legacy-background-rgb": rgbChannels(background),
"--legacy-border-rgb": rgbChannels(border),
"--legacy-brand-200-rgb": rgbChannels(accent),
"--legacy-card-rgb": rgbChannels(card),
"--legacy-destructive-rgb": rgbChannels(toRgb(destructive)),
"--legacy-foreground-rgb": rgbChannels(foregroundRgb),
"--legacy-input-rgb": rgbChannels(input),
"--legacy-muted-rgb": rgbChannels(secondary),
"--legacy-muted-foreground-rgb": rgbChannels(mutedForegroundRgb),
"--legacy-primary-rgb": rgbChannels(primary),
"--legacy-primary-foreground-rgb": rgbChannels(toRgb(primaryForeground)),
"--legacy-ring-rgb": ring,
"--legacy-secondary-rgb": rgbChannels(secondary),
"--legacy-sidebar-accent-rgb": rgbChannels(accent),
"--legacy-sidebar-border-rgb": rgbChannels(border),
"--legacy-sidebar-foreground-rgb": rgbChannels(foregroundRgb),
"--legacy-workspace-brand-rgb": rgbChannels(primary),
"--td-brand-color-1": backgroundHex,
"--td-brand-color-2": secondaryHex,
"--td-brand-color-3": accentHex,
"--td-brand-color-4": accentHoverHex,
"--td-brand-color-5": primaryHex,
"--td-brand-color-6": primaryHex,
"--td-brand-color-7": actionHex,
"--td-brand-color-8": actionHoverHex,
"--td-brand-color-9": actionActiveHex,
"--td-brand-color-10": actionActiveHex,
"--td-brand-color": primaryHex,
"--td-brand-color-hover": primaryHoverHex,
"--td-brand-color-focus": accentHex,
"--td-brand-color-active": primaryActiveHex,
"--td-brand-color-disabled": accentHoverHex,
"--td-brand-color-light": accentHex,
"--td-brand-color-light-hover": accentHoverHex,
"--td-text-color-primary": foreground,
"--td-text-color-secondary": mutedForeground,
"--td-text-color-brand": actionHex,
"--td-text-color-link": actionHex,
"--td-text-color-anti": buttonForeground,
"--td-bg-color-page": backgroundHex,
"--td-bg-color-container": cardHex,
"--td-bg-color-container-hover": secondaryHex,
"--td-bg-color-container-active": accentHex,
"--td-bg-color-secondarycontainer": secondaryHex,
"--td-bg-color-secondarycontainer-hover": accentHex,
"--td-bg-color-secondarycontainer-active": accentHoverHex,
"--td-border-level-1-color": borderHex,
"--td-component-stroke": borderHex,
"--td-border-level-2-color": inputHex,
"--td-component-border": inputHex,
};
}
/** Tokens produced from the automatic palette only, used by the editor to
* show which advanced values are currently inheriting from the system. */
export function getCustomThemeAutomaticTokens(
theme: Pick<CustomThemePreset, "primary" | "mode">,
): Record<string, string> {
return getCustomThemeTokens(theme);
}
export function customThemeContrast(primary: string): number {
const color = toRgb(primary);
return Math.max(contrastRatio(color, BLACK), contrastRatio(color, WHITE));
}
export function customThemeTextContrast(theme: CustomThemeTokenInput): number {
const tokens = getCustomThemeTokens(theme);
return contrastRatio(toRgb(tokens["--button-foreground"]!), toRgb(tokens["--primary"]!));
}
+512 -137
View File
@@ -25,31 +25,78 @@ 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;
context: LocalSettings["context"];
isMock?: boolean;
assistantId?: string;
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
context: LocalSettings["context"];
isMock?: boolean;
assistantId?: string;
/**
* Per-page run behaviour. Most chats keep the SDK defaults; long-running
* workspace flows can opt into a resumable stream without changing global
* chat semantics.
*/
runOptions?: {
onDisconnect?: "cancel" | "continue";
multitaskStrategy?: "reject" | "rollback" | "interrupt" | "enqueue";
};
/** 额外写到新建 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>;
/**
* Extra human-context messages submitted before the visible user message.
* They are persisted with `hide_from_ui` so the model receives rich business
* context without dumping long prompts into the conversation surface.
*/
hiddenContext?:
| string
| {
text: string;
name?: string;
additionalKwargs?: Record<string, unknown>;
}
| Array<{
text: string;
name?: string;
additionalKwargs?: Record<string, unknown>;
}>;
};
type ThreadTitleRefreshState = Pick<AgentThreadState, "title"> & {
title_provisional?: boolean;
};
function normalizeHiddenContextMessages(options?: SendMessageOptions) {
const value = options?.hiddenContext;
if (!value) return [];
const items =
typeof value === "string"
? [{ text: value }]
: Array.isArray(value)
? value
: [value];
return items
.map((item) => ({
...item,
text: item.text.trim(),
}))
.filter((item) => item.text.length > 0);
}
function normalizeStoredRunId(runId: string | null): string | null {
if (!runId) {
@@ -182,6 +229,27 @@ function getStreamErrorMessage(error: unknown): string {
return "Request failed.";
}
const TERMINAL_RUN_STATUSES = new Set([
"success",
"error",
"timeout",
"interrupted",
]);
// A completed server run normally sends a terminal SSE event. In local
// development, however, a Vite hot update, a proxy disconnect, or a brief
// network interruption can make the browser miss that final event while the
// backend continues (and successfully completes) the run. Do not probe on
// every render: only treat the stream as suspicious after it has been quiet
// for a while, then reconcile it from the authoritative run/state endpoints.
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000;
// A run can legitimately be quiet while a long tool call is in progress, so
// reconnect more conservatively than the terminal-status check above. The
// reconnection only replaces the local SSE reader; the backend run keeps going.
const STREAM_RECONNECT_IDLE_MS = 30_000;
const STREAM_RECONNECT_MAX_ATTEMPTS = 3;
async function fetchThreadTitleRefreshState(
threadId: string,
signal?: AbortSignal,
@@ -263,11 +331,24 @@ function isModelConcurrencyLimitError(message: string): boolean {
return (
/\b429\b/.test(message) ||
/并发已达到上限/.test(message) ||
/并发调用已达到上限/.test(message) ||
/使用人数较多/.test(message) ||
/too many requests/i.test(message)
);
}
function friendlySendLimitMessage(message: string): string | null {
if (isActiveRunConflictError(message)) {
return "当前对话还在生成回答,请等待本轮回复结束,或先点“停止”再发送新的问题。";
}
if (isModelConcurrencyLimitError(message)) {
return /当前账号对该模型/.test(message)
? "当前账号对该模型的并发调用已达到上限,请等待已有回答结束后再试。"
: "当前使用人数较多,请稍等一下再试。";
}
return null;
}
function buildCurrentUserLabel(): string {
const auth = getStoredAuth();
if (!auth) {
@@ -371,32 +452,45 @@ function lastHumanFromChatModelInput(input: unknown): string {
export function useThreadStream({
threadId,
context,
isMock,
assistantId,
threadMetadata,
onStart,
onFinish,
onToolEnd,
}: ThreadStreamOptions) {
isMock,
assistantId,
runOptions,
threadMetadata,
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
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders,
// and to allow access to the current thread id in onUpdateEvent
const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false);
const threadIdRef = useRef<string | null>(threadId ?? null);
const startedRef = useRef(false);
const lastStreamActivityAtRef = useRef(0);
const streamReconciliationInFlightRef = useRef(false);
const streamReconnectAttemptsRef = useRef(0);
const streamReconnectFailureToastShownRef = useRef(false);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
streamReconnectAttemptsRef.current = 0;
streamReconnectFailureToastShownRef.current = false;
}, []);
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;
@@ -417,13 +511,14 @@ export function useThreadStream({
}
}, []);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
_handleOnStart(_threadId);
},
[_handleOnStart],
);
const handleStreamStart = useCallback(
(_threadId: string) => {
threadIdRef.current = _threadId;
markStreamActivity();
_handleOnStart(_threadId);
},
[_handleOnStart, markStreamActivity],
);
const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask();
@@ -447,8 +542,8 @@ export function useThreadStream({
? () => runMetadataStorageRef.current!
: false,
fetchStateHistory: { limit: 1 },
onCreated(meta) {
handleStreamStart(meta.thread_id);
onCreated(meta) {
handleStreamStart(meta.thread_id);
setOnStreamThreadId(meta.thread_id);
const metadataAgentId = context.agent_id ?? context.agent_name;
const extraMetadata =
@@ -462,14 +557,9 @@ export function useThreadStream({
.threads.update(meta.thread_id, { metadata })
.catch(() => ({}));
}
},
onLangChainEvent(event) {
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.data,
});
}
},
onLangChainEvent(event) {
markStreamActivity();
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
@@ -491,10 +581,35 @@ export function useThreadStream({
} catch {
/* 上报绝不影响问答 */
}
}
},
onUpdateEvent(data) {
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
}
},
// 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(
data || {},
);
for (const update of updates) {
@@ -515,9 +630,10 @@ export function useThreadStream({
);
}
}
},
onCustomEvent(event: unknown) {
if (
},
onCustomEvent(event: unknown) {
markStreamActivity();
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
@@ -532,19 +648,20 @@ export function useThreadStream({
return;
}
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "llm_retry" &&
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "llm_retry" &&
"message" in event &&
typeof event.message === "string" &&
event.message.trim()
) {
const e = event as { type: "llm_retry"; message: string };
toast(e.message);
return;
}
typeof event.message === "string" &&
event.message.trim()
) {
// The backend is already handling this with bounded retry/backoff.
// Surfacing every transient model retry as a toast made normal model
// stalls look like repeated "network fluctuation" failures.
return;
}
// Context compression about to run a (blocking) summarization LLM call —
// surface a transient hint so the wait before the answer streams isn't a
@@ -561,9 +678,10 @@ export function useThreadStream({
const e = event as { type: "context_compacting"; message: string };
toast(e.message);
}
},
onError(error) {
setOptimisticMessages([]);
},
onError(error) {
markStreamActivity();
setOptimisticMessages([]);
const message = getStreamErrorMessage(error);
// A stale auto-reconnect ("Run <id> not found") after a backend restart:
// drop the dirty ``lg:stream:{threadId}`` key so switching back into this
@@ -613,29 +731,27 @@ export function useThreadStream({
}
return;
}
// 上一轮还没结束又发了新问题(后端 409):给一句明确的中文提示,而不是
// 把原始的英文/HTTP 错误抛给用户。
if (isActiveRunConflictError(message)) {
toast.info("当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。");
return;
}
if (isModelConcurrencyLimitError(message)) {
toast.info("当前使用人数较多,请稍等一下再试。");
// 上一轮还没结束又发了新问题(409)或并发达到上限(429):
// 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。
const friendly = friendlySendLimitMessage(message);
if (friendly) {
toast.info(friendly);
return;
}
toast.error(message);
},
onFinish(state) {
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
onFinish(state) {
markStreamActivity();
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
},
});
// Optimistic messages shown before the server stream responds
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [threadValueOverrides, setThreadValueOverrides] = useState<
Partial<ThreadTitleRefreshState>
Partial<AgentThreadState>
>({});
const sendInFlightRef = useRef(false);
// Carries the ask time of an in-flight send until the server-assigned human
@@ -651,8 +767,136 @@ export function useThreadStream({
// Keep a ref to the latest `thread` so the threadId-change effect can stop
// an in-flight stream without re-running on every render that produces a
// new `thread` identity.
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
useEffect(() => {
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (!activeThreadId || typeof window === "undefined") {
return;
}
let disposed = false;
let refreshTimer: number | null = null;
const reconcileRun = async () => {
if (
disposed ||
streamReconciliationInFlightRef.current ||
Date.now() - lastStreamActivityAtRef.current < STREAM_RECONCILIATION_IDLE_MS
) {
return;
}
const storage = runMetadataStorageRef.current;
const runId = storage?.getItem(`lg:stream:${activeThreadId}`);
if (!runId) return;
streamReconciliationInFlightRef.current = true;
try {
const client = getAPIClient(isMock);
const run = await client.runs.get(activeThreadId, runId);
if (disposed) {
return;
}
if (!TERMINAL_RUN_STATUSES.has(run.status)) {
// ``useStream`` retries one failed connection on its own. If that
// reader quietly stalls, however, its internal queue never gets a
// terminal error and the page remains frozen despite the backend
// continuing the run. Abort only the local reader, then join the
// same run so no work or tokens are lost server-side.
if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) {
return;
}
if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。");
}
return;
}
streamReconnectAttemptsRef.current += 1;
// Avoid enqueueing another reconnect before this new reader has had
// enough time to receive its first event. A genuine event resets
// the attempt counter through ``markStreamActivity``.
lastStreamActivityAtRef.current = Date.now();
const stream = threadStreamRef.current;
void Promise.resolve(stream.stop())
.catch(() => {
// Stopping a stale local reader is best-effort; joining is still
// useful if the SDK already considers the stream stopped.
})
.finally(() => {
if (disposed || threadIdRef.current !== activeThreadId) return;
void stream.joinStream(runId).catch(() => {
// The status poll will either reconcile a terminal run or
// schedule the next bounded reconnect attempt.
});
});
return;
}
// The server is already terminal, so the existing SSE listener cannot
// receive any more useful events. Load the checkpoint directly before
// resetting the SDK stream; this preserves the normal onFinish side
// effects (e.g. marking a position delivery complete) even when the
// browser missed the terminal event or already reported a transport
// error and dropped ``isLoading``.
const finalState = await client.threads.getState(activeThreadId);
if (disposed) {
return;
}
storage?.removeItem(`lg:stream:${activeThreadId}`);
streamReconnectAttemptsRef.current = 0;
streamReconnectFailureToastShownRef.current = false;
if (run.status === "success") {
setOptimisticMessages([]);
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
listeners.current.onFinish?.(finalState.values as AgentThreadState);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} else {
setThreadValueOverrides(finalState.values as Partial<AgentThreadState>);
toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。");
}
// Changing the controlled thread id clears only the local SDK stream
// (it does not cancel the already-terminal backend run). Switching it
// back immediately makes useStream fetch the persisted messages, so
// the page exits the false loading state without a manual refresh.
setOnStreamThreadId(null);
refreshTimer = window.setTimeout(() => {
if (!disposed && threadIdRef.current === activeThreadId) {
setOnStreamThreadId(activeThreadId);
}
}, 0);
} catch {
// A transient status lookup failure must not disrupt a real stream;
// the next polling interval will try again if it is still quiet.
} finally {
if (!disposed) {
streamReconciliationInFlightRef.current = false;
}
}
};
const interval = window.setInterval(
() => void reconcileRun(),
STREAM_RECONCILIATION_POLL_MS,
);
return () => {
disposed = true;
window.clearInterval(interval);
if (refreshTimer !== null) {
window.clearTimeout(refreshTimer);
}
};
}, [isMock, onStreamThreadId, queryClient, thread.isLoading]);
// Reset thread-local pending UI state when the caller actually switches AWAY
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
@@ -713,10 +957,11 @@ export function useThreadStream({
return;
}
setThreadValueOverrides({
setThreadValueOverrides((prev) => ({
...prev,
title: nextState.title,
title_provisional: nextState.title_provisional,
});
}));
if (nextState.title) {
updateCachedThreadLists(queryClient, (threads) =>
@@ -951,46 +1196,71 @@ export function useThreadStream({
}),
);
const ragModeEnabled =
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
const mergedContext = {
...context,
...extraContext,
};
const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const ragModeEnabled =
localSettings.display.ragModeEnabled ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0;
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false;
const mergedContext = {
...context,
...extraContext,
};
await thread.submit(
{
messages: [
{
type: "human",
content: [
{
type: "text",
text,
},
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
},
],
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
const hiddenContextMessages = normalizeHiddenContextMessages(options);
const submittedMessages: Message[] = [
...hiddenContextMessages.map((item) => ({
type: "human" as const,
...(item.name ? { name: item.name } : {}),
content: [
{
type: "text" as const,
text: item.text,
},
],
additional_kwargs: {
...item.additionalKwargs,
hide_from_ui: true,
},
})),
{
type: "human" as const,
content: [
{
type: "text" as const,
text,
},
],
additional_kwargs: {
...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0
? { files: filesForSubmit }
: {}),
},
},
];
await thread.submit(
{
messages: submittedMessages,
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
onDisconnect: runOptions?.onDisconnect,
multitaskStrategy: runOptions?.multitaskStrategy,
config: {
recursion_limit: 1000,
},
context: {
@@ -1017,15 +1287,35 @@ export function useThreadStream({
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
} catch (error) {
setOptimisticMessages([]);
setIsUploading(false);
throw error;
} finally {
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
const friendly = friendlySendLimitMessage(message);
if (friendly) {
setOptimisticMessages((messages) => {
const human = messages.find((item) => item.type === "human");
const kept = human ? [human] : [];
return [
...kept,
{
type: "ai",
id: `opt-ai-limit-${Date.now()}`,
content: friendly,
additional_kwargs: { element: "task" },
} as Message,
];
});
setIsUploading(false);
return;
}
setOptimisticMessages([]);
setIsUploading(false);
throw error;
} finally {
sendInFlightRef.current = false;
}
},
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient],
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions],
);
// Merge thread with optimistic messages for display
@@ -1037,12 +1327,17 @@ export function useThreadStream({
}
: thread.values;
const mergedMessages =
Array.isArray(mergedValues.messages)
? (mergedValues.messages as Message[])
: thread.messages;
const mergedThread =
optimisticMessages.length > 0 || mergedValues !== thread.values
? ({
...thread,
values: mergedValues,
messages: [...thread.messages, ...optimisticMessages],
messages: [...mergedMessages, ...optimisticMessages],
} as typeof thread)
: thread;
@@ -1300,8 +1595,8 @@ export function useDeleteThread() {
* `MessageList`) so the removal shows immediately; a reload then reads the
* persisted, already-trimmed state.
*/
export function useDeleteMessage() {
return useMutation({
export function useDeleteMessage() {
return useMutation({
mutationFn: async ({
threadId,
messageId,
@@ -1331,12 +1626,92 @@ export function useDeleteMessage() {
// Fall back to the requested id so the caller can always hide something.
return body.deleted_ids?.length ? body.deleted_ids : [messageId];
},
});
}
/**
* Merge metadata into a conversation's record via the Gateway
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
});
}
/**
* Clear all messages from a thread checkpoint. The caller should immediately
* hide the returned ids locally because the active stream cache may still hold
* the old message objects until a reload/refetch reads the trimmed checkpoint.
*/
export function useClearThreadMessages() {
return useMutation({
mutationFn: async ({
threadId,
}: {
threadId: string;
}): Promise<string[]> => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/messages`,
{
method: "DELETE",
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "清空对话失败。" }));
throw new Error(error.detail ?? "清空对话失败。");
}
const body = (await response.json().catch(() => ({}))) as {
deleted_ids?: string[];
};
return body.deleted_ids ?? [];
},
});
}
/**
* Persist an in-place message edit in the latest thread checkpoint.
*
* This is opt-in UI behaviour: most chat pages keep their existing
* "edit question and resubmit" affordance, while position roundtable can pass
* this mutation down to save the original message content itself.
*/
export function useEditMessage() {
return useMutation({
mutationFn: async ({
threadId,
messageId,
content,
}: {
threadId: string;
messageId: string;
content: string;
}): Promise<Message> => {
const response = await apiFetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/messages/${encodeURIComponent(messageId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "消息保存失败。" }));
throw new Error(error.detail ?? "消息保存失败。");
}
const body = (await response.json().catch(() => ({}))) as {
message?: Message;
};
return body.message ?? ({ id: messageId, type: "human", content } as Message);
},
});
}
/**
* Merge metadata into a conversation's record via the Gateway
* `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such
* as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a
* single user, so these live directly on the thread metadata (no join table).
* Updates the `["threads", "search"]` cache optimistically so the UI reflects
+7 -3
View File
@@ -23,9 +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). */
knowledge_rag_enabled?: boolean;
agent_id?: string;
/** 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[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
@@ -0,0 +1,483 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
const STORAGE_PREFIX = "workspace.multi-round-rag.";
export type MultiRoundRagRetrieval = {
id?: string;
title: string;
content: string;
source?: string;
time?: string;
recUuid?: string;
url?: string;
};
export type MultiRoundRagRound = {
id: string;
title: string;
thought: string;
/** Model text emitted for this round, before its retrieval results. */
body: string;
query: string;
isSearching: boolean;
retrievals: MultiRoundRagRetrieval[];
};
export type MultiRoundRagTrace = {
rounds: MultiRoundRagRound[];
answer: string;
isComplete: boolean;
};
type MultiRoundRagSource = {
id?: string;
title: string;
type: string;
snippet: string;
content: string;
source?: string;
time?: string;
url?: string;
raw: Record<string, unknown>;
};
type MultiRoundEvent = {
event?: unknown;
data?: unknown;
};
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 {
// Storage quota failures must not interrupt a running answer.
}
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
}
function isAbortError(error: unknown) {
return error instanceof DOMException && error.name === "AbortError";
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function parseJson(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
function parseEventLine(line: string): MultiRoundEvent | null {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return null;
try {
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent;
} catch {
return null;
}
}
function initialTrace(): MultiRoundRagTrace {
return {
rounds: [
{
id: createId("multi-rag-round"),
title: "正在准备检索",
thought: "",
body: "",
query: "",
isSearching: true,
retrievals: [],
},
],
answer: "",
isComplete: false,
};
}
function latestRound(trace: MultiRoundRagTrace): MultiRoundRagRound {
const current = trace.rounds.at(-1);
if (current) return current;
const next = initialTrace().rounds[0]!;
trace.rounds.push(next);
return next;
}
function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
const current = latestRound(trace);
if (current.title === "正在准备检索" && !current.thought && !current.query) {
current.title = "思考过程";
current.thought = thought;
return;
}
current.isSearching = false;
trace.rounds.push({
id: createId("multi-rag-round"),
title: "思考过程",
thought,
body: "",
query: "",
isSearching: true,
retrievals: [],
});
}
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
if (!body) return;
const current = latestRound(trace);
// Stored traces created before `body` was introduced remain readable.
current.body = `${current.body ?? ""}${body}`;
}
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
const root = asRecord(parseJson(value));
const list =
(Array.isArray(root?.results) && root.results) ||
(Array.isArray(root?.organic) && root.organic) ||
[];
return list.flatMap((item, index) => {
const record = asRecord(item);
if (!record) return [];
const recUuid = asString(record.recUuid ?? record.rec_uuid);
const url = asString(record.url ?? record.link ?? record.href);
return [
{
id: asString(record.id) || recUuid || undefined,
title: asString(record.title) || `检索资料 ${index + 1}`,
content: asString(record.content ?? record.snippet ?? record.text),
source: asString(record.source ?? record.source1 ?? url) || undefined,
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined,
recUuid: recUuid || undefined,
url: /^https?:\/\//i.test(url) ? url : undefined,
},
];
});
}
function mapReferences(value: unknown): MultiRoundRagSource[] {
const record = asRecord(value);
const list = Array.isArray(record?.references) ? record.references : [];
return list.flatMap((item, index) => {
const source = asRecord(item);
if (!source) return [];
const content = asString(source.content ?? source.snippet);
const rawUrl = asString(source.url ?? source.link ?? source.href);
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid);
return [
{
id: asString(source.id) || recUuid || undefined,
title: asString(source.title) || `参考资料 ${index + 1}`,
type: asString(source.type) || "参考资料",
snippet: asString(source.snippet) || content,
content,
source: asString(source.source ?? source.source1) || undefined,
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined,
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: source,
},
];
});
}
function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
return {
...trace,
rounds: trace.rounds.map((round) => ({
...round,
retrievals: [...round.retrievals],
})),
};
}
function isSingleRoundRagMessage(message: Message) {
return message.additional_kwargs?.single_round_rag === true;
}
export function isMultiRoundRagMessage(message: Message): boolean {
return message.additional_kwargs?.multi_round_rag === true;
}
export function getMultiRoundRagTrace(
message: Message,
): MultiRoundRagTrace | null {
const trace = message.additional_kwargs?.multi_round_rag_trace;
return asRecord(trace)
? (trace as unknown as MultiRoundRagTrace)
: null;
}
/**
* Frontend adapter for GeneralQA's multi-round endpoint. It keeps the external
* SSE protocol outside DeerFlow and stores only its UI messages in sessionStorage.
*/
export function useMultiRoundRagMessages(threadId: string) {
const [messages, setMessages] = useState<Message[]>(() => readStoredMessages(threadId));
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
},
[],
);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const submit = useCallback(
async ({ targetThreadId, query }: { targetThreadId: string; query: string }) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const humanId = createId("multi-rag-human");
const assistantId = createId("multi-rag-ai");
const sourceId = createId("multi-rag-sources");
const trace = initialTrace();
let sources: MultiRoundRagSource[] = [];
const clientTs = new Date().toISOString();
const userMessage: Message = {
id: humanId,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: { client_ts: clientTs, multi_round_rag: true },
};
const assistantMessage: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: {
multi_round_rag: true,
multi_round_rag_trace: trace,
},
};
updateMessages(targetThreadId, (current) => [
...current,
userMessage,
assistantMessage,
]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
const refreshAssistant = () => {
const traceSnapshot = cloneTrace(trace);
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: traceSnapshot.answer,
additional_kwargs: {
...next[assistantIndex]!.additional_kwargs,
multi_round_rag: true,
multi_round_rag_trace: traceSnapshot,
reasoning_content: traceSnapshot.rounds
.map((round) => round.thought)
.filter(Boolean)
.join("\n\n") || null,
},
} as Message;
if (sources.length > 0) {
next.splice(assistantIndex, 0, {
id: sourceId,
type: "tool",
name: "multi_round_rag_references",
content: { results: sources },
additional_kwargs: {
hide_from_ui: true,
multi_round_rag: true,
},
} as unknown as Message);
}
return next;
});
};
try {
const baseUrl = readRuntimeEnv("VITE_AIPI_GENERATE_CHAT_URL") || "";
if (!baseUrl) {
throw new Error("未配置 VITE_AIPI_GENERATE_CHAT_URL,无法发起多轮 RAG 问答。");
}
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({ prompt: question }),
});
if (!response.ok) {
throw new Error(`多轮 RAG 服务请求失败(${response.status})。`);
}
if (!response.body) {
throw new Error("多轮 RAG 服务未返回流式响应。");
}
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 lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
const event = parseEventLine(line);
if (!event) continue;
const eventType = asString(event.event);
const data = event.data;
if (eventType === "tool_call") {
const tool = asRecord(data);
const toolName = asString(tool?.tool_name);
const input = asRecord(tool?.tool_input) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
const text = asString(input.text);
if (text.includes("<think>")) {
const thinkEnd = text.indexOf("</think>");
addThinkingRound(
trace,
(thinkEnd >= 0
? text.slice(0, thinkEnd + "</think>".length)
: text
)
.replace(/<\/?think>/g, "")
.trim(),
);
if (thinkEnd >= 0) {
const roundBody = text
.slice(thinkEnd + "</think>".length)
.trim();
if (roundBody) {
appendRoundBody(trace, roundBody);
}
}
} else {
// A non-thinking `show_text` immediately after a thought is
// this round's narrative; the terminal `message` event is
// still rendered only as the final answer below all rounds.
current.isSearching = false;
trace.answer += text;
trace.isComplete = true;
}
} else if (
toolName === "local_search" ||
toolName === "tavily_search" ||
toolName === "google_search"
) {
const queryData = asRecord(parseJson(input.result));
current.title =
toolName === "local_search" ? "本地知识检索" : "联网检索";
current.query =
asString(input.q ?? input.query) ||
asString(queryData?.query) ||
current.query;
const retrievals = mapRetrievals(input.result);
if (retrievals.length > 0) current.retrievals = retrievals;
current.isSearching = false;
} else if (toolName === "get_textid_content") {
const article = asRecord(parseJson(input.result));
const body =
asString(article?.content ?? article?.text) ||
asString(input.content);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
}
refreshAssistant();
} else if (eventType === "message" && typeof data === "string") {
latestRound(trace).isSearching = false;
trace.answer += data;
trace.isComplete = true;
refreshAssistant();
} else if (eventType === "references") {
sources = mapReferences(data);
refreshAssistant();
} else if (eventType === "done") {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
}
}
}
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
} catch (caught) {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "多轮 RAG 问答失败。";
setError(message);
if (!trace.answer) trace.answer = message;
}
refreshAssistant();
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[isLoading, updateMessages],
);
return { messages, isLoading, error, submit, stop };
}
export function isFrontendRagMessage(message: Message): boolean {
return isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message);
}
@@ -0,0 +1,432 @@
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>;
};
type RagHistoryEntry = {
role: "user" | "assistant";
content: string;
};
const MAX_HISTORY_MESSAGES = 12;
/** 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";
}
function textFromMessage(message: Message): string {
if (typeof message.content === "string") {
return message.content.trim();
}
if (!Array.isArray(message.content)) {
return "";
}
return message.content
.map((part) => {
if (typeof part === "string") return part;
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("\n")
.trim();
}
/**
* The general-QA endpoint accepts the usual `{ role, content }` history
* format. Keep it scoped to completed single-round-RAG turns so a normal
* DeerFlow conversation never leaks into this external endpoint.
*/
function buildRagHistory(messages: Message[]): RagHistoryEntry[] {
return messages
.filter(
(message) =>
message.additional_kwargs?.single_round_rag === true &&
(message.type === "human" || message.type === "ai"),
)
.map((message): RagHistoryEntry => ({
role: message.type === "human" ? "user" : "assistant",
content: textFromMessage(message),
}))
.filter((entry) => entry.content.length > 0)
.slice(-MAX_HISTORY_MESSAGES);
}
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 messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
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 history = buildRagHistory(messagesRef.current);
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 };
}