Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# 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:
commit
e0b6e1cd19
600 files changed
+83188
-3340
No files matched your search
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -402,15 +402,26 @@ export async function getSkillContent(
|
||||
return response.json() as Promise<SkillContentResponse>;
|
||||
}
|
||||
|
||||
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to delete skill");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a built-in skill. The backend permits this only for administrators. */
|
||||
export async function deleteBuiltinSkill(skillName: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to delete built-in skill");
|
||||
}
|
||||
}
|
||||
|
||||
export interface SkillFileNode {
|
||||
name: string;
|
||||
@@ -479,7 +490,7 @@ export function getSkillFileDownloadURL(skillName: string, path: string): string
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveSkillFile(
|
||||
export async function saveSkillFile(
|
||||
skillName: string,
|
||||
path: string,
|
||||
content: string,
|
||||
@@ -494,9 +505,22 @@ export async function saveSkillFile(
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to save skill file");
|
||||
}
|
||||
return response.json() as Promise<SkillFileSaveResponse>;
|
||||
}
|
||||
}
|
||||
return response.json() as Promise<SkillFileSaveResponse>;
|
||||
}
|
||||
|
||||
export async function deleteSkillFile(
|
||||
skillName: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
const url =
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
|
||||
`?path=${encodeURIComponent(path)}`;
|
||||
const response = await apiFetch(url, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to delete skill file");
|
||||
}
|
||||
}
|
||||
|
||||
export interface SkillNameZhResponse {
|
||||
name: string;
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
import {
|
||||
deleteBuiltinSkill,
|
||||
deleteCustomSkill,
|
||||
deleteSkillFile,
|
||||
duplicateSkill,
|
||||
enableSkill,
|
||||
generateMissingSkillNameZh,
|
||||
@@ -318,7 +320,7 @@ export function useSkillFileContent(skillName: string | null, path: string | nul
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSkillFile() {
|
||||
export function useSaveSkillFile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -339,5 +341,33 @@ export function useSaveSkillFile() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSkillFile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
path,
|
||||
}: {
|
||||
skillName: string;
|
||||
path: string;
|
||||
}) => deleteSkillFile(skillName, path),
|
||||
onSuccess: (_data, { skillName, path }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
|
||||
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteBuiltinSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteBuiltinSkill,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]!));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user