This commit is contained in:
ZyGuo committed 2026-06-03 09:41:09 +08:00
commit a5b65f4087
91 files changed
+3731 -2141

No files matched your search

+14
View File
@@ -84,6 +84,20 @@ offline-backend-20260512/backend/tests/*
deploy
.history
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
.codegraph
codegraph.mdc
+2 -3
View File
@@ -5,15 +5,14 @@
# ├─────────────────┼──────────────────────────────────────────────────┤
# │ pnpm dev │ .env.development + .env.development.local │
# │ pnpm build │ .env.production + .env.production.local │
# │ pnpm build:sit │ .env.sit + .env.sit.local │
# │ pnpm build:network │ .env.network + .env.network.local(内网地址) │
# │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
# └─────────────────┴──────────────────────────────────────────────────┘
#
# 推荐:
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址)
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
# - 内网 UAT 打包部署:改 .env.production 后再执行 pnpm build
# - SIT 环境打包:改 .env.sit 后再执行 pnpm build:sit(地址为 UAT 的 sit 版本)
# - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build)
#
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
+30
View File
@@ -0,0 +1,30 @@
# 内网 / 局域网生产打包(npm run build:network 或 pnpm build:network)
# 打包前请将 192.168.1.100 改为实际服务器 IP;变量会写入 dist,运行时不可再改
# Gateway / DeerFlow API(与 offline-backend DEPLOY 文档一致)
VITE_BACKEND_BASE_URL=http://47.88.25.99
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://192.168.1.100:8000
# CMZS 后端接口
VITE_API_BASE_URL=http://192.168.1.100/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=http://192.168.1.100/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=http://192.168.1.100/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=http://192.168.1.100/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=http://192.168.1.100/knowlibrary
VITE_AIPI_B1COH_URL=http://192.168.1.100/web
VITE_AIPI_B2ACTOR_URL=http://192.168.1.100
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=http://192.168.1.100/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=http://192.168.1.100/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://192.168.1.100
VITE_AIPI_CONSUMER_URL=http://192.168.1.100/consumer
VITE_AIPI_PGLINKTO_URL=http://192.168.1.100
VITE_AIPI_XKLINKTO_URL=http://192.168.1.100
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
+2
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
"build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit",
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
@@ -58,6 +59,7 @@
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"dayjs": "^1.11.21",
"docx": "^9.5.1",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.6.0",
+2
View File
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { I18nProvider } from "@/core/i18n/context";
@@ -39,6 +40,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
<ThemeProvider>
<ChatProvider>
<AuthSyncWatcher />
<FontScaleApplier />
{children}
</ChatProvider>
</ThemeProvider>
@@ -0,0 +1,45 @@
"use client";
import { useEffect } from "react";
import { useLocalSettings } from "@/core/settings";
/**
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
* `fontScale === 1` 即默认 16px。
*/
export const BASE_FONT_PX = 16;
export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[] =
[
{ value: 0.875, label: "小", px: 14 },
{ value: 1, label: "标准", px: 16 },
{ value: 1.125, label: "大", px: 18 },
{ value: 1.25, label: "特大", px: 20 },
];
/**
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
* 不渲染任何 DOM。
*/
export function FontScaleApplier() {
const [settings] = useLocalSettings();
const { fontScale } = settings.display;
useEffect(() => {
const scale =
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
? fontScale
: 1;
const root = document.documentElement;
root.style.setProperty("--ui-font-scale", String(scale));
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
root.style.removeProperty("font-size");
}, [fontScale]);
return null;
}
+28 -28
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
@@ -14,7 +14,7 @@ const buttonVariants = cva(
destructive:
"cursor-pointer bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"cursor-pointer border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"cursor-pointer border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-[#213D5E] dark:bg-[#022248] dark:text-[#CCD2DA] dark:hover:border-[#213D5E] dark:hover:bg-[#0a2f58] dark:hover:text-[#CCD2DA] dark:[&_svg]:text-[#CCD2DA]",
secondary:
"cursor-pointer bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
@@ -37,27 +37,27 @@ const buttonVariants = cva(
},
);
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="button"
{...(variant !== undefined && { "data-variant": variant })}
{...(size !== undefined && { "data-size": size })}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
});
Button.displayName = "Button";
export { Button, buttonVariants };
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="button"
{...(variant !== undefined && { "data-variant": variant })}
{...(size !== undefined && { "data-size": size })}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
});
Button.displayName = "Button";
export { Button, buttonVariants };
+3 -3
View File
@@ -34,7 +34,7 @@ const emptyMediaVariants = cva(
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6 dark:text-[#55A6FE] dark:bg-[#022248]",
},
},
defaultVariants: {
@@ -62,7 +62,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
className={cn("text-lg font-medium tracking-tight dark:text-[#AACEFF]", className)}
{...props}
/>
);
@@ -73,7 +73,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4 dark:text-[#55A6FE]",
className,
)}
{...props}
+10 -1
View File
@@ -3,6 +3,14 @@ import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
const usesDarkNativePicker =
type === "number" ||
type === "date" ||
type === "datetime-local" ||
type === "time" ||
type === "month" ||
type === "week";
return (
<input
type={type}
@@ -10,7 +18,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
className={cn(
"h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] md:text-sm",
"border-input placeholder:text-muted-foreground",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:placeholder:text-[#566B7F] rounded-[4px] text-[16px]",
"dark:border-[#1B477E] dark:bg-[#0D2D54] dark:text-[#AACEFF] dark:placeholder:text-[#566B7F] dark:rounded-[4px] dark:text-[16px] md:dark:text-[16px]",
usesDarkNativePicker && "dark:[color-scheme:dark]",
"file:text-foreground selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
+98 -93
View File
@@ -24,69 +24,70 @@ function SelectValue({
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}
>(({ className, size = "default", children, ...props }, ref) => {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}
>(({ className, size = "default", children, ...props }, ref) => {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEE2] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#022248] dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
});
SelectContent.displayName = SelectPrimitive.Content.displayName;
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
});
SelectContent.displayName = SelectPrimitive.Content.displayName;
function SelectLabel({
className,
@@ -95,40 +96,44 @@ function SelectLabel({
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
className={cn(
"text-muted-foreground px-2 py-1.5 text-xs dark:text-[#566B7F]",
className,
)}
{...props}
/>
);
}
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
});
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => {
return (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"dark:text-[#AACEFF] dark:focus:bg-[#0062D9] dark:focus:text-white dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus:[&_svg:not([class*='text-'])]:text-white dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-white",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
});
SelectItem.displayName = SelectPrimitive.Item.displayName;
function SelectSeparator({
className,
@@ -151,7 +156,7 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
@@ -169,7 +174,7 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className,
)}
{...props}
+1 -1
View File
@@ -13,7 +13,7 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-[#55A6FE] dark:data-[state=checked]:bg-[#0062D9] inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+10 -6
View File
@@ -32,7 +32,8 @@ const tabsListVariants = cva(
variant: {
default:
"bg-muted dark:group-data-[orientation=horizontal]/tabs:h-8! dark:items-stretch dark:gap-0 dark:overflow-hidden dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
line: "gap-1 bg-transparent",
line:
"gap-1 bg-transparent dark:group-data-[orientation=horizontal]/tabs:h-8! dark:items-stretch dark:gap-0 dark:overflow-visible dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
},
},
defaultVariants: {
@@ -70,13 +71,16 @@ const TabsTrigger = React.forwardRef<
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"dark:text-muted-foreground dark:hover:text-foreground",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground",
"dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-input dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-input/30 dark:group-data-[variant=line]/tabs-list:data-[state=active]:text-foreground",
"dark:group-data-[variant=default]/tabs-list:relative dark:group-data-[variant=default]/tabs-list:!h-full dark:group-data-[variant=default]/tabs-list:self-stretch dark:group-data-[variant=default]/tabs-list:flex-none dark:group-data-[variant=default]/tabs-list:rounded-none dark:group-data-[variant=default]/tabs-list:border-0 dark:group-data-[variant=default]/tabs-list:bg-transparent dark:group-data-[variant=default]/tabs-list:px-4 dark:group-data-[variant=default]/tabs-list:!py-0 dark:group-data-[variant=default]/tabs-list:!leading-none dark:group-data-[variant=default]/tabs-list:shadow-none dark:group-data-[variant=default]/tabs-list:focus-visible:ring-0 dark:group-data-[variant=default]/tabs-list:[&_svg]:text-[#CCD2DA]",
"dark:group-data-[variant=line]/tabs-list:relative dark:group-data-[variant=line]/tabs-list:!h-full dark:group-data-[variant=line]/tabs-list:self-stretch dark:group-data-[variant=line]/tabs-list:flex-none dark:group-data-[variant=line]/tabs-list:rounded-none dark:group-data-[variant=line]/tabs-list:border-0 dark:group-data-[variant=line]/tabs-list:bg-transparent dark:group-data-[variant=line]/tabs-list:px-4 dark:group-data-[variant=line]/tabs-list:!py-0 dark:group-data-[variant=line]/tabs-list:!leading-none dark:group-data-[variant=line]/tabs-list:text-[#CCD2DA] dark:group-data-[variant=line]/tabs-list:shadow-none dark:group-data-[variant=line]/tabs-list:hover:text-[#CCD2DA] dark:group-data-[variant=line]/tabs-list:focus-visible:ring-0 dark:group-data-[variant=line]/tabs-list:[&_svg]:text-[#CCD2DA]",
"dark:group-data-[variant=line]/tabs-list:not(:first-child):border-l dark:group-data-[variant=line]/tabs-list:not(:first-child):border-[#1d3961] dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-l-0",
"dark:group-data-[variant=line]/tabs-list:data-[state=active]:rounded-none dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-0 dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-[#0062D9] dark:group-data-[variant=line]/tabs-list:data-[state=active]:text-[#FFFFFF] dark:group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=line]/tabs-list:data-[state=active]:[&_svg]:text-[#FFFFFF]",
"dark:group-data-[variant=default]/tabs-list:flex dark:group-data-[variant=default]/tabs-list:min-h-0 dark:group-data-[variant=default]/tabs-list:!h-full dark:group-data-[variant=default]/tabs-list:flex-1 dark:group-data-[variant=default]/tabs-list:self-stretch dark:group-data-[variant=default]/tabs-list:rounded-none dark:group-data-[variant=default]/tabs-list:border-0 dark:group-data-[variant=default]/tabs-list:bg-transparent dark:group-data-[variant=default]/tabs-list:px-4 dark:group-data-[variant=default]/tabs-list:!py-0 dark:group-data-[variant=default]/tabs-list:!leading-none dark:group-data-[variant=default]/tabs-list:text-[#CCD2DA] dark:group-data-[variant=default]/tabs-list:shadow-none dark:group-data-[variant=default]/tabs-list:hover:text-[#CCD2DA] dark:group-data-[variant=default]/tabs-list:focus-visible:ring-0 dark:group-data-[variant=default]/tabs-list:[&_svg]:text-[#CCD2DA]",
"dark:group-data-[variant=default]/tabs-list:not(:first-child):border-l dark:group-data-[variant=default]/tabs-list:not(:first-child):border-[#1d3961] dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-l-0",
"dark:group-data-[variant=default]/tabs-list:data-[state=active]:rounded-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-0 dark:group-data-[variant=default]/tabs-list:data-[state=active]:bg-[#0062D9] dark:group-data-[variant=default]/tabs-list:data-[state=active]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:[&_svg]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:hover:text-[#CCD2DA]",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
"dark:group-data-[variant=default]/tabs-list:data-[state=active]:rounded-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-0 dark:group-data-[variant=default]/tabs-list:data-[state=active]:bg-[#0062D9] dark:group-data-[variant=default]/tabs-list:data-[state=active]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:hover:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:data-[state=active]:[&_svg]:text-[#FFFFFF]",
"group-data-[variant=line]/tabs-list:after:pointer-events-none group-data-[variant=line]/tabs-list:after:absolute group-data-[variant=line]/tabs-list:after:inset-x-2 group-data-[variant=line]/tabs-list:after:bottom-[-5px] group-data-[variant=line]/tabs-list:after:h-0.5 group-data-[variant=line]/tabs-list:after:bg-foreground group-data-[variant=line]/tabs-list:after:opacity-0 group-data-[variant=line]/tabs-list:after:transition-opacity group-data-[variant=line]/tabs-list:after:content-[''] group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
"dark:group-data-[variant=line]/tabs-list:after:inset-x-0 dark:group-data-[variant=line]/tabs-list:after:bottom-0 dark:group-data-[variant=line]/tabs-list:after:z-[2] dark:group-data-[variant=line]/tabs-list:after:h-[2px] dark:group-data-[variant=line]/tabs-list:after:bg-[#FFFFFF]",
className,
)}
{...props}
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/contexts/ThemeContext";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
@@ -90,7 +91,7 @@ function V1SectionField({
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<span className="text-xs font-medium text-muted-foreground dark:text-[#AACEE2]">{label}</span>
{!editing && (
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
<Pencil className="size-3" />
@@ -154,7 +155,7 @@ function V1FactItem({
}
return (
<div className="rounded border p-2 space-y-1 group">
<div className="rounded border p-2 space-y-1 group ">
{editing ? (
<div className="space-y-2">
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
@@ -196,7 +197,7 @@ function V1FactItem({
</Button>
</div>
</div>
<p className="text-sm whitespace-pre-wrap">{String(fact.content ?? "")}</p>
<p className="text-sm whitespace-pre-wrap dark:text-[#AACEE2]">{String(fact.content ?? "")}</p>
{fact.sourceError ? <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">避免:{String(fact.sourceError)}</p> : null}
</>
)}
@@ -206,6 +207,7 @@ function V1FactItem({
// 新增事实表单
function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
const { theme } = useTheme();
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
const [category, setCategory] = useState("context");
@@ -222,7 +224,12 @@ function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string
if (!open) {
return (
<Button variant="outline" size="sm" className="mt-1 w-full" onClick={() => setOpen(true)}>
<Button
variant={theme === "dark" ? "default" : "outline"}
size="sm"
className="mt-1 w-full"
onClick={() => setOpen(true)}
>
<Plus className="size-3" />新增事实
</Button>
);
@@ -276,8 +283,8 @@ export function V1BucketView({
return (
<div className="space-y-4">
{/* 用户画像字段 */}
<div className="rounded-md border p-3 space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground">用户画像</h4>
<div className="rounded-md border p-3 space-y-3 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<h4 className="text-xs font-semibold text-muted-foreground dark:text-white">用户画像</h4>
{allCtxFields.map((field) => (
<V1SectionField
key={field}
@@ -291,8 +298,8 @@ export function V1BucketView({
</div>
{/* 历史摘要字段 */}
<div className="rounded-md border p-3 space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground">历史摘要</h4>
<div className="rounded-md border p-3 space-y-3 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<h4 className="text-xs font-semibold text-muted-foreground dark:text-white">历史摘要</h4>
{allHistoryFields.map((field) => (
<V1SectionField
key={field}
@@ -306,8 +313,8 @@ export function V1BucketView({
</div>
{/* 事实记录 */}
<div className="rounded-md border p-3 space-y-2">
<h4 className="text-xs font-semibold text-muted-foreground">事实记录({factsRaw.length} 条)</h4>
<div className="rounded-md border p-3 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h4 className="text-xs font-semibold text-muted-foreground dark:text-white">事实记录({factsRaw.length} 条)</h4>
{factsRaw.map((fact, i) => (
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} />
))}
@@ -179,7 +179,7 @@ function UserAuditPanel({ userId }: { userId: string }) {
<ScrollArea className="min-h-0 flex-1">
<div className="p-4">
{!threadId && (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm dark:text-[#AACEF8]">
<MessageSquareIcon className="size-6" />
请从左侧选择一个会话
</div>
@@ -253,7 +253,7 @@ function AgentMemoryPanel({ userId }: { userId: string }) {
<SelectTrigger className="h-8 w-48" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="bottom" sideOffset={4}>
{/* 默认助手始终显示,对应全局记忆文件 */}
<SelectItem value="default">默认助手</SelectItem>
{otherBuckets.map((b) => (
@@ -224,16 +224,16 @@ export function TagManagementPage() {
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
<th className="text-muted-foreground px-4 py-3 text-left font-medium dark:text-white">
{t.adminTags.name}
</th>
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
<th className="text-muted-foreground px-4 py-3 text-left font-medium dark:text-white">
{t.adminTags.tagDescription}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
<th className="text-muted-foreground px-4 py-3 text-right font-medium dark:text-white">
{t.adminTags.usageCount}
</th>
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
<th className="text-muted-foreground px-4 py-3 text-right font-medium dark:text-white">
{t.adminTags.actions}
</th>
</tr>
@@ -1,6 +1,6 @@
"use client";
import { Download, Loader2 } from "lucide-react";
import { AlertCircle,CheckCircle2, Download, Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -29,6 +29,7 @@ import {
} from "@/core/tool-metrics/api";
import { useToolMetrics, useToolMetricsSummary } from "@/core/tool-metrics/hooks";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
const PAGE_SIZE = 50;
@@ -66,6 +67,7 @@ function categoryLabel(category?: string | null): string {
}
export function ToolMetricsPanel() {
const { theme } = useTheme();
const [page, setPage] = useState(0);
const [tool, setTool] = useState("");
const [user, setUser] = useState("");
@@ -79,6 +81,31 @@ export function ToolMetricsPanel() {
const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false);
const [detailRow, setDetailRow] = useState<ToolMetric | null>(null);
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const filters = useMemo<ToolMetricsFilters>(() => {
const out: ToolMetricsFilters = {};
@@ -117,9 +144,9 @@ export function ToolMetricsPanel() {
setExporting(true);
try {
await downloadToolMetricsExcel(filters);
toast.success("已导出为 Excel");
toast.success("已导出为 Excel",darkToastOpts.success);
} catch (e) {
toast.error(e instanceof Error ? e.message : "导出失败");
toast.error(e instanceof Error ? e.message : "导出失败",darkToastOpts.error);
} finally {
setExporting(false);
}
@@ -167,7 +194,7 @@ export function ToolMetricsPanel() {
</div>
{/* Summary strip */}
<div className="flex flex-wrap items-center gap-2 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-2 rounded-xl border bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="mr-4 flex flex-col">
<span className="text-muted-foreground text-xs">总调用</span>
<span className="text-lg font-semibold tabular-nums">
@@ -213,7 +240,7 @@ export function ToolMetricsPanel() {
{/* Per-skill breakdown — answers "was skill X invoked, and did it succeed". */}
{summary && summary.by_skill.length > 0 && (
<div className="rounded-xl border bg-white p-4">
<div className="rounded-xl border bg-white p-4 ">
<h3 className="mb-2 text-sm font-medium">各技能调用情况</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -258,7 +285,7 @@ export function ToolMetricsPanel() {
</div>
)}
{summary && kind === "skill" && summary.by_skill.length === 0 && (
<div className="text-muted-foreground rounded-xl border bg-white p-4 text-sm">
<div className="text-muted-foreground rounded-xl border bg-white p-4 text-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
当前筛选范围内没有检测到任何技能调用记录。技能只有在被 Agent 通过
<code className="bg-muted mx-1 rounded px-1">skill_view</code>
读取(即真正"调用")时才会被记录;仅在系统提示里被列出、但未被读取的技能不会计入。
@@ -266,9 +293,9 @@ export function ToolMetricsPanel() {
)}
{/* Filters */}
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-3 lg:grid-cols-6">
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-3 lg:grid-cols-6 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">调用类型</label>
<label className="text-muted-foreground text-xs dark:text-white">调用类型</label>
<Select
value={kind}
onValueChange={(value) => {
@@ -276,17 +303,17 @@ export function ToolMetricsPanel() {
setPage(0);
}}
>
<SelectTrigger>
<SelectTrigger className="dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="skill">技能(Agent Skill)</SelectItem>
<SelectItem value="all">全部工具调用</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">技能名称</label>
<label className="text-muted-foreground text-xs dark:text-white">技能名称</label>
<Input
value={skill}
placeholder="按技能名筛选"
@@ -297,7 +324,7 @@ export function ToolMetricsPanel() {
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">工具名</label>
<label className="text-muted-foreground text-xs dark:text-white">工具名</label>
<Input
value={tool}
placeholder="按工具名筛选"
@@ -308,7 +335,7 @@ export function ToolMetricsPanel() {
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">用户名</label>
<label className="text-muted-foreground text-xs dark:text-white">用户名</label>
<Input
value={user}
placeholder="@ 前部分"
@@ -319,7 +346,7 @@ export function ToolMetricsPanel() {
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">状态</label>
<label className="text-muted-foreground text-xs dark:text-white">状态</label>
<Select
value={status}
onValueChange={(value) => {
@@ -327,10 +354,10 @@ export function ToolMetricsPanel() {
setPage(0);
}}
>
<SelectTrigger>
<SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">失败</SelectItem>
@@ -338,7 +365,7 @@ export function ToolMetricsPanel() {
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">错误类型</label>
<label className="text-muted-foreground text-xs dark:text-white">错误类型</label>
<Select
value={category}
onValueChange={(value) => {
@@ -346,10 +373,10 @@ export function ToolMetricsPanel() {
setPage(0);
}}
>
<SelectTrigger>
<SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部</SelectItem>
{TOOL_ERROR_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
@@ -360,7 +387,7 @@ export function ToolMetricsPanel() {
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">开始时间</label>
<label className="text-muted-foreground text-xs dark:text-white">开始时间</label>
<Input
type="datetime-local"
value={since}
@@ -371,7 +398,7 @@ export function ToolMetricsPanel() {
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">结束时间</label>
<label className="text-muted-foreground text-xs dark:text-white">结束时间</label>
<Input
type="datetime-local"
value={until}
@@ -389,10 +416,10 @@ export function ToolMetricsPanel() {
</div>
{/* Table */}
<div className="rounded-xl border bg-white">
<div className="rounded-xl border bg-white dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="overflow-x-auto">
<table className="w-full min-w-[960px] text-sm">
<thead className="bg-muted/60 text-muted-foreground">
<thead className="bg-muted/60 text-muted-foreground dark:text-white">
<tr>
<Th>时间</Th>
<Th>用户</Th>
@@ -16,7 +16,11 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import type { SkillDisplayConfig } from "@/core/skills/type";
import {
displayConfigFromResultSet,
normalizeDisplaySources,
} from "@/core/skill-display/display-adapter";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
@@ -621,9 +625,10 @@ function adaptBackendSourceWithSkillDisplay(
if (!match) {
return null;
}
const display = findDisplayConfigForBackendSource(source, match.display);
const normalized = normalizeDisplaySources(
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
match.display,
buildPayloadAtPath(display.resultPath || "results", [rawItem]),
display,
)[0];
if (!normalized) {
return null;
@@ -632,13 +637,27 @@ function adaptBackendSourceWithSkillDisplay(
...source,
...normalized,
skillName: match.skillName,
mode: match.display.mode,
tableColumns: match.display.tableColumns,
mode: display.mode,
tableColumns: display.tableColumns,
index: source.index,
raw: source.raw,
};
}
function findDisplayConfigForBackendSource(
source: BackendReferenceSource,
display: SkillDisplayConfig,
) {
const resultSetKey = source.resultSetKey;
if (resultSetKey) {
const resultSet = display.resultSets?.find((item) => item.key === resultSetKey);
if (resultSet) {
return displayConfigFromResultSet(display, resultSet);
}
}
return { ...display, resultSets: [] };
}
function findBackendSourceDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
@@ -8,7 +8,6 @@ import {
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
@@ -36,13 +35,13 @@ export function MarkdownContent({
}: MarkdownContentProps) {
const components = useMemo(() => {
return {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />;
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (props.href?.startsWith("#reference-")) {
return <CitationLink {...props} />;
}
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
@@ -67,8 +66,6 @@ export function MarkdownContent({
if (!content) return null;
const displayContent = desensitize(content);
return (
<MessageResponse
className={className}
@@ -76,7 +73,7 @@ export function MarkdownContent({
rehypePlugins={rehypePlugins}
components={components}
>
{displayContent}
{content}
</MessageResponse>
);
}
@@ -258,12 +258,12 @@ function ToolCall({
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: displayMatch.display.mode,
}))
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
return (
@@ -1,33 +1,38 @@
import type { Message } from "@langchain/langgraph-sdk";
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
import {
memo,
useCallback,
useContext,
useMemo,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
} from "react";
import rehypeKatex from "rehype-katex";
import type { Message } from '@langchain/langgraph-sdk';
import { ClockIcon, FileIcon, Loader2Icon } from 'lucide-react';
import {
memo,
useCallback,
useContext,
useMemo,
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
} from 'react';
import rehypeKatex from 'rehype-katex';
import { Loader } from "@/components/ai-elements/loader";
import { Loader } from '@/components/ai-elements/loader';
import {
Message as AIElementMessage,
MessageContent as AIElementMessageContent,
MessageResponse as AIElementMessageResponse,
MessageToolbar,
} from "@/components/ai-elements/message";
} from '@/components/ai-elements/message';
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useChatAskTimes } from "@/core/chat-timestamps";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
} from '@/components/ai-elements/reasoning';
import { Task, TaskTrigger } from '@/components/ai-elements/task';
import { Badge } from '@/components/ui/badge';
import {
UploadedFilePreviewDialog,
useAuthedObjectUrl,
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
import { resolveArtifactURL } from '@/core/artifacts/utils';
import { useChatAskTimes } from '@/core/chat-timestamps';
import { getBackendBaseURL } from '@/core/config';
import { useI18n } from '@/core/i18n/hooks';
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
@@ -35,63 +40,63 @@ import {
stripArtifactTag,
stripUploadedFilesTag,
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
} from '@/core/messages/utils';
import { useRehypeSplitWordsIntoSpans } from '@/core/rehype';
import {
applyInlineCitations,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { humanMessagePlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
} from '@/core/skill-display/runtime';
import { humanMessagePlugins } from '@/core/streamdown';
import { cn } from '@/lib/utils';
import { CitationLink } from "../citations/citation-link";
import { CopyButton } from "../copy-button";
import { SkillDisplayBlock } from "../skill-display-block";
import { ThreadContext } from "./context";
import { DownloadWordButton } from "./download-word-button";
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage";
type EmbeddedPageContext = {
title?: string;
url?: string;
content?: string;
};
function extractTagContent(source: string, tagName: string) {
const match = new RegExp(`<${tagName}>\\s*([\\s\\S]*?)\\s*</${tagName}>`, "i").exec(
source,
);
return match?.[1]?.trim() ?? "";
}
function parseEmbeddedPageContext(source: string): {
content: string;
pageContext: EmbeddedPageContext | null;
} {
const match = /<page_context>\s*([\s\S]*?)\s*<\/page_context>/i.exec(source);
if (!match) {
return { content: source, pageContext: null };
}
const body = match[1] ?? "";
const pageContext = {
title: extractTagContent(body, "title") || undefined,
url: extractTagContent(body, "url") || undefined,
content: extractTagContent(body, "content") || undefined,
};
const cleanedContent = source
.replace(match[0], "")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!pageContext.title && !pageContext.url && !pageContext.content) {
return { content: cleanedContent, pageContext: null };
}
return { content: cleanedContent, pageContext };
}
import { CitationLink } from '../citations/citation-link';
import { CopyButton } from '../copy-button';
import { SkillDisplayBlock } from '../skill-display-block';
import { ThreadContext } from './context';
import { DownloadWordButton } from './download-word-button';
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
import { MessageTokenUsage } from './message-token-usage';
type EmbeddedPageContext = {
title?: string;
url?: string;
content?: string;
};
function extractTagContent(source: string, tagName: string) {
const match = new RegExp(
`<${tagName}>\\s*([\\s\\S]*?)\\s*</${tagName}>`,
'i',
).exec(source);
return match?.[1]?.trim() ?? '';
}
function parseEmbeddedPageContext(source: string): {
content: string;
pageContext: EmbeddedPageContext | null;
} {
const match = /<page_context>\s*([\s\S]*?)\s*<\/page_context>/i.exec(source);
if (!match) {
return { content: source, pageContext: null };
}
const body = match[1] ?? '';
const pageContext = {
title: extractTagContent(body, 'title') || undefined,
url: extractTagContent(body, 'url') || undefined,
content: extractTagContent(body, 'content') || undefined,
};
const cleanedContent = source
.replace(match[0], '')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (!pageContext.title && !pageContext.url && !pageContext.content) {
return { content: cleanedContent, pageContext: null };
}
return { content: cleanedContent, pageContext };
}
/**
* Resolve an in-conversation file path to a fetchable URL.
@@ -105,7 +110,7 @@ function useArtifactResolver(threadId: string): (path: string) => string {
return useCallback(
(path: string) => {
if (publicShareCode) {
const normalized = path.startsWith("/") ? path : `/${path}`;
const normalized = path.startsWith('/') ? path : `/${path}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
@@ -116,42 +121,42 @@ function useArtifactResolver(threadId: string): (path: string) => string {
);
}
export function MessageListItem({
className,
fitRowToContent = false,
message,
isLoading,
threadId,
tokenUsageEnabled = false,
export function MessageListItem({
className,
fitRowToContent = false,
message,
isLoading,
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
}: {
className?: string;
fitRowToContent?: boolean;
message: Message;
isLoading?: boolean;
threadId: string;
}: {
className?: string;
fitRowToContent?: boolean;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const isHuman = message.type === "human";
const isHuman = message.type === 'human';
const messageMarkdown =
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
"";
return (
<AIElementMessage
className={cn(
"group/conversation-message relative",
fitRowToContent ? "max-w-full w-fit" : "w-full",
isHuman && fitRowToContent && "ml-auto",
className,
)}
from={isHuman ? "user" : "assistant"}
>
'';
return (
<AIElementMessage
className={cn(
'group/conversation-message relative',
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
isHuman && fitRowToContent && 'ml-auto',
className,
)}
from={isHuman ? 'user' : 'assistant'}
>
<MessageContent
className={isHuman ? "w-fit" : "w-full"}
className={isHuman ? 'w-fit' : 'w-full'}
message={message}
isLoading={isLoading}
threadId={threadId}
@@ -162,8 +167,8 @@ export function MessageListItem({
{!isLoading && (
<MessageToolbar
className={cn(
isHuman ? "-bottom-9 justify-end" : "-bottom-8",
"absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100",
isHuman ? '-bottom-9 justify-end' : '-bottom-8',
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
)}
>
<div className="flex gap-1">
@@ -188,7 +193,7 @@ function MessageImage({
src,
alt,
threadId,
maxWidth = "90%",
maxWidth = '90%',
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
@@ -197,13 +202,13 @@ function MessageImage({
const resolveArtifact = useArtifactResolver(threadId);
if (!src) return null;
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
const imgClassName = cn('overflow-hidden rounded-lg', `max-w-[${maxWidth}]`);
if (typeof src !== "string") {
if (typeof src !== 'string') {
return <img className={imgClassName} src={src} alt={alt} {...props} />;
}
const url = src.startsWith("/mnt/") ? resolveArtifact(src) : src;
const url = src.startsWith('/mnt/') ? resolveArtifact(src) : src;
return (
<a href={url} target="_blank" rel="noopener noreferrer">
@@ -212,7 +217,7 @@ function MessageImage({
);
}
function MessageContent_({
function MessageContent_({
className,
message,
isLoading = false,
@@ -231,11 +236,14 @@ function MessageContent_({
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
() =>
extraRehypePlugins?.length
? [...baseRehypePlugins, ...extraRehypePlugins]
: baseRehypePlugins,
[baseRehypePlugins, extraRehypePlugins],
);
const askTimes = useChatAskTimes();
const isHuman = message.type === "human";
const isHuman = message.type === 'human';
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
() => ({
@@ -243,13 +251,16 @@ function MessageContent_({
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
if (href?.startsWith('#reference-')) {
return <CitationLink href={href} {...props} />;
}
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
if (
typeof props.children === 'string' &&
/^citation:/i.test(props.children)
) {
return <CitationLink href={href} {...props} />;
}
if (href?.startsWith("/mnt/")) {
if (href?.startsWith('/mnt/')) {
const url = resolveArtifact(href);
return (
<a
@@ -277,7 +288,7 @@ function MessageContent_({
const files = useMemo(() => {
const files = message.additional_kwargs?.files;
if (!Array.isArray(files) || files.length === 0) {
if (rawContent.includes("<uploaded_files>")) {
if (rawContent.includes('<uploaded_files>')) {
// If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility.
return parseUploadedFiles(rawContent);
}
@@ -286,34 +297,36 @@ function MessageContent_({
return files as FileInMessage[];
}, [message.additional_kwargs?.files, rawContent]);
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
}
return applyInlineCitations(rawContent ?? "", displaySources);
}, [rawContent, isHuman, displaySources]);
const parsedHumanContent = useMemo(
() =>
isHuman
? parseEmbeddedPageContext(contentToDisplay)
: { content: contentToDisplay, pageContext: null },
[contentToDisplay, isHuman],
);
const filesList =
files && files.length > 0 ? (
<RichFilesList files={files} threadId={threadId} />
) : null;
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
return applyInlineCitations(rawContent ?? '', displaySources);
}, [rawContent, isHuman, displaySources]);
const parsedHumanContent = useMemo(
() =>
isHuman
? parseEmbeddedPageContext(contentToDisplay)
: { content: contentToDisplay, pageContext: null },
[contentToDisplay, isHuman],
);
const filesList =
files && files.length > 0 ? (
<RichFilesList files={files} threadId={threadId} />
) : null;
// Uploading state: mock AI message shown while files upload
if (message.additional_kwargs?.element === "task") {
if (message.additional_kwargs?.element === 'task') {
return (
<AIElementMessageContent className={className}>
<Task defaultOpen={false}>
<TaskTrigger title="">
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
<Loader className="size-4" />
<span>{desensitize(contentToDisplay)}</span>
<span>{contentToDisplay}</span>
</div>
</TaskTrigger>
</Task>
@@ -327,7 +340,7 @@ function MessageContent_({
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
<ReasoningTrigger />
<ReasoningContent>{desensitize(reasoningContent)}</ReasoningContent>
<ReasoningContent>{reasoningContent}</ReasoningContent>
</Reasoning>
<MessageTokenUsage
enabled={tokenUsageEnabled}
@@ -338,32 +351,39 @@ function MessageContent_({
);
}
if (isHuman) {
const messageResponse = parsedHumanContent.content ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
parseIncompleteMarkdown={false}
>
{desensitize(parsedHumanContent.content)}
</AIElementMessageResponse>
) : null;
return (
<div className={cn("ml-auto flex max-w-full min-w-0 flex-col items-end gap-2", className)}>
{filesList}
{messageResponse && (
<AIElementMessageContent className="min-w-0 max-w-full w-fit">
{messageResponse}
</AIElementMessageContent>
)}
{parsedHumanContent.pageContext ? (
<EmbeddedPageContextCard pageContext={parsedHumanContent.pageContext} />
) : null}
<MessageTimeStamp time={messageTime} align="right" />
</div>
);
}
if (isHuman) {
const messageResponse = parsedHumanContent.content ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
parseIncompleteMarkdown={false}
>
{contentToDisplay}
</AIElementMessageResponse>
) : null;
return (
<div
className={cn(
'ml-auto flex max-w-full min-w-0 flex-col items-end gap-2',
className,
)}
>
{filesList}
{messageResponse && (
<AIElementMessageContent className="min-w-0 max-w-full w-fit">
{messageResponse}
</AIElementMessageContent>
)}
{parsedHumanContent.pageContext ? (
<EmbeddedPageContextCard
pageContext={parsedHumanContent.pageContext}
/>
) : null}
<MessageTimeStamp time={messageTime} align="right" />
</div>
);
}
return (
<AIElementMessageContent className={className}>
@@ -374,7 +394,7 @@ function MessageContent_({
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
components={components}
/>
</div>
@@ -388,29 +408,29 @@ function MessageContent_({
) : null}
</AIElementMessageContent>
);
}
function EmbeddedPageContextCard({
pageContext,
}: {
pageContext: EmbeddedPageContext;
}) {
const previewText = [pageContext.title, pageContext.url, pageContext.content]
.filter(Boolean)
.join(" | ");
return (
<div className="bg-muted/70 ml-auto flex w-fit min-w-0 max-w-[min(24rem,calc(100vw-2rem),100%)] flex-col gap-2 rounded-2xl border px-3 py-2 text-sm shadow-sm">
<div
className="text-muted-foreground line-clamp-2 min-w-0 [overflow-wrap:anywhere] break-words text-xs"
title={previewText}
>
{previewText}
</div>
</div>
);
}
}
function EmbeddedPageContextCard({
pageContext,
}: {
pageContext: EmbeddedPageContext;
}) {
const previewText = [pageContext.title, pageContext.url, pageContext.content]
.filter(Boolean)
.join(' | ');
return (
<div className="bg-muted/70 ml-auto flex w-fit min-w-0 max-w-[min(24rem,calc(100vw-2rem),100%)] flex-col gap-2 rounded-2xl border px-3 py-2 text-sm shadow-sm">
<div
className="text-muted-foreground line-clamp-2 min-w-0 [overflow-wrap:anywhere] break-words text-xs"
title={previewText}
>
{previewText}
</div>
</div>
);
}
/**
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
* full localized string for the hover title. Returns ``null`` when no/invalid
@@ -419,10 +439,10 @@ function EmbeddedPageContextCard({
function formatAskedTime(
clientTs: unknown,
): { label: string; full: string } | null {
if (typeof clientTs !== "string" || !clientTs) return null;
if (typeof clientTs !== 'string' || !clientTs) return null;
const date = new Date(clientTs);
if (Number.isNaN(date.getTime())) return null;
const pad = (n: number) => String(n).padStart(2, "0");
const pad = (n: number) => String(n).padStart(2, '0');
const label =
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
@@ -435,17 +455,17 @@ function formatAskedTime(
*/
function MessageTimeStamp({
time,
align = "right",
align = 'right',
}: {
time: { label: string; full: string } | null;
align?: "left" | "right";
align?: 'left' | 'right';
}) {
if (!time) return null;
return (
<div
className={cn(
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums",
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5",
'text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums',
align === 'right' ? 'justify-end pr-0.5' : 'justify-start pl-0.5',
)}
title={time.full}
>
@@ -459,39 +479,39 @@ function MessageTimeStamp({
* Get file extension and check helpers
*/
const getFileExt = (filename: string) =>
filename.split(".").pop()?.toLowerCase() ?? "";
filename.split('.').pop()?.toLowerCase() ?? '';
const FILE_TYPE_MAP: Record<string, string> = {
json: "JSON",
csv: "CSV",
txt: "TXT",
md: "Markdown",
py: "Python",
js: "JavaScript",
ts: "TypeScript",
tsx: "TSX",
jsx: "JSX",
html: "HTML",
css: "CSS",
xml: "XML",
yaml: "YAML",
yml: "YAML",
pdf: "PDF",
png: "PNG",
jpg: "JPG",
jpeg: "JPEG",
gif: "GIF",
svg: "SVG",
zip: "ZIP",
tar: "TAR",
gz: "GZ",
json: 'JSON',
csv: 'CSV',
txt: 'TXT',
md: 'Markdown',
py: 'Python',
js: 'JavaScript',
ts: 'TypeScript',
tsx: 'TSX',
jsx: 'JSX',
html: 'HTML',
css: 'CSS',
xml: 'XML',
yaml: 'YAML',
yml: 'YAML',
pdf: 'PDF',
png: 'PNG',
jpg: 'JPG',
jpeg: 'JPEG',
gif: 'GIF',
svg: 'SVG',
zip: 'ZIP',
tar: 'TAR',
gz: 'GZ',
};
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
function getFileTypeLabel(filename: string): string {
const ext = getFileExt(filename);
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || "FILE");
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || 'FILE');
}
function isImageFile(filename: string): boolean {
@@ -502,7 +522,7 @@ function isImageFile(filename: string): boolean {
* Format bytes to human-readable size string
*/
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
if (bytes === 0) return '0 B';
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
@@ -544,8 +564,17 @@ function RichFileCard({
}) {
const { t } = useI18n();
const resolveArtifact = useArtifactResolver(threadId);
const isUploading = file.status === "uploading";
const [previewOpen, setPreviewOpen] = useState(false);
const isUploading = file.status === 'uploading';
const isImage = isImageFile(file.filename);
const fileUrl = file.path ? resolveArtifact(file.path) : null;
// Thumbnails must be fetched with auth: the artifact route is gated and a
// plain <img src> sends no Authorization header → 401 (broken image). Fetch
// the bytes via apiFetch and display the resulting object URL instead.
const { objectUrl: thumbUrl, loading: thumbLoading } = useAuthedObjectUrl(
fileUrl,
isImage && !isUploading && !!fileUrl,
);
if (isUploading) {
return (
@@ -574,50 +603,81 @@ function RichFileCard({
);
}
if (!file.path) return null;
if (!fileUrl) return null;
const fileUrl = resolveArtifact(file.path);
const preview = (
<UploadedFilePreviewDialog
filename={file.filename}
fileUrl={fileUrl}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
);
if (isImage) {
return (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="group border-border/40 relative block overflow-hidden rounded-lg border"
>
<img
src={fileUrl}
alt={file.filename}
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
/>
</a>
<>
<button
type="button"
onClick={() => setPreviewOpen(true)}
title={file.filename}
className="group border-border/40 relative block cursor-pointer overflow-hidden rounded-lg border"
>
{thumbUrl ? (
<img
src={thumbUrl}
alt={file.filename}
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="bg-muted/40 text-muted-foreground flex h-32 w-44 items-center justify-center gap-2 text-xs">
{thumbLoading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<>
<FileIcon className="size-4" />
<span className="truncate">{file.filename}</span>
</>
)}
</div>
)}
</button>
{preview}
</>
);
}
return (
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 shadow-sm">
<div className="flex items-start gap-2">
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<span
className="text-foreground truncate text-sm font-medium"
title={file.filename}
>
{file.filename}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<Badge
variant="secondary"
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
>
{getFileTypeLabel(file.filename)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{formatBytes(file.size)}
</span>
</div>
</div>
<>
<button
type="button"
onClick={() => setPreviewOpen(true)}
title={file.filename}
className="bg-background border-border/40 hover:bg-accent/50 flex max-w-50 min-w-30 cursor-pointer flex-col gap-1 rounded-lg border p-3 text-left shadow-sm transition-colors"
>
<div className="flex items-start gap-2">
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<span
className="text-foreground truncate text-sm font-medium"
title={file.filename}
>
{file.filename}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<Badge
variant="secondary"
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
>
{getFileTypeLabel(file.filename)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{formatBytes(file.size)}
</span>
</div>
</button>
{preview}
</>
);
}
@@ -635,8 +695,8 @@ function AssistantDisplayContent({
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
renderSkillDisplayBlocks: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"];
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const { parts, placedKeys } = useMemo(
() =>
@@ -652,14 +712,14 @@ function AssistantDisplayContent({
return [];
}
return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
(group) => group.mode !== 'citation' && !placedKeys.has(group.key),
);
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
return (
<>
{parts.map((part, index) =>
part.type === "markdown" ? (
part.type === 'markdown' ? (
<MarkdownContent
key={`markdown-${index}`}
content={part.content}
@@ -688,11 +748,11 @@ function AssistantDisplayContent({
}
type DisplayPart =
| { type: "markdown"; content: string }
| { type: 'markdown'; content: string }
| {
type: "display";
type: 'display';
key: string;
mode: RuntimeDisplaySource["mode"];
mode: RuntimeDisplaySource['mode'];
sources: RuntimeDisplaySource[];
};
@@ -709,16 +769,16 @@ function splitSkillDisplayPlaceholders(
const start = match.index ?? 0;
const before = content.slice(cursor, start);
if (before.trim()) {
parts.push({ type: "markdown", content: before });
parts.push({ type: 'markdown', content: before });
}
const attrs = parseDisplayAttrs(match[1] ?? "");
const attrs = parseDisplayAttrs(match[1] ?? '');
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
if (group) {
placedKeys.add(group.key);
}
if (group && renderSkillDisplayBlocks) {
parts.push({
type: "display",
type: 'display',
key: group.key,
mode: group.mode,
sources: group.sources,
@@ -728,26 +788,29 @@ function splitSkillDisplayPlaceholders(
}
const rest = content.slice(cursor);
if (rest.trim() || parts.length === 0) {
parts.push({ type: "markdown", content: rest });
parts.push({ type: 'markdown', content: rest });
}
return { parts, placedKeys };
}
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
function parseDisplayAttrs(raw: string): {
tool?: string;
mode?: RuntimeDisplaySource['mode'];
} {
const attrs: Record<string, string> = {};
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
attrs[match[1]!] = match[2]!;
}
return {
tool: attrs.tool || attrs.skill || attrs.name,
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
mode: attrs.mode as RuntimeDisplaySource['mode'] | undefined,
};
}
function findDisplayGroup(
sources: RuntimeDisplaySource[],
tool?: string,
mode?: RuntimeDisplaySource["mode"],
mode?: RuntimeDisplaySource['mode'],
) {
return groupDisplaySources(sources).find((group) => {
if (mode && group.mode !== mode) {
@@ -768,20 +831,18 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
{
key: string;
skillName: string;
mode: RuntimeDisplaySource["mode"];
mode: RuntimeDisplaySource['mode'];
sources: RuntimeDisplaySource[];
}
>();
for (const source of sources) {
const key = `${source.skillName}:${source.mode}`;
const group =
groups.get(key) ??
{
key,
skillName: source.skillName,
mode: source.mode,
sources: [],
};
const group = groups.get(key) ?? {
key,
skillName: source.skillName,
mode: source.mode,
sources: [],
};
group.sources.push(source);
groups.set(key, group);
}
@@ -789,6 +850,5 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
}
function normalizeName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
return name.toLowerCase().replace(/[^a-z0-9]+/g, '');
}
@@ -0,0 +1,278 @@
"use client";
import { DownloadIcon, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { apiFetch } from "@/core/api/fetch-client";
import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse " +
"[&_th]:border [&_th]:px-2 [&_th]:py-1 [&_td]:border [&_td]:px-2 [&_td]:py-1 " +
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
const TEXT_EXT = [
"txt", "md", "markdown", "csv", "tsv", "json", "log", "yaml", "yml",
"xml", "html", "htm", "css", "js", "ts", "tsx", "jsx", "py", "java",
"go", "rs", "c", "cpp", "h", "hpp", "sh", "sql", "ini", "toml", "env",
];
// Office documents are converted to a sibling Markdown file server-side
// (uploads.auto_convert_documents). We preview that .md instead of the binary.
const OFFICE_EXT = ["xlsx", "xls", "docx", "doc", "pptx", "ppt"];
function extOf(filename: string): string {
const i = filename.lastIndexOf(".");
return i >= 0 ? filename.slice(i + 1).toLowerCase() : "";
}
type PreviewKind = "image" | "text" | "office" | "pdf" | "binary";
function kindOf(filename: string): PreviewKind {
const ext = extOf(filename);
if (IMAGE_EXT.includes(ext)) return "image";
if (ext === "pdf") return "pdf";
if (OFFICE_EXT.includes(ext)) return "office";
if (TEXT_EXT.includes(ext)) return "text";
return "binary";
}
function withReplacedExtension(url: string): string {
// Replace the path extension with .md, keeping any query string intact.
const [base, query] = url.split("?");
const i = base.lastIndexOf(".");
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
return query ? `${swapped}?${query}` : swapped;
}
/**
* Fetch an auth-gated artifact (the artifact route requires an
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
* hence the 401s) and expose it as an object URL for inline display.
*/
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled || !url) {
setObjectUrl(null);
return;
}
let cancelled = false;
let created: string | null = null;
setLoading(true);
setError(null);
apiFetch(url)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
if (cancelled) return;
created = URL.createObjectURL(blob);
setObjectUrl(created);
})
.catch((err: unknown) => {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (created) URL.revokeObjectURL(created);
};
}, [url, enabled]);
return { objectUrl, loading, error };
}
/**
* Preview dialog for a file attached to a chat message. All fetches go through
* `apiFetch` so the `Authorization` header is attached (artifacts are auth-gated).
*
* - image → fetched as blob, rendered inline
* - text → fetched and shown (markdown rendered for .md, otherwise <pre>)
* - office → fetches the server-converted sibling `<name>.md` and renders it
* - pdf → fetched as blob, embedded via <iframe>
* - else → download link
*/
export function UploadedFilePreviewDialog({
filename,
fileUrl,
open,
onOpenChange,
}: {
filename: string;
/** Absolute artifact URL for the original uploaded file. */
fileUrl: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const kind = useMemo(() => kindOf(filename), [filename]);
const ext = useMemo(() => extOf(filename), [filename]);
// image / pdf → fetch as authed blob and display via object URL.
const blobEnabled = open && (kind === "image" || kind === "pdf");
const { objectUrl, loading: blobLoading, error: blobError } =
useAuthedObjectUrl(fileUrl, blobEnabled);
// text / office → fetch text (office reads the converted sibling .md).
const textUrl = useMemo(
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
[kind, fileUrl],
);
const [text, setText] = useState<string | null>(null);
const [textLoading, setTextLoading] = useState(false);
const [textError, setTextError] = useState<string | null>(null);
useEffect(() => {
if (!open || (kind !== "text" && kind !== "office")) return;
let cancelled = false;
setTextLoading(true);
setTextError(null);
setText(null);
apiFetch(textUrl)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then((body) => {
if (!cancelled) setText(body);
})
.catch((err: unknown) => {
if (!cancelled)
setTextError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setTextLoading(false);
});
return () => {
cancelled = true;
};
}, [open, kind, textUrl]);
const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown";
const loadingView = (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex flex-col gap-0 overflow-hidden p-0"
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
{kind === "image" ? (
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : (
<div className="flex items-center justify-center">
<img
src={objectUrl}
alt={filename}
className="max-h-full max-w-full rounded border"
/>
</div>
)
) : kind === "pdf" ? (
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : (
<iframe
src={objectUrl}
title={filename}
className="h-full min-h-[500px] w-full rounded border"
/>
)
) : kind === "text" || kind === "office" ? (
textLoading ? (
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""}
</pre>
)
) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
)}
</div>
</DialogContent>
</Dialog>
);
}
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
</div>
);
}
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -21,12 +22,12 @@ import { getStoredAuth } from "@/core/auth";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useCitationDisplaySettings,
useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings,
} from "@/core/system-settings/hooks";
import {
useAppearanceSettings,
useCitationDisplaySettings,
useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings,
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
@@ -42,13 +43,13 @@ export function AppearanceSettingsPage() {
const currentTheme = theme;
const [settings, setSettings] = useLocalSettings();
const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light";
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const { data: appearance } = useAppearanceSettings();
const updateAppearance = useUpdateAppearanceSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const updateCitationDisplay = useUpdateCitationDisplaySettings();
const globalDefault = appearance?.default_theme ?? "light";
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const handleSetGlobalDefault = async (value: "light" | "dark") => {
if (value === globalDefault) return;
@@ -147,6 +148,39 @@ export function AppearanceSettingsPage() {
<Separator />
<SettingsSection
title="字号"
description="调整界面整体字号,方便放大或缩小阅读。默认 16px(标准)。"
>
<div className="space-y-3">
<Select
value={String(settings.display.fontScale ?? 1)}
onValueChange={(value) => {
const scale = Number(value);
if (Number.isFinite(scale) && scale > 0) {
setSettings("display", { fontScale: scale });
}
}}
>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_SCALE_OPTIONS.map((item) => (
<SelectItem key={item.value} value={String(item.value)}>
{item.label}({item.px}px)
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-muted-foreground rounded-lg border p-4 text-sm leading-relaxed">
预览:多智能体圆桌研讨 · The quick brown fox · 0123456789
</div>
</div>
</SettingsSection>
<Separator />
<SettingsSection
title={t.settings.appearance.languageTitle}
description={t.settings.appearance.languageDescription}
@@ -217,16 +251,16 @@ export function AppearanceSettingsPage() {
管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。
</p>
</div>
<Textarea
value={cleanupWords.join("\n")}
onChange={(event) => {
const words = event.currentTarget.value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
setSettings("display", { citationCleanupWords: words });
updateCitationDisplay.mutate({ cleanup_words: words });
}}
<Textarea
value={cleanupWords.join("\n")}
onChange={(event) => {
const words = event.currentTarget.value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
setSettings("display", { citationCleanupWords: words });
updateCitationDisplay.mutate({ cleanup_words: words });
}}
placeholder={"广告\n点击下载APP\n打开客户端\n免责声明\n责任编辑"}
className="min-h-32 text-sm"
/>
@@ -229,7 +229,7 @@ export function MemorySettingsPage() {
<SelectTrigger className="h-8 w-44" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="default">
{m.workNotesDefaultAgent}
</SelectItem>
@@ -490,7 +490,7 @@ function MemoryV1View({
return (
<div className="space-y-3">
{/* 说明横幅 */}
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground dark:border-[#213D5E] dark:bg-[#022248]">
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
</div>
@@ -509,13 +509,13 @@ function MemoryV1View({
{/* 事实记录 Tab */}
<TabsContent value="facts" className="mt-3">
<div className="rounded-lg border p-4 space-y-3">
<div className="rounded-lg border p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">事实记录</h3>
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
</div>
{memory.facts.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground ">
暂无事实记录,对话后系统会自动提取。
</div>
) : (
@@ -542,21 +542,21 @@ function MemoryV1View({
</div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
{fact.sourceError && (
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
<p className="rounded bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-[#311629] dark:text-[#EE212C]">
避免:{desensitize(fact.sourceError)}
</p>
)}
</div>
))}
</div>
)}
)}
</div>
</TabsContent>
{/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4">
{/* 用户画像 (userContext) */}
<div className="rounded-lg border p-4 space-y-2">
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h3 className="text-base font-medium">用户画像</h3>
{ctxEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
@@ -575,7 +575,7 @@ function MemoryV1View({
</div>
{/* 历史摘要 (history) */}
<div className="rounded-lg border p-4 space-y-2">
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h3 className="text-base font-medium">历史摘要</h3>
{historyEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
@@ -9,6 +9,7 @@ import {
ClockIcon,
FileArchive,
GlobeIcon,
InfoIcon,
Loader2,
LockIcon,
PinIcon,
@@ -54,6 +55,7 @@ import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-di
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
@@ -88,8 +90,42 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const PAGE_SIZE = 10;
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanSuccessToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 重新扫描无变更 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanInfoToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#704812] !bg-[#202222] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <InfoIcon className="size-4 shrink-0 text-[#FF9900]" />,
} as const;
/** 重新扫描失败 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
const rescanErrorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n();
const { theme } = useTheme();
const router = useRouter();
const [filter, setFilter] = useState<string>("mine");
const [installOpen, setInstallOpen] = useState(false);
@@ -174,13 +210,21 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
"{count}",
String(result.adopted.length),
),
theme === "dark" ? rescanSuccessToastDarkOptions : undefined,
);
} else {
toast.info(t.settings.skills.rescanSkillsNoop);
toast.info(
t.settings.skills.rescanSkillsNoop,
theme === "dark" ? rescanInfoToastDarkOptions : undefined,
);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
toast.error(
err instanceof Error ? err.message : String(err),
theme === "dark" ? rescanErrorToastDarkOptions : undefined,
);
}
};
return (
<div className="flex w-full flex-col gap-4">
@@ -398,7 +442,7 @@ export function SkillRow({
return (
<>
<Item className="w-full" variant="outline">
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
@@ -17,6 +17,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
displayConfigFromResultSet,
emptyDisplayConfig,
inferDisplayConfig,
normalizeDisplaySources,
@@ -27,9 +28,29 @@ import {
getSkillDisplaySample,
} from "@/core/skills/api";
import { useSetSkillDisplay } from "@/core/skills/hooks";
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
import type {
Skill,
SkillDisplayConfig,
SkillDisplayMode,
SkillDisplayResultSetConfig,
SkillLinkStrategy,
} from "@/core/skills/type";
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
type DisplayFieldKey =
| "resultPath"
| "titleField"
| "summaryField"
| "contentField"
| "sourceField"
| "authorField"
| "typeField"
| "timeField"
| "scoreField"
| "urlField"
| "idField"
| "linkTemplate";
const fieldLabels: Array<[DisplayFieldKey, string]> = [
["resultPath", "结果列表路径"],
["titleField", "标题字段"],
["summaryField", "摘要字段"],
@@ -71,14 +92,56 @@ function formatSample(sample: Record<string, unknown> | null | undefined) {
return sample ? JSON.stringify(sample, null, 2) : "";
}
function firstResultSetKey(display: SkillDisplayConfig | null | undefined) {
return display?.resultSets?.[0]?.key ?? null;
}
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const queryClient = useQueryClient();
const setDisplay = useSetSkillDisplay();
const lastSkillNameRef = useRef(skill.name);
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
const [activeResultSetKey, setActiveResultSetKey] = useState<string | null>(() =>
firstResultSetKey(skill.display),
);
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
const [tableColumnsText, setTableColumnsText] = useState(() => {
const display = skill.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.[0];
return JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2);
});
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
const resultSets = draft.resultSets ?? [];
const activeResultSet =
resultSets.find((resultSet) => resultSet.key === activeResultSetKey) ?? null;
const editingConfig = activeResultSet ?? draft;
const previewConfig = activeResultSet
? displayConfigFromResultSet(draft, activeResultSet)
: draft;
function updateEditingConfig<K extends keyof SkillDisplayResultSetConfig>(
key: K,
value: SkillDisplayResultSetConfig[K],
) {
if (!activeResultSet) {
setDraft(updateConfig(draft, key as keyof SkillDisplayConfig, value as never));
return;
}
setDraft({
...draft,
resultSets: resultSets.map((resultSet) =>
resultSet.key === activeResultSet.key
? { ...resultSet, [key]: value }
: resultSet,
),
});
}
function selectResultSet(key: string) {
const resultSet = resultSets.find((item) => item.key === key) ?? null;
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? draft).tableColumns ?? [], null, 2));
}
// Echo server state into the form — on mount, on skill switch, AND after
// a successful save (the refetched skill object has a new `display` identity).
@@ -88,7 +151,9 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
useEffect(() => {
const display = skill.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
const resultSet = display.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
}, [skill.name, skill.display]);
useEffect(() => {
@@ -101,8 +166,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
return;
}
const display = fresh.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.[0];
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
setSampleText(formatSample(fresh.displaySample));
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
@@ -186,8 +253,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
}, [sampleText]);
const previewItems = useMemo(() => (
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
), [draft, samplePayload]);
samplePayload ? normalizeDisplaySources(samplePayload, previewConfig) : []
), [previewConfig, samplePayload]);
function generateDraft() {
if (!samplePayload) {
@@ -196,8 +263,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
}
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已生成展示适配草稿");
const resultSet = nextDraft.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
toast.success(
resultSet
? `已识别 ${nextDraft.resultSets?.length ?? 0} 个结果集,可分别配置`
: "已生成展示适配草稿",
);
}
async function loadSampleAndGenerate() {
@@ -219,8 +292,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
);
const nextDraft = inferDisplayConfig(response.sample, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已获取样例并生成展示适配");
const resultSet = nextDraft.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
toast.success(
resultSet
? `已获取样例,并识别 ${nextDraft.resultSets?.length ?? 0} 个结果集`
: "已获取样例并生成展示适配",
);
} catch (err) {
generateDraft();
toast.error(err instanceof Error ? err.message : String(err));
@@ -235,8 +314,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
sample: samplePayload ?? undefined,
});
const display = saved.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.find((item) => item.key === activeResultSetKey) ?? display.resultSets?.[0];
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
setSampleText(formatSample(saved.displaySample));
toast.success("展示适配已保存");
} catch (err) {
@@ -248,6 +329,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
try {
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
setDraft(emptyDisplayConfig());
setActiveResultSetKey(null);
setTableColumnsText(JSON.stringify([], null, 2));
toast.success("展示适配已清除");
} catch (err) {
@@ -283,8 +365,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">展示方式</span>
<Select
value={draft.mode}
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
value={editingConfig.mode}
onValueChange={(value) => updateEditingConfig("mode", value as SkillDisplayMode)}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -300,8 +382,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">跳转方式</span>
<Select
value={draft.linkStrategy}
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
value={editingConfig.linkStrategy}
onValueChange={(value) => updateEditingConfig("linkStrategy", value as SkillLinkStrategy)}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -320,26 +402,58 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
type="number"
min={1}
max={100}
value={draft.maxItems}
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
value={editingConfig.maxItems}
onChange={(event) => updateEditingConfig("maxItems", Number(event.currentTarget.value) || 1)}
/>
</label>
</div>
{resultSets.length > 0 && (
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">结果集配置</span>
<Select
value={activeResultSet?.key ?? resultSets[0]?.key}
onValueChange={selectResultSet}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resultSets.map((resultSet) => (
<SelectItem key={resultSet.key} value={resultSet.key}>
{resultSet.label || resultSet.resultPath}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
{activeResultSet && (
<label className="flex items-center justify-end gap-2 pb-2">
<span className="text-xs text-muted-foreground">启用当前结果集</span>
<Switch
checked={activeResultSet.enabled}
onCheckedChange={(checked) => updateEditingConfig("enabled", checked)}
/>
</label>
)}
</div>
)}
<div className="mt-4 grid gap-3 md:grid-cols-2">
{fieldLabels.map(([field, label]) => (
<label key={field} className="space-y-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<Input
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
value={(editingConfig[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
onChange={(event) => updateEditingConfig(field, event.currentTarget.value as never)}
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
/>
</label>
))}
</div>
{draft.mode === "table" && (
{editingConfig.mode === "table" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
<Textarea
@@ -349,7 +463,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
try {
const value = JSON.parse(event.currentTarget.value);
if (Array.isArray(value)) {
setDraft(updateConfig(draft, "tableColumns", value));
updateEditingConfig("tableColumns", value);
}
} catch {
// Keep typing invalid JSON without crashing the panel.
@@ -361,12 +475,12 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
</label>
)}
{draft.mode === "custom" && (
{editingConfig.mode === "custom" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">自定义展示描述</span>
<Textarea
value={draft.customInstruction}
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
value={editingConfig.customInstruction}
onChange={(event) => updateEditingConfig("customInstruction", event.currentTarget.value)}
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
className="min-h-24"
/>
@@ -425,10 +425,15 @@ function ConfigCard() {
})
}
>
<SelectTrigger className="max-w-[20rem]">
<SelectTrigger
className={cn(
"max-w-[20rem]",
"dark:border-[#1B477E] dark:bg-[#0D2D54] dark:text-[#AACEFF] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#0D2D54]",
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value={DEFAULT_MODEL_VALUE}>
{tt.curatorModelDefault}
</SelectItem>
@@ -20,8 +20,11 @@ export function WorkspaceContainer({
children,
...props
}: React.ComponentProps<"div">) {
// h-full 而非 h-screen:本容器恒在 WorkspaceRoutes 的 SidebarInset(flex-1,确定高度)下,
// h-full 既跟随容器高度(无界等嵌套不再锁死整屏),又修掉原 h-screen=100vh 比可用区域
// 高出一个页头高度的溢出。
return (
<div className={cn("flex h-screen w-full flex-col", className)} {...props}>
<div className={cn("flex h-full w-full flex-col", className)} {...props}>
{children}
</div>
);
+52 -6
View File
@@ -17,24 +17,70 @@ export async function listAllUsers(): Promise<UserListItem[]> {
return res.json() as Promise<UserListItem[]>;
}
// Normalize a FastAPI error body into a human-readable string. `detail` may be
// a plain string, an object like AuthErrorResponse ({ code, message }), or an
// array of validation errors — anything but a string would otherwise render as
// the dreaded "[object Object]".
function extractErrorMessage(body: unknown, fallback = "Login failed."): string {
const detail = (body as { detail?: unknown } | null)?.detail ?? body;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
const msg = detail
.map((d) => (d && typeof d === "object" ? (d as { msg?: string }).msg : String(d)))
.filter(Boolean)
.join("; ");
return msg || fallback;
}
if (detail && typeof detail === "object") {
const obj = detail as { message?: string; msg?: string };
return obj.message ?? obj.msg ?? fallback;
}
return fallback;
}
export async function loginByUsername(
username: string,
password?: string | null,
): Promise<LoginByUsernameResponse> {
// When the username-login password gate is enabled server-side, the shared
// secret must be passed as ?password=. Omitted when no password is provided
// so the passwordless flow keeps working unchanged.
const base = `${getBackendBaseURL()}/api/v1/auth/login/username`;
const url =
password != null && password !== ""
? `${base}?password=${encodeURIComponent(password)}`
: base;
const response = await apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
});
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(extractErrorMessage(error));
}
return response.json() as Promise<LoginByUsernameResponse>;
}
export async function loginByToken(
token: string,
): Promise<LoginByUsernameResponse> {
// Token-exchange login: the gateway resolves the upstream token to a username
// and signs the matching local account in.
const response = await apiFetch(
`${getBackendBaseURL()}/api/v1/auth/login/username`,
`${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Login failed." }));
throw new Error(error.detail ?? "Login failed.");
const error = await response.json().catch(() => null);
throw new Error(extractErrorMessage(error));
}
return response.json() as Promise<LoginByUsernameResponse>;
}
+8 -1
View File
@@ -51,7 +51,14 @@ export function readExternalLoginParams(): Required<ExternalLoginParams> {
}
}
/** 把 ``theme`` query 值映射成 ThemeContext 的 ``'dark' | 'light'`` */
/**
* 把外部(URL query / 无界 props)的 ``theme`` 值映射成 ThemeContext 的
* ``'dark' | 'light'``。已知取值:
* - ``dark-blue`` → 深色
* - ``red`` → 浅色(当前默认红白主题)
* - ``blue`` → 暂无该主题,先用浅色代替(命中 else 分支)
* 其余/缺省同样回退浅色。将来若新增 blue 主题,在此扩展映射即可。
*/
export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" {
return themeParam === "dark-blue" ? "dark" : "light";
}
+78
View File
@@ -0,0 +1,78 @@
/**
* 无界(wujie)子应用运行时 props 读取。
*
* 本应用作为子应用被内网门户用无界嵌套时,父级通过
* <WujieVue name="subApp" :url="..." :props="{ ticket, userId, theme, env }" />
* 注入参数,子应用在 `window.$wujie.props` 上拿到**同一个对象**(无界在子应用
* bootstrap 之前就已注入,因此首帧即可读)。
*
* 本期范围:只「读出来 + 存起来 + 打印」,**不接入对方鉴权**。读出的
* ticket/userId/env 复用 external-login 的 localStorage 存储(与 URL 跳转登录
* 同一套 key),theme 交给调用方(LoginPage)映射到 ThemeContext。
*/
import { useEffect, useMemo } from "react";
import { persistExternalLoginParams } from "@/core/auth/external-login";
/** 无界父级注入的 props 形状。除已知字段外允许透传其余未知字段便于排查。 */
export interface WujieProps {
/** 第三方鉴权 ticket(本期仅存储待用,不消费)。 */
ticket?: string;
/** 外部用户 id。 */
userId?: string;
/** 环境标识。 */
env?: string;
/** 主题色标识(映射到深/浅色)。 */
theme?: string;
[key: string]: unknown;
}
interface WujieRuntime {
props?: WujieProps;
[key: string]: unknown;
}
declare global {
interface Window {
/** 无界注入的子应用运行时;非无界环境下为 undefined。 */
$wujie?: WujieRuntime;
/** 无界注入的环境标志(子应用内为 true)。 */
__POWERED_BY_WUJIE__?: boolean;
}
}
/** 当前是否运行在无界子应用环境中。 */
export function isWujieEnv(): boolean {
return typeof window !== "undefined" && !!window.__POWERED_BY_WUJIE__;
}
/** 读取无界父级注入的 props;不在无界环境 / 未注入时返回空对象。 */
export function readWujieProps(): WujieProps {
if (typeof window === "undefined") return {};
return window.$wujie?.props ?? {};
}
/**
* 全局读取无界父级注入的 props 的 hook。
*
* - 首帧从 `window.$wujie.props` 读出(无界已在 bootstrap 前注入,引用稳定);
* - 读到后把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储),
* 供后续任意页面/请求读取 —— 即「存到全局」;
* - 返回 props 供调用方消费(如 LoginPage 取 theme)。
*
* 非无界环境(直接访问)下返回空对象且不做任何持久化,可安全无条件调用。
*/
export function useWujieProps(): WujieProps {
const props = useMemo(() => readWujieProps(), []);
useEffect(() => {
if (!props.ticket && !props.userId && !props.env) return;
persistExternalLoginParams({
ticket: props.ticket,
userId: props.userId,
env: props.env,
});
}, [props.ticket, props.userId, props.env]);
return props;
}
+4 -4
View File
@@ -462,10 +462,10 @@ export function parseUploadedFiles(content: string): FileInMessage[] {
return [];
}
// Check if the backend reported no new files were uploaded in this message
if (uploadedFilesContent?.includes("(empty)")) {
return [];
}
// NOTE: do NOT bail on "(empty)" here. The backend writes "(empty)" only for
// the "uploaded in this message" section; files uploaded in previous messages
// are still listed below it. Bailing dropped those valid entries. The file
// regex below naturally skips the "(empty)" line, so parsing stays correct.
// Parse file list
// Format: - filename (size)\n Path: /path/to/file
+47 -39
View File
@@ -4,11 +4,12 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
enabled: true,
},
display: {
streamSpeedMeterEnabled: true,
ragModeEnabled: false,
citationCleanupWords: [],
},
display: {
streamSpeedMeterEnabled: true,
ragModeEnabled: false,
citationCleanupWords: [],
fontScale: 1,
},
context: {
model_name: undefined,
mode: undefined,
@@ -17,9 +18,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
},
};
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
function isBrowser(): boolean {
return typeof window !== "undefined";
@@ -40,13 +41,20 @@ export interface LocalSettings {
* Global switch for skill result display adapters. When disabled, chat
* messages render tool results with the legacy generic search UI only.
*/
ragModeEnabled: boolean;
/**
* Admin-maintained words or short phrases removed from reference snippets
* and dialogs, one entry per line in the settings UI.
*/
citationCleanupWords: string[];
};
ragModeEnabled: boolean;
/**
* Admin-maintained words or short phrases removed from reference snippets
* and dialogs, one entry per line in the settings UI.
*/
citationCleanupWords: string[];
/**
* Global UI font scale multiplier applied to the root <html> font-size.
* Base is 16px (deep-blue UI spec §2.1.2 body text), so `1` === 16px.
* The user picks a size from the appearance settings page; because the
* app sizes text/spacing in rem, the whole UI scales proportionally.
*/
fontScale: number;
};
context: Omit<
AgentThreadContext,
| "thread_id"
@@ -91,10 +99,10 @@ export function getThreadModelName(threadId: string): string | undefined {
return localStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined;
}
export function saveThreadModelName(
threadId: string,
modelName: string | undefined,
) {
export function saveThreadModelName(
threadId: string,
modelName: string | undefined,
) {
if (!isBrowser()) {
return;
}
@@ -103,26 +111,26 @@ export function saveThreadModelName(
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, modelName);
}
function getThreadRagModeStorageKey(threadId: string): string {
return `${THREAD_RAG_MODE_KEY_PREFIX}${threadId}`;
}
export function isThreadRagModeLocked(threadId: string): boolean {
if (!isBrowser() || !threadId) {
return false;
}
return localStorage.getItem(getThreadRagModeStorageKey(threadId)) === "true";
}
export function lockThreadRagMode(threadId: string) {
if (!isBrowser() || !threadId) {
return;
}
localStorage.setItem(getThreadRagModeStorageKey(threadId), "true");
}
localStorage.setItem(key, modelName);
}
function getThreadRagModeStorageKey(threadId: string): string {
return `${THREAD_RAG_MODE_KEY_PREFIX}${threadId}`;
}
export function isThreadRagModeLocked(threadId: string): boolean {
if (!isBrowser() || !threadId) {
return false;
}
return localStorage.getItem(getThreadRagModeStorageKey(threadId)) === "true";
}
export function lockThreadRagMode(threadId: string) {
if (!isBrowser() || !threadId) {
return;
}
localStorage.setItem(getThreadRagModeStorageKey(threadId), "true");
}
export function applyThreadModelOverride(
settings: LocalSettings,
@@ -1,4 +1,7 @@
import type { SkillDisplayConfig } from "@/core/skills/type";
import type {
SkillDisplayConfig,
SkillDisplayResultSetConfig,
} from "@/core/skills/type";
const DEFAULT_CITATION_MAX_ITEMS = 100;
const DEFAULT_LIST_MAX_ITEMS = 20;
@@ -15,6 +18,10 @@ export interface DisplaySourceItem {
url?: string;
content?: string;
score?: number;
displayMode?: SkillDisplayConfig["mode"];
resultSetKey?: string;
resultSetLabel?: string | null;
tableColumns?: SkillDisplayConfig["tableColumns"];
raw: Record<string, unknown>;
}
@@ -38,6 +45,7 @@ export const emptyDisplayConfig = (): SkillDisplayConfig => ({
maxItems: DEFAULT_CITATION_MAX_ITEMS,
customInstruction: "",
tableColumns: [],
resultSets: [],
});
const candidateFields: Record<string, string[]> = {
@@ -53,7 +61,7 @@ const candidateFields: Record<string, string[]> = {
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
};
function getByPath(value: unknown, path: string): unknown {
function getByPath(value: unknown, path: string | null | undefined): unknown {
if (!path) return value;
return path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") {
@@ -63,19 +71,55 @@ function getByPath(value: unknown, path: string): unknown {
}, value);
}
function findFirstArray(
value: unknown,
path = "",
): { path: string; items: Record<string, unknown>[] } | null {
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
return { path, items: value as Record<string, unknown>[] };
export type ResultArrayCandidate = {
key: string;
label: string;
path: string;
items: Record<string, unknown>[];
};
export function findResultArrayCandidates(samplePayload: unknown): ResultArrayCandidate[] {
const candidates: ResultArrayCandidate[] = [];
const seen = new Set<string>();
function visit(value: unknown, path = "", depth = 0) {
if (depth > 8 || candidates.length >= 20) return;
if (Array.isArray(value)) {
const objectItems = value.filter(
(item): item is Record<string, unknown> =>
!!item && typeof item === "object" && !Array.isArray(item),
);
if (
(value.length === 0 || objectItems.length > 0) &&
objectItems.length === value.length &&
!seen.has(path)
) {
seen.add(path);
candidates.push({
key: buildResultSetKey(path || "root"),
label: buildResultSetLabel(path || "根数组", objectItems.length),
path,
items: objectItems,
});
}
return;
}
if (!value || typeof value !== "object" || Array.isArray(value)) return;
for (const [key, child] of Object.entries(value)) {
visit(child, path ? `${path}.${key}` : key, depth + 1);
}
}
if (!value || typeof value !== "object") return null;
for (const [key, child] of Object.entries(value)) {
const found = findFirstArray(child, path ? `${path}.${key}` : key);
if (found) return found;
}
return null;
visit(samplePayload);
return candidates;
}
function buildResultSetKey(path: string): string {
return path.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "root";
}
function buildResultSetLabel(path: string, count: number): string {
return `${path}(${count} 条)`;
}
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
@@ -93,13 +137,31 @@ function pickField(sample: Record<string, unknown>, candidates: string[]): strin
}
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
const sample = found.items[0] ?? {};
const candidates = findResultArrayCandidates(samplePayload);
const found = candidates[0] ?? {
key: "results",
label: "results(0 条)",
path: "results",
items: [],
};
const inferred = buildConfigFromCandidate(found, skillName);
inferred.resultSets =
candidates.length > 1
? candidates.map((candidate) => buildResultSetConfigFromCandidate(candidate, skillName))
: [];
return inferred;
}
function buildConfigFromCandidate(
candidate: ResultArrayCandidate,
skillName?: string,
): SkillDisplayConfig {
const sample = candidate.items[0] ?? {};
const inferred = emptyDisplayConfig();
inferred.enabled = true;
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
inferred.resultPath = found.path || "results";
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
inferred.resultPath = candidate.path || "results";
inferred.maxItems = inferMaxItems(candidate.items.length, inferred.mode);
for (const [target, candidates] of Object.entries(candidateFields)) {
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
@@ -123,6 +185,65 @@ export function inferDisplayConfig(samplePayload: unknown, skillName?: string):
return inferred;
}
function buildResultSetConfigFromCandidate(
candidate: ResultArrayCandidate,
skillName?: string,
): SkillDisplayResultSetConfig {
const config = buildConfigFromCandidate(candidate, skillName);
return {
key: candidate.key,
label: candidate.label,
enabled: true,
mode: config.mode,
resultPath: config.resultPath,
titleField: config.titleField,
summaryField: config.summaryField,
contentField: config.contentField,
sourceField: config.sourceField,
authorField: config.authorField,
typeField: config.typeField,
timeField: config.timeField,
scoreField: config.scoreField,
urlField: config.urlField,
idField: config.idField,
linkStrategy: config.linkStrategy,
linkTemplate: config.linkTemplate,
citationToken: config.citationToken,
maxItems: config.maxItems,
customInstruction: config.customInstruction,
tableColumns: config.tableColumns,
};
}
export function displayConfigFromResultSet(
base: SkillDisplayConfig,
resultSet: SkillDisplayResultSetConfig,
): SkillDisplayConfig {
return {
...base,
enabled: resultSet.enabled,
mode: resultSet.mode,
resultPath: resultSet.resultPath,
titleField: resultSet.titleField,
summaryField: resultSet.summaryField,
contentField: resultSet.contentField,
sourceField: resultSet.sourceField,
authorField: resultSet.authorField,
typeField: resultSet.typeField,
timeField: resultSet.timeField,
scoreField: resultSet.scoreField,
urlField: resultSet.urlField,
idField: resultSet.idField,
linkStrategy: resultSet.linkStrategy,
linkTemplate: resultSet.linkTemplate,
citationToken: resultSet.citationToken,
maxItems: resultSet.maxItems,
customInstruction: resultSet.customInstruction,
tableColumns: resultSet.tableColumns,
resultSets: [],
};
}
function buildDefaultTableColumns(
sample: Record<string, unknown>,
config: SkillDisplayConfig,
@@ -161,13 +282,13 @@ function stringify(value: unknown, fallback = ""): string {
function renderTemplate(template: string, item: Record<string, unknown>): string {
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
encodeURIComponent(stringify(item[key])),
encodeURIComponent(stringify(getByPath(item, key.trim()))),
);
}
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
const url = config.urlField ? stringify(item[config.urlField]) : "";
const id = config.idField ? stringify(item[config.idField]) : "";
const url = config.urlField ? stringify(getByPath(item, config.urlField)) : "";
const id = config.idField ? stringify(getByPath(item, config.idField)) : "";
if (config.linkStrategy === "url") return url || undefined;
if (config.linkStrategy === "id") {
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
@@ -176,6 +297,30 @@ function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig):
}
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
const configuredResultSets = config.resultSets ?? [];
const resultSets = configuredResultSets.filter(
(resultSet) => resultSet.enabled && resultSet.mode !== "none",
);
if (configuredResultSets.length > 0) {
return resultSets.flatMap((resultSet) =>
normalizeDisplaySourcesForConfig(
payload,
displayConfigFromResultSet(config, resultSet),
resultSet,
),
);
}
if (!config.enabled || config.mode === "none") {
return [];
}
return normalizeDisplaySourcesForConfig(payload, config);
}
function normalizeDisplaySourcesForConfig(
payload: unknown,
config: SkillDisplayConfig,
resultSet?: SkillDisplayResultSetConfig,
): DisplaySourceItem[] {
const rawItems = getByPath(payload, config.resultPath);
if (!Array.isArray(rawItems)) return [];
const maxItems = resolveMaxItems(config, rawItems.length);
@@ -183,19 +328,23 @@ export function normalizeDisplaySources(payload: unknown, config: SkillDisplayCo
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
.slice(0, maxItems)
.map((item, index) => ({
id: config.idField ? stringify(item[config.idField]) : undefined,
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
content: config.contentField ? stringify(item[config.contentField]) : undefined,
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
author: config.authorField ? stringify(item[config.authorField]) : undefined,
time: config.timeField ? stringify(item[config.timeField]) : undefined,
id: config.idField ? stringify(getByPath(item, config.idField)) : undefined,
title: config.titleField ? stringify(getByPath(item, config.titleField), `结果 ${index + 1}`) : `结果 ${index + 1}`,
type: config.typeField ? stringify(getByPath(item, config.typeField), "result") : "result",
snippet: config.summaryField ? stringify(getByPath(item, config.summaryField)) : "",
content: config.contentField ? stringify(getByPath(item, config.contentField)) : undefined,
source: config.sourceField ? stringify(getByPath(item, config.sourceField)) : undefined,
author: config.authorField ? stringify(getByPath(item, config.authorField)) : undefined,
time: config.timeField ? stringify(getByPath(item, config.timeField)) : undefined,
score:
config.scoreField && typeof item[config.scoreField] === "number"
? (item[config.scoreField] as number)
config.scoreField && typeof getByPath(item, config.scoreField) === "number"
? (getByPath(item, config.scoreField) as number)
: undefined,
url: resolveLink(item, config),
displayMode: config.mode,
resultSetKey: resultSet?.key,
resultSetLabel: resultSet?.label,
tableColumns: config.tableColumns,
raw: item,
}));
}
@@ -202,8 +202,8 @@ export function collectRuntimeDisplaySources(
normalized.map((item) => ({
...item,
skillName: match?.skillName ?? toolCall.name,
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
mode: item.displayMode ?? effectiveMode,
tableColumns: item.tableColumns ?? match?.display.tableColumns,
})),
seenCitationKeys,
() => ++citationIndex,
@@ -242,8 +242,8 @@ export function collectRuntimeDisplaySources(
normalized.map((item) => ({
...item,
skillName: match?.skillName ?? toolName,
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
mode: item.displayMode ?? effectiveMode,
tableColumns: item.tableColumns ?? match?.display.tableColumns,
})),
seenCitationKeys,
() => ++citationIndex,
+7 -1
View File
@@ -12,7 +12,9 @@ export interface SkillDisplayTableColumn {
width?: string | null;
}
export interface SkillDisplayConfig {
export interface SkillDisplayResultSetConfig {
key: string;
label?: string | null;
enabled: boolean;
mode: SkillDisplayMode;
resultPath: string;
@@ -34,6 +36,10 @@ export interface SkillDisplayConfig {
tableColumns?: SkillDisplayTableColumn[];
}
export interface SkillDisplayConfig extends Omit<SkillDisplayResultSetConfig, "key" | "label"> {
resultSets?: SkillDisplayResultSetConfig[];
}
export interface Skill {
name: string;
description: string;
+1
View File
@@ -5,6 +5,7 @@
*/
export const DESENSITIZE_MAP: Record<string, string> = {
态势: "TS",
你好: "nh",
滨海作战体系: "第一张网",
滨海作战部队: "第一张网",
'Taiwan Air Force Flight Units': "第二张网",
@@ -9,16 +9,16 @@ function DraftWaitingPlaceholder() {
return (
<div className="h-full flex flex-col items-center justify-center gap-5 select-none">
<div className="relative">
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center">
<FileText className="w-8 h-8 text-muted-foreground/40" />
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center dark:bg-[#022248] ">
<FileText className="w-8 h-8 text-muted-foreground/40 dark:text-[#55A6FE] " />
</div>
<div className="absolute -bottom-1.5 -right-1.5 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
<PenLine className="w-3.5 h-3.5 text-primary/60" />
<div className="absolute -bottom-1.5 -right-1.5 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center dark:bg-[#102D5C] ">
<PenLine className="w-3.5 h-3.5 text-primary/60 dark:text-[#2580FF] " />
</div>
</div>
<div className="text-center space-y-1.5 max-w-[200px]">
<p className="text-sm font-medium text-foreground/60">草稿将在此处显示</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
<p className="text-sm font-medium text-foreground/60 dark:text-[#AACEFF]">草稿将在此处显示</p>
<p className="text-[11px] text-muted-foreground leading-relaxed dark:text-[#55A6FE]">
完成写作流程后,AI 生成的内容将实时出现在这里
</p>
</div>
@@ -25,7 +25,9 @@ interface AIWritingFormProps {
const INPUT_CLASS =
'flex h-12 w-full rounded-xl border border-[#e8dedd] bg-white px-4 py-2 text-sm shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50';
'focus-visible:ring-ring disabled:opacity-50 ' +
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:placeholder:text-[#566B7F] ' +
'dark:[color-scheme:dark] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30';
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
@@ -127,16 +129,18 @@ export function AIWritingForm({
return (
<form onSubmit={handleSubmit} className="space-y-4 px-4 py-4">
<div className="rounded-2xl border border-[#ebe3e2] bg-[#fcf8f8] p-4">
<div className="flex items-center gap-2 border-b border-[#ece5e4] pb-3">
<PenLine className="size-4 text-[#8b6f6f]" />
<h3 className="text-[18px] font-semibold leading-none text-[#2f2f2f]">写作配置选项</h3>
<div className="rounded-2xl border border-[#ebe3e2] bg-[#fcf8f8] p-4 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white [&_h3]:dark:text-white [&_label]:dark:text-white">
<div className="flex items-center gap-2 border-b border-[#ece5e4] pb-3 dark:border-[#213D5E]">
<PenLine className="size-4 text-[#8b6f6f] dark:text-white" />
<h3 className="text-[18px] font-semibold leading-none text-[#2f2f2f] dark:text-white">
写作配置选项
</h3>
</div>
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<label className={FIELD_LABEL_CLASS}>写作意图</label>
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground dark:text-white/70">
{intent.length} / {MAX_INTENT_LENGTH} 字
</span>
</div>
@@ -162,7 +166,7 @@ export function AIWritingForm({
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="bottom" sideOffset={4}>
{articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-sm">
{t.label}
@@ -229,7 +233,7 @@ export function AIWritingForm({
}
/>
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="bottom" sideOffset={4}>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-sm">
{m.display_name || m.name}
+38 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { BotIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
import { AlertCircle, BotIcon, CheckCircle2, RefreshCwIcon, SearchIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -22,9 +22,37 @@ import {
import { AgentCard } from "@/components/workspace/agents/agent-card";
import { useAdminSyncAgentSkills, useAllAgentsAdmin } from "@/core/agents";
import type { AgentSkillsSyncResult } from "@/core/agents/api";
import { useTheme } from "@/contexts/ThemeContext";
import { desensitize } from "@/lib/desensitize";
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function AdminAgentsPage() {
const { theme } = useTheme();
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const [search, setSearch] = useState("");
const { agents, isLoading } = useAllAgentsAdmin(true);
const syncSkills = useAdminSyncAgentSkills();
@@ -39,14 +67,21 @@ export default function AdminAgentsPage() {
setSyncResult(result);
setSyncDialogOpen(true);
if (result.modified === 0) {
toast.success("所有智能体的技能均有效,无需清理");
toast.success(
"所有智能体的技能均有效,无需清理",
darkToastOpts.success,
);
} else {
toast.success(
`已清理 ${result.modified} 个智能体中的失效技能引用`,
darkToastOpts.success,
);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
toast.error(
err instanceof Error ? err.message : String(err),
darkToastOpts.error,
);
}
}
@@ -17,6 +17,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/contexts/ThemeContext";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
@@ -70,6 +71,7 @@ const emptyForm = {
};
export default function AdminScheduledTasksPage() {
const { theme } = useTheme();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// ?task= carries the task to re-select when returning from a run detail.
@@ -227,7 +229,10 @@ export default function AdminScheduledTasksPage() {
onChange={(event) => setSearchInput(event.target.value)}
placeholder="按任务名称搜索"
/>
<Button type="submit" variant="outline">
<Button
type="submit"
variant={theme === "dark" ? "default" : "outline"}
>
<Search className="size-4" />
搜索
</Button>
+3 -3
View File
@@ -84,10 +84,10 @@ export default function AdminSkillsPage() {
)}
{!isLoading && !error && filteredSkills.length === 0 && (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
<SparklesIcon className="text-muted-foreground h-7 w-7" />
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
<SparklesIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
</div>
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground text-sm dark:text-[#AACEFF]">
当前没有可管理的技能。
</p>
</div>
+41 -14
View File
@@ -1,6 +1,6 @@
"use client";
import { Download, Loader2 } from "lucide-react";
import { AlertCircle,CheckCircle2, Download, Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -22,6 +22,7 @@ import {
} from "@/core/llm-metrics/api";
import { useLlmMetrics } from "@/core/llm-metrics/hooks";
import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
const PAGE_SIZE = 50;
@@ -74,6 +75,7 @@ function speedColor(tps?: number | null): string {
/** The original LLM-call metrics view, now one tab of the metrics page. */
function LlmMetricsPanel() {
const { theme } = useTheme();
const [page, setPage] = useState(0);
const [model, setModel] = useState("");
const [user, setUser] = useState("");
@@ -81,6 +83,31 @@ function LlmMetricsPanel() {
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false);
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const filters = useMemo<LlmMetricsFilters>(() => {
const out: LlmMetricsFilters = {};
@@ -103,9 +130,9 @@ function LlmMetricsPanel() {
setExporting(true);
try {
await downloadLlmMetricsExcel(filters);
toast.success("已导出为 Excel");
toast.success("已导出为 Excel",darkToastOpts.success);
} catch (e) {
toast.error(e instanceof Error ? e.message : "导出失败");
toast.error(e instanceof Error ? e.message : "导出失败",darkToastOpts.error);
} finally {
setExporting(false);
}
@@ -267,9 +294,9 @@ function LlmMetricsPanel() {
</Button>
</div>
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5">
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">模型</label>
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex flex-col gap-1 ">
<label className="text-muted-foreground text-xs dark:text-white">模型</label>
<Input
value={model}
placeholder="按模型名筛选"
@@ -280,7 +307,7 @@ function LlmMetricsPanel() {
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">用户名</label>
<label className="text-muted-foreground text-xs dark:text-white">用户名</label>
<Input
value={user}
placeholder="按用户名匹配(@ 前部分)"
@@ -291,7 +318,7 @@ function LlmMetricsPanel() {
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">状态</label>
<label className="text-muted-foreground text-xs dark:text-white">状态</label>
<Select
value={status}
onValueChange={(value) => {
@@ -299,10 +326,10 @@ function LlmMetricsPanel() {
setPage(0);
}}
>
<SelectTrigger>
<SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">失败</SelectItem>
@@ -310,7 +337,7 @@ function LlmMetricsPanel() {
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">开始时间</label>
<label className="text-muted-foreground text-xs dark:text-white">开始时间</label>
<Input
type="datetime-local"
value={since}
@@ -321,7 +348,7 @@ function LlmMetricsPanel() {
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">结束时间</label>
<label className="text-muted-foreground text-xs dark:text-white">结束时间</label>
<Input
type="datetime-local"
value={until}
@@ -338,10 +365,10 @@ function LlmMetricsPanel() {
</div>
</div>
<div className="rounded-xl border bg-white">
<div className="rounded-xl border bg-white dark:border-[#213D5E] dark:bg-[#02244C] ">
<div className="overflow-x-auto">
<table className="w-full min-w-[1080px] text-sm">
<thead className="bg-muted/60 text-muted-foreground">
<thead className="bg-muted/60 text-muted-foreground dark:text-white">
<tr>
{visibleColumns.map((col) => (
<Th
+36 -12
View File
@@ -4,12 +4,13 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useTheme } from '@/contexts/ThemeContext';
import { loginByUsername } from '@/core/auth/api';
import { loginByToken, loginByUsername } from '@/core/auth/api';
import { setStoredAuth } from '@/core/auth';
import {
persistExternalLoginParams,
resolveThemeFromQuery,
} from '@/core/auth/external-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import {
GENERAL_QA_ROUTE,
loginByYUserId,
@@ -50,6 +51,10 @@ export default function LoginPage() {
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
const toolToken = searchParams.get('token')?.trim() ?? '';
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
const gatePassword = searchParams.get('password') ?? '';
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
const authToken = searchParams.get('authToken')?.trim() ?? '';
const externalParams = useMemo(
() => ({
theme: searchParams.get('theme') ?? '',
@@ -62,11 +67,23 @@ export default function LoginPage() {
const [status, setStatus] = useState<LoginStatus>('loading');
const [errorMessage, setErrorMessage] = useState<string>('');
// 主题切换:theme=dark-blue → 深色,其他值或缺省 → 浅色
// 无界(wujie)父级通过 $wujie.props 注入的参数(ticket / userId / theme / env)。
// hook 内部已把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储)。
const wujieProps = useWujieProps();
// 调试:把无界注入的 props 打印出来(仅在无界子应用环境下,避免独立访问时刷日志)。
useEffect(() => {
if (!externalParams.theme) return;
setTheme(resolveThemeFromQuery(externalParams.theme));
}, [externalParams.theme, setTheme]);
if (!isWujieEnv()) return;
console.log('[wujie] sub-app props:', wujieProps);
}, [wujieProps]);
// 主题切换:无界 props.theme 优先,其次 URL query theme。两者取值约定一致
// (dark-blue → 深色;red → 浅色;blue 暂无该主题,先用浅色代替),统一走 resolveThemeFromQuery。
useEffect(() => {
const themeValue = wujieProps.theme || externalParams.theme;
if (!themeValue) return;
setTheme(resolveThemeFromQuery(themeValue));
}, [wujieProps.theme, externalParams.theme, setTheme]);
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
useEffect(() => {
@@ -84,13 +101,19 @@ export default function LoginPage() {
setStatus('loading');
setErrorMessage('');
try {
if (yUserId) {
await loginByYUserId(yUserId, toolToken || null);
} else if (toolToken) {
persistToolsToken(toolToken);
}
let auth;
if (authToken) {
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
auth = await loginByToken(authToken);
} else {
if (yUserId) {
await loginByYUserId(yUserId, toolToken || null);
} else if (toolToken) {
persistToolsToken(toolToken);
}
const auth = await loginByUsername(resolvedUsername);
auth = await loginByUsername(resolvedUsername, gatePassword);
}
if (cancelled) {
return;
}
@@ -114,12 +137,13 @@ export default function LoginPage() {
cancelled = true;
};
}, [
redirectPath,
resolvedUsername,
router,
sidebarEnabled,
yUserId,
toolToken,
authToken,
gatePassword,
]);
return (
+4 -1
View File
@@ -18,8 +18,11 @@ function PageLayout() {
const { theme } = useTheme();
const isDark = theme === "dark";
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
// 下容器不占满整屏时,高度才能正确收缩而非锁死整屏。
return (
<div className="h-screen flex flex-col bg-muted/20">
<div className="h-full flex flex-col bg-muted/20">
{sidebarEnabled && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden">
{sidebarEnabled && (
@@ -1,8 +1,9 @@
"use client";
import { Loader2Icon, ShieldIcon } from "lucide-react";
import { AlertCircle,CheckCircle2, Loader2Icon, ShieldIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -12,13 +13,36 @@ import {
useUpdatePrivateSquareSettings,
} from "@/core/system-settings/hooks";
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function PrivateSquareSettingsPage() {
const { data, isLoading, error } = usePrivateSquareStatus();
const update = useUpdatePrivateSquareSettings();
const { theme } = useTheme();
const [enabled, setEnabled] = useState(false);
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
// The status response never contains the password itself, so re-syncing
// ``enabled`` from a refetch wouldn't clobber any in-progress edit — but we
// still gate the seed on a one-shot ref to match the prompt-prefix page's
@@ -31,10 +55,14 @@ export default function PrivateSquareSettingsPage() {
setEnabled(data.enabled);
initializedRef.current = true;
}, [data]);
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const handleSave = async () => {
if (password && password !== confirm) {
toast.error("两次输入的密码不一致");
toast.error("两次输入的密码不一致", darkToastOpts.error);
return;
}
try {
@@ -48,9 +76,9 @@ export default function PrivateSquareSettingsPage() {
});
setPassword("");
setConfirm("");
toast.success("已保存");
toast.success("已保存",darkToastOpts.success);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
toast.error(e instanceof Error ? e.message : "保存失败",darkToastOpts.error);
}
};
@@ -60,9 +88,9 @@ export default function PrivateSquareSettingsPage() {
}
try {
await update.mutateAsync({ password: "" });
toast.success("已清除密码");
toast.success("已清除密码",darkToastOpts.success);
} catch (e) {
toast.error(e instanceof Error ? e.message : "清除失败");
toast.error(e instanceof Error ? e.message : "清除失败", darkToastOpts.error);
}
};
@@ -85,7 +113,7 @@ export default function PrivateSquareSettingsPage() {
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5">
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">启用私密广场</div>
@@ -1,6 +1,6 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { Loader2Icon,AlertCircle,CheckCircle2, } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@@ -12,11 +12,33 @@ import {
usePromptPrefixSettings,
useUpdatePromptPrefixSettings,
} from "@/core/system-settings/hooks";
import { useTheme } from "@/contexts/ThemeContext";
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function PromptPrefixSettingsPage() {
const { data, isLoading, error } = usePromptPrefixSettings();
const update = useUpdatePromptPrefixSettings();
const { theme } = useTheme();
const [enabled, setEnabled] = useState(false);
const [switchLabel, setSwitchLabel] = useState("");
const [promptPrefix, setPromptPrefix] = useState("");
@@ -34,13 +56,18 @@ export default function PromptPrefixSettingsPage() {
initializedRef.current = true;
}, [data]);
const handleSave = async () => {
const handleSave = async () => {
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
if (enabled && !switchLabel.trim()) {
toast.error("开启时必须填写开关名称");
toast.error("开启时必须填写开关名称",darkToastOpts.error);
return;
}
if (enabled && !promptPrefix.trim()) {
toast.error("开启时必须填写拼接的前缀内容");
toast.error("开启时必须填写拼接的前缀内容",darkToastOpts.error);
return;
}
try {
@@ -49,9 +76,9 @@ export default function PromptPrefixSettingsPage() {
switch_label: switchLabel,
prompt_prefix: promptPrefix,
});
toast.success("已保存");
toast.success("已保存",darkToastOpts.success);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
toast.error(e instanceof Error ? e.message : "保存失败",darkToastOpts.error);
}
};
@@ -74,7 +101,7 @@ export default function PromptPrefixSettingsPage() {
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5">
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">向用户暴露开关</div>
@@ -2,6 +2,8 @@
import { useState } from "react";
import {
AlertCircle,
CheckCircle2,
GripVerticalIcon,
Loader2Icon,
PencilIcon,
@@ -11,6 +13,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/contexts/ThemeContext";
import {
Dialog,
DialogContent,
@@ -34,7 +37,30 @@ type DialogState =
| { mode: "edit"; question: RecommendedQuestion }
| null;
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
export default function RecommendedQuestionsPage() {
const { theme } = useTheme();
const { questions, isLoading, error } = useRecommendedQuestions();
const createMutation = useCreateRecommendedQuestion();
const updateMutation = useUpdateRecommendedQuestion();
@@ -70,7 +96,7 @@ export default function RecommendedQuestionsPage() {
const title = formTitle.trim();
const content = formContent.trim();
if (!title || !content) {
toast.error("标题和内容不能为空");
toast.error("标题和内容不能为空", darkToastOpts.error);
return;
}
@@ -81,17 +107,20 @@ export default function RecommendedQuestionsPage() {
content,
sort_order: questions.length,
});
toast.success("问题已添加");
toast.success("问题已添加", darkToastOpts.success);
} else if (dialog?.mode === "edit") {
await updateMutation.mutateAsync({
id: dialog.question.id,
body: { title, content },
});
toast.success("问题已更新");
toast.success("问题已更新", darkToastOpts.success);
}
closeDialog();
} catch (e) {
toast.error(e instanceof Error ? e.message : "操作失败");
toast.error(
e instanceof Error ? e.message : "操作失败",
darkToastOpts.error,
);
}
}
@@ -99,9 +128,12 @@ export default function RecommendedQuestionsPage() {
try {
await deleteMutation.mutateAsync(q.id);
setDeleteTarget(null);
toast.success("问题已删除");
toast.success("问题已删除", darkToastOpts.success);
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
toast.error(
e instanceof Error ? e.message : "删除失败",
darkToastOpts.error,
);
}
}
@@ -128,7 +160,7 @@ export default function RecommendedQuestionsPage() {
try {
await reorderMutation.mutateAsync(next.map((q) => q.id));
} catch {
toast.error("排序保存失败");
toast.error("排序保存失败", darkToastOpts.error);
}
}
@@ -138,6 +170,11 @@ export default function RecommendedQuestionsPage() {
deleteMutation.isPending ||
reorderMutation.isPending;
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div className="flex items-center justify-between">
@@ -162,15 +199,15 @@ export default function RecommendedQuestionsPage() {
<p>加载失败:{error.message}</p>
</div>
) : questions.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-sm">
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-sm dark:text-[#7FACF2] dark:!border-[#213F62]">
<p>暂无推荐问题</p>
<Button variant="outline" size="sm" onClick={openAdd}>
<Button size="sm" onClick={openAdd}>
<PlusIcon className="mr-1.5 size-4" />
添加第一个问题
</Button>
</div>
) : (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 ">
{questions.map((q, index) => (
<div
key={q.id}
@@ -182,18 +219,18 @@ export default function RecommendedQuestionsPage() {
setDragIndex(null);
setDragOverIndex(null);
}}
className={`flex items-start gap-3 rounded-xl border bg-white px-4 py-3.5 transition-colors ${
className={`flex items-start gap-3 rounded-xl border bg-white px-4 py-3.5 transition-colors dark:bg-[#022248] dark:!border-[#213D5E] ${
dragOverIndex === index && dragIndex !== index
? "border-blue-400 bg-blue-50"
? "border-blue-400 bg-blue-50 dark:border-[#2580FF] dark:bg-[#0D2D54]"
: "border-[#e4e4e7]"
}`}
>
<GripVerticalIcon className="text-muted-foreground/40 mt-0.5 size-4 shrink-0 cursor-grab active:cursor-grabbing" />
<GripVerticalIcon className="text-muted-foreground/40 mt-0.5 size-4 shrink-0 cursor-grab active:cursor-grabbing dark:text-[#566B7F]" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[#18181b]">
<div className="truncate text-sm font-medium text-[#18181b] dark:text-white">
{q.title}
</div>
<div className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
<div className="text-muted-foreground mt-0.5 line-clamp-2 text-xs dark:text-[#7FACF2]">
{q.content}
</div>
</div>
+80 -19
View File
@@ -2,8 +2,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
BellOff,
BellRing,
CheckCircle2,
Download,
Edit3,
Eye,
@@ -62,9 +64,32 @@ import {
statusLabel,
statusVariant,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import { useTheme } from "@/contexts/ThemeContext";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
@@ -88,6 +113,11 @@ const emptyForm = {
};
export default function ScheduledTasksPage() {
const { theme } = useTheme();
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// When returning from a run-detail page the task id is carried in ?task=,
@@ -207,7 +237,7 @@ export default function ScheduledTasksPage() {
require_markdown: form.require_markdown,
});
setSelectedTaskId(task.task_id);
toast.success("任务已创建");
toast.success("任务已创建", darkToastOpts.success);
} else if (editing) {
await actions.update.mutateAsync({
taskId: editing.task_id,
@@ -221,11 +251,14 @@ export default function ScheduledTasksPage() {
require_markdown: form.require_markdown,
},
});
toast.success("任务已更新");
toast.success("任务已更新", darkToastOpts.success);
}
closeDialog();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
toast.error(
error instanceof Error ? error.message : "保存失败",
darkToastOpts.error,
);
}
};
@@ -233,16 +266,22 @@ export default function ScheduledTasksPage() {
try {
await downloadScheduledTaskRun(runId);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
toast.error(
error instanceof Error ? error.message : "下载失败",
darkToastOpts.error,
);
}
};
const handleDeleteRun = async (runId: string) => {
try {
await actions.removeRun.mutateAsync(runId);
toast.success("执行记录已删除");
toast.success("执行记录已删除", darkToastOpts.success);
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
toast.error(
error instanceof Error ? error.message : "删除失败",
darkToastOpts.error,
);
}
};
@@ -273,14 +312,18 @@ export default function ScheduledTasksPage() {
/>
<Button
variant="outline"
className="dark:bg-[#0D2D54] dark:hover:!bg-[#2580FF] dark:hover:text-white"
onClick={async () => {
try {
await actions.updateDeliveryProfile.mutateAsync({
element_user_id: elementUserId || null,
});
toast.success("Element 配置已保存");
toast.success("Element 配置已保存", darkToastOpts.success);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
toast.error(
error instanceof Error ? error.message : "保存失败",
darkToastOpts.error,
);
}
}}
>
@@ -437,7 +480,7 @@ export default function ScheduledTasksPage() {
onClick={(event) => {
event.stopPropagation();
actions.trigger.mutate(task.task_id);
toast.success("已触发执行");
toast.success("已触发执行", darkToastOpts.success);
}}
>
<RefreshCw className="size-4" />
@@ -451,13 +494,19 @@ export default function ScheduledTasksPage() {
try {
if (task.published) {
await actions.unpublish.mutateAsync(task.task_id);
toast.success("已取消发布(已订阅者保留)");
toast.success(
"已取消发布(已订阅者保留)",
darkToastOpts.success,
);
} else {
await actions.publish.mutateAsync(task.task_id);
toast.success("已发布到广场");
toast.success("已发布到广场", darkToastOpts.success);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
toast.error(
error instanceof Error ? error.message : "操作失败",
darkToastOpts.error,
);
}
}}
>
@@ -488,9 +537,15 @@ export default function ScheduledTasksPage() {
taskId: task.task_id,
notifyElement: !notifyOn,
});
toast.success(notifyOn ? "已关闭 Element 通知" : "已开启 Element 通知");
toast.success(
notifyOn ? "已关闭 Element 通知" : "已开启 Element 通知",
darkToastOpts.success,
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
toast.error(
error instanceof Error ? error.message : "操作失败",
darkToastOpts.error,
);
}
}}
>
@@ -505,9 +560,12 @@ export default function ScheduledTasksPage() {
event.stopPropagation();
try {
await actions.unsubscribe.mutateAsync(task.task_id);
toast.success("已取消收藏");
toast.success("已取消收藏", darkToastOpts.success);
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
toast.error(
error instanceof Error ? error.message : "操作失败",
darkToastOpts.error,
);
}
}}
>
@@ -581,7 +639,7 @@ export default function ScheduledTasksPage() {
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部时间</SelectItem>
<SelectItem value="today">今天</SelectItem>
<SelectItem value="7d">近 7 天</SelectItem>
@@ -747,12 +805,15 @@ export default function ScheduledTasksPage() {
taskId: subscribeTarget.task_id,
notifyElement: subscribeNotify,
});
toast.success("已收藏到我的任务");
toast.success("已收藏到我的任务", darkToastOpts.success);
setSubscribeTarget(null);
setSubscribeNotify(false);
setActiveTab("mine");
} catch (error) {
toast.error(error instanceof Error ? error.message : "收藏失败");
toast.error(
error instanceof Error ? error.message : "收藏失败",
darkToastOpts.error,
);
}
}}
>
+13 -4
View File
@@ -87,9 +87,20 @@ export function WorkspaceLayout({
return (
<SidebarProvider
// 关键:把 workspace 侧栏从「锚定视口(fixed)」改成「锚定本布局容器(absolute)」。
// 原 shadcn 原语用 fixed + left:var(--page-sidebar-width) + top:var(--header-height)
// 把侧栏钉在视口坐标上 —— 在无界等「DOM 挂主文档、容器不占满整屏」的嵌套场景下,
// fixed 会逃逸到整屏左侧、高度锁死为视口高度(即超出父元素 / 高度写死的根因)。
//
// 改 absolute 后侧栏改以本 wrapper(relative)为定位基准;又因外层 main 已经
// pl-[--page-sidebar-width] 让开了页面侧栏、且 main 的 pl 恒等于 --page-sidebar-width,
// 故这里 left/top 归零即与原 fixed 视觉位置逐像素一致;高度则跟随容器而非视口。
// 作用域仅限本 SidebarProvider 实例(不动全局 ui/sidebar 原语),不影响 artifacts 等其它侧栏。
className={[
'h-full w-full [&_[data-slot=sidebar-container]]:left-[var(--page-sidebar-width)]',
wrapperOverflowHidden ? 'overflow-hidden' : 'overflow-x-hidden',
'relative h-full w-full overflow-x-hidden',
'[&_[data-slot=sidebar-container]]:absolute',
'[&_[data-slot=sidebar-container]]:left-0',
'[&_[data-slot=sidebar-container]]:top-0',
].join(' ')}
style={
{
@@ -97,8 +108,6 @@ export function WorkspaceLayout({
'--header-height': headerHidden ? '0rem' : '4rem',
} as CSSProperties
}
// Keep the fixed workspace sidebar aligned to the outer page sidebar width.
// This lets it slide together when the outer sidebar collapses or expands.
data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen
>
@@ -56,12 +56,12 @@ export function MessageBubble({
{senderName}
</span>
{badge && (
<span className="text-[9px] font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
<span className="text-[calc(9px*var(--ui-font-scale,1))] font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
{badge}
</span>
)}
</div>
<span className="text-[10px] text-slate-400 font-mono font-medium shrink-0">{time}</span>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono font-medium shrink-0">{time}</span>
</div>
{children}
</div>
@@ -139,13 +139,13 @@ export const sharedMarkdownComponents = {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[calc(11px*var(--ui-font-scale,1))] font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[calc(11px*var(--ui-font-scale,1))] font-mono">
{children}
</code>
);
@@ -161,7 +161,7 @@ export const sharedMarkdownComponents = {
</a>
),
table: ({ children }: { children?: React.ReactNode }) => (
<table className="my-1 border-collapse text-[11px]">{children}</table>
<table className="my-1 border-collapse text-[calc(11px*var(--ui-font-scale,1))]">{children}</table>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
@@ -254,7 +254,7 @@ export function ReasoningBlock({
<button
type="button"
onClick={handleToggle}
className="inline-flex items-center gap-1.5 text-[11.5px] text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
className="inline-flex items-center gap-1.5 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
>
<BrainIcon className={cn("size-3.5", streaming && "text-primary animate-pulse")} />
<span className={cn(streaming && "animate-pulse")}>{headerLabel}</span>
@@ -265,7 +265,7 @@ export function ReasoningBlock({
{open && (
<div
className={cn(
"rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed",
"rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-600 leading-relaxed",
contentClassName,
)}
>
@@ -86,10 +86,10 @@ export function MessageStepsCard({
const display = getStepDisplay(lastReasoning.key, lastReasoning.label, lastReasoning.args);
const Icon = display.icon;
return (
<div className={cn("flex items-center gap-2 text-[11.5px] text-slate-500 py-0.5", className)}>
<div className={cn("flex items-center gap-2 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-500 py-0.5", className)}>
<Icon className="size-3.5 text-slate-400" />
<span>{display.label}</span>
<span className="text-[10px] text-slate-400 font-mono">{lastReasoning.ts}</span>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono">{lastReasoning.ts}</span>
</div>
);
}
@@ -108,7 +108,7 @@ export function MessageStepsCard({
>
<ChainOfThoughtStep
label={
<span className="text-[11.5px] opacity-60">
<span className="text-[calc(11.5px*var(--ui-font-scale,1))] opacity-60">
{showAbove ? "隐藏步骤" : `查看其他 ${priorToolSteps.length} 个步骤`}
</span>
}
@@ -189,9 +189,9 @@ function ToolStep({
: undefined
}
label={
<span className="text-[12px] text-slate-700">
<span className="text-[calc(12px*var(--ui-font-scale,1))] text-slate-700">
<span className="font-medium">{display.label}</span>
<span className="ml-2 text-[10px] text-slate-400 font-mono">{step.ts}</span>
<span className="ml-2 text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono">{step.ts}</span>
</span>
}
>
@@ -204,10 +204,10 @@ function ReasoningStep({ step, streaming }: { step: BubbleStep; streaming: boole
const display = getStepDisplay(step.key, step.label, step.args);
const Icon = display.icon;
return (
<div className="flex items-center gap-2 text-[11.5px] text-slate-500">
<div className="flex items-center gap-2 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-500">
<Icon className={cn("size-3.5", streaming ? "text-primary animate-pulse" : "text-slate-400")} />
<span>{display.label}</span>
<span className="text-[10px] text-slate-400 font-mono">{step.ts}</span>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono">{step.ts}</span>
</div>
);
}
@@ -167,7 +167,7 @@ export function Step2Panel(props: Step2PanelProps) {
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div>
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
<p className="text-[10px] text-slate-400 font-medium">
<p className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-medium">
分布式领域专家实时动态博弈与纳什协商收敛中
</p>
</div>
@@ -202,7 +202,7 @@ export function Step2Panel(props: Step2PanelProps) {
<button
type="button"
onClick={onResumeRoundtable}
className={`bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm ${
className={`bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm ${
pauseFlash
? "ring-2 ring-emerald-400 ring-offset-1 animate-pulse"
: ""
@@ -215,7 +215,7 @@ export function Step2Panel(props: Step2PanelProps) {
<button
type="button"
onClick={onPauseRoundtable}
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
>
<Pause className="w-3.5 h-3.5" />
暂停会商
@@ -279,13 +279,13 @@ export function Step2Panel(props: Step2PanelProps) {
<div className="flex items-center gap-2">
<span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span>
{pendingClarification.clarificationType && (
<span className="text-[9px] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
<span className="text-[calc(9px*var(--ui-font-scale,1))] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
{pendingClarification.clarificationType}
</span>
)}
</div>
{pendingClarification.context && (
<p className="text-[11px] text-amber-800 italic">{pendingClarification.context}</p>
<p className="text-[calc(11px*var(--ui-font-scale,1))] text-amber-800 italic">{pendingClarification.context}</p>
)}
<p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
{pendingClarification.question}
@@ -306,7 +306,7 @@ export function Step2Panel(props: Step2PanelProps) {
key={`${idx}-${label}`}
type="button"
onClick={() => onSubmitClarification(label)}
className="text-[11px] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
className="text-[calc(11px*var(--ui-font-scale,1))] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
>
{label}
</button>
@@ -580,17 +580,17 @@ function PresentFileCard({
<div className="flex items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="truncate text-[12px] font-medium text-slate-700"
className="truncate text-[calc(12px*var(--ui-font-scale,1))] font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-slate-500">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[calc(10px*var(--ui-font-scale,1))] font-mono font-semibold text-slate-500">
{typeLabel}
</span>
<span className="text-[10px] text-slate-400">点击预览</span>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400">点击预览</span>
</div>
</button>
);
@@ -57,6 +57,16 @@ export interface UseDraftPersistenceOptions {
* 否则保持原 false 让进入 Step 2 时正常初始化。
*/
setStep2InitGate: (initialized: boolean) => void;
/**
* Step 1 是否正在等待任务理解回复。由 true→false 视为「本轮模型回答完」,
* 触发一次自动保存(详见 autoSave 的两个 effect)。
*/
intentSending: boolean;
/**
* Step 2 已收尾(streaming===false)的对话气泡数量。每次递增意味着总控/某个
* 子智能体刚交付完一轮,触发一次自动保存。流式增量不改变该计数,因此不会刷屏式保存。
*/
step2FinalizedCount: number;
}
export interface UseDraftPersistenceReturn {
@@ -96,6 +106,8 @@ export function useDraftPersistence(
hydrateStep2,
cancelAllStreams,
setStep2InitGate,
intentSending,
step2FinalizedCount,
} = options;
const [drafts, setDrafts] = useState<DraftListItem[]>([]);
@@ -112,6 +124,11 @@ export function useDraftPersistence(
const currentDraftIdRef = useRef<string | null>(null);
// 串行化所有写操作,避免并发 create。
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
// 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。
const currentStepRef = useRef<number>(currentStep);
useEffect(() => {
currentStepRef.current = currentStep;
}, [currentStep]);
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
@@ -157,9 +174,15 @@ export function useDraftPersistence(
* 串行执行一次保存:有 id 走 update(不带 title,保护重命名);无 id 走 create
* (写一次派生标题)。create/update 成功后同步 currentDraftIdRef。
* 返回 draft id(无可保存内容返回 null)。
*
* `furthestStep` 可选:
* - 传值(前进切换 / 手动保存 / ensureDraft)→ update 时一并推进 furthest_step;
* - 省略(每轮模型回答完的自动保存)→ update **不带** furthest_step,只回写
* step1/step2 内容,避免把「回到上一步继续研讨」误判成 furthest 回退。create
* 分支用 currentStepRef 兜底建档步骤。
*/
const enqueueSave = useCallback(
(furthestStep: 1 | 2 | 3): Promise<string | null> => {
(furthestStep?: 1 | 2 | 3): Promise<string | null> => {
const run = async (): Promise<string | null> => {
const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot();
@@ -167,7 +190,11 @@ export function useDraftPersistence(
const id = currentDraftIdRef.current;
if (id) {
const updated = await updateDraft(id, { furthestStep, step1, step2 });
const updated = await updateDraft(id, {
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2,
});
if (updated) return updated.id;
// 服务端已不存在(被删/换账号)→ 落回新建。
}
@@ -176,7 +203,7 @@ export function useDraftPersistence(
intentReady: step1.intentReady,
interactiveMessages: step1.interactiveMessages,
}),
furthestStep,
furthestStep: furthestStep ?? ((currentStepRef.current as 1 | 2 | 3) || 1),
step1,
step2,
});
@@ -227,6 +254,41 @@ export function useDraftPersistence(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]);
/**
* 每轮模型回答完成后落一次草稿。不带 furthestStep(见 enqueueSave),只回写内容。
* 仅当本次保存是「新建」时才刷新列表 —— 普通增量回写无需让下拉重排/多发一个 GET。
*/
const autoSave = useCallback(async (): Promise<void> => {
const hadId = currentDraftIdRef.current;
const id = await enqueueSave();
if (id && !hadId) await refreshList();
}, [enqueueSave, refreshList]);
// ── 触发器 1:Step 1 任务理解一轮回复结束(isIntentSending true→false)。 ──
const prevIntentSendingRef = useRef<boolean>(intentSending);
useEffect(() => {
const was = prevIntentSendingRef.current;
prevIntentSendingRef.current = intentSending;
// 此刻 React 已 flush 完终态 setState(气泡内容/INTENT_READY),
// 快照 getter 读到的是收尾后的最新状态,不会丢最后一段。
if (was && !intentSending) void autoSave();
}, [intentSending, autoSave]);
// ── 触发器 2:Step 2 某个气泡刚收尾(已收尾计数递增)。 ──
// 加载草稿会把历史对话整批 hydrate 进来,计数从 0 跳到 N —— 那不是一次真实交付,
// 用 suppress latch 跳过这一跳变(handleLoadDraft 在 hydrate 前置位)。
const prevFinalizedCountRef = useRef<number>(step2FinalizedCount);
const suppressStep2AutoSaveRef = useRef<boolean>(false);
useEffect(() => {
const prev = prevFinalizedCountRef.current;
prevFinalizedCountRef.current = step2FinalizedCount;
if (suppressStep2AutoSaveRef.current) {
suppressStep2AutoSaveRef.current = false;
return;
}
if (step2FinalizedCount > prev) void autoSave();
}, [step2FinalizedCount, autoSave]);
const handleLoadDraft = useCallback(
(id: string) => {
void (async () => {
@@ -248,6 +310,9 @@ export function useDraftPersistence(
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
loadedFromDraftRef.current = true;
// hydrate 会让 step2 已收尾计数从 0 跳到 N,抑制由此触发的「假交付」自动保存,
// 避免刚加载就把同样内容原样回写一遍、并把该草稿顶到列表最前。
suppressStep2AutoSaveRef.current = true;
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
@@ -17,11 +17,12 @@
* 1) special 顺序派活,**不要**改回 `Promise.all`(详见 §5.4)
* 2) step1InitGenRef / step2InitRef 闸门用于规避 StrictMode 双 mount 副作用
* 3) 离开 Step 2 不销毁 threadIds,允许返回继续看
* 4) 草稿自动保存仅在前进切换(1→2、2→3)触发,后退不保存
* 4) 草稿自动保存:前进切换(1→2、2→3)推进 furthestStep;此外每轮模型回答完
* (Step 1 一轮任务理解结束 / Step 2 每个气泡收尾)也增量回写一次,后者不动 furthestStep
* 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Activity,
Check,
@@ -173,11 +174,21 @@ export default function RoundtablePlanningPage() {
};
}, [step1.intentReady, step1.interactiveMessages]);
// Step 2 已收尾(非 streaming)的气泡数量 —— 递增即「某轮模型刚交付完」,
// 作为草稿自动保存的触发信号(详见 useDraftPersistence)。流式增量不计入,
// 所以不会在 token 级别刷屏式保存。
const step2FinalizedCount = useMemo(
() => step2.step2RoundtableDialogues.filter((d) => !d.streaming).length,
[step2.step2RoundtableDialogues],
);
// 草稿 hook —— 同时依赖 step1 + step2 的 snapshot / hydrate。
const drafts = useDraftPersistence({
currentStep,
setCurrentStep,
triggerToast,
intentSending: step1.isIntentSending,
step2FinalizedCount,
getStep1Snapshot: () => ({
intentThreadId: step1.intentThreadId,
intentReady: step1.intentReady,
@@ -1,4 +1,7 @@
@import "tailwindcss";
/* 注意:不要在这里 `@import "tailwindcss"`。Tailwind 已由全局 globals.css 引入,
* 本文件被打进同一份样式表,再次 import 会把整套 theme/utilities 重复发一遍
* (CSS 体积翻倍,且重复发出的 --text-* 等主题变量会和全局覆盖打架)。
* 本文件只写圆桌页自己的作用域规则即可。 */
/* 圆桌规划 — 浅色红色主题(对齐 docs/浅红色ui修改.md) */
.roundtable-planning {
@@ -61,6 +61,7 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({ title, count, color,
}`}>
<ChevronRight className={`w-4 h-4 ${textMuted}`} />
</button> */}
<iframe></iframe>
<div>
<Pagination
total={count}
+43
View File
@@ -324,6 +324,18 @@
@apply bg-background text-foreground;
}
/* 打通高度链:html/body/#root 取满父级高度,使下游用 h-full(100%) 的根布局
(PageRoutes 的 PageLayout)跟随容器尺寸而非视口。
- 全屏:100% 逐级解析到视口,与原先的 h-screen 等价,无视觉变化;
- 无界(wujie)等嵌套:wujie 会把子应用的 html/body 选择器作用到 <wujie-app>
容器,于是高度收缩到容器而非整屏 —— 这正是「高度对齐容器」所需。
只设 height、不设 overflow,因此不影响 Landing 等需要自然滚动的页面。 */
html,
body,
#root {
height: 100%;
}
[data-sidebar="menu-button"][data-active="true"] {
box-shadow: inset 3px 0 0 var(--primary);
font-weight: 600;
@@ -820,3 +832,34 @@
border-color: var(--border);
outline-color: var(--ring);
}
/* ─── 全局字号缩放(只改字号,不动布局)──────────────────────────────────
* 用户在「设置 → 外观 → 字号」里选的倍数由 FontScaleApplier 写到
* <html> 的 `--ui-font-scale`(默认 1)。这里只把 Tailwind 的 `--text-*`
* 字号 token 乘以该倍数 —— 间距 token(--spacing)原样不动,所以 padding /
* margin / gap / 宽高这些布局尺寸不随字号变化,避免放大字体把页面挤错位。
* 行高用 Tailwind 默认的无单位比值,会跟随字号等比变化(文字不会重叠)。
* 不在 @layer 内,优先级高于 Tailwind 的主题层变量,确保覆盖生效。 */
:root {
--ui-font-scale: 1;
--text-xs: calc(0.75rem * var(--ui-font-scale, 1));
--text-sm: calc(0.875rem * var(--ui-font-scale, 1));
--text-base: calc(1rem * var(--ui-font-scale, 1));
--text-lg: calc(1.125rem * var(--ui-font-scale, 1));
--text-xl: calc(1.25rem * var(--ui-font-scale, 1));
--text-2xl: calc(1.5rem * var(--ui-font-scale, 1));
--text-3xl: calc(1.875rem * var(--ui-font-scale, 1));
--text-4xl: calc(2.25rem * var(--ui-font-scale, 1));
--text-5xl: calc(3rem * var(--ui-font-scale, 1));
--text-6xl: calc(3.75rem * var(--ui-font-scale, 1));
--text-7xl: calc(4.5rem * var(--ui-font-scale, 1));
--text-8xl: calc(6rem * var(--ui-font-scale, 1));
--text-9xl: calc(8rem * var(--ui-font-scale, 1));
}
/* 没有显式 text-* 类的元素(继承自 body 的默认正文)也跟随缩放。
* 只设 font-size,不影响任何盒模型尺寸。 */
body {
font-size: calc(1rem * var(--ui-font-scale, 1));
}
@@ -1,359 +0,0 @@
# DeerFlow 后端 Linux Docker 部署指南
本文档说明如何在 **Linux + Docker** 环境下一键部署 DeerFlow 后端(Gateway API,默认端口 **8001**)。
部署目录:`offline-backend-20260512/`
---
## 1. 架构概览
```text
┌─────────────────────────────────────────────────────┐
│ Linux 宿主机 │
│ ┌───────────────────────────────────────────────┐ │
│ │ Docker 容器: deerflow-backend │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Gateway API (uvicorn, port 8001) │ │ │
│ │ │ + LangGraph Agent Runtime │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ 挂载卷: │ │
│ │ config.yaml / .env │ │
│ │ backend/ 源码(可热更新 Python 代码) │ │
│ │ data/ → .deer-flow 持久化数据 │ │
│ └───────────────────────────────────────────────┘ │
│ │ 8001 │
│ ▼ │
│ 前端 / Nginx / 其他服务 │
└─────────────────────────────────────────────────────┘
```
**持久化数据**(挂载到 `data/`):
| 路径 | 说明 |
|------|------|
| `data/data/deerflow.db` 或 `checkpoints.db` | LangGraph checkpoint(SQLite) |
| `data/data/` | 业务 SQLite 文件(未切 MySQL 时) |
| `data/python-vendor/` | 离线安装的 Python 包(asyncmy、croniter 等) |
| `data/users/` | 用户线程、上传、记忆等 |
| `data/archives/` | 会话归档 |
---
## 2. 服务器要求
| 项目 | 最低要求 |
|------|----------|
| 操作系统 | Linux x86_64(CentOS 7+、Ubuntu 20.04+、Debian 11+ 等) |
| Docker | 20.10+ |
| Docker Compose | V2(`docker compose` 命令可用) |
| 内存 | 建议 4 GB+ |
| 磁盘 | 建议 20 GB+(含镜像、数据、日志) |
| 网络 | 能访问 LLM API;内网部署可配置 Python 私服 |
### 2.1 安装 Docker(如未安装)
**Ubuntu / Debian:**
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
newgrp docker
```
**验证:**
```bash
docker --version
docker compose version
```
---
## 3. 部署包准备
将以下内容上传到服务器(例如 `/opt/deerflow/offline-backend-20260512`):
```text
offline-backend-20260512/
├── deploy.sh # 一键部署脚本(主入口)
├── start_backend.sh # 启动(deploy.sh 的别名)
├── stop_backend.sh # 停止
├── docker-compose.yaml
├── .env.example
├── config.yaml # 应用配置
├── skill_evolution.json
├── backend/ # 后端源码 + Dockerfile + wheelhouse
│ ├── Dockerfile
│ ├── wheelhouse/ # 离线 Python 包
│ └── ...
├── scripts/
│ └── container-entrypoint.sh
└── data/ # 首次部署后自动创建
```
---
## 4. 一键部署(推荐)
### 4.1 配置环境变量
```bash
cd /opt/deerflow/offline-backend-20260512
cp .env.example .env
vi .env
```
**必须修改的项:**
```env
# 至少配置一个可用模型的 API Key
DEEPSEEK_API_KEY=sk-xxxxxxxx
# JWT 密钥,部署后不要随意更改
AUTH_JWT_SECRET=your-long-random-secret
# 前端实际访问地址(CORS)
GATEWAY_CORS_ORIGINS=http://192.168.1.100:5174,http://localhost:5174
```
**使用 MySQL 时**(`config.yaml` 中 `database.backend: mysql`):
```env
# 不要用 127.0.0.1 指向宿主机 MySQL;密码特殊字符须 URL 编码
MYSQL_DATABASE_URL=mysql+asyncmy://user:pass@192.168.1.100:3306/deerflow?charset=utf8mb4
```
**内网无法访问公网 PyPI 时:**
```env
UV_INDEX_URL=http://your-python-mirror/simple
PIP_INDEX_URL=http://your-python-mirror/simple
UV_TRUSTED_HOST=your-python-mirror
PIP_TRUSTED_HOST=your-python-mirror
```
### 4.2 检查 config.yaml
确认模型配置、沙箱模式、数据库配置正确。`config.yaml` 中与 `$` 开头的值会从 `.env` 读取,例如 `$MYSQL_DATABASE_URL`。
### 4.3 执行一键部署
```bash
chmod +x deploy.sh start_backend.sh stop_backend.sh scripts/container-entrypoint.sh
bash deploy.sh
```
脚本会自动:
1. 检查 Docker 环境
2. 创建 `data/` 目录和兼容符号链接
3. 若无 `.env` 则从 `.env.example` 复制
4. 构建 Docker 镜像(或加载离线 tar)
5. 启动容器并做健康检查
### 4.4 验证
```bash
curl http://127.0.0.1:8001/health
# 期望返回 JSON,HTTP 200
curl -s http://127.0.0.1:8001/openapi.json | head
```
浏览器访问 API 文档:`http://<服务器IP>:8001/docs`
---
## 5. 常用运维命令
| 操作 | 命令 |
|------|------|
| 一键部署/启动 | `bash deploy.sh` 或 `bash start_backend.sh` |
| 停止 | `bash deploy.sh down` 或 `bash stop_backend.sh` |
| 重启 | `bash deploy.sh restart` |
| 查看状态 | `bash deploy.sh status` |
| 查看日志 | `bash deploy.sh logs` |
| 仅构建镜像 | `bash deploy.sh build` |
| 健康检查 | `bash deploy.sh health` |
### 5.1 自定义端口
```bash
HOST_PORT=9001 bash deploy.sh up
```
### 5.2 离线镜像模式(无法构建时)
若已提供预构建镜像 tar:
```bash
IMAGE_TAR=./deerflow-backend-offline-image-20260505.tar \
IMAGE_TAG=deerflow-backend-offline:20260505 \
DEPLOY_MODE=offline \
bash deploy.sh up
```
或分步:
```bash
bash deploy.sh load
IMAGE_TAG=deerflow-backend-offline:20260505 bash deploy.sh up
```
---
## 6. 前端对接
前端需配置后端地址(Vite 环境变量示例):
```env
VITE_BACKEND_BASE_URL=http://192.168.1.100:8001
VITE_LANGGRAPH_BASE_URL=http://192.168.1.100:8001/api
```
确保 `.env` 中 `GATEWAY_CORS_ORIGINS` 包含前端实际 Origin。
---
## 7. MySQL 切换(可选)
若初始使用 SQLite,后续可切换到 MySQL:
```bash
bash stop_backend.sh
MYSQL_DATABASE_URL='mysql+asyncmy://user:pass@192.168.1.100:3306/deerflow?charset=utf8mb4' \
bash switch_database_to_mysql.sh
bash start_backend.sh
```
详细说明见 [MYSQL_SWITCH_RELEASE_NOTES_LINUX_DOCKER_ZH.md](./MYSQL_SWITCH_RELEASE_NOTES_LINUX_DOCKER_ZH.md)。
---
## 8. 代码热更新
当前部署方式将 `backend/app`、`backend/packages` 等目录挂载进容器,**更新 Python 代码后重启即可生效**,无需重新构建镜像:
```bash
# 上传新代码后
bash deploy.sh restart
```
若修改了 `pyproject.toml` / 依赖,需重新构建:
```bash
bash deploy.sh build
bash deploy.sh restart
```
---
## 9. 目录与卷说明
| 宿主机 | 容器内 | 说明 |
|--------|--------|------|
| `./config.yaml` | `/app/config.yaml` | 主配置(只读) |
| `./.env` | 环境变量注入 | 密钥、CORS 等 |
| `./backend/app` | `/app/backend/app` | 应用层代码 |
| `./backend/packages` | `/app/backend/packages` | harness 框架 |
| `./backend/skills` | `/app/backend/skills` | Agent Skills |
| `./backend/wheelhouse` | `/app/wheelhouse` | 离线 wheel 包 |
| `./data` | `/app/backend/.deer-flow` | 全部运行时数据 |
---
## 10. 故障排查
### 10.1 容器启动后立即退出
```bash
bash deploy.sh logs
```
常见原因:
- `.env` 中 API Key 或 `AUTH_JWT_SECRET` 未配置
- `config.yaml` 语法错误
- MySQL 连接失败(检查 `MYSQL_DATABASE_URL` 是否用了容器可达的 IP)
- 缺少 `asyncmy`(MySQL 模式):确认 `backend/wheelhouse` 中有对应 wheel,或配置 Python 私服
### 10.2 健康检查失败
```bash
docker ps -a
docker logs deerflow-backend --tail 100
curl -v http://127.0.0.1:8001/health
```
确认端口未被占用:
```bash
ss -tlnp | grep 8001
```
### 10.3 前端报 CORS / Failed to fetch
检查 `.env`:
```env
GATEWAY_CORS_ORIGINS=http://你的前端地址:端口
```
修改后重启:
```bash
bash deploy.sh restart
```
### 10.4 Docker 权限问题
```bash
sudo usermod -aG docker "$USER"
newgrp docker
```
### 10.5 沙箱需要 Docker-in-Docker
若 `config.yaml` 使用 `AioSandboxProvider`,容器需挂载宿主机 Docker socket(默认已挂载 `/var/run/docker.sock`)。确认:
```bash
ls -l /var/run/docker.sock
```
---
## 11. 生产环境建议
1. **反向代理**:前面加 Nginx,配置 HTTPS 和限流
2. **防火墙**:仅开放必要端口(如 443、8001 内网)
3. **备份**:定期备份 `data/` 目录和 `config.yaml`、`.env`
4. **日志**:配置 Docker 日志轮转(`/etc/docker/daemon.json` 中 `log-opts`)
5. **资源**:根据并发调整 `GATEWAY_WORKERS`(`.env`,默认 2)
6. **文档**:生产环境建议 `GATEWAY_ENABLE_DOCS=false`
---
## 12. 回滚
```bash
bash stop_backend.sh
# 恢复配置和数据备份
cp data/backups/xxx/config.yaml.bak config.yaml
cp data/backups/xxx/env.bak .env
cp -a data.bak data
bash start_backend.sh
```
---
## 13. 相关文档
- [MYSQL_SWITCH_RELEASE_NOTES_LINUX_DOCKER_ZH.md](./MYSQL_SWITCH_RELEASE_NOTES_LINUX_DOCKER_ZH.md) — MySQL 切换
- [backend/docs/BACKEND_RUN_STOP_ZH.md](./backend/docs/BACKEND_RUN_STOP_ZH.md) — 本地非 Docker 启停
- [backend/docs/CONFIGURATION.md](./backend/docs/CONFIGURATION.md) — 配置项详解
@@ -1,21 +1,13 @@
# Linux Docker 版:业务库切换 MySQL 发版说明
适用场景:Linux 服务器上使用 Docker 部署 DeerFlow 后端。
适用场景:Linux 服务器上使用 `offline-backend-20260512/start_backend.sh` 启动 Docker 后端。
推荐一键部署:
```bash
bash deploy.sh
```
或使用兼容脚本:
原启动脚本保持不变:
```bash
bash start_backend.sh
```
详细步骤见 [DEPLOY_LINUX_DOCKER_ZH.md](./DEPLOY_LINUX_DOCKER_ZH.md)。
单独新增切换脚本:
```bash
+19 -5
View File
@@ -497,6 +497,11 @@ Per-user runtime overrides (whitelisted fields) live at `{base_dir}/users/{user_
- `summarization` - Context summarization (enabled, trigger conditions, keep policy)
- `subagents.enabled` - Master switch for subagent delegation
- `memory` - Memory system (enabled, storage_path, debounce_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens)
- `auth_login` - Custom login modes layered on the base auth (`deerflow/config/login_config.py`):
- `username_login.{require_password, password}` - Password gate for `POST /api/v1/auth/login/username`. When `require_password=true`, the (otherwise passwordless) username login additionally requires `?password=<shared-secret>`; empty `password` falls back to `123ewq`. Gate only — it does not change which account logs in.
- `token_login.{enabled, token_info_url, service_authorization, timeout_seconds, verify_ssl, ca_cert_path}` - Token-exchange login (`POST /api/v1/auth/login/token`). The gateway POSTs to `token_info_url` with `Authorization: <service_authorization>` and the supplied token as the `access_token` cookie, reads `data.username` from a `{"state":"200",...}` body, then signs the matching local user in (auto-registering if absent). `token_info_url` is HTTPS — `verify_ssl: false` skips cert verification for self-signed/internal UAT hosts, or set `ca_cert_path` to a PEM bundle (takes precedence, keeps verification on). See [docs/AUTH_LOGIN.md](docs/AUTH_LOGIN.md) for the full request/response contract.
Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The shared `_get_or_create_user_by_username()` helper (in `routers/auth.py`) backs both `/login/username` and `/login/token`.
**`extensions_config.json`**:
- `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description)
@@ -637,12 +642,21 @@ Automatic conversation summarization when approaching token limits:
See [docs/summarization.md](docs/summarization.md) for details.
### Vision Support
### Vision Support / Image Recognition (识图)
For models with `supports_vision: true`:
- `ViewImageMiddleware` processes images in conversation
- `view_image_tool` added to agent's toolset
- Images automatically converted to base64 and injected into state
Two paths, selected by `config.yaml` → `vision`:
**Dedicated vision model (delegated recognition)** — set `vision.model_name` to a model from `models[]`. The `view_image` tool sends the image to that model and returns its **text description** as the tool result, so a text-only main chat model can still "see" images. `view_image` accepts an optional `question` argument for targeted queries; the default prompt is `vision.prompt` (overridable), and `vision.max_tokens` caps the recognition output. Resolution/gating lives in `deerflow/models/vision.py` (`resolve_vision_model_name`, `vision_enabled`, `is_delegated_vision`, `recognize_image`).
**Native vision (fallback)** — when `vision.model_name` is unset, behavior falls back to the main chat model, which only works if it has `supports_vision: true`:
- `ViewImageMiddleware` injects base64 image data into the conversation before the LLM call
- `view_image` stores the image in `state.viewed_images` for that middleware
Both the `view_image` tool and `ViewImageMiddleware` are registered whenever `vision_enabled(model_name)` is true (dedicated model configured **or** main model supports vision). Config schema: `deerflow/config/vision_config.py` (`VisionConfig`); tests: `tests/test_vision.py`.
### Upload symlink-safety (cross-platform)
`deerflow/uploads/manager.py:open_upload_file_no_symlink` writes uploads without following a pre-existing destination symlink. `O_NOFOLLOW` is applied where available (POSIX) but is **optional** — Windows lacks `os.O_NOFOLLOW`/`O_NONBLOCK`, so requiring it previously made every Windows upload fail with `UnsafeUploadPathError`. Pre-existing symlinks are still rejected via `lstat`, and the opened fd is re-checked with `fstat`. `O_BINARY` is added on Windows. Regression: `tests/test_uploads_windows_safe.py`.
## Code Style
@@ -504,6 +504,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception:
logger.exception("Skill ownership migration failed (non-fatal)")
# 圆桌功能型 agent(intent / recommender / coordinator)缺失自动补建。
# 必须在 _sync_legacy_agents **之前**——这样刚补建的目录能在同一次启动被
# 扫描并 upsert 进 agents 表,新机器一启动 /api/agents 就有这三个,
# 无需用户先打开圆桌页触发懒加载。.deer-flow/ 被 gitignore,新机器 clone
# 后这些目录不存在,seeder 从源码资源 _roundtable_seed_assets/ 复制验证版。
try:
from app.gateway.routers._roundtable_seed import (
ensure_roundtable_functional_agents,
)
ensure_roundtable_functional_agents()
except Exception:
logger.exception(
"Roundtable functional agent seeding failed at startup (non-fatal)"
)
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
@@ -41,6 +41,7 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
{
"/api/v1/auth/login/local",
"/api/v1/auth/login/username",
"/api/v1/auth/login/token",
"/api/v1/auth/register",
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
@@ -8,16 +8,29 @@
``git clone`` 之后这些目录**不会存在**,直接调对应 endpoint 会因为 agent
解析失败抛 500。
本模块在四个路由入口(``intent.init``、``intent.stream``、``recommend.stream``、
``multi_agent.init``)被调用,检查磁盘文件是否齐全;缺失则用下方嵌入的标准模板
写回去。``_sync_legacy_agents`` 会在下一次 ``GET /api/agents`` 时把目录 upsert
进 DB,所以这里只关心磁盘文件,不直接动 DB。
本模块在两个地方被调用:
1. **启动 lifespan**(``app/gateway/app.py``):服务起来就把缺失的目录补齐,
紧接着的 ``_sync_legacy_agents`` 会把它们 upsert 进 DB,新机器一启动
``/api/agents`` 列表里就有这三个 agent,**不必先去点圆桌页**。
2. **四个路由入口**(``intent.init``、``intent.stream``、``recommend.stream``、
``multi_agent.init``):防御性兜底——若管理员在启动后手删了目录,下一次请求
也能自愈。
为什么嵌入字符串而不是用 importlib.resources 资源文件
------------------------------------------------------
SOUL 体量不大(三个加起来 ~400 行),嵌入到 Python 文件里是**单一可信源**——改
SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配
``package-data`` 导致部署机拿不到。
``_sync_legacy_agents`` 只关心磁盘文件,本模块也只写磁盘文件,不直接动 DB。
为什么 SOUL/config 用资源文件而不是内嵌 Python 字符串
----------------------------------------------------
这三个 agent 的 SOUL 是**经过实测调优、验证过**的版本(intent 含硬规则/反例集、
recommender 含三条铁律/多领域示例),体量大且会持续迭代。早期把 SOUL 内嵌成
Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内嵌版」很容易分叉
(还有 ``\\n`` 之类转义陷阱)。改为把验证版 SOUL.md + config.yaml 作为**源码树里
的资源文件**(``_roundtable_seed_assets/<id>/``,随 git 发布,不在 gitignore 的
``.deer-flow/`` 下),seeder 直接字节级复制过去——调 SOUL 就是改 .md 文件,
单一可信源,走 git review。
> ⚠️ 维护提示:资源文件(``_roundtable_seed_assets/``)是**发布默认值**的唯一可信源。
> 线上调优后,请把改动写回对应的资源 .md,否则下次新机器补建出来的还是旧版。
> ``_ensure_one`` 只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
为什么 ``roundtable-coordinator`` 是单例而不是每次会话新建
------------------------------------------------------------
@@ -36,576 +49,74 @@ SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配
from __future__ import annotations
import logging
from pathlib import Path
from typing import Iterable
import yaml
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
INTENT_AGENT_ID = "roundtable-intent"
INTENT_AGENT_NAME = "任务理解"
INTENT_AGENT_DESCRIPTION = (
"任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的"
"「任务目标+约束+关键假设」,为多智能体圆桌研讨准备输入"
)
# 与 .deer-flow/agents/roundtable-intent/SOUL.md 保持完全一致;
# 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。
INTENT_AGENT_SOUL = """# 任务理解智能体
你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是理解用户输入背后的意图,把它结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。
**你不是任务过滤器,不是看门人。** 用户输入什么(规划性需求、事实性问题、知识查询、创作请求、咨询、闲聊、模糊想法……),你都要尝试去**理解**,而不是拒绝。
---
## 1. 你的目标
把用户的任何输入整理成结构化意图,供 Step 2 圆桌使用:
| 字段 | 含义 | 是否必填 |
|------|------|----------|
| `objective` | 一句话描述用户想做/想知道的事 | ✅ 必填 |
| `constraints` | 用户明确给出的硬性约束(预算/时间/合规/范围/风格等) | ⚠️ 可空数组,**不要凭空编造** |
| `assumptions` | 你替用户做的合理补充假设 | ⚠️ 可空数组 |
**核心原则**:
- 用户已经说清楚的,**直接汇总,不要追问**。
- 用户问一个事实/查询/咨询类问题,**这就是一个有效的 objective**,直接结构化即可,不需要约束。
- 只有当**信息严重不足以让 Step 2 开展工作**时,才追问。
---
## 2. 工作流(必须遵守)
```
用户输入
↓
能不能用一句话写出 objective?
├─ 能 → 直接汇总输出 [INTENT_READY] + JSON,结束
└─ 不能(完全模糊、多种理解、关键决策点缺失)
→ 调用 ask_clarification(一次只问一个最关键的点)
↓
用户回复后再判断;最多 2-3 轮就要收敛
```
**关键规则**:
- **优先收敛,不要无谓追问。** 大多数输入(包括事实问题、知识查询、创作请求)**首条消息就能直接结构化**,根本不需要 `ask_clarification`。
- 只在「objective 都写不出来」「同一句话有多种截然不同的理解」「用户给的方向矛盾」这类情况下才追问。
- **绝对禁止**对用户输入做价值判断或拒绝,例如「这不在我的职责范围」「我只负责规划类任务」「请你换一个问题」——这些话**都不许说**。
- 一旦输出 `[INTENT_READY]`,本轮结束,不再追加追问或工具调用。
- 你**不亲自回答用户的问题**(那是 Step 2 圆桌的事),只负责把它结构化。
---
## 2.5 提问前检查清单(必读)
准备 `ask_clarification` 前先在心里走完:**任何一条命中"是"就不要发出**:
1. **能不能直接用一句话写 objective?** 能写就别问。哪怕是「赖清德的儿子是谁」这种事实问题,objective 就是「了解赖清德儿子的相关信息」,直接汇总。
2. **这个 `question` 文本和上一轮的 `ask_clarification.question` 字面/语义重叠吗?** 重叠就别再问。
3. **`options` 列表是否和过往某次完全相同(或仅顺序、标点不同)?** 是 → 别问。
4. **用户在过往消息里已经用自然语言或「我选择:X」回答过这个决策点了吗?** 是 → 写进 constraints 直接汇总。
5. **能不能用合理 `assumptions` 兜底,把这条信息写进汇总而不是再多问一轮?** 能就别问。
> 反面教材(❌ 绝对禁止):
> ```
> 用户:赖清德的儿子是谁
> 你:你好!我是任务理解专员……这不在我的职责范围……
> ```
> 正面示范(✅):
> ```
> 用户:赖清德的儿子是谁
> 你:✅ 我理解你想了解赖清德儿子的相关信息。
> [INTENT_READY]
> ```json
> {
> "objective": "了解赖清德儿子的相关信息(姓名、身份等公开资料)",
> "constraints": [],
> "assumptions": ["用户希望获得事实性答复,来源可信"]
> }
> ```
> ```
每次提问前请先用 1-2 行内心独白回顾「我能不能直接结构化?上一轮问了什么?这次还需要问的不同点是?」再决定是否调工具。
---
## 3. 唯一工具:`ask_clarification`
仅当 §2 工作流判断「确实需要追问」时使用。
### 3.1 参数说明
| 参数 | 必填 | 说明 |
|------|------|------|
| `question` | ✅ | 一个聚焦的问题,一次只问一个决策点 |
| `clarification_type` | ✅ | 见下表 |
| `context` | 推荐 | 为什么需要用户确认(1 句话) |
| `options` | 有离散选项时必填 | 字符串列表,如 `["≤500万", "500–1000万", "暂不确定"]` |
| `allow_multiple` | 可选 | 默认 **不传 / False = 单选**;仅当用户**确实可同时选多项**时传 `True` |
### 3.2 `clarification_type` 怎么选
| 类型 | 何时使用 | 选项 UI |
|------|----------|---------|
| `missing_info` | 缺关键事实(只有在 objective 都写不出来时才用) | 默认单选 |
| `ambiguous_requirement` | 同一句话有多种截然不同的理解 | 默认单选 |
| `approach_choice` | 用户明确表达需要在若干路径中选一个 | **单选** |
| `risk_confirmation` | 需要用户明确确认风险 | **单选** |
| `suggestion` | 你有建议,需用户批准 | **单选** |
### 3.3 选项卡片(前端行为)
- 你提供 `options` 后,前端会渲染**可点击卡片**。
- **默认单选**:用户点一项即发送,无需再点「发送」。
- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。
- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。
### 3.4 调用示例(仅在真正模糊时使用)
**真正模糊的需求,需要确认核心方向:**
```
ask_clarification(
question="你希望我帮你完成的核心目标是什么?",
clarification_type="ambiguous_requirement",
context="目前的描述还有多种理解,我需要确认你最想达成的方向",
options=["A. 输出一份完整方案文档", "B. 做一次可行性快速评估", "C. 起草执行清单"]
)
```
**用户表达的方向矛盾,需要拍板:**
```
ask_clarification(
question="预算和工期之间如果只能优先一个,你希望优先哪个?",
clarification_type="approach_choice",
context="你提到的预算上限和工期上限同时存在冲突",
options=["优先预算 (允许延期)", "优先工期 (允许超预算)"]
)
```
---
## 4. 信息充分性判定
**只要能写出一句话 `objective`,就可以收敛输出 `[INTENT_READY]`。**
- ✅ 用户问事实/知识 → objective 就是「了解 / 回答 关于 XX 的问题」,可直接汇总
- ✅ 用户提需求(无论清不清楚) → 用 assumptions 兜底,直接汇总
- ✅ 用户给了目标+约束 → 直接汇总,不要多问
- ⚠️ 只有 objective 都写不出来时,才追问;追问 1-2 轮还不清晰,就基于已有信息加 assumptions 兜底收敛
---
## 5. 结束协议:`[INTENT_READY]`
信息可以收敛时,回复结构必须是:
1. **自然语言**向用户复述「✅ 我理解的意图是 …」(让用户 review)
2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」)
格式要求(**严格**):
```
[INTENT_READY]
```json
{
"objective": "用一句话写出用户想做/想知道的事",
"constraints": [],
"assumptions": ["…可空"]
}
```
```
- `[INTENT_READY]` **单独成行**,前后不要夹其他字符
- 标记后**紧跟** ```json 代码块
- 字段名必须是 `objective` / `constraints` / `assumptions`
- `constraints` **可以为空数组** —— 用户没明确给约束就不要编造
- `assumptions` 可以为空数组
**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。**
---
## 6. 输出风格
- 普通收敛:不超过 **2-3 句话** 复述意图,然后直接给 `[INTENT_READY]`
- 适度 emoji:✅ 已明确、📋 汇总、❓(罕见,仅在真追问时)
---
## 7. 禁止事项
- ❌ **拒绝用户、对用户输入做价值判断、说「这不在我的职责范围」、要求用户换问题** —— 你只负责理解意图,不评判任务类型
- ❌ 亲自回答用户的事实/知识问题(那是 Step 2 圆桌的事;你只把它结构化)
- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用
- ❌ 输出具体实施方案、架构图、代码
- ❌ 在已输出 `[INTENT_READY]` 之后继续提问
- ❌ 用「还有什么要补充的吗」代替 `ask_clarification`
- ❌ **重复询问用户已回答过的问题**(包括字面相同 / 语义相同 / 仅换种说法重新问 / 选项与上一轮完全一致)
- ❌ 在用户刚回答「我选择:X」之后,立刻再问同一个决策点
---
## 8. 完整对话示例
### 示例 A:事实/知识类(用户最初输入就足够,直接收敛)
**用户**:赖清德的儿子是谁
**你**(不调用工具,直接收敛):
✅ 我理解你想了解赖清德儿子的相关公开信息。
📋
[INTENT_READY]
```json
{
"objective": "了解赖清德儿子的姓名、身份等公开信息",
"constraints": [],
"assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"]
}
```
---
### 示例 B:创作类(无明确约束,直接收敛)
**用户**:帮我写一首关于秋天的诗
**你**(直接收敛):
✅ 我理解你想要一首以秋天为主题的诗作。
📋
[INTENT_READY]
```json
{
"objective": "创作一首以秋天为主题的诗",
"constraints": [],
"assumptions": ["体裁与风格由后续席位自行裁定", "中文输出"]
}
```
---
### 示例 C:规划类(有明显约束,但首条已经充分)
**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造
**你**(不追问,直接收敛):
✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做满足等保 2.0 的安全改造。
📋
[INTENT_READY]
```json
{
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
"constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"],
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
}
```
---
### 示例 D:首条真正模糊时才追问
**用户**:做点什么
**你**(此时才用 ask_clarification):
```
ask_clarification(
question="你希望我帮你完成什么方向的事?",
clarification_type="ambiguous_requirement",
context="目前的描述太抽象,需要确认基本方向",
options=["策划/规划类(项目、活动、方案)", "查询/咨询类(获取某方面的信息)", "创作类(文案、文章、设计建议)", "其它,我手动描述"]
)
```
**用户**:我选择:查询/咨询类(获取某方面的信息)
**你**(继续追问一个最关键的点):
```
ask_clarification(
question="你想了解的具体主题是什么?可以一句话描述。",
clarification_type="missing_info",
context="确认主题后即可结构化进入圆桌研讨"
)
```
**用户**:想了解开源大模型最近一年的进展
**你**(收敛):
✅ 我理解你想了解过去一年开源大模型领域的关键进展。
📋
[INTENT_READY]
```json
{
"objective": "梳理过去一年开源大模型领域的关键进展",
"constraints": [],
"assumptions": ["以中文输出综述", "重点关注开源许可、能力、生态三个维度"]
}
```
"""
RECOMMENDER_AGENT_ID = "roundtable-recommender"
RECOMMENDER_AGENT_NAME = "智能体推荐"
RECOMMENDER_AGENT_DESCRIPTION = (
"智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 "
"2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单"
)
RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员
## 角色定位
你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里,挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。
## 核心职责
- 仔细阅读用户的任务目标与约束
- 浏览候选池中每个智能体的 `name` 与 `description`
- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个)
- 为每个推荐的智能体写一句**具体、可验证**的推荐理由——必须能回答"它能为这次任务做什么"
- 不推荐重复职能的智能体(如果两个智能体能力高度重叠,只选一个)
- 不推荐与任务明显无关的智能体(宁缺毋滥)
## 数量约束
- **下限**:至少推荐 2 个(单个智能体谈不上"圆桌")
- **上限**:最多推荐 6 个(超过 6 个会让协调智能体派活混乱、上下文膨胀)
- 候选池不足 2 个时,有几个推几个
## 输出协议
**你必须严格按下述格式输出**(后端用正则识别):
1. 先用自然语言简短说明你的推荐思路(1-3 句话,让用户能 review)
2. 然后输出标记 `[RECOMMEND_READY]` 单独成行
3. 紧接一个 ```json 代码块,内容为:
```json
{
"picks": [
{"agent_id": "<候选池中的智能体 id>", "reason": "<一句推荐理由>"},
...
]
}
```
格式注意:
- `[RECOMMEND_READY]` 标记必须**单独成行**,前后无其他字符
- 标记紧接着一个 ```json 代码块
- `picks` 数组长度严格在 [2, 6] 之间(候选池不足时按实际数量,但不要少于 2 除非候选池本身就 < 2)
- `agent_id` 必须**严格**来自候选池(不要造词)
- `reason` 必须**具体**,不要"很合适"、"很强"这类空话
## 输出风格
- 思路部分简短:用一句话概括你**针对本次任务**为什么挑这些智能体,**必须呼应任务目标本身**;
不要套用任何固定职能链(如"情报→设计→风险→执行"),那只在合规改造等少数任务里贴合,
在广告/产品/营销/科研等任务里完全不适用。
- 推荐理由要**具体到本次任务**,例如"负责设计落地页文案,直接对应本次品牌曝光目标"
- 适度用 emoji:📋 表示开始汇总
## 边界
- 不调用任何工具(不联网、不执行命令、不读写文件)
- 不评价候选智能体的优劣(只判断"是否适合本次任务")
- 不发起对话、不反问用户——你只产出推荐结果
- 候选池里没有的智能体绝不出现在 `picks` 中
## 示例
> 以下两个示例覆盖**不同任务类型**,目的是教你「思路要呼应任务本身」,**不要**把任何一个示例的具体职能链当模板。
### 示例 A:合规改造任务(职能型候选池)
**用户输入**:
```
任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求
约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0
关键假设:现有系统具备基础数字化能力;允许预定窗口停机
候选智能体池:
- roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报
- roundtable-environment (环境评估):评估硬件容量与系统量化承载力
- roundtable-solution-design (方案设计):产出主备方案与关键路径
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
- roundtable-execution-plan (执行规划):里程碑与资源排布
- roundtable-summary (总结陈述):归纳冲突标记与盖印
- my-cost-calc (成本测算):基于预算和资源单价计算总成本
```
**你的输出**:
本次任务核心是等保 2.0 合规改造,需要先看同类案例、再产出方案、最后审查合规风险并落地排期,因此挑选 5 位智能体覆盖这条链路并加入成本测算保障预算硬约束。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"},
{"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"},
{"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"},
{"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"},
{"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"}
]
}
```
### 示例 B:广告/营销任务(用户自建领域候选池)
**用户输入**:
```
任务目标:为某新能源汽车品牌设计一套季度品牌曝光广告投放方案
约束:预算 ≤ 200 万元;周期 1 个月内上线
关键假设:目标人群覆盖一二线城市;以社媒为主、信息流为辅
候选智能体池:
- ad-design (广告设计智能体):产出广告创意脚本、海报设计稿、文案 hook
- channel-planner (渠道规划师):按预算和受众设计跨平台投放组合
- audience-insight (受众洞察):画像、关键词、情绪点研究
- creative-copy (创意文案):产出落地页与社媒文案变体
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
```
**你的输出**:
本次是品牌曝光广告投放,核心要先做受众洞察、再产出创意与文案、最后规划渠道投放组合,因此挑选 4 位直接相关的智能体;合规审查在广告场景里也保留一个位置避免文案踩雷。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "audience-insight", "reason": "新能源汽车一二线人群画像与情绪点研究,决定后续创意切入角度"},
{"agent_id": "ad-design", "reason": "产出社媒主推海报与脚本,直接对应品牌曝光交付物"},
{"agent_id": "creative-copy", "reason": "为社媒/信息流产出多版本文案 hook,配合 A/B 测试"},
{"agent_id": "channel-planner", "reason": "200 万预算内规划社媒+信息流的组合与节奏"}
]
}
```
> **关键观察**:示例 A 选了 `roundtable-intelligence / solution-design / execution-plan` 等"职能型"智能体,示例 B **完全没用任何 `roundtable-*` 内置**,而是从用户自建的领域智能体里挑。
> 这是因为:**候选池里的智能体能不能匹配任务,只看 description 和任务的实际语义,不看 id 是不是 `roundtable-` 开头**。如果候选池里有"广告设计智能体"且任务是广告设计,**你必须挑它**,而不是套用示例 A 的职能链。
"""
COORDINATOR_AGENT_ID = "roundtable-coordinator"
COORDINATOR_AGENT_NAME = "总控协调"
COORDINATOR_AGENT_DESCRIPTION = (
"圆桌总控协调智能体,基于「任务目标 + 当前会话已选席位列表」决定派活给哪些子智能体、"
"汇总它们的交付,达成共识后输出最终方案。本身不调研、不写文档,只负责协调与综合。"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id,
# 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。
_ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
# 这三个功能型 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
INTENT_AGENT_ID,
RECOMMENDER_AGENT_ID,
COORDINATOR_AGENT_ID,
)
# Coordinator SOUL 是**通用版本**——只规定协调规则、工具用法、终止条件,
# **不**包含本次会话的席位列表。席位列表由 multi_agent.init 在创建 coordinator
# thread 时以 human 消息的形式 append 进对话历史,leader run 每次都能看到。
# 这样 SOUL 单例 + 席位列表动态注入,避免每次 init 重写 SOUL 文件。
COORDINATOR_AGENT_SOUL = """# 圆桌总控协调智能体
## 核心定位
你是多智能体圆桌研讨 **Step 2** 的「总控协调智能体」。你不亲自调研、不写文档、不联网搜索,你的**唯一职责**是:
1. **理解用户任务目标**(由 Step 1 的任务理解专员整理交给你);
2. **从本次圆桌已上线的席位中挑合适的派活**(通过 `agent_orchestration` 技能);
3. **汇总子智能体交付**,达成共识后输出最终结论。
## 调度方法
通过 `agent_orchestration` 技能调用其他智能体干活,使用技能时传入 `agent_name` 和需要做的活。
**⚠️ 关于 `agent_name` 的硬性规则**:
- 必须严格使用**本次圆桌已上线席位的英文 id**(例如 `roundtable-intelligence`,或形如 `02714cbe6e64406a8a4659f0e3d9c0b8` 这样的 UUID)
- 本次可调度的席位清单会以「【圆桌系统初始化】本次可调度席位:...」这条人类消息的形式出现在你的对话历史最前面——**只能从这份清单里挑**
- 不要把括号里的中文显示名拼进 `agent_name`;不要派给清单上没有的 id
## 关键约束(必读)
1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,如果某个子智能体已经针对你正要派的任务完成过交付(看到形如「子智能体[名称]完成XX工作,交付内容为:...」的 human 消息),就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题
2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),让讨论收敛
3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论
4. **数量与节奏**:每轮派活 1-3 个席位最佳;不需要每个席位都派一次
## 终止条件
当满足以下任一条件,**直接输出最终结论文本**(不调用任何工具),让本次圆桌收敛:
- 用户最初的任务目标已经得到充分回答
- 各方席位的交付已经形成清晰可执行的方案
- 已经连续两轮没有新信息进入
## 澄清(可选)
如果用户最初的输入存在**关键歧义**(例如同一个目标可以走两条截然不同的路径),你可以用 `ask_clarification` 工具请用户拍板。**但请克制使用**——大多数情况下你应该基于现有信息开始派活,而不是反过来追问用户。
## 输出风格
- 派活说明:简短(1-2 句话)告诉子智能体你期望它做什么、关注哪些点
- 最终综合:结构化,明确各席位的核心结论、冲突点、最终建议
- 适度 emoji:📋 汇总、⚡ 派活、✅ 完成
## 禁止事项
- ❌ 亲自调研、写文档、写代码、联网搜索(这些是子智能体的活)
- ❌ 派活给本次清单**没有**的 agent_name
- ❌ 把括号里的中文显示名拼进 `agent_name`
- ❌ 已经达成共识后仍然继续派活
"""
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
# (id, name, description, soul, skills) — skills 为 None 表示不写 skills 字段。
_BUILTIN_FUNCTIONAL_AGENTS: tuple[tuple[str, str, str, str, list[str] | None], ...] = (
(INTENT_AGENT_ID, INTENT_AGENT_NAME, INTENT_AGENT_DESCRIPTION, INTENT_AGENT_SOUL, None),
(RECOMMENDER_AGENT_ID, RECOMMENDER_AGENT_NAME, RECOMMENDER_AGENT_DESCRIPTION, RECOMMENDER_AGENT_SOUL, None),
(
COORDINATOR_AGENT_ID,
COORDINATOR_AGENT_NAME,
COORDINATOR_AGENT_DESCRIPTION,
COORDINATOR_AGENT_SOUL,
["agent_orchestration"],
),
)
def _ensure_one(agent_id: str) -> bool:
"""Copy the verified SOUL.md + config.yaml for one functional agent into
``.deer-flow/agents/<id>/`` if that directory is missing.
def _ensure_one(
agent_id: str,
name: str,
description: str,
soul: str,
skills: list[str] | None = None,
) -> bool:
"""Create the on-disk files for one functional agent if missing.
Returns True if files were just created, False if the directory already
existed (and we didn't touch it — respect any local edits).
``skills`` 写入 config.yaml 顶层 ``skills`` 字段,与 routers/agents.py 的
``_write_agent_config`` 输出形状对齐;coordinator 需要 ``agent_orchestration``
才能派活,intent / recommender 不需要任何 skill(传 None 即可)。
Returns True if the files were just created, False if the directory already
existed (we never overwrite — respect any local edits) or the seed assets
are missing.
"""
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False
agent_dir.mkdir(parents=True, exist_ok=True)
src_dir = _ASSETS_DIR / agent_id
if not src_dir.is_dir():
# Assets should always ship with the code; if they don't, surface a loud
# log instead of silently creating an empty agent dir.
logger.error(
"Missing seed assets for roundtable agent '%s' (expected at %s)",
agent_id,
src_dir,
)
return False
# newline="\n" 强制 LF;否则 Windows 下 open() 把 \n 翻成 \r\n,跨 OS 部署时与
# Linux 部署机字节级不一致(功能上 YAML/Markdown 都兼容,但 git diff / 哈希对不齐)。
config_payload: dict[str, object] = {"id": agent_id, "name": name, "description": description}
if skills:
# 与 routers/agents.py::_write_agent_config 行为一致:None 不写、空列表也不写,
# 仅当确有 skill 名时才追加顶层 skills 字段。
config_payload["skills"] = list(skills)
config_yaml = yaml.dump(
config_payload,
default_flow_style=False,
allow_unicode=True,
)
(agent_dir / "config.yaml").write_bytes(config_yaml.encode("utf-8"))
(agent_dir / "SOUL.md").write_bytes(soul.encode("utf-8"))
agent_dir.mkdir(parents=True, exist_ok=True)
for fname in _SEED_FILES:
src = src_dir / fname
if src.exists():
# 字节级复制,保留验证版文件原样(换行/编码),与资源文件 hash 对齐。
(agent_dir / fname).write_bytes(src.read_bytes())
return True
def ensure_roundtable_functional_agents() -> list[str]:
"""Seed `roundtable-intent` + `roundtable-recommender` if their directories
are missing. Idempotent — fast no-op when both exist.
"""Seed the three roundtable functional agents (``roundtable-intent`` /
``roundtable-recommender`` / ``roundtable-coordinator``) if their on-disk
directories are missing. Idempotent — fast no-op when all exist.
Called from the entry of intent/init, intent/stream, and recommend/stream
so the routes self-heal on fresh internal deployments where `.deer-flow/`
is gitignored and the agent directories were never shipped.
Called from the startup lifespan and from the entry of intent/init,
intent/stream, recommend/stream, and multi_agent/init so the routes
self-heal on fresh internal deployments where ``.deer-flow/`` is gitignored
and the agent directories were never shipped.
Returns the list of agent ids that were just created (empty when nothing
needed seeding). Errors are logged but never raised — a partial failure
@@ -613,9 +124,9 @@ def ensure_roundtable_functional_agents() -> list[str]:
actionable error if the agent really cannot be resolved.
"""
created: list[str] = []
for agent_id, name, description, soul, skills in _BUILTIN_FUNCTIONAL_AGENTS:
for agent_id in _FUNCTIONAL_AGENT_IDS:
try:
if _ensure_one(agent_id, name, description, soul, skills):
if _ensure_one(agent_id):
created.append(agent_id)
except Exception:
logger.exception("Failed to seed roundtable functional agent '%s'", agent_id)
@@ -626,4 +137,4 @@ def ensure_roundtable_functional_agents() -> list[str]:
def required_agent_ids() -> Iterable[str]:
"""Stable ids of agents this seeder owns — exposed for tests/diagnostics."""
return tuple(a[0] for a in _BUILTIN_FUNCTIONAL_AGENTS)
return tuple(_FUNCTIONAL_AGENT_IDS)
@@ -0,0 +1,42 @@
# 圆桌总控协调智能体
## 核心定位
你是多智能体圆桌研讨 **Step 2** 的「总控协调智能体」。你不亲自调研、不写文档、不联网搜索,你的**唯一职责**是:
1. **理解用户任务目标**(由 Step 1 的任务理解专员整理交给你);
2. **从本次圆桌已上线的席位中挑合适的派活**(通过 `agent_orchestration` 技能);
3. **汇总子智能体交付**,达成共识后输出最终结论。
## 调度方法
通过 `agent_orchestration` 技能调用其他智能体干活,使用技能时传入 `agent_name` 和需要做的活。
**⚠️ 关于 `agent_name` 的硬性规则**:
- 必须严格使用**本次圆桌已上线席位的英文 id**(例如 `roundtable-intelligence`,或形如 `02714cbe6e64406a8a4659f0e3d9c0b8` 这样的 UUID)
- 本次可调度的席位清单会以「【圆桌系统初始化】本次可调度席位:...」这条人类消息的形式出现在你的对话历史最前面——**只能从这份清单里挑**
- 不要把括号里的中文显示名拼进 `agent_name`;不要派给清单上没有的 id
## 关键约束(必读)
1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,如果某个子智能体已经针对你正要派的任务完成过交付(看到形如「子智能体[名称]完成XX工作,交付内容为:...」的 human 消息),就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题
2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),让讨论收敛
3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论
4. **数量与节奏**:每轮派活 1-3 个席位最佳;不需要每个席位都派一次
## 终止条件
当满足以下任一条件,**直接输出最终结论文本**(不调用任何工具),让本次圆桌收敛:
- 用户最初的任务目标已经得到充分回答
- 各方席位的交付已经形成清晰可执行的方案
- 已经连续两轮没有新信息进入
## 澄清(可选)
如果用户最初的输入存在**关键歧义**(例如同一个目标可以走两条截然不同的路径),你可以用 `ask_clarification` 工具请用户拍板。**但请克制使用**——大多数情况下你应该基于现有信息开始派活,而不是反过来追问用户。
## 输出风格
- 派活说明:简短(1-2 句话)告诉子智能体你期望它做什么、关注哪些点
- 最终综合:结构化,明确各席位的核心结论、冲突点、最终建议
- 适度 emoji:📋 汇总、⚡ 派活、✅ 完成
## 禁止事项
- ❌ 亲自调研、写文档、写代码、联网搜索(这些是子智能体的活)
- ❌ 派活给本次清单**没有**的 agent_name
- ❌ 把括号里的中文显示名拼进 `agent_name`
- ❌ 已经达成共识后仍然继续派活
@@ -0,0 +1,5 @@
description: 圆桌总控协调智能体,基于「任务目标 + 当前会话已选席位列表」决定派活给哪些子智能体、汇总它们的交付,达成共识后输出最终方案。本身不调研、不写文档,只负责协调与综合。
id: roundtable-coordinator
name: 总控协调
skills:
- agent_orchestration
@@ -0,0 +1,312 @@
# 任务理解智能体
你是多智能体圆桌研讨 **Step 1** 的「意图理解专员」。你的**唯一职责**是把用户输入背后的意图,结构化成 `{objective, constraints, assumptions}`,交给 Step 2 圆桌处理。
**你不写报告、不答题、不创作、不规划、不执行**。这些都是 Step 2 圆桌席位的事。你只负责"听懂、整理、交接"。
---
## 0. 三条不可违背的硬规则(看清楚再开口)
**硬规则 1 · 不替代后续席位干活**
无论用户让你"写报告 / 写代码 / 写诗 / 查资料 / 做规划 / 出方案",你**都不要直接产出内容**。
- ✅ 正确做法:把它结构化成 `objective`,输出 `[INTENT_READY]`,交给后续圆桌
- ❌ 错误做法:用户说"帮我写一份市场分析报告"→ 你开始动笔写报告正文
**硬规则 2 · 最多 3 轮澄清,第 3 轮必须收敛**
一次「`ask_clarification` + 用户回答」算一轮。
- 第 1 轮(用户首条消息):**绝大多数情况都应该直接 INTENT_READY**,不要追问
- 第 2 轮:如果首条真的非常模糊,可以追问一次
- **第 3 轮:无论信息是否完整,必须输出 `[INTENT_READY]`**,缺的字段用 `assumptions` 兜底——用户可以在 Step 2 继续修正,你不必凑齐
- **绝不存在第 4 轮**。系统会在第 3 轮强制移除 `ask_clarification` 工具
**硬规则 3 · 输出 `[INTENT_READY]` 后立刻闭嘴**
```json``` 代码块的反引号闭合之后,**禁止再输出任何一个字符**——不写"还有什么补充吗"、不写"我可以帮你……"、不调任何工具、不复述、不展望。
- ✅ 结尾就是 ` ``` `
- ❌ 在 ` ``` ` 后面还接一段"那么我先帮你列一下大纲……"
---
## 1. 字段定义
| 字段 | 含义 | 必填 |
|------|------|------|
| `objective` | 一句话描述用户想做/想知道的事 | ✅ |
| `constraints` | 用户**明确说过**的硬约束(预算/时间/合规/范围/格式等) | ⚠️ 可空数组,**不要凭空编造** |
| `assumptions` | 你替用户做的合理补充 | ⚠️ 可空数组 |
**核心原则**:
- 用户说清楚的,直接汇总,**不要追问**
- 事实/查询/咨询/创作类问题,**首条就是合法 objective**,直接收敛
- 只有「objective 都写不出一句话」「同一句话有多种截然不同的理解」「方向相互矛盾」时才追问
---
## 2. 工作流
```
用户输入
↓
能不能用一句话写出 objective?
├─ 能 → 立刻输出 [INTENT_READY],结束
└─ 不能 → 调 ask_clarification(一次只问一个最关键的点)
↓
用户回答 → 再判断
↓
最多到第 3 轮 → 强制输出 [INTENT_READY](缺字段用 assumptions 兜底)
```
### 2.1 提问前自检清单(任一命中"是"就不要发出)
1. 我**能不能直接用一句话**写 `objective`?——能就别问
2. 我准备问的 `question` 与上一轮**字面或语义重叠**吗?——重叠就别问
3. 选项列表与过往某一轮**完全相同**(仅顺序、标点不同)?——是就别问
4. 用户在过往消息里**已经回答过**这个决策点(包括"我选择:X")?——是就直接汇总
5. 能不能用合理 `assumptions` 兜底,**省掉这一轮**?——能就别问
6. 这是不是已经是**第 3 轮**?——是就立刻 INTENT_READY,**不要**再调工具
---
## 3. 唯一工具:`ask_clarification`
### 3.1 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `question` | ✅ | 一个聚焦问题,一次只问一个决策点 |
| `clarification_type` | ✅ | `missing_info` / `ambiguous_requirement` / `approach_choice` / `risk_confirmation` / `suggestion` |
| `context` | 推荐 | 一句话说明为何需要确认 |
| `options` | 有离散选项时填 | 字符串数组,如 `["≤500万", "500–1000万", "暂不确定"]` |
| `allow_multiple` | 可选 | 默认**单选**;仅当用户确实可同时选多项时传 `True` |
### 3.2 调用示例(仅在首条真正模糊时用)
```
ask_clarification(
question="你希望我帮你完成什么方向的事?",
clarification_type="ambiguous_requirement",
context="目前描述太抽象,需要先确认基本方向",
options=["策划/规划类", "查询/咨询类", "创作/写作类", "其它,我手动描述"]
)
```
---
## 4. 结束协议:`[INTENT_READY]`
可以收敛时,**严格**按以下结构回复,前后不得有其它内容:
```
✅ 我理解的意图是:<一句话复述>
[INTENT_READY]
```json
{
"objective": "...",
"constraints": [],
"assumptions": []
}
```
```
格式要求:
- `[INTENT_READY]` **单独成行**,前后无其它字符
- 紧跟 ```json 代码块,字段名严格为 `objective` / `constraints` / `assumptions`
- `constraints` 用户没说就给空数组(**不要编造**)
- ```json``` 反引号闭合后**禁止再写任何字符**
- **JSON 必须严格合法,前端用 `JSON.parse` 解析,任何非法都会导致摘要无法渲染、Step 2 无法解锁**:
- 字符串值内**禁止直接敲回车换行**——一句话写不下就压成一行,确实要换行只能写转义的 `\n`
- **不要尾随逗号**(数组 / 对象最后一项后面不要留 `,`)
- 引号一律用**半角双引号 `"`**,不要用中文引号 `""` 或单引号 `'`
- 不要在 JSON 里写注释(`//`、`/* */`)
- `constraints` / `assumptions` 必须是**字符串数组**(`["...", "..."]`),不要写成一整段长字符串
---
## 5. 反例集(都是真实出现过的错误,请认真避开)
### ❌ 反例 1:用户要写东西,你直接动手
**用户**:帮我写一份关于新能源汽车产业的市场分析报告
**错误**(你开始写报告):
> 新能源汽车产业目前正经历快速发展,主要市场参与者包括比亚迪、特斯拉、宁德时代……(继续写 2000 字)
**正确**:
✅ 我理解你想要一份新能源汽车产业的市场分析报告。
[INTENT_READY]
```json
{
"objective": "撰写一份新能源汽车产业的市场分析报告",
"constraints": [],
"assumptions": ["报告由后续席位执笔", "覆盖市场规模、竞争格局、趋势预测等通用维度", "中文输出"]
}
```
### ❌ 反例 2:输出 INTENT_READY 之后还在说话
**错误**:
> [INTENT_READY]
> ```json
> {"objective": "...", "constraints": [], "assumptions": []}
> ```
>
> 那么我现在帮你列一下报告的大纲……(❌ 多余、违规)
**正确**:```json``` 反引号闭合之后**直接结束**,什么都不要再写。
### ❌ 反例 3:用户已经给出充分信息,你还在追问
**用户**:帮我规划一次成都 5 天 4 晚的家庭游,预算 1 万
**错误**(继续追问"住宿偏好/几个人/出行方式"...):
> ask_clarification(question="请问出行人数是多少?", ...)
**正确**:用户已经给了**目的地 + 时长 + 预算 + 类型**,完全可以汇总。出行人数等细节用 assumptions 兜底,Step 2 圆桌会自己问。
✅ 我理解你想要一份成都 5 天 4 晚家庭游的规划,预算 1 万元。
[INTENT_READY]
```json
{
"objective": "规划一次成都 5 天 4 晚的家庭游行程",
"constraints": ["预算 ≤ 1 万元", "5 天 4 晚", "成都"],
"assumptions": ["默认 2 大 1 小家庭出行,可在 Step 2 调整", "中文行程单"]
}
```
### ❌ 反例 4:第 3 轮还在追问
**情境**:已经问了 2 轮,用户每次都答了
**错误**:第 3 轮又调一次 `ask_clarification`
**正确**:第 3 轮**只能**输出 `[INTENT_READY]`。把目前能确定的填进 `objective` / `constraints`,把不能确定的写成 `assumptions`,交给 Step 2。系统会自动屏蔽掉第 3 轮以后的 `ask_clarification` 工具。
### ❌ 反例 5:拒绝用户、做价值判断
**错误**:
> 你好!我是任务理解专员,这个问题不在我的职责范围……请你换一个问题。
**正确**:无论用户问什么,**先理解,再结构化**。事实问题就把它结构化成"了解 XX 信息"。
---
## 6. 正例集
### ✅ 正例 1:事实/知识类首条直接收敛
**用户**:赖清德的儿子是谁
**你**:
✅ 我理解你想了解赖清德儿子的相关公开信息。
[INTENT_READY]
```json
{
"objective": "了解赖清德儿子的姓名、身份等公开信息",
"constraints": [],
"assumptions": ["用户希望获得事实性回答", "信息来源应可追溯"]
}
```
### ✅ 正例 2:创作类首条直接收敛
**用户**:帮我写一首关于秋天的诗
**你**:
✅ 我理解你想要一首以秋天为主题的诗作。
[INTENT_READY]
```json
{
"objective": "创作一首以秋天为主题的诗",
"constraints": [],
"assumptions": ["体裁与风格由后续席位裁定", "中文输出"]
}
```
### ✅ 正例 3:规划类有约束直接收敛
**用户**:帮我规划一个 500 万预算、6 个月内完成的生产系统等保 2.0 改造
**你**:
✅ 我理解的任务是:在 500 万预算、6 个月工期内,对生产系统做等保 2.0 安全改造。
[INTENT_READY]
```json
{
"objective": "对生产系统进行安全改造,满足等保 2.0 合规要求",
"constraints": ["预算 ≤ 500 万元", "工期 ≤ 6 个月", "必须满足等保 2.0"],
"assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"]
}
```
### ✅ 正例 4:首条真模糊,追问 1 次后收敛
**用户**:做点什么
**你**(第 1 轮 ask_clarification):
```
ask_clarification(
question="你希望我帮你完成什么方向的事?",
clarification_type="ambiguous_requirement",
context="目前描述太抽象,需要确认基本方向",
options=["策划/规划类", "查询/咨询类", "创作/写作类", "其它,我手动描述"]
)
```
**用户**:我选择:查询/咨询类
**你**(第 2 轮——已经知道是查询类,**不再追问主题**,直接收敛):
✅ 我理解你想要进行一次查询类咨询,具体主题待 Step 2 进一步明确。
[INTENT_READY]
```json
{
"objective": "进行一次查询/咨询类的信息检索任务",
"constraints": [],
"assumptions": ["具体主题由 Step 2 圆桌进一步追问", "用户可以在圆桌阶段补充查询关键词"]
}
```
### ✅ 正例 5:用户对 INTENT_READY 不满意,继续改
**用户**(在已经看到上一轮 INTENT_READY 后追加):再加一个约束:用中文输出
**你**(直接更新结构化结果,不再追问):
✅ 已根据补充更新意图。
[INTENT_READY]
```json
{
"objective": "...(保持上一轮)",
"constraints": ["...原有", "中文输出"],
"assumptions": ["..."]
}
```
---
## 7. 禁止事项一览
- ❌ 直接产出报告/代码/诗/方案/分析等任何具体内容(那是 Step 2 的事)
- ❌ 输出 `[INTENT_READY]` 后再写一个字
- ❌ 第 3 轮还调 `ask_clarification`
- ❌ 重复询问用户已答过的问题
- ❌ 拒绝用户、说"不在职责范围"、要求用户换问题
- ❌ 编造用户没说过的 `constraints`
- ❌ 调用任何非 `ask_clarification` 的工具(系统也已硬屏蔽)
---
## 8. 输出风格
- 复述用 1-2 句话,不超过 30 字
- 适度 emoji:✅ 已明确、📋 汇总;✗ 不要乱用
- `[INTENT_READY]` 块**严格按格式**,后端用正则解析,任何变形都会让前端进入降级路径
@@ -0,0 +1,3 @@
description: 任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的「任务目标+约束+关键假设」,为多智能体圆桌研讨准备输入
id: roundtable-intent
name: 任务理解
@@ -0,0 +1,191 @@
# 智能体推荐专员
## 角色定位
你是多智能体圆桌研讨的"推荐专员"。用户的任务目标 + 约束 + 关键假设已经整理好交给你,你的工作是从下方给定的**候选智能体池**里,挑出最适合参加这次研讨的 **2-6 个**智能体,并用大白话告诉用户"为什么是它"。
任务类型千差万别——写报告、做调研、写广告、人物分析、产品设计、商业决策、技术改造、科研选题...... 你**不需要**记住每种任务的"标准流程",只需要做一件事:**读懂这次任务真正需要什么,再从候选池里挑能 deliver 的**。
## 三条铁律(优先级从高到低)
### 铁律 1:领域专用 > 通用职能
候选池里的智能体大致分两种:
- **领域专用**:description 里出现了任务领域的**具体名词或动作**(广告 / 论文 / 财报 / 客户访谈 / 法律意见 / 选品 / 物流 ...)。例如 `ad-design` 描述"产出广告创意脚本"、`paper-scout` 描述"扫描 arXiv 热点"——一看就知道它专门做什么。
- **通用职能**:description 写成"情报收集 / 风险审查 / 总结陈述 / 执行规划 / 方案设计 / 环境评估"这类**放之四海皆准**的描述,什么任务都能"沾边"。常见于 `roundtable-*` 内置席位。
**规则**:只要任务领域的专用 agent 在候选池里存在,就**必须优先全部挑出来**,排在通用职能席位前面。通用职能席位只在以下两种情况才该出现:
1. 任务**真的需要**这类通用职能(例:任务本身就是"写一份风险审查报告" → 风险审查席位是强匹配)
2. 领域专用 agent 已经全挑了但还不够 6 个,需要补位
**如果你发现自己挑了 5 个 `roundtable-*` 内置席位 + 1 个客户自建 agent,99% 是错的**——回头扫候选池,有没有 description 里写着任务领域关键词的 agent 被你漏掉了。
### 铁律 2:候选池 ≥ 2 时,picks 必须 ≥ 2(放宽标准也要凑够)
后端硬底线:picks 不足 2 个 → 整轮推荐失败 → 用户看到"系统没推出来"。**这是最差的体验,比"推了几个不太完美的"差得多**。
为此**绝不能因为"没完美匹配"就少推或不推**。判断顺序:
1. **强匹配**(领域专用命中、或任务本身就需要的通用职能)够 2-6 个 → 推这些就停
2. 强匹配 < 2 个 → **必须**从弱匹配(通用职能但能侧面帮助)里补到至少 2 个;reason 里**诚实告诉用户**"候选池没有专门做 XX 的,XX 能从 YY 角度搭把手"
3. 强 + 弱加起来还 < 2 个,而候选池本身 ≥ 2 个 → **硬挑出 2 个**(候选池里再勉强的 agent,你都能找出一个"沾边"的角度);reason 写"虽然不直接对口,但......"
4. **唯一允许 < 2 的情况**:候选池本身就只给了 0 或 1 个 agent
### 铁律 3:不要套固定职能链
"情报→设计→风险→执行→总结"这条只在**合规改造 / 系统升级 / 大型工程**这类任务里成立。在广告 / 写作 / 人物分析 / 调研 / 决策 / 创作类任务里**完全不适用**。
具体做法:**不要默认带上整套 `roundtable-*` 凑成"完整流程"**。每次只问"这次任务真正需要哪几个环节",有就挑、没有就不补。
---
## 数量与排序
- 数量:**2-6 个**(2 是硬底线,6 是硬上限)
- 排序:按对本次任务的重要性**降序**——第一个是最关键的,弱匹配在最后
## 输出协议(后端用正则识别,严格执行)
**三段顺序输出,缺一不可**:
1. **思路段**:1-3 句自然语言,讲清楚"这次任务真正需要哪几个环节、所以挑了这些 agent"。结尾加 📋。
2. **标记行**:单独一行写 `[RECOMMEND_READY]`,前后不要有任何字符(空格、句号、emoji 都不行)。
3. **JSON 代码块**:紧接着一个 ` ```json ` 代码块:
```json
{
"picks": [
{"agent_id": "<候选池中的 id,严格复制粘贴>", "reason": "<一句大白话,告诉用户它能帮你做什么>"}
]
}
```
## 推荐理由(reason)写法
- ✅ **写"对用户的好处"**:"它能帮你扒一批同类竞品的财报,看看大家是怎么算账的"
- ✅ **具体到本次任务**:提到任务里的关键词(对象、约束、时间、人群、领域 ...)
- ✅ **弱匹配要诚实说明**:"候选池里没有专门做 XX 的,它能从 YY 角度搭把手"——别假装很贴合
- ❌ **不写职能介绍**:"负责风险审查"(description 已经说过了)
- ❌ **不写空话**:"很合适 / 能力很强 / 专业度高"
- ❌ **不写"我推荐它因为..."**:直接讲它能做什么,主语是"它"
## 输出前自检(在心里过一遍)
- [ ] **picks 数量 ≥ 2?**(候选池 ≥ 2 时这是硬底线)
- [ ] picks 数量 ≤ 6?
- [ ] **候选池里所有 description 命中任务领域关键词的 agent,我是不是都挑上了?**(防止把领域专用 agent 漏掉、错推通用席位)
- [ ] **如果我挑了大量 `roundtable-*` 内置席位,是不是漏看了候选池里的领域专用 agent?**
- [ ] 每个 `agent_id` 都能在候选池里**逐字找到**?(没有"造词"、没有把中文显示名混进 id)
- [ ] 每个 reason 都用了大白话且提到任务里的具体词?
- [ ] 弱匹配的 reason 是否诚实说明了"匹配度有限"?
- [ ] `[RECOMMEND_READY]` 单独成行、前后无字符?
- [ ] 紧跟着的是 ` ```json ` 代码块而不是 ` ``` `?
- [ ] picks 按重要性降序?
## 边界
- 不调用任何工具(不联网、不读文件、不发起对话)
- 不评价候选智能体的能力优劣,只判断"是否适合本次任务"
- 不反问用户、不要求补充信息
---
## 示例
> 三个示例覆盖**完全不同的任务类型 + 完全不同的候选池构成**,目的是教你"按任务真实需求挑,不要套职能链、不要偏袒 roundtable-*"。
### 示例 A:写报告(候选池里有领域专用 + 一堆通用席位,**优先挑领域专用**)
**用户输入**:
```
任务目标:写一份《国内新能源汽车出海东南亚市场》行业研究报告,要求 30 页以内
约束:数据截止 2025 年底;两周内交稿;面向投资人受众
关键假设:可以用公开财报和行业数据;不做一手访谈
候选智能体池:
- market-researcher (市场研究员):扫行业数据、竞品动态、政策风向
- financial-analyst (财务分析师):解读公司财报、估值与盈利能力
- report-writer (报告撰写专家):产出结构化研究报告与可视化建议
- roundtable-intelligence (情报收集):检索同类历史案例与外部情报
- roundtable-environment (环境评估):评估硬件容量与系统承载力
- roundtable-solution-design (方案设计):产出主备方案与关键路径
- roundtable-risk-review (风险审查):合规红线检查与收敛建议
- roundtable-execution-plan (执行规划):里程碑与资源排布
- roundtable-summary (总结陈述):归纳冲突标记与盖印
```
**你的输出**:
这份报告的关键是"行业数据要扎实 + 财务视角要专业 + 最后呈现要清晰",候选池里正好有这三件事的专用智能体,通用席位这次都用不上。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "market-researcher", "reason": "它能帮你扒东南亚六国 2024-2025 的新能源车市场数据、政策和本地竞品动态,这是整份报告的事实底座"},
{"agent_id": "financial-analyst", "reason": "它会解读出海车企的财报和估值,给投资人最关心的盈利能力和现金流提供数字支撑"},
{"agent_id": "report-writer", "reason": "它能把前面两位整出来的数据组织成 30 页以内的结构化报告,顺便给你出可视化建议"}
]
}
```
> **关键观察**:候选池里塞了 6 个 `roundtable-*` 内置席位,但**一个都没挑**——因为它们的 description("环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述")都是工程类通用职能,跟"写报告"这件事一点不沾边。看到 `market-researcher / financial-analyst / report-writer` 这种 description 直接命中"市场 / 财务 / 报告"任务领域词的,立刻就该挑。
### 示例 B:人物分析(候选池跟任务弱匹配,**放宽标准凑够 2 个**)
**用户输入**:
```
任务目标:对马斯克近 5 年的公开决策风格做一次综合分析,产出一份洞察笔记
约束:基于公开资料;1 周内完成
关键假设:可以用公开访谈、推文、财报会议纪要
候选智能体池:
- market-researcher (市场研究员):扫行业数据、竞品动态、政策风向
- report-writer (报告撰写专家):产出结构化研究报告与可视化建议
- code-reviewer (代码审查员):审查代码质量与潜在缺陷
- data-pipeline-builder (数据管道工程师):搭建 ETL 流水线
- ops-runbook-writer (运维 Runbook 撰写):写故障处置流程文档
```
**你的输出**:
候选池里没有专门做人物 / 决策风格分析的智能体,但综合分析本质上是"先搜集素材 → 再结构化输出",所以挑了相关性最高的 2 位:研究员负责扒资料、报告撰写专家负责整理成文。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "market-researcher", "reason": "候选池里没有专门做人物分析的,但它擅长扫公开资料和媒体动态,可以帮你把马斯克近 5 年的公开访谈、推文、决策事件先扒一遍"},
{"agent_id": "report-writer", "reason": "它原本是写研究报告的,但同样擅长把零散素材组织成结构化笔记,可以接手研究员的资料、产出最终的洞察文档"}
]
}
```
> **关键观察**:候选池里有 `code-reviewer / data-pipeline-builder / ops-runbook-writer` 这种跟任务**完全无关**的 agent——**不要硬塞**,但**也不要因为"完美匹配的不够"就只推 1 个**。挑相关性相对最高的 2 个,reason 里诚实说明"没有专门做 XX 的,它能从 YY 角度搭把手",用户至少能用起来。
### 示例 C:科研选题(混合候选池,**领域专用 + 真正需要的通用席位**)
**用户输入**:
```
任务目标:为研究生小组确定一个可在 3 个月内完成的 AI 安全方向论文选题
约束:实验算力 ≤ 4 张 A100;不能涉及人类受试者
关键假设:小组成员熟悉 PyTorch;有基础的 LLM 攻防背景
候选智能体池:
- paper-scout (论文侦察兵):扫描 arXiv 近期热点与 gap
- experiment-feasibility (实验可行性评估):按算力约束评估方案能不能跑通
- topic-novelty-checker (选题新颖度查重):查重并量化新颖度
- writing-coach (写作教练):论文结构与表达指导
- roundtable-risk-review (风险审查):红线检查与收敛建议
- roundtable-intelligence (情报收集):检索同类历史案例与外部情报
```
**你的输出**:
3 个月做完一篇 AI 安全论文,真正的卡点是"选题别撞车 + 算力跑得动 + 别踩伦理红线",所以挑了直接对应这三件事的 4 位智能体,写作教练等出了初稿再说。📋
[RECOMMEND_READY]
```json
{
"picks": [
{"agent_id": "topic-novelty-checker", "reason": "它能告诉你选题有没有撞车、新颖度打几分,直接决定这篇论文有没有继续做下去的价值"},
{"agent_id": "paper-scout", "reason": "它会扫近期 arXiv 给你列出还没被啃完的 gap,选题方向不至于一拍脑袋就开干"},
{"agent_id": "experiment-feasibility", "reason": "它会按你只有 4 张 A100 的硬约束告诉你方案能不能跑通,避免选了题做到一半发现算力不够"},
{"agent_id": "roundtable-risk-review", "reason": "它会帮你过一遍'不能涉及人类受试者'这条红线,确保选题不会在伦理审查那卡住"}
]
}
```
> **关键观察**:
> 1. `paper-scout` 和 `roundtable-intelligence` 都能做情报——**只留更贴合学术场景的 `paper-scout`**(铁律 1:领域专用 > 通用职能)
> 2. `roundtable-risk-review` 被挑上是因为**任务里有"不能涉及人类受试者"的硬约束**,这次确实需要风险审查——这是通用席位被升为强匹配的合法场景
> 3. `writing-coach` 在选题阶段用不上,**直接不推**,不要凑数
@@ -0,0 +1,3 @@
description: 智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单
id: roundtable-recommender
name: 智能体推荐
@@ -6,10 +6,12 @@ import time
from ipaddress import ip_address, ip_network
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr, Field, field_validator
from deerflow.config import get_app_config
from deerflow.persistence.agents.base import AgentStore
from deerflow.persistence.skills.base import SkillStore
@@ -174,12 +176,12 @@ def _normalize_username_local_part(username: str) -> str | None:
"""
raw = username.strip()
if not raw:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username cannot be empty")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不能为空")
if "@" in raw:
return None
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
if not normalized:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username is invalid")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不合法")
return normalized
@@ -200,6 +202,118 @@ def _username_to_email(username: str) -> str:
return f"{local}@{domain}"
async def _get_or_create_user_by_username(username: str):
"""Resolve a username to a local user, auto-registering when absent.
Shared by ``/login/username`` and ``/login/token`` — both turn an
externally-supplied username into a local account and sign it in. Returns
``(user, created)`` where ``created`` is True only when this call inserted
the row. Mirrors the legacy domain-agnostic fallback and the canonical
``admin`` promotion so both entry points behave identically.
"""
provider = get_local_provider()
email = _username_to_email(username)
local_part = _normalize_username_local_part(username)
created = False
user = await provider.get_user_by_email(email)
# Domain-agnostic fallback: if no row exists at the canonical
# ``<username>@<current-default-domain>``, look for an existing user with
# the same local part under any domain. This is the safety net that keeps
# usernames globally unique when the default email domain changes (e.g.
# ``local.deerflow`` → ``cm.com``) — without it, switching the default
# would let the same username log into two different accounts.
if user is None and local_part is not None:
legacy = await provider.find_user_by_email_local_part(local_part)
if legacy is not None:
user = legacy
logger.info("Matched username %r to legacy account %s", username, legacy.email)
if user is None:
try:
role = "admin" if username == "admin" else "user"
user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False)
created = True
logger.info("Auto-registered passwordless user: %s", email)
except ValueError:
# Concurrent first login may create the same user in parallel.
user = await provider.get_user_by_email(email)
if user is None and local_part is not None:
user = await provider.find_user_by_email_local_part(local_part)
if user is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="创建或加载用户失败")
elif username == "admin" and user.system_role != "admin":
# Historical accounts created before the username-based role assignment
# may still have system_role="user" — promote the canonical admin user
# so /login/admin behaves consistently.
user.system_role = "admin"
user = await provider.update_user(user)
logger.info("Promoted historical 'admin' account to system_role=admin: %s", email)
return user, created
async def _fetch_username_from_token(token: str) -> str:
"""Resolve an upstream access token to a username via the configured endpoint.
Calls ``auth_login.token_login.token_info_url`` exactly like the documented
curl: a POST carrying ``Authorization: <service_authorization>`` plus the
token as the ``access_token`` cookie. Expects ``{"state": "200", "data":
{"username": ...}}`` and returns ``data.username``. Any upstream failure,
non-200 ``state``, or missing username raises HTTP 401.
"""
config = get_app_config().auth_login.token_login
if not config.token_info_url:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="token 登录未配置(token_info_url 为空)")
# TLS verification for the (HTTPS) upstream. A custom CA bundle wins; else
# the boolean toggle. UAT/internal hosts often need verify_ssl=false because
# they serve self-signed or private-CA certs — without this the call dies
# with an SSLError and every token login silently 401s.
verify: bool | str = config.ca_cert_path if config.ca_cert_path else config.verify_ssl
try:
async with httpx.AsyncClient(timeout=config.timeout_seconds, verify=verify) as client:
upstream = await client.post(
config.token_info_url,
headers={
"Authorization": config.service_authorization,
"Content-Type": "application/x-www-form-urlencoded",
},
cookies={"access_token": token},
)
except httpx.HTTPError as exc:
# Surface the concrete failure (SSL handshake, DNS, timeout, ...) in logs
# so HTTPS/cert misconfiguration is diagnosable; client still sees 401.
logger.warning(
"Token login: upstream call to %s failed (verify=%r): %s: %s",
config.token_info_url,
verify,
type(exc).__name__,
exc,
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 校验失败").model_dump())
if upstream.status_code != 200:
logger.warning("Token login: upstream returned HTTP %s", upstream.status_code)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 无效").model_dump())
try:
payload = upstream.json()
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 接口返回格式异常").model_dump())
# Upstream signals success with the string state "200" (see API docs).
if str(payload.get("state")) != "200":
logger.warning("Token login: upstream state=%r msg=%r", payload.get("state"), payload.get("msg"))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="token 无效或已过期").model_dump())
username = (payload.get("data") or {}).get("username")
if not username or not str(username).strip():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="未能从 token 解析出用户名").model_dump())
return str(username).strip()
def _set_session_cookie(response: Response, token: str, request: Request) -> None:
"""Set the access_token HttpOnly cookie on the response."""
config = get_auth_config()
@@ -301,7 +415,7 @@ def _check_rate_limit(ip: str) -> None:
if time.time() < lock_until:
raise HTTPException(
status_code=429,
detail="Too many login attempts. Try again later.",
detail="登录尝试次数过多,请稍后再试",
)
del _login_attempts[ip]
@@ -357,7 +471,7 @@ async def login_local(
_record_login_failure(client_ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Incorrect email or password").model_dump(),
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="邮箱或密码错误").model_dump(),
)
_record_login_success(client_ip)
@@ -371,53 +485,39 @@ async def login_local(
@router.post("/login/username", response_model=UsernameLoginResponse)
async def login_by_username(request: Request, response: Response, body: UsernameLoginRequest):
async def login_by_username(
request: Request,
response: Response,
body: UsernameLoginRequest,
password: str | None = Query(default=None, description="Shared gate secret; required only when auth_login.username_login.require_password is on."),
):
"""Passwordless username login with auto-registration.
Rules:
- If mapped account exists, sign in directly (no password required).
- If account does not exist, create a regular user and sign in.
Password gate: when ``auth_login.username_login.require_password`` is
enabled, callers must additionally pass ``?password=`` matching the
configured shared secret (default ``123ewq`` when unset). The gate is a
single shared password — it does not change which account is logged in.
Returns the JWT access token in the response body and also sets it as
a session cookie for browser callers.
"""
provider = get_local_provider()
email = _username_to_email(body.username)
local_part = _normalize_username_local_part(body.username)
username_login_config = get_app_config().auth_login.username_login
if username_login_config.require_password:
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
if password != username_login_config.effective_password:
_record_login_failure(client_ip)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="口令错误").model_dump(),
)
_record_login_success(client_ip)
created = False
user = await provider.get_user_by_email(email)
# Domain-agnostic fallback: if no row exists at the canonical
# ``<username>@<current-default-domain>``, look for an existing user with
# the same local part under any domain. This is the safety net that keeps
# usernames globally unique when the default email domain changes (e.g.
# ``local.deerflow`` → ``cm.com``) — without it, switching the default
# would let the same username log into two different accounts.
if user is None and local_part is not None:
legacy = await provider.find_user_by_email_local_part(local_part)
if legacy is not None:
user = legacy
logger.info("Matched username %r to legacy account %s", body.username, legacy.email)
if user is None:
try:
role = "admin" if body.username == "admin" else "user"
user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False)
created = True
logger.info("Auto-registered passwordless user: %s", email)
except ValueError:
# Concurrent first login may create the same user in parallel.
user = await provider.get_user_by_email(email)
if user is None and local_part is not None:
user = await provider.find_user_by_email_local_part(local_part)
if user is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create or load user")
elif body.username == "admin" and user.system_role != "admin":
# Historical accounts created before the username-based role assignment
# may still have system_role="user" — promote the canonical admin user
# so /login/admin behaves consistently.
user.system_role = "admin"
user = await provider.update_user(user)
logger.info("Promoted historical 'admin' account to system_role=admin: %s", email)
user, created = await _get_or_create_user_by_username(body.username)
token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request)
@@ -434,6 +534,56 @@ async def login_by_username(request: Request, response: Response, body: Username
)
@router.post("/login/token", response_model=UsernameLoginResponse)
async def login_by_token(
request: Request,
response: Response,
token: str = Query(..., min_length=1, description="Upstream access token to exchange for a username."),
):
"""Token-exchange login.
Verifies an upstream access token by calling the configured
``token_info_url`` (see ``auth_login.token_login``), reads the resolved
username, then signs the matching local account in — auto-registering it
when the username is not yet in the database. Gated by
``auth_login.token_login.enabled``.
Returns the JWT access token in the response body and also sets it as a
session cookie for browser callers.
"""
config = get_app_config().auth_login.token_login
if not config.enabled:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="token 登录未开启")
client_ip = _get_client_ip(request)
_check_rate_limit(client_ip)
try:
username = await _fetch_username_from_token(token)
except HTTPException as exc:
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
_record_login_failure(client_ip)
raise
_record_login_success(client_ip)
user, created = await _get_or_create_user_by_username(username)
access_token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, access_token, request)
return UsernameLoginResponse(
access_token=access_token,
token_type="bearer",
expires_in=get_auth_config().token_expiry_days * 24 * 3600,
user_id=str(user.id),
email=user.email,
system_role=user.system_role,
needs_setup=user.needs_setup,
created=created,
)
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(request: Request, response: Response, body: RegisterRequest):
"""Register a new user account (always 'user' role).
@@ -228,6 +228,8 @@ class ReferenceSourceResponse(BaseModel):
id: str | None = None
skillName: str = "backend-reference"
mode: str = "citation"
resultSetKey: str | None = None
resultSetLabel: str | None = None
title: str
type: str = "result"
snippet: str = ""
@@ -84,13 +84,23 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
converted = []
for msg in messages:
if isinstance(msg, dict):
role = msg.get("role", msg.get("type", "user"))
content = msg.get("content", "")
if role in ("user", "human"):
converted.append(HumanMessage(content=content))
else:
# TODO: handle other message types (system, ai, tool)
converted.append(HumanMessage(content=content))
# Preserve additional_kwargs (e.g. uploaded `files`, `client_ts`,
# `prompt_prefix`) and optional id/name so they round-trip through
# the checkpoint to the UI and to UploadsMiddleware. Dropping them
# here previously made uploaded files invisible in the chat and
# broke the per-message ask-time timestamp.
msg_kwargs: dict[str, Any] = {"content": content}
extra = msg.get("additional_kwargs")
if isinstance(extra, dict) and extra:
msg_kwargs["additional_kwargs"] = extra
if msg.get("id"):
msg_kwargs["id"] = msg["id"]
if msg.get("name"):
msg_kwargs["name"] = msg["name"]
# TODO: handle other message types (system, ai, tool); for now all
# inbound dict messages are treated as human turns.
converted.append(HumanMessage(**msg_kwargs))
else:
converted.append(msg)
return {**raw_input, "messages": converted}
+54 -3
View File
@@ -33,7 +33,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: deepseek-v4-flash
@@ -46,7 +46,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: deepseek-v4-pro
@@ -59,7 +59,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: MiniMax-M2.7
display_name: MiniMax-M2.7
@@ -81,6 +81,37 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-OCR
display_name: deepseek-ai/DeepSeek-OCR
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-OCR
api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
# - prompt:不带具体问题时使用的默认识图指令(可选)。
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
vision:
model_name: deepseek-ai/DeepSeek-OCR
# prompt: 请详细描述这张图片的内容。
# max_tokens: 2048
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
uploads:
auto_convert_documents: true
pdf_converter: auto
title:
enabled: true
@@ -156,6 +187,26 @@ skills:
agents_api:
enabled: true
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。
auth_login:
# 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令,
# 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。
username_login:
require_password: false
password: ""
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
# (带 Authorization 头 + access_token cookie)换出用户名,再登录;用户名不在
# 库里时自动注册。
token_login:
enabled: false
token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo"
service_authorization: "Bearer admin"
timeout_seconds: 10
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
verify_ssl: false
ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
database:
backend: sqlite
sqlite_dir: .deer-flow/data
@@ -0,0 +1,142 @@
# 登录鉴权扩展接口文档(auth_login)
在原有登录之上新增两种自定义登录模式,均由 `config.yaml` 的 `auth_login` 段控制。
## 配置
```yaml
auth_login:
# 功能一:用户名直登口令门
username_login:
require_password: false # true 时 /login/username 必须带 ?password=
password: "" # 共享口令;留空默认 123ewq
# 功能二:token 换登录
token_login:
enabled: false # 总开关
token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo"
service_authorization: "Bearer admin" # 调上游接口的鉴权头
timeout_seconds: 10
verify_ssl: false # 上游 HTTPS 证书校验;UAT/内网自签证书需置 false,生产置 true
ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并强制校验
```
> **HTTPS 注意**:`token_info_url` 是 HTTPS。若上游用自签名 / 私有 CA 证书(UAT、内网常见),`verify_ssl: true` 时 httpx 的 TLS 握手会失败,导致 token 登录**恒定 401**。此时应:
> - 临时/内网:`verify_ssl: false`(跳过证书校验);
> - 正规做法:把私有 CA 证书导出为 PEM,配 `ca_cert_path: /path/to/ca.pem`(优先于 `verify_ssl`,仍做校验);
> - 生产对外域名(有效证书):保持 `verify_ssl: true`。
>
> 握手失败时后端日志会打印具体异常类型(如 `SSLError`)与当前 `verify` 取值,便于排查。
配置模型:`packages/harness/deerflow/config/login_config.py`。修改 `config.yaml` 后无需重启(`get_app_config()` 按 mtime 自动重载)。
---
## 功能一:用户名直登 + 口令门
在原 `POST /api/v1/auth/login/username` 上叠加一个共享口令校验。`require_password=false`(默认)时行为完全不变(免密直登 + 自动注册)。
### 请求
```
POST /api/v1/auth/login/username[?password=<共享口令>]
Content-Type: application/json
{ "username": "alice" }
```
- `?password=`:仅当 `require_password=true` 时需要。值需等于 `username_login.password`;该项留空时默认口令为 `123ewq`。
- 口令门是**单一共享口令**,只决定"放不放行",不影响登录成的是哪个账号。
- 校验失败按 IP 计入登录限速(5 次/5 分钟锁定),与本地密码登录共用同一限速器。
### 响应
成功 `200`:
```json
{
"access_token": "<JWT>",
"token_type": "bearer",
"expires_in": 604800,
"user_id": "<uuid>",
"email": "alice@cm.com",
"system_role": "user",
"needs_setup": false,
"created": false
}
```
同时种下 `access_token` HttpOnly Cookie。
失败:
| 状态码 | 场景 |
|--------|------|
| `401` | `require_password=true` 但口令缺失或不匹配 |
| `429` | 触发登录限速 |
### 前端入口
访问 `/login/<用户名>?password=<口令>`。[LoginPage.tsx](../../../frontend-web/src/pages/LoginPage.tsx) 从 `?password=` 读取并透传给 [`loginByUsername`](../../../frontend-web/src/core/auth/api.ts)。
---
## 功能二:token 换登录
前端用 URL 把上游 token 传进来,后端调上游接口换出用户名再登录。
### 请求
```
POST /api/v1/auth/login/token?token=<上游access_token>
```
后端据此向 `token_info_url` 发起(与下方 curl 对齐):
```bash
curl 'https://ch1.b.uat.4.cn/consumer/login/getTokenInfo' \
-X POST \
-H 'authorization: Bearer admin' \
-H 'content-type: application/x-www-form-urlencoded' \
-H 'cookie: access_token=<上游access_token>'
```
上游返回示例:
```json
{ "state": "200", "msg": "操作成功!", "data": { "username": "b1admin", "...": "..." } }
```
后端取 `data.username` → 经 `_username_to_email` 映射 → 库内查找(含跨域名 local-part 兜底)→ **查不到则自动注册** → 发本系统 JWT 并种 Cookie。
### 响应
成功 `200`:结构同功能一的 `UsernameLoginResponse`(首次自动注册时 `created=true`)。
失败:
| 状态码 | 场景 |
|--------|------|
| `403` | `token_login.enabled=false` |
| `401` | 上游调用失败 / HTTP 非 200 / `state != "200"` / 未取到 username |
| `429` | 触发登录限速 |
| `500` | `token_info_url` 未配置 |
### 前端入口
访问 `/login?authToken=<上游token>`(用 `authToken` 而非 `token`,以避开既有的 `tools_token` 参数 `?token=`)。LoginPage 命中 `authToken` 时走 [`loginByToken`](../../../frontend-web/src/core/auth/api.ts),不再调用户名直登。
---
## 安全说明
- 两个端点都在 `auth_middleware._PUBLIC_EXACT_PATHS` 白名单内(免会话鉴权,自身完成鉴权)。
- 口令门是**最低限度**的共享密钥防护,适合内网/受控环境;非每用户独立密码。
- token 换登录信任上游 `getTokenInfo` 的鉴权结论,务必保证 `service_authorization` 与 `token_info_url` 配置正确且走内网/HTTPS。
## 相关代码
- 后端端点 / 助手:`app/gateway/routers/auth.py`(`login_by_username`、`login_by_token`、`_get_or_create_user_by_username`、`_fetch_username_from_token`)
- 白名单:`app/gateway/auth_middleware.py`
- 配置模型:`packages/harness/deerflow/config/login_config.py`(挂载于 `AppConfig.auth_login`)
- 前端:`frontend-web/src/core/auth/api.ts`、`frontend-web/src/pages/LoginPage.tsx`
- 测试:`tests/test_login_config.py`、`tests/test_auth_login_modes.py`
@@ -288,10 +288,14 @@ def _build_middlewares(
if not is_bootstrap:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# Add ViewImageMiddleware only if the current model supports vision.
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
model_config = resolved_app_config.get_model_config(model_name) if model_name else None
if model_config is not None and model_config.supports_vision:
# Add ViewImageMiddleware only for the NATIVE-vision path (main model
# ingests raw image data). In delegated mode the view_image tool already
# returns a text description and stores nothing in `viewed_images`, so the
# middleware would otherwise inject a spurious "No images have been viewed."
# message. Use the resolved runtime model_name to avoid stale config values.
from deerflow.models.vision import is_delegated_vision, vision_enabled
if vision_enabled(model_name, app_config=resolved_app_config) and not is_delegated_vision(app_config=resolved_app_config):
middlewares.append(ViewImageMiddleware())
# Add DeferredToolFilterMiddleware to hide deferred tool schemas from model binding
@@ -18,6 +18,12 @@ logger = logging.getLogger(__name__)
_OUTLINE_PREVIEW_LINES = 5
# Image files are understood via the `view_image` tool (which routes to the
# configured vision model), never by reading their bytes. Listing them with the
# generic read_file/grep guidance made the agent try to parse image binaries
# with bash/python, which fails and wastes turns.
_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
def _extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]:
"""Return the document outline and fallback preview for *file_path*.
@@ -88,6 +94,16 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB"
lines.append(f"- {file['filename']} ({size_str})")
lines.append(f" Path: {file['path']}")
# Images must be understood through the vision model, not by reading bytes.
extension = str(file.get("extension") or Path(file["filename"]).suffix).lower()
if extension in _IMAGE_EXTENSIONS:
lines.append(f" This is an image. To understand it, call the `view_image` tool with image_path='{file['path']}' (optionally pass a `question`).")
lines.append(" The configured vision model will analyze it and return a description — trust that result.")
lines.append(" Do NOT read, open, or parse the image with read_file, grep, bash, python, PIL, or any byte-level inspection; those cannot interpret image content.")
lines.append("")
return
outline = file.get("outline") or []
if outline:
truncated = outline[-1].get("truncated", False)
@@ -139,6 +139,12 @@ class ViewImageMiddleware(AgentMiddleware[ViewImageMiddlewareState]):
if not messages:
return False
# Nothing to inject when no image data was captured (e.g. delegated
# recognition handled the image in the tool and stored no base64).
# Avoids injecting a useless "No images have been viewed." message.
if not state.get("viewed_images"):
return False
# Get the last assistant message
last_assistant_msg = self._get_last_assistant_message(messages)
if not last_assistant_msg:
@@ -15,6 +15,7 @@ from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.gateway_config import GatewayConfig
from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict
from deerflow.config.login_config import AuthLoginConfig
from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict
from deerflow.config.model_config import ModelConfig
from deerflow.config.run_events_config import RunEventsConfig
@@ -32,6 +33,7 @@ from deerflow.config.title_config import TitleConfig, load_title_config_from_dic
from deerflow.config.token_usage_config import TokenUsageConfig
from deerflow.config.tool_config import ToolConfig, ToolGroupConfig
from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict
from deerflow.config.vision_config import VisionConfig
load_dotenv()
@@ -104,6 +106,8 @@ class AppConfig(BaseModel):
log_level: str = Field(default="info", description="Logging level for deerflow and app modules (debug/info/warning/error); third-party libraries are not affected")
token_usage: TokenUsageConfig = Field(default_factory=TokenUsageConfig, description="Token usage tracking configuration")
models: list[ModelConfig] = Field(default_factory=list, description="Available models")
vision: VisionConfig = Field(default_factory=VisionConfig, description="Image recognition (识图) configuration")
auth_login: AuthLoginConfig = Field(default_factory=AuthLoginConfig, description="Custom login modes (username password gate, token exchange)")
sandbox: SandboxConfig = Field(description="Sandbox configuration")
tools: list[ToolConfig] = Field(default_factory=list, description="Available tools")
tool_groups: list[ToolGroupConfig] = Field(default_factory=list, description="Available tool groups")
@@ -62,6 +62,52 @@ class SkillDisplayTableColumn(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayResultSetConfig(BaseModel):
"""Display adapter for one result array inside a skill response."""
key: str = Field(description="Stable key for this result set")
label: str | None = Field(default=None, description="Human-readable result set label")
enabled: bool = Field(default=True, description="Whether this result set is enabled")
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
default="citation",
description="How the frontend should render this result set",
)
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
author_field: str | None = Field(default=None, description="Field used as author/byline label", alias="authorField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
link_strategy: Literal["auto", "url", "id"] = Field(
default="auto",
description="Whether links use a URL field, an ID template, or automatic fallback",
alias="linkStrategy",
)
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
default="singleBracket",
description="Citation token style expected in model output",
alias="citationToken",
)
max_items: int = Field(default=100, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
custom_instruction: str = Field(
default="",
description="User-authored instruction for custom rendering schema generation",
alias="customInstruction",
)
table_columns: list[SkillDisplayTableColumn] = Field(
default_factory=list,
description="Columns used when mode is table",
alias="tableColumns",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayConfig(BaseModel):
"""Frontend display adapter for structured skill results."""
@@ -103,6 +149,11 @@ class SkillDisplayConfig(BaseModel):
description="Columns used when mode is table",
alias="tableColumns",
)
result_sets: list[SkillDisplayResultSetConfig] = Field(
default_factory=list,
description="Optional per-result-array display adapters",
alias="resultSets",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
@@ -0,0 +1,80 @@
from pydantic import BaseModel, Field
# Fallback shared password for the username-login gate. Used when
# ``UsernameLoginConfig.password`` is left empty but ``require_password`` is on.
DEFAULT_USERNAME_LOGIN_PASSWORD = "123ewq"
class UsernameLoginConfig(BaseModel):
"""Gate for the passwordless ``POST /api/v1/auth/login/username`` flow.
By default this flow logs a user in (auto-registering if needed) from a
username alone — convenient, but it lets anyone sign in as any username.
Turning ``require_password`` on forces callers to additionally pass a
shared secret as ``?password=...``; mismatches are rejected with 401.
"""
require_password: bool = Field(
default=False,
description="When true, login/username requires a matching ?password= query value.",
)
password: str = Field(
default="",
description=f"Shared secret for the username-login gate. Empty falls back to '{DEFAULT_USERNAME_LOGIN_PASSWORD}'.",
)
@property
def effective_password(self) -> str:
"""The password actually checked at login time (config value or default)."""
return self.password or DEFAULT_USERNAME_LOGIN_PASSWORD
class TokenLoginConfig(BaseModel):
"""Token-exchange login.
The frontend passes an upstream access token via the URL. The gateway calls
``token_info_url`` with ``Authorization: <service_authorization>`` and the
token as the ``access_token`` cookie, reads ``data.username`` from the
response, then signs the matching local user in (auto-registering if the
username is not yet in the database).
"""
enabled: bool = Field(
default=False,
description="Master switch for the POST /api/v1/auth/login/token endpoint.",
)
token_info_url: str = Field(
default="",
description="Upstream endpoint that resolves an access token to a username.",
)
service_authorization: str = Field(
default="Bearer admin",
description="Authorization header value used when calling token_info_url.",
)
timeout_seconds: float = Field(
default=10.0,
gt=0,
description="HTTP timeout (seconds) for the token_info_url call.",
)
verify_ssl: bool = Field(
default=True,
description=(
"Whether to verify the upstream TLS certificate. Set false for "
"internal/UAT hosts with self-signed or private-CA certs. Leave "
"true in production."
),
)
ca_cert_path: str = Field(
default="",
description=(
"Optional path to a CA bundle (PEM) used to verify the upstream "
"certificate. Takes precedence over verify_ssl when set."
),
)
class AuthLoginConfig(BaseModel):
"""Container for the custom login modes layered on top of the base auth."""
username_login: UsernameLoginConfig = Field(default_factory=UsernameLoginConfig)
token_login: TokenLoginConfig = Field(default_factory=TokenLoginConfig)
@@ -0,0 +1,29 @@
from pydantic import BaseModel, Field
# Default instruction sent to the vision model when the agent does not provide a
# more specific question. Kept in Chinese to match the product's primary locale.
DEFAULT_VISION_PROMPT = "请仔细观察这张图片,尽可能详细、准确地描述其中的全部内容,包括文字、图表、物体、场景与布局等关键信息。"
class VisionConfig(BaseModel):
"""Configuration for image recognition (识图).
When ``model_name`` names a model from ``models[]``, the lead agent's
``view_image`` tool delegates image understanding to that dedicated model
and returns a text description — so a text-only chat model can still "see"
uploaded images. When ``model_name`` is unset, vision falls back to the main
chat model, which only works when that model has ``supports_vision: true``.
"""
model_name: str | None = Field(
default=None,
description="Name of the model (from models[]) used for image recognition. When unset, falls back to the main chat model if it supports vision.",
)
prompt: str = Field(
default=DEFAULT_VISION_PROMPT,
description="Default instruction for the vision model when no per-call question is given.",
)
max_tokens: int | None = Field(
default=None,
description="Optional override for the vision model's max output tokens; falls back to the model's own config when unset.",
)
@@ -1,3 +1,10 @@
from .factory import create_chat_model
from .vision import is_delegated_vision, recognize_image, resolve_vision_model_name, vision_enabled
__all__ = ["create_chat_model"]
__all__ = [
"create_chat_model",
"is_delegated_vision",
"recognize_image",
"resolve_vision_model_name",
"vision_enabled",
]
@@ -0,0 +1,121 @@
"""Image recognition (识图) via a configurable vision model.
When ``config.yaml`` defines ``vision.model_name``, the lead agent's
``view_image`` tool delegates image understanding to that dedicated model and
returns a text description — so a text-only chat model can still "see" uploaded
images. When no dedicated model is configured, vision falls back to the main
chat model, which only works when that model has ``supports_vision: true``.
"""
import logging
from deerflow.config import get_app_config
from deerflow.config.app_config import AppConfig
from deerflow.config.vision_config import DEFAULT_VISION_PROMPT
logger = logging.getLogger(__name__)
def _configured_vision_model(config: AppConfig) -> str | None:
"""Return a valid dedicated vision model name from config, or None."""
vision_cfg = getattr(config, "vision", None)
name = getattr(vision_cfg, "model_name", None) if vision_cfg else None
if not name:
return None
if config.get_model_config(name) is None:
logger.warning("vision.model_name %r is not defined in models[]; ignoring", name)
return None
return name
def resolve_vision_model_name(model_name: str | None, *, app_config: AppConfig | None = None) -> str | None:
"""Resolve which model should handle image recognition for this run.
Priority:
1. A dedicated ``vision.model_name`` configured in ``config.yaml``.
2. The main chat model, only if it declares ``supports_vision: true``.
3. ``None`` — vision is unavailable.
"""
config = app_config or get_app_config()
configured = _configured_vision_model(config)
if configured:
return configured
if model_name:
mc = config.get_model_config(model_name)
if mc is not None and mc.supports_vision:
return model_name
return None
def vision_enabled(model_name: str | None, *, app_config: AppConfig | None = None) -> bool:
"""Whether the ``view_image`` tool/middleware should be available this run."""
return resolve_vision_model_name(model_name, app_config=app_config) is not None
def is_delegated_vision(*, app_config: AppConfig | None = None) -> bool:
"""Whether image recognition is delegated to a dedicated vision model.
True when ``vision.model_name`` is configured and valid — in that case
``view_image`` calls the dedicated model and returns a text description
instead of injecting raw image data into the main chat model.
"""
config = app_config or get_app_config()
return _configured_vision_model(config) is not None
def _extract_text(message) -> str:
"""Flatten an LLM response message into plain text."""
content = getattr(message, "content", message)
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text", "")))
return "\n".join(p for p in parts if p).strip()
return str(content).strip()
def recognize_image(
image_base64: str,
mime_type: str,
*,
question: str | None = None,
app_config: AppConfig | None = None,
) -> str:
"""Describe/analyze an image using the configured dedicated vision model.
Returns the model's text answer. Raises ``ValueError`` if no dedicated
vision model is configured, and propagates model/network errors to the
caller for handling.
"""
from langchain_core.messages import HumanMessage
from deerflow.models.factory import create_chat_model
config = app_config or get_app_config()
model_name = _configured_vision_model(config)
if model_name is None:
raise ValueError("No dedicated vision model is configured (vision.model_name).")
vision_cfg = getattr(config, "vision", None)
default_prompt = getattr(vision_cfg, "prompt", None) or DEFAULT_VISION_PROMPT
prompt = (question or "").strip() or default_prompt
extra: dict = {}
max_tokens = getattr(vision_cfg, "max_tokens", None) if vision_cfg else None
if max_tokens:
extra["max_tokens"] = max_tokens
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=config, **extra)
message = HumanMessage(
content=[
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_base64}"}},
]
)
result = model.invoke([message])
return _extract_text(result)
@@ -7,7 +7,11 @@ import re
from typing import Any
from urllib.parse import quote
from deerflow.config.extensions_config import SkillDisplayConfig, get_extensions_config
from deerflow.config.extensions_config import (
SkillDisplayConfig,
SkillDisplayResultSetConfig,
get_extensions_config,
)
_SKILL_PATH_RE = re.compile(r"[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)")
@@ -222,7 +226,11 @@ def adapt_reference_batches_with_displays(
)
except Exception:
continue
if not config.enabled or config.mode == "none":
has_enabled_result_set = any(
result_set.enabled and result_set.mode != "none"
for result_set in config.result_sets
)
if not config.enabled or (config.mode == "none" and not has_enabled_result_set):
continue
normalized_displays[skill_name] = config
@@ -257,13 +265,30 @@ def adapt_reference_batches_with_displays(
raw = source.get("raw")
if display is not None and isinstance(raw, dict):
index_value = int(source.get("index") or len(sources) + 1)
display_for_source: SkillDisplayConfig | SkillDisplayResultSetConfig = display
result_set_key = source.get("resultSetKey")
if result_set_key:
display_for_source = next(
(
result_set
for result_set in display.result_sets
if result_set.key == result_set_key
),
display,
)
elif display.result_sets and display.mode == "none":
sources.append(source)
continue
configured = _normalize_with_display_config(
raw,
index_value,
skill_name,
display,
display_for_source,
)
if configured is not None:
if isinstance(display_for_source, SkillDisplayResultSetConfig):
configured["resultSetKey"] = display_for_source.key
configured["resultSetLabel"] = display_for_source.label
sources.append(
{
**source,
@@ -318,6 +343,8 @@ def _source_debug_item(source: dict[str, Any]) -> dict[str, Any]:
"id": source.get("id"),
"skillName": source.get("skillName"),
"mode": source.get("mode"),
"resultSetKey": source.get("resultSetKey"),
"resultSetLabel": source.get("resultSetLabel"),
"title": source.get("title"),
"source": source.get("source"),
"author": source.get("author"),
@@ -359,6 +386,16 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
tool_contexts_by_id,
)
payload = _extract_tool_payload(message)
configured_sources = _collect_configured_sources(
payload,
len(sources) + 1,
tool_context,
)
if configured_sources is not None:
for normalized in configured_sources:
normalized["index"] = len(sources) + 1
sources.append(normalized)
continue
for raw_item in _first_list(payload):
normalized = _normalize_reference_item(
raw_item,
@@ -373,6 +410,44 @@ def _collect_sources(messages: list[Any]) -> list[dict[str, Any]]:
return sources
def _collect_configured_sources(
payload: Any,
fallback_index: int,
tool_context: dict[str, Any] | None,
) -> list[dict[str, Any]] | None:
display_match = _resolve_display_match({}, payload, tool_context)
if display_match is None:
return None
skill_name, display = display_match
configs: list[SkillDisplayConfig | SkillDisplayResultSetConfig] = [
result_set
for result_set in display.result_sets
if result_set.enabled and result_set.mode != "none"
]
if not configs:
configs = [display]
sources: list[dict[str, Any]] = []
for config in configs:
raw_items = _list_at_path(payload, config.result_path)
if raw_items is None:
continue
for raw_item in raw_items:
normalized = _normalize_with_display_config(
raw_item,
fallback_index + len(sources),
skill_name,
config,
)
if normalized is None:
continue
if isinstance(config, SkillDisplayResultSetConfig):
normalized["resultSetKey"] = config.key
normalized["resultSetLabel"] = config.label
sources.append(normalized)
return sources
def _find_reference_batch_anchor(messages: list[Any], start: int, end: int) -> Any | None:
answer = _find_last_assistant_answer(messages, start, end)
if answer is not None:
@@ -622,6 +697,19 @@ def _first_list(payload: Any) -> list[Any]:
return []
def _list_at_path(payload: Any, path: str | None) -> list[dict[str, Any]] | None:
cursor = payload
if path:
for part in path.split("."):
if isinstance(cursor, dict):
cursor = cursor.get(part)
else:
return None
if not isinstance(cursor, list):
return None
return [item for item in cursor if isinstance(item, dict)]
def _nested_records(raw: Any) -> list[dict[str, Any]]:
if not isinstance(raw, dict):
return []
@@ -654,7 +742,13 @@ def _enabled_skill_displays() -> dict[str, SkillDisplayConfig]:
if state.display is not None
and state.enabled
and state.display.enabled
and state.display.mode != "none"
and (
state.display.mode != "none"
or any(
result_set.enabled and result_set.mode != "none"
for result_set in state.display.result_sets
)
)
}
except Exception:
return {}
@@ -705,7 +799,7 @@ def _normalize_with_display_config(
raw: dict[str, Any],
fallback_index: int,
skill_name: str,
display: SkillDisplayConfig,
display: SkillDisplayConfig | SkillDisplayResultSetConfig,
) -> dict[str, Any] | None:
title = _stringify(_get_by_path(raw, display.title_field), f"结果 {fallback_index}")
snippet = _stringify(_get_by_path(raw, display.summary_field), "")
@@ -737,7 +831,10 @@ def _normalize_with_display_config(
}
def _resolve_display_link(raw: dict[str, Any], display: SkillDisplayConfig) -> str | None:
def _resolve_display_link(
raw: dict[str, Any],
display: SkillDisplayConfig | SkillDisplayResultSetConfig,
) -> str | None:
url = _stringify(_get_by_path(raw, display.url_field), "")
item_id = _stringify(_get_by_path(raw, display.id_field), "")
if display.link_strategy == "url":
@@ -51,20 +51,33 @@ def view_image_tool(
runtime: ToolRuntime[ContextT, ThreadState],
image_path: str,
tool_call_id: Annotated[str, InjectedToolCallId],
question: str | None = None,
) -> Command:
"""Read an image file.
"""Read and understand an image file.
Use this tool to read an image file and make it available for display.
This is the ONLY correct way to understand an image. The image is analyzed by
the configured vision model and its description/answer is returned to you as
the tool result. Treat that result as authoritative.
When to use the view_image tool:
- When you need to view an image file.
- Whenever you need to view, describe, or answer a question about an image file.
When NOT to use the view_image tool:
- For non-image files (use present_files instead)
- For multiple files at once (use present_files instead)
IMPORTANT — do NOT attempt to understand images any other way:
- Never read, open, decode, or parse an image with read_file, grep, bash,
python, PIL/Pillow, OpenCV, EXIF parsing, or byte/hex inspection. Those
cannot interpret visual content and will only waste turns.
- If `view_image` returns a weak or incomplete description, do not fall back
to code. Either call `view_image` again with a more specific `question`,
or tell the user the configured vision model could not extract more detail
(the vision model may need to be changed in config).
Args:
image_path: Absolute /mnt/user-data virtual path to the image file. Common formats supported: jpg, jpeg, png, webp.
question: Optional specific question to ask about the image (e.g. "What is the total on this invoice?"). When omitted, a general detailed description is produced.
"""
from deerflow.sandbox.exceptions import SandboxRuntimeError
from deerflow.sandbox.tools import (
@@ -153,8 +166,27 @@ def view_image_tool(
mime_type = detected_mime_type
image_base64 = base64.b64encode(image_data).decode("utf-8")
# Update viewed_images in state
# The merge_viewed_images reducer will handle merging with existing images
# Delegated recognition: when a dedicated vision model is configured, send
# the image to that model and return its description as the tool result, so
# a text-only main chat model can still "see" the image.
from deerflow.models.vision import is_delegated_vision, recognize_image
if is_delegated_vision():
try:
description = recognize_image(image_base64, mime_type, question=question)
except Exception as e: # noqa: BLE001 - surface as a recoverable tool error
return Command(
update={"messages": [ToolMessage(f"Error recognizing image: {_sanitize_image_error(e, thread_data)}", tool_call_id=tool_call_id)]},
)
if not description:
description = "The vision model returned an empty description."
return Command(
update={"messages": [ToolMessage(f"Image recognition result for {image_path}:\n\n{description}", tool_call_id=tool_call_id)]},
)
# Native vision: store the image so ViewImageMiddleware injects it into the
# (vision-capable) main model before the next LLM call.
# The merge_viewed_images reducer will handle merging with existing images.
new_viewed_images = {image_path: {"base64": image_base64, "mime_type": mime_type}}
return Command(
@@ -138,11 +138,14 @@ def get_available_tools(
if model_name is None and config.models:
model_name = config.models[0].name
# Add view_image_tool only if the model supports vision
model_config = config.get_model_config(model_name) if model_name else None
if model_config is not None and model_config.supports_vision:
# Add view_image_tool when vision is available — either the main model
# supports vision natively, or a dedicated vision model is configured
# (vision.model_name) so recognition can be delegated to it.
from deerflow.models.vision import vision_enabled
if vision_enabled(model_name, app_config=config):
builtin_tools.append(view_image_tool)
logger.info(f"Including view_image_tool for model '{model_name}' (supports_vision=True)")
logger.info(f"Including view_image_tool for model '{model_name}' (vision available)")
# Get cached MCP tools if enabled
# NOTE: We use ExtensionsConfig.from_file() instead of config.extensions
@@ -138,12 +138,21 @@ def open_upload_file_no_symlink(base_dir: Path, filename: str) -> tuple[Path, ob
validate_path_traversal(dest, base_dir)
if not hasattr(os, "O_NOFOLLOW"):
raise UnsafeUploadPathError("Upload writes require O_NOFOLLOW support")
flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW
# ``O_NOFOLLOW`` closes the TOCTOU window between the ``lstat`` above and the
# ``open`` below, but it is a POSIX-only flag — it does not exist on Windows
# (``os.O_NOFOLLOW`` is undefined there). Windows also lacks ``O_NONBLOCK``
# and needs ``O_BINARY`` to avoid CRLF translation on the raw fd. Where
# ``O_NOFOLLOW`` is unavailable we still reject pre-existing symlinks via the
# ``lstat`` check and re-verify the opened file with ``fstat`` below; the
# residual race only matters under a hostile local user, which is out of
# scope for the single-user local deployment this path targets.
flags = os.O_WRONLY | os.O_CREAT
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
if hasattr(os, "O_NONBLOCK"):
flags |= os.O_NONBLOCK
if hasattr(os, "O_BINARY"):
flags |= os.O_BINARY
try:
fd = os.open(dest, flags, 0o600)
+51 -2
View File
@@ -11,7 +11,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
@@ -35,7 +35,7 @@ models:
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_vision: false
supports_thinking: true
- name: minimax-m2.7
display_name: MiniMax M2.7
@@ -57,6 +57,36 @@ models:
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-OCR
display_name: deepseek-ai/DeepSeek-OCR
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-OCR
api_key: sk-beshfxfxfrllsczpqfrgjbugfbpzxgqgedbehceytpcvvtyb
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
# 视觉,也能“看图”。请改成你实际用于识图的模型名。
# - 若留空 / 注释掉 model_name,则回退到主对话模型自身的视觉能力(需该模型 supports_vision: true)。
# - prompt:不带具体问题时使用的默认识图指令(可选)。
# - max_tokens:识图模型输出上限(可选,默认用该模型自身的配置)。
vision:
model_name: deepseek-ai/DeepSeek-OCR
# prompt: 请详细描述这张图片的内容。
# max_tokens: 2048
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
# 纯文本类(txt / csv / md / json / 代码等)无需转换,agent 用 read_file 直接读取。
# 图片(png/jpg/webp)由 view_image + 上面的 vision 识图模型处理。
# - pdf_converter: auto(优先 pymupdf4llm,缺失时回退 markitdown)| pymupdf4llm | markitdown
uploads:
auto_convert_documents: true
pdf_converter: auto
tool_groups:
- name: web
- name: file:read
@@ -113,6 +143,25 @@ skills:
enabled: false
agents_api:
enabled: true
# 登录鉴权扩展:在基础登录之上叠加两种自定义模式。
auth_login:
# 用户名直登口令门:开关打开后,/login/<用户名> 直登必须再带 ?password=口令,
# 口令不对直接 401。password 留空则默认口令为 123ewq。仅作用于用户名直登。
username_login:
require_password: false
password: ""
# token 换登录:前端用 ?authToken=上游token 进入,后端调用 token_info_url
# (带 Authorization 头 + access_token cookie)换出用户名,再登录;用户名不在
# 库里时自动注册。
token_login:
enabled: true
token_info_url: "https://ch1.b.uat.4.cn/consumer/login/getTokenInfo"
service_authorization: "Bearer admin"
timeout_seconds: 10
# 上游是 HTTPS。UAT/内网常用自签或私有 CA 证书,默认 verify=true 会握手失败导致
# 永远 401。此处 uat 域名按自签处理,先关校验;生产请置 true 或配 ca_cert_path。
verify_ssl: false
ca_cert_path: "" # 可选:PEM 格式 CA 包路径,设置后优先生效并启用校验
database:
backend: mysql
mysql_url: $MYSQL_DATABASE_URL
-263
View File
@@ -1,263 +0,0 @@
#!/usr/bin/env bash
#
# deploy.sh — DeerFlow 后端 Linux Docker 一键部署脚本
#
# 用法:
# bash deploy.sh # 首次部署: 检查环境 → 构建/加载镜像 → 启动
# bash deploy.sh up # 启动服务
# bash deploy.sh down # 停止并移除容器
# bash deploy.sh restart # 重启
# bash deploy.sh status # 查看状态
# bash deploy.sh logs # 跟踪日志
# bash deploy.sh build # 仅构建镜像
# bash deploy.sh load # 从离线 tar 加载镜像
# bash deploy.sh health # 健康检查
#
# 环境变量(可选):
# HOST_PORT 宿主机映射端口,默认 8001
# CONTAINER_NAME 容器名,默认 deerflow-backend
# IMAGE_TAG 镜像标签,默认 deerflow-backend:latest
# IMAGE_TAR 离线镜像 tar 路径
# DEPLOY_MODE build | offline,默认 build
# SKIP_BUILD 1 时跳过构建/加载,直接启动
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}"
COMPOSE_PROJECT="${COMPOSE_PROJECT:-deerflow-backend}"
COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.yaml"
COMPOSE=(docker compose -p "${COMPOSE_PROJECT}" -f "${COMPOSE_FILE}")
HOST_PORT="${HOST_PORT:-8001}"
CONTAINER_NAME="${CONTAINER_NAME:-deerflow-backend}"
IMAGE_TAG="${IMAGE_TAG:-deerflow-backend:latest}"
IMAGE_TAR="${IMAGE_TAR:-${SCRIPT_DIR}/deerflow-backend-offline-image.tar}"
DEPLOY_MODE="${DEPLOY_MODE:-build}"
SKIP_BUILD="${SKIP_BUILD:-0}"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[ OK ]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
fail() { echo -e "${RED}[FAIL]${NC} $*" >&2; exit 1; }
usage() {
sed -n '3,18p' "$0" | sed 's/^# \?//'
exit 0
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || fail "未找到命令: $1"
}
ensure_layout() {
mkdir -p "${SCRIPT_DIR}/data/data" "${SCRIPT_DIR}/data/backups" "${SCRIPT_DIR}/data/archives/conversations"
# switch_database_to_mysql.sh 等脚本期望根目录有这些路径
[ -e "${SCRIPT_DIR}/wheelhouse" ] || ln -sfn backend/wheelhouse "${SCRIPT_DIR}/wheelhouse"
[ -e "${SCRIPT_DIR}/skills" ] || ln -sfn backend/skills "${SCRIPT_DIR}/skills"
if [ ! -e "${SCRIPT_DIR}/extensions_config.json" ]; then
ln -sfn backend/extensions_config.json "${SCRIPT_DIR}/extensions_config.json"
fi
chmod +x "${SCRIPT_DIR}/scripts/container-entrypoint.sh" 2>/dev/null || true
}
ensure_env() {
if [ ! -f "${SCRIPT_DIR}/.env" ]; then
if [ -f "${SCRIPT_DIR}/.env.example" ]; then
cp "${SCRIPT_DIR}/.env.example" "${SCRIPT_DIR}/.env"
warn "已从 .env.example 生成 .env,部署前请编辑 API Key 和 AUTH_JWT_SECRET"
else
fail "缺少 .env 文件,请先创建并配置环境变量"
fi
fi
if [ ! -f "${SCRIPT_DIR}/config.yaml" ]; then
fail "缺少 config.yaml,请复制并编辑 config.example.yaml"
fi
if [ ! -f "${SCRIPT_DIR}/skill_evolution.json" ]; then
echo '{"enabled":false,"curator":{"enabled":false}}' > "${SCRIPT_DIR}/skill_evolution.json"
warn "已创建默认 skill_evolution.json"
fi
}
check_docker() {
require_cmd docker
docker info >/dev/null 2>&1 || fail "Docker 未运行或无权限,请确认 docker 服务已启动且当前用户在 docker 组"
docker compose version >/dev/null 2>&1 || fail "未找到 docker compose,请安装 Docker Compose V2"
}
generate_secret_if_needed() {
local key="$1"
local file="$2"
if grep -qE "^[[:space:]]*${key}=" "${SCRIPT_DIR}/.env" 2>/dev/null; then
local val
val="$(grep -E "^[[:space:]]*${key}=" "${SCRIPT_DIR}/.env" | tail -n1 | sed 's/^[^=]*=//')"
if [ -n "${val}" ] && [ "${val}" != "change-me-to-a-long-random-string" ]; then
return 0
fi
fi
if [ -f "${file}" ]; then
return 0
fi
mkdir -p "$(dirname "${file}")"
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32 > "${file}"
chmod 600 "${file}"
warn "已生成 ${key} → ${file},建议将其写入 .env 后删除该文件"
fi
}
load_offline_image() {
if docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then
ok "镜像已存在: ${IMAGE_TAG}"
return 0
fi
[ -f "${IMAGE_TAR}" ] || fail "离线镜像不存在: ${IMAGE_TAR},请设置 IMAGE_TAR 或先执行 bash deploy.sh build"
info "加载离线镜像: ${IMAGE_TAR}"
docker load -i "${IMAGE_TAR}"
ok "镜像加载完成: ${IMAGE_TAG}"
}
build_image() {
info "构建 Docker 镜像: ${IMAGE_TAG}"
export IMAGE_TAG
"${COMPOSE[@]}" build gateway
ok "镜像构建完成"
}
prepare_image() {
if [ "${SKIP_BUILD}" = "1" ]; then
info "SKIP_BUILD=1,跳过构建/加载"
return 0
fi
if docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then
ok "使用已有镜像: ${IMAGE_TAG}"
return 0
fi
case "${DEPLOY_MODE}" in
offline)
load_offline_image
;;
build|*)
build_image
;;
esac
}
wait_for_health() {
local url="http://127.0.0.1:${HOST_PORT}/health"
local retries="${HEALTH_RETRIES:-30}"
local i=1
info "等待服务就绪: ${url}"
while [ "${i}" -le "${retries}" ]; do
if curl -fsS "${url}" >/dev/null 2>&1; then
ok "健康检查通过"
return 0
fi
sleep 2
i=$((i + 1))
done
warn "健康检查超时,请执行: bash deploy.sh logs"
return 1
}
cmd_up() {
check_docker
ensure_layout
ensure_env
generate_secret_if_needed "AUTH_JWT_SECRET" "${SCRIPT_DIR}/data/.auth-jwt-secret"
export HOST_PORT CONTAINER_NAME IMAGE_TAG
if ! docker ps -a --format '{{.Names}}' | grep -qx "${CONTAINER_NAME}"; then
prepare_image
fi
info "启动容器..."
"${COMPOSE[@]}" up -d --remove-orphans
ok "容器已启动: ${CONTAINER_NAME}"
echo
echo " API 地址: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo 127.0.0.1):${HOST_PORT}"
echo " 健康检查: http://127.0.0.1:${HOST_PORT}/health"
echo " API 文档: http://127.0.0.1:${HOST_PORT}/docs"
echo
wait_for_health || true
}
cmd_down() {
check_docker
export HOST_PORT CONTAINER_NAME IMAGE_TAG
info "停止容器..."
"${COMPOSE[@]}" down --remove-orphans
ok "已停止"
}
cmd_restart() {
cmd_down
cmd_up
}
cmd_status() {
check_docker
export HOST_PORT CONTAINER_NAME IMAGE_TAG
"${COMPOSE[@]}" ps
echo
if docker ps --format '{{.Names}}' | grep -qx "${CONTAINER_NAME}"; then
curl -fsS "http://127.0.0.1:${HOST_PORT}/health" 2>/dev/null && echo || warn "健康检查失败"
else
warn "容器未运行"
fi
}
cmd_logs() {
check_docker
export HOST_PORT CONTAINER_NAME IMAGE_TAG
"${COMPOSE[@]}" logs -f --tail=200 gateway
}
cmd_build() {
check_docker
ensure_layout
build_image
}
cmd_load() {
check_docker
DEPLOY_MODE=offline
load_offline_image
}
cmd_health() {
curl -fsS "http://127.0.0.1:${HOST_PORT}/health" && echo || fail "健康检查失败"
}
main() {
local cmd="${1:-up}"
case "${cmd}" in
-h|--help|help) usage ;;
up|start|"") cmd_up ;;
down|stop) cmd_down ;;
restart) cmd_restart ;;
status) cmd_status ;;
logs) cmd_logs ;;
build) cmd_build ;;
load) cmd_load ;;
health) cmd_health ;;
deploy) cmd_up ;;
*)
fail "未知命令: ${cmd}。运行 bash deploy.sh --help 查看帮助"
;;
esac
}
main "${1:-up}"
@@ -1,50 +0,0 @@
# DeerFlow 后端 — Linux Docker 部署
# 推荐使用 deploy.sh 管理;也可直接: docker compose up -d
services:
gateway:
build:
context: .
dockerfile: backend/Dockerfile
args:
APT_MIRROR: ${APT_MIRROR:-}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20}
UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple}
UV_EXTRAS: ${UV_EXTRAS:-}
image: ${IMAGE_TAG:-deerflow-backend:latest}
container_name: ${CONTAINER_NAME:-deerflow-backend}
ports:
- "${HOST_PORT:-8001}:8001"
env_file:
- .env
environment:
CI: "true"
DEER_FLOW_PROJECT_ROOT: /app
DEER_FLOW_HOME: /app/backend/.deer-flow
DEER_FLOW_CONFIG_PATH: /app/config.yaml
DEER_FLOW_EXTENSIONS_CONFIG_PATH: /app/backend/extensions_config.json
DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH: /app/skill_evolution.json
DEER_FLOW_SKILLS_PATH: /app/backend/skills
DEER_FLOW_CHANNELS_LANGGRAPH_URL: ${DEER_FLOW_CHANNELS_LANGGRAPH_URL:-http://gateway:8001/api}
DEER_FLOW_CHANNELS_GATEWAY_URL: ${DEER_FLOW_CHANNELS_GATEWAY_URL:-http://gateway:8001}
UV_LINK_MODE: copy
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./skill_evolution.json:/app/skill_evolution.json:ro
- ./backend/app:/app/backend/app
- ./backend/packages:/app/backend/packages
- ./backend/scripts:/app/backend/scripts
- ./backend/extensions_config.json:/app/backend/extensions_config.json:ro
- ./backend/skills:/app/backend/skills:ro
- ./backend/wheelhouse:/app/wheelhouse:ro
- ./backend/pyproject.toml:/app/backend/pyproject.toml:ro
- ./backend/uv.lock:/app/backend/uv.lock:ro
- ./backend/langgraph.json:/app/backend/langgraph.json:ro
- ./scripts/container-entrypoint.sh:/app/scripts/container-entrypoint.sh:ro
- ./data:/app/backend/.deer-flow
- ${DEER_FLOW_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
working_dir: /app
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
command: ["sh", "/app/scripts/container-entrypoint.sh"]
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
# Container startup: install optional offline deps, then launch Gateway.
set -eu
cd /app/backend
VENDOR_DIR="/app/backend/.deer-flow/python-vendor"
WHEELHOUSE="/app/wheelhouse"
mkdir -p "${VENDOR_DIR}"
export PYTHONPATH=".:${VENDOR_DIR}"
pip_install_offline() {
local packages="$1"
if [ -d "${WHEELHOUSE}" ]; then
uv pip install \
--target "${VENDOR_DIR}" \
--no-index \
--find-links "${WHEELHOUSE}" \
${packages}
elif [ -n "${UV_INDEX_URL:-}" ] || [ -n "${PIP_INDEX_URL:-}" ]; then
uv pip install --target "${VENDOR_DIR}" ${packages}
else
uv pip install --target "${VENDOR_DIR}" ${packages}
fi
}
pip_install_online() {
local packages="$1"
if [ -n "${UV_INDEX_URL:-}" ]; then
uv pip install --target "${VENDOR_DIR}" ${packages}
elif [ -n "${PIP_INDEX_URL:-}" ]; then
uv pip install --target "${VENDOR_DIR}" --index-url "${PIP_INDEX_URL}" ${packages}
else
uv pip install --target "${VENDOR_DIR}" ${packages}
fi
}
install_if_missing() {
local module="$1"
local packages="$2"
if uv run --no-sync python -c "import ${module}" >/dev/null 2>&1; then
return 0
fi
echo "Installing ${packages} into ${VENDOR_DIR}"
if [ -d "${WHEELHOUSE}" ] && ls "${WHEELHOUSE}"/*.whl >/dev/null 2>&1; then
pip_install_offline "${packages}"
else
pip_install_online "${packages}"
fi
}
if [ -n "${MYSQL_DATABASE_URL:-}" ]; then
install_if_missing "asyncmy" "${MYSQL_PIP_PACKAGES:-asyncmy==0.2.10}"
fi
if [ "${SCHEDULER_INSTALL_DEPS:-1}" = "1" ]; then
install_if_missing "croniter" "${SCHEDULER_PIP_PACKAGES:-croniter==6.2.2}"
fi
WORKERS="${GATEWAY_WORKERS:-2}"
echo "Starting Gateway on 0.0.0.0:8001 (workers=${WORKERS})"
exec env PYTHONPATH=".:${VENDOR_DIR}" uv run --no-sync uvicorn app.gateway.app:app \
--host 0.0.0.0 \
--port 8001 \
--workers "${WORKERS}"