Merge remote-tracking branch 'origin/master' into ji

This commit is contained in:
吉鑫升 committed 2026-06-02 17:40:56 +08:00
commit 89cb5ab2f1
61 files changed
+1531 -1508

No files matched your search

+7
View File
@@ -84,5 +84,12 @@ offline-backend-20260512/backend/tests/*
deploy deploy
.history .history
# Repo-root offline dependency pack (local only)
/dist/
# Frontend offline pack working directories
frontend-web/.offline-pack-build/
frontend-web/.offline-extract-test/
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above) # Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/ !frontend-web/src/components/workspace/skills/
+2 -3
View File
@@ -5,15 +5,14 @@
# ├─────────────────┼──────────────────────────────────────────────────┤ # ├─────────────────┼──────────────────────────────────────────────────┤
# │ pnpm dev │ .env.development + .env.development.local │ # │ pnpm dev │ .env.development + .env.development.local │
# │ pnpm build │ .env.production + .env.production.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-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │ # │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
# └─────────────────┴──────────────────────────────────────────────────┘ # └─────────────────┴──────────────────────────────────────────────────┘
# #
# 推荐: # 推荐:
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址) # - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址)
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local # - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
# - 内网 UAT 打包部署:改 .env.production 后再执行 pnpm build # - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build)
# - SIT 环境打包:改 .env.sit 后再执行 pnpm build:sit(地址为 UAT 的 sit 版本)
# #
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如: # 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174 # 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=
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build", "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: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", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
+2
View File
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProvider as NextThemesProvider } from "next-themes";
import { FontScaleApplier } from "@/components/font-scale";
import { QueryClientProvider } from "@/components/query-client-provider"; import { QueryClientProvider } from "@/components/query-client-provider";
import { AuthSyncWatcher } from "@/core/auth/sync"; import { AuthSyncWatcher } from "@/core/auth/sync";
import { I18nProvider } from "@/core/i18n/context"; import { I18nProvider } from "@/core/i18n/context";
@@ -39,6 +40,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
<ThemeProvider> <ThemeProvider>
<ChatProvider> <ChatProvider>
<AuthSyncWatcher /> <AuthSyncWatcher />
<FontScaleApplier />
{children} {children}
</ChatProvider> </ChatProvider>
</ThemeProvider> </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;
}
+1 -1
View File
@@ -14,7 +14,7 @@ const buttonVariants = cva(
destructive: 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", "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: 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: secondary:
"cursor-pointer bg-secondary text-secondary-foreground hover:bg-secondary/80", "cursor-pointer bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
+3 -3
View File
@@ -34,7 +34,7 @@ const emptyMediaVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-transparent", 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: { defaultVariants: {
@@ -62,7 +62,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="empty-title" 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} {...props}
/> />
); );
@@ -73,7 +73,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
<div <div
data-slot="empty-description" data-slot="empty-description"
className={cn( 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, className,
)} )}
{...props} {...props}
+10 -1
View File
@@ -3,6 +3,14 @@ import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
const usesDarkNativePicker =
type === "number" ||
type === "date" ||
type === "datetime-local" ||
type === "time" ||
type === "month" ||
type === "week";
return ( return (
<input <input
type={type} type={type}
@@ -10,7 +18,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
className={cn( 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", "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", "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", "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", "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", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
+10 -5
View File
@@ -36,7 +36,8 @@ const SelectTrigger = React.forwardRef<
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( 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", "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, className,
)} )}
{...props} {...props}
@@ -61,7 +62,7 @@ const SelectContent = React.forwardRef<
ref={ref} ref={ref}
data-slot="select-content" data-slot="select-content"
className={cn( 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", "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" && 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", "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, className,
@@ -95,7 +96,10 @@ function SelectLabel({
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-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} {...props}
/> />
); );
@@ -111,6 +115,7 @@ const SelectItem = React.forwardRef<
data-slot="select-item" data-slot="select-item"
className={cn( 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", "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, className,
)} )}
{...props} {...props}
@@ -151,7 +156,7 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className, className,
)} )}
{...props} {...props}
@@ -169,7 +174,7 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -13,7 +13,7 @@ function Switch({
<SwitchPrimitive.Root <SwitchPrimitive.Root
data-slot="switch" data-slot="switch"
className={cn( 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, className,
)} )}
{...props} {...props}
+10 -6
View File
@@ -32,7 +32,8 @@ const tabsListVariants = cva(
variant: { variant: {
default: 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", "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: { defaultVariants: {
@@ -70,13 +71,16 @@ const TabsTrigger = React.forwardRef<
className={cn( 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", "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", "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", "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=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=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: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: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]", "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]",
"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", "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, className,
)} )}
{...props} {...props}
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTheme } from "@/contexts/ThemeContext";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
@@ -90,7 +91,7 @@ function V1SectionField({
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between"> <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 && ( {!editing && (
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}> <Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
<Pencil className="size-3" /> <Pencil className="size-3" />
@@ -154,7 +155,7 @@ function V1FactItem({
} }
return ( return (
<div className="rounded border p-2 space-y-1 group"> <div className="rounded border p-2 space-y-1 group ">
{editing ? ( {editing ? (
<div className="space-y-2"> <div className="space-y-2">
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus /> <Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
@@ -196,7 +197,7 @@ function V1FactItem({
</Button> </Button>
</div> </div>
</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} {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 }) { function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
const { theme } = useTheme();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [category, setCategory] = useState("context"); const [category, setCategory] = useState("context");
@@ -222,7 +224,12 @@ function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string
if (!open) { if (!open) {
return ( 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" />新增事实 <Plus className="size-3" />新增事实
</Button> </Button>
); );
@@ -276,8 +283,8 @@ export function V1BucketView({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 用户画像字段 */} {/* 用户画像字段 */}
<div className="rounded-md border p-3 space-y-3"> <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">用户画像</h4> <h4 className="text-xs font-semibold text-muted-foreground dark:text-white">用户画像</h4>
{allCtxFields.map((field) => ( {allCtxFields.map((field) => (
<V1SectionField <V1SectionField
key={field} key={field}
@@ -291,8 +298,8 @@ export function V1BucketView({
</div> </div>
{/* 历史摘要字段 */} {/* 历史摘要字段 */}
<div className="rounded-md border p-3 space-y-3"> <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">历史摘要</h4> <h4 className="text-xs font-semibold text-muted-foreground dark:text-white">历史摘要</h4>
{allHistoryFields.map((field) => ( {allHistoryFields.map((field) => (
<V1SectionField <V1SectionField
key={field} key={field}
@@ -306,8 +313,8 @@ export function V1BucketView({
</div> </div>
{/* 事实记录 */} {/* 事实记录 */}
<div className="rounded-md border p-3 space-y-2"> <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">事实记录({factsRaw.length} 条)</h4> <h4 className="text-xs font-semibold text-muted-foreground dark:text-white">事实记录({factsRaw.length} 条)</h4>
{factsRaw.map((fact, i) => ( {factsRaw.map((fact, i) => (
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} /> <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"> <ScrollArea className="min-h-0 flex-1">
<div className="p-4"> <div className="p-4">
{!threadId && ( {!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" /> <MessageSquareIcon className="size-6" />
请从左侧选择一个会话 请从左侧选择一个会话
</div> </div>
@@ -253,7 +253,7 @@ function AgentMemoryPanel({ userId }: { userId: string }) {
<SelectTrigger className="h-8 w-48" size="sm"> <SelectTrigger className="h-8 w-48" size="sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" side="bottom" sideOffset={4}>
{/* 默认助手始终显示,对应全局记忆文件 */} {/* 默认助手始终显示,对应全局记忆文件 */}
<SelectItem value="default">默认助手</SelectItem> <SelectItem value="default">默认助手</SelectItem>
{otherBuckets.map((b) => ( {otherBuckets.map((b) => (
@@ -224,16 +224,16 @@ export function TagManagementPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr> <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} {t.adminTags.name}
</th> </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} {t.adminTags.tagDescription}
</th> </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} {t.adminTags.usageCount}
</th> </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} {t.adminTags.actions}
</th> </th>
</tr> </tr>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Download, Loader2 } from "lucide-react"; import { AlertCircle,CheckCircle2, Download, Loader2 } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -29,6 +29,7 @@ import {
} from "@/core/tool-metrics/api"; } from "@/core/tool-metrics/api";
import { useToolMetrics, useToolMetricsSummary } from "@/core/tool-metrics/hooks"; import { useToolMetrics, useToolMetricsSummary } from "@/core/tool-metrics/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -66,6 +67,7 @@ function categoryLabel(category?: string | null): string {
} }
export function ToolMetricsPanel() { export function ToolMetricsPanel() {
const { theme } = useTheme();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [tool, setTool] = useState(""); const [tool, setTool] = useState("");
const [user, setUser] = useState(""); const [user, setUser] = useState("");
@@ -79,6 +81,31 @@ export function ToolMetricsPanel() {
const [until, setUntil] = useState(""); const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [detailRow, setDetailRow] = useState<ToolMetric | null>(null); 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 filters = useMemo<ToolMetricsFilters>(() => {
const out: ToolMetricsFilters = {}; const out: ToolMetricsFilters = {};
@@ -117,9 +144,9 @@ export function ToolMetricsPanel() {
setExporting(true); setExporting(true);
try { try {
await downloadToolMetricsExcel(filters); await downloadToolMetricsExcel(filters);
toast.success("已导出为 Excel"); toast.success("已导出为 Excel",darkToastOpts.success);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "导出失败"); toast.error(e instanceof Error ? e.message : "导出失败",darkToastOpts.error);
} finally { } finally {
setExporting(false); setExporting(false);
} }
@@ -167,7 +194,7 @@ export function ToolMetricsPanel() {
</div> </div>
{/* Summary strip */} {/* 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"> <div className="mr-4 flex flex-col">
<span className="text-muted-foreground text-xs">总调用</span> <span className="text-muted-foreground text-xs">总调用</span>
<span className="text-lg font-semibold tabular-nums"> <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". */} {/* Per-skill breakdown — answers "was skill X invoked, and did it succeed". */}
{summary && summary.by_skill.length > 0 && ( {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> <h3 className="mb-2 text-sm font-medium">各技能调用情况</h3>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -258,7 +285,7 @@ export function ToolMetricsPanel() {
</div> </div>
)} )}
{summary && kind === "skill" && summary.by_skill.length === 0 && ( {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 通过 当前筛选范围内没有检测到任何技能调用记录。技能只有在被 Agent 通过
<code className="bg-muted mx-1 rounded px-1">skill_view</code> <code className="bg-muted mx-1 rounded px-1">skill_view</code>
读取(即真正"调用")时才会被记录;仅在系统提示里被列出、但未被读取的技能不会计入。 读取(即真正"调用")时才会被记录;仅在系统提示里被列出、但未被读取的技能不会计入。
@@ -266,9 +293,9 @@ export function ToolMetricsPanel() {
)} )}
{/* Filters */} {/* 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"> <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 <Select
value={kind} value={kind}
onValueChange={(value) => { onValueChange={(value) => {
@@ -276,17 +303,17 @@ export function ToolMetricsPanel() {
setPage(0); setPage(0);
}} }}
> >
<SelectTrigger> <SelectTrigger className="dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="skill">技能(Agent Skill)</SelectItem> <SelectItem value="skill">技能(Agent Skill)</SelectItem>
<SelectItem value="all">全部工具调用</SelectItem> <SelectItem value="all">全部工具调用</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1"> <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 <Input
value={skill} value={skill}
placeholder="按技能名筛选" placeholder="按技能名筛选"
@@ -297,7 +324,7 @@ export function ToolMetricsPanel() {
/> />
</div> </div>
<div className="flex flex-col gap-1"> <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 <Input
value={tool} value={tool}
placeholder="按工具名筛选" placeholder="按工具名筛选"
@@ -308,7 +335,7 @@ export function ToolMetricsPanel() {
/> />
</div> </div>
<div className="flex flex-col gap-1"> <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 <Input
value={user} value={user}
placeholder="@ 前部分" placeholder="@ 前部分"
@@ -319,7 +346,7 @@ export function ToolMetricsPanel() {
/> />
</div> </div>
<div className="flex flex-col gap-1"> <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 <Select
value={status} value={status}
onValueChange={(value) => { onValueChange={(value) => {
@@ -327,10 +354,10 @@ export function ToolMetricsPanel() {
setPage(0); setPage(0);
}} }}
> >
<SelectTrigger> <SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部</SelectItem> <SelectItem value="all">全部</SelectItem>
<SelectItem value="success">成功</SelectItem> <SelectItem value="success">成功</SelectItem>
<SelectItem value="error">失败</SelectItem> <SelectItem value="error">失败</SelectItem>
@@ -338,7 +365,7 @@ export function ToolMetricsPanel() {
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1"> <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 <Select
value={category} value={category}
onValueChange={(value) => { onValueChange={(value) => {
@@ -346,10 +373,10 @@ export function ToolMetricsPanel() {
setPage(0); setPage(0);
}} }}
> >
<SelectTrigger> <SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部</SelectItem> <SelectItem value="all">全部</SelectItem>
{TOOL_ERROR_CATEGORIES.map((cat) => ( {TOOL_ERROR_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}> <SelectItem key={cat} value={cat}>
@@ -360,7 +387,7 @@ export function ToolMetricsPanel() {
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1"> <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 <Input
type="datetime-local" type="datetime-local"
value={since} value={since}
@@ -371,7 +398,7 @@ export function ToolMetricsPanel() {
/> />
</div> </div>
<div className="flex flex-col gap-1"> <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 <Input
type="datetime-local" type="datetime-local"
value={until} value={until}
@@ -389,10 +416,10 @@ export function ToolMetricsPanel() {
</div> </div>
{/* Table */} {/* 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"> <div className="overflow-x-auto">
<table className="w-full min-w-[960px] text-sm"> <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> <tr>
<Th>时间</Th> <Th>时间</Th>
<Th>用户</Th> <Th>用户</Th>
@@ -8,7 +8,6 @@ import {
type MessageResponseProps, type MessageResponseProps,
} from "@/components/ai-elements/message"; } from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from "../citations/citation-link";
@@ -67,8 +66,6 @@ export function MarkdownContent({
if (!content) return null; if (!content) return null;
const displayContent = desensitize(content);
return ( return (
<MessageResponse <MessageResponse
className={className} className={className}
@@ -76,7 +73,7 @@ export function MarkdownContent({
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
components={components} components={components}
> >
{displayContent} {content}
</MessageResponse> </MessageResponse>
); );
} }
@@ -47,7 +47,6 @@ import {
type RuntimeDisplaySource, type RuntimeDisplaySource,
} from "@/core/skill-display/runtime"; } from "@/core/skill-display/runtime";
import { humanMessagePlugins } from "@/core/streamdown"; import { humanMessagePlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link"; import { CitationLink } from "../citations/citation-link";
@@ -264,7 +263,7 @@ function MessageContent_({
<TaskTrigger title=""> <TaskTrigger title="">
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none"> <div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
<Loader className="size-4" /> <Loader className="size-4" />
<span>{desensitize(contentToDisplay)}</span> <span>{contentToDisplay}</span>
</div> </div>
</TaskTrigger> </TaskTrigger>
</Task> </Task>
@@ -278,7 +277,7 @@ function MessageContent_({
<AIElementMessageContent className={className}> <AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}> <Reasoning isStreaming={isLoading}>
<ReasoningTrigger /> <ReasoningTrigger />
<ReasoningContent>{desensitize(reasoningContent)}</ReasoningContent> <ReasoningContent>{reasoningContent}</ReasoningContent>
</Reasoning> </Reasoning>
<MessageTokenUsage <MessageTokenUsage
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
@@ -297,7 +296,7 @@ function MessageContent_({
components={components} components={components}
parseIncompleteMarkdown={false} parseIncompleteMarkdown={false}
> >
{desensitize(contentToDisplay)} {contentToDisplay}
</AIElementMessageResponse> </AIElementMessageResponse>
) : null; ) : null;
return ( return (
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
@@ -147,6 +148,39 @@ export function AppearanceSettingsPage() {
<Separator /> <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 <SettingsSection
title={t.settings.appearance.languageTitle} title={t.settings.appearance.languageTitle}
description={t.settings.appearance.languageDescription} description={t.settings.appearance.languageDescription}
@@ -229,7 +229,7 @@ export function MemorySettingsPage() {
<SelectTrigger className="h-8 w-44" size="sm"> <SelectTrigger className="h-8 w-44" size="sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="default"> <SelectItem value="default">
{m.workNotesDefaultAgent} {m.workNotesDefaultAgent}
</SelectItem> </SelectItem>
@@ -490,7 +490,7 @@ function MemoryV1View({
return ( return (
<div className="space-y-3"> <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 模式:记忆由系统在对话结束后自动提取,无需手动管理。 V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
</div> </div>
@@ -509,13 +509,13 @@ function MemoryV1View({
{/* 事实记录 Tab */} {/* 事实记录 Tab */}
<TabsContent value="facts" className="mt-3"> <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"> <div className="flex items-center justify-between">
<h3 className="text-base font-medium">事实记录</h3> <h3 className="text-base font-medium">事实记录</h3>
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span> <span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
</div> </div>
{memory.facts.length === 0 ? ( {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> </div>
) : ( ) : (
@@ -542,21 +542,21 @@ function MemoryV1View({
</div> </div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p> <p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
{fact.sourceError && ( {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)} 避免:{desensitize(fact.sourceError)}
</p> </p>
)} )}
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
</TabsContent> </TabsContent>
{/* 用户画像 & 历史摘要 Tab */} {/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4"> <TabsContent value="profile" className="mt-3 space-y-4">
{/* 用户画像 (userContext) */} {/* 用户画像 (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> <h3 className="text-base font-medium">用户画像</h3>
{ctxEntries.length === 0 ? ( {ctxEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p> <p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
@@ -575,7 +575,7 @@ function MemoryV1View({
</div> </div>
{/* 历史摘要 (history) */} {/* 历史摘要 (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> <h3 className="text-base font-medium">历史摘要</h3>
{historyEntries.length === 0 ? ( {historyEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无历史摘要。</p> <p className="text-sm text-muted-foreground">暂无历史摘要。</p>
@@ -9,6 +9,7 @@ import {
ClockIcon, ClockIcon,
FileArchive, FileArchive,
GlobeIcon, GlobeIcon,
InfoIcon,
Loader2, Loader2,
LockIcon, LockIcon,
PinIcon, PinIcon,
@@ -54,6 +55,7 @@ import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-di
import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api"; import type { ValidateSkillUploadResponse } from "@/core/skills/api";
@@ -88,8 +90,42 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
const PAGE_SIZE = 10; 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 }) { function SkillSettingsList({ onClose }: { onClose?: () => void }) {
const { t } = useI18n(); const { t } = useI18n();
const { theme } = useTheme();
const router = useRouter(); const router = useRouter();
const [filter, setFilter] = useState<string>("mine"); const [filter, setFilter] = useState<string>("mine");
const [installOpen, setInstallOpen] = useState(false); const [installOpen, setInstallOpen] = useState(false);
@@ -174,13 +210,21 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
"{count}", "{count}",
String(result.adopted.length), String(result.adopted.length),
), ),
theme === "dark" ? rescanSuccessToastDarkOptions : undefined,
); );
} else { } else {
toast.info(t.settings.skills.rescanSkillsNoop); toast.info(
t.settings.skills.rescanSkillsNoop,
theme === "dark" ? rescanInfoToastDarkOptions : undefined,
);
} }
} catch (err) { } 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 ( return (
<div className="flex w-full flex-col gap-4"> <div className="flex w-full flex-col gap-4">
@@ -398,7 +442,7 @@ export function SkillRow({
return ( return (
<> <>
<Item className="w-full" variant="outline"> <Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<ItemContent <ItemContent
className="cursor-pointer" className="cursor-pointer"
onClick={() => setDetailOpen(true)} onClick={() => setDetailOpen(true)}
@@ -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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value={DEFAULT_MODEL_VALUE}> <SelectItem value={DEFAULT_MODEL_VALUE}>
{tt.curatorModelDefault} {tt.curatorModelDefault}
</SelectItem> </SelectItem>
@@ -20,8 +20,11 @@ export function WorkspaceContainer({
children, children,
...props ...props
}: React.ComponentProps<"div">) { }: React.ComponentProps<"div">) {
// h-full 而非 h-screen:本容器恒在 WorkspaceRoutes 的 SidebarInset(flex-1,确定高度)下,
// h-full 既跟随容器高度(无界等嵌套不再锁死整屏),又修掉原 h-screen=100vh 比可用区域
// 高出一个页头高度的溢出。
return ( 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} {children}
</div> </div>
); );
+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" { export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" {
return themeParam === "dark-blue" ? "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;
}
+8
View File
@@ -8,6 +8,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
streamSpeedMeterEnabled: true, streamSpeedMeterEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
citationCleanupWords: [], citationCleanupWords: [],
fontScale: 1,
}, },
context: { context: {
model_name: undefined, model_name: undefined,
@@ -46,6 +47,13 @@ export interface LocalSettings {
* and dialogs, one entry per line in the settings UI. * and dialogs, one entry per line in the settings UI.
*/ */
citationCleanupWords: string[]; 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< context: Omit<
AgentThreadContext, AgentThreadContext,
+1
View File
@@ -5,6 +5,7 @@
*/ */
export const DESENSITIZE_MAP: Record<string, string> = { export const DESENSITIZE_MAP: Record<string, string> = {
态势: "TS", 态势: "TS",
你好: "nh",
滨海作战体系: "第一张网", 滨海作战体系: "第一张网",
滨海作战部队: "第一张网", 滨海作战部队: "第一张网",
'Taiwan Air Force Flight Units': "第二张网", 'Taiwan Air Force Flight Units': "第二张网",
@@ -9,16 +9,16 @@ function DraftWaitingPlaceholder() {
return ( return (
<div className="h-full flex flex-col items-center justify-center gap-5 select-none"> <div className="h-full flex flex-col items-center justify-center gap-5 select-none">
<div className="relative"> <div className="relative">
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center"> <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" /> <FileText className="w-8 h-8 text-muted-foreground/40 dark:text-[#55A6FE] " />
</div> </div>
<div className="absolute -bottom-1.5 -right-1.5 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center"> <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" /> <PenLine className="w-3.5 h-3.5 text-primary/60 dark:text-[#2580FF] " />
</div> </div>
</div> </div>
<div className="text-center space-y-1.5 max-w-[200px]"> <div className="text-center space-y-1.5 max-w-[200px]">
<p className="text-sm font-medium text-foreground/60">草稿将在此处显示</p> <p className="text-sm font-medium text-foreground/60 dark:text-[#AACEFF]">草稿将在此处显示</p>
<p className="text-[11px] text-muted-foreground leading-relaxed"> <p className="text-[11px] text-muted-foreground leading-relaxed dark:text-[#55A6FE]">
完成写作流程后,AI 生成的内容将实时出现在这里 完成写作流程后,AI 生成的内容将实时出现在这里
</p> </p>
</div> </div>
@@ -25,7 +25,9 @@ interface AIWritingFormProps {
const INPUT_CLASS = const INPUT_CLASS =
'flex h-12 w-full rounded-xl border border-[#e8dedd] bg-white px-4 py-2 text-sm shadow-sm ' + '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 ' + '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_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者'; const DEFAULT_AUDIENCE = '普通读者';
@@ -127,16 +129,18 @@ export function AIWritingForm({
return ( return (
<form onSubmit={handleSubmit} className="space-y-4 px-4 py-4"> <form onSubmit={handleSubmit} className="space-y-4 px-4 py-4">
<div className="rounded-2xl border border-[#ebe3e2] bg-[#fcf8f8] p-4"> <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"> <div className="flex items-center gap-2 border-b border-[#ece5e4] pb-3 dark:border-[#213D5E]">
<PenLine className="size-4 text-[#8b6f6f]" /> <PenLine className="size-4 text-[#8b6f6f] dark:text-white" />
<h3 className="text-[18px] font-semibold leading-none text-[#2f2f2f]">写作配置选项</h3> <h3 className="text-[18px] font-semibold leading-none text-[#2f2f2f] dark:text-white">
写作配置选项
</h3>
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className={FIELD_LABEL_CLASS}>写作意图</label> <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} 字 {intent.length} / {MAX_INTENT_LENGTH} 字
</span> </span>
</div> </div>
@@ -162,7 +166,7 @@ export function AIWritingForm({
<SelectTrigger className={FIELD_TRIGGER_CLASS}> <SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} /> <SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" side="bottom" sideOffset={4}>
{articleTypes.map((t) => ( {articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-sm"> <SelectItem key={t.key} value={t.key} className="text-sm">
{t.label} {t.label}
@@ -229,7 +233,7 @@ export function AIWritingForm({
} }
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" side="bottom" sideOffset={4}>
{models.map((m) => ( {models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-sm"> <SelectItem key={m.name} value={m.name} className="text-sm">
{m.display_name || m.name} {m.display_name || m.name}
+38 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { BotIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { AlertCircle, BotIcon, CheckCircle2, RefreshCwIcon, SearchIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -22,9 +22,37 @@ import {
import { AgentCard } from "@/components/workspace/agents/agent-card"; import { AgentCard } from "@/components/workspace/agents/agent-card";
import { useAdminSyncAgentSkills, useAllAgentsAdmin } from "@/core/agents"; import { useAdminSyncAgentSkills, useAllAgentsAdmin } from "@/core/agents";
import type { AgentSkillsSyncResult } from "@/core/agents/api"; import type { AgentSkillsSyncResult } from "@/core/agents/api";
import { useTheme } from "@/contexts/ThemeContext";
import { desensitize } from "@/lib/desensitize"; 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() { export default function AdminAgentsPage() {
const { theme } = useTheme();
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { agents, isLoading } = useAllAgentsAdmin(true); const { agents, isLoading } = useAllAgentsAdmin(true);
const syncSkills = useAdminSyncAgentSkills(); const syncSkills = useAdminSyncAgentSkills();
@@ -39,14 +67,21 @@ export default function AdminAgentsPage() {
setSyncResult(result); setSyncResult(result);
setSyncDialogOpen(true); setSyncDialogOpen(true);
if (result.modified === 0) { if (result.modified === 0) {
toast.success("所有智能体的技能均有效,无需清理"); toast.success(
"所有智能体的技能均有效,无需清理",
darkToastOpts.success,
);
} else { } else {
toast.success( toast.success(
`已清理 ${result.modified} 个智能体中的失效技能引用`, `已清理 ${result.modified} 个智能体中的失效技能引用`,
darkToastOpts.success,
); );
} }
} catch (err) { } 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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTheme } from "@/contexts/ThemeContext";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Dialog, Dialog,
@@ -70,6 +71,7 @@ const emptyForm = {
}; };
export default function AdminScheduledTasksPage() { export default function AdminScheduledTasksPage() {
const { theme } = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// ?task= carries the task to re-select when returning from a run detail. // ?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)} onChange={(event) => setSearchInput(event.target.value)}
placeholder="按任务名称搜索" placeholder="按任务名称搜索"
/> />
<Button type="submit" variant="outline"> <Button
type="submit"
variant={theme === "dark" ? "default" : "outline"}
>
<Search className="size-4" /> <Search className="size-4" />
搜索 搜索
</Button> </Button>
+3 -3
View File
@@ -84,10 +84,10 @@ export default function AdminSkillsPage() {
)} )}
{!isLoading && !error && filteredSkills.length === 0 && ( {!isLoading && !error && filteredSkills.length === 0 && (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center"> <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"> <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" /> <SparklesIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
</div> </div>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm dark:text-[#AACEFF]">
当前没有可管理的技能。 当前没有可管理的技能。
</p> </p>
</div> </div>
+41 -14
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Download, Loader2 } from "lucide-react"; import { AlertCircle,CheckCircle2, Download, Loader2 } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -22,6 +22,7 @@ import {
} from "@/core/llm-metrics/api"; } from "@/core/llm-metrics/api";
import { useLlmMetrics } from "@/core/llm-metrics/hooks"; import { useLlmMetrics } from "@/core/llm-metrics/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTheme } from "@/contexts/ThemeContext";
const PAGE_SIZE = 50; 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. */ /** The original LLM-call metrics view, now one tab of the metrics page. */
function LlmMetricsPanel() { function LlmMetricsPanel() {
const { theme } = useTheme();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [model, setModel] = useState(""); const [model, setModel] = useState("");
const [user, setUser] = useState(""); const [user, setUser] = useState("");
@@ -81,6 +83,31 @@ function LlmMetricsPanel() {
const [since, setSince] = useState(""); const [since, setSince] = useState("");
const [until, setUntil] = useState(""); const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false); 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 filters = useMemo<LlmMetricsFilters>(() => {
const out: LlmMetricsFilters = {}; const out: LlmMetricsFilters = {};
@@ -103,9 +130,9 @@ function LlmMetricsPanel() {
setExporting(true); setExporting(true);
try { try {
await downloadLlmMetricsExcel(filters); await downloadLlmMetricsExcel(filters);
toast.success("已导出为 Excel"); toast.success("已导出为 Excel",darkToastOpts.success);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "导出失败"); toast.error(e instanceof Error ? e.message : "导出失败",darkToastOpts.error);
} finally { } finally {
setExporting(false); setExporting(false);
} }
@@ -267,9 +294,9 @@ function LlmMetricsPanel() {
</Button> </Button>
</div> </div>
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5"> <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"> <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 <Input
value={model} value={model}
placeholder="按模型名筛选" placeholder="按模型名筛选"
@@ -280,7 +307,7 @@ function LlmMetricsPanel() {
/> />
</div> </div>
<div className="flex flex-col gap-1"> <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 <Input
value={user} value={user}
placeholder="按用户名匹配(@ 前部分)" placeholder="按用户名匹配(@ 前部分)"
@@ -291,7 +318,7 @@ function LlmMetricsPanel() {
/> />
</div> </div>
<div className="flex flex-col gap-1"> <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 <Select
value={status} value={status}
onValueChange={(value) => { onValueChange={(value) => {
@@ -299,10 +326,10 @@ function LlmMetricsPanel() {
setPage(0); setPage(0);
}} }}
> >
<SelectTrigger> <SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部</SelectItem> <SelectItem value="all">全部</SelectItem>
<SelectItem value="success">成功</SelectItem> <SelectItem value="success">成功</SelectItem>
<SelectItem value="error">失败</SelectItem> <SelectItem value="error">失败</SelectItem>
@@ -310,7 +337,7 @@ function LlmMetricsPanel() {
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1"> <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 <Input
type="datetime-local" type="datetime-local"
value={since} value={since}
@@ -321,7 +348,7 @@ function LlmMetricsPanel() {
/> />
</div> </div>
<div className="flex flex-col gap-1"> <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 <Input
type="datetime-local" type="datetime-local"
value={until} value={until}
@@ -338,10 +365,10 @@ function LlmMetricsPanel() {
</div> </div>
</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"> <div className="overflow-x-auto">
<table className="w-full min-w-[1080px] text-sm"> <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> <tr>
{visibleColumns.map((col) => ( {visibleColumns.map((col) => (
<Th <Th
+17 -4
View File
@@ -10,6 +10,7 @@ import {
persistExternalLoginParams, persistExternalLoginParams,
resolveThemeFromQuery, resolveThemeFromQuery,
} from "@/core/auth/external-login"; } from "@/core/auth/external-login";
import { isWujieEnv, useWujieProps } from "@/core/embed/wujie";
import { import {
GENERAL_QA_ROUTE, GENERAL_QA_ROUTE,
loginByYUserId, loginByYUserId,
@@ -53,11 +54,23 @@ export default function LoginPage() {
const [status, setStatus] = useState<LoginStatus>("loading"); const [status, setStatus] = useState<LoginStatus>("loading");
const [errorMessage, setErrorMessage] = useState<string>(""); 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(() => { useEffect(() => {
if (!externalParams.theme) return; if (!isWujieEnv()) return;
setTheme(resolveThemeFromQuery(externalParams.theme)); console.log("[wujie] sub-app props:", wujieProps);
}, [externalParams.theme, setTheme]); }, [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 请求并行做,互不阻塞) // ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
useEffect(() => { useEffect(() => {
+4 -1
View File
@@ -18,8 +18,11 @@ function PageLayout() {
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
// h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100%
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
// 下容器不占满整屏时,高度才能正确收缩而非锁死整屏。
return ( 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} />} {sidebarEnabled && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{sidebarEnabled && ( {sidebarEnabled && (
@@ -1,8 +1,9 @@
"use client"; "use client";
import { Loader2Icon, ShieldIcon } from "lucide-react"; import { AlertCircle,CheckCircle2, Loader2Icon, ShieldIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -12,13 +13,36 @@ import {
useUpdatePrivateSquareSettings, useUpdatePrivateSquareSettings,
} from "@/core/system-settings/hooks"; } 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() { export default function PrivateSquareSettingsPage() {
const { data, isLoading, error } = usePrivateSquareStatus(); const { data, isLoading, error } = usePrivateSquareStatus();
const update = useUpdatePrivateSquareSettings(); const update = useUpdatePrivateSquareSettings();
const { theme } = useTheme();
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState(""); const [confirm, setConfirm] = useState("");
// The status response never contains the password itself, so re-syncing // The status response never contains the password itself, so re-syncing
// ``enabled`` from a refetch wouldn't clobber any in-progress edit — but we // ``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 // 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); setEnabled(data.enabled);
initializedRef.current = true; initializedRef.current = true;
}, [data]); }, [data]);
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const handleSave = async () => { const handleSave = async () => {
if (password && password !== confirm) { if (password && password !== confirm) {
toast.error("两次输入的密码不一致"); toast.error("两次输入的密码不一致", darkToastOpts.error);
return; return;
} }
try { try {
@@ -48,9 +76,9 @@ export default function PrivateSquareSettingsPage() {
}); });
setPassword(""); setPassword("");
setConfirm(""); setConfirm("");
toast.success("已保存"); toast.success("已保存",darkToastOpts.success);
} catch (e) { } 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 { try {
await update.mutateAsync({ password: "" }); await update.mutateAsync({ password: "" });
toast.success("已清除密码"); toast.success("已清除密码",darkToastOpts.success);
} catch (e) { } 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} 加载失败:{error.message}
</div> </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 className="flex items-center justify-between gap-4">
<div> <div>
<div className="text-sm font-medium">启用私密广场</div> <div className="text-sm font-medium">启用私密广场</div>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Loader2Icon } from "lucide-react"; import { Loader2Icon,AlertCircle,CheckCircle2, } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -12,11 +12,33 @@ import {
usePromptPrefixSettings, usePromptPrefixSettings,
useUpdatePromptPrefixSettings, useUpdatePromptPrefixSettings,
} from "@/core/system-settings/hooks"; } 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() { export default function PromptPrefixSettingsPage() {
const { data, isLoading, error } = usePromptPrefixSettings(); const { data, isLoading, error } = usePromptPrefixSettings();
const update = useUpdatePromptPrefixSettings(); const update = useUpdatePromptPrefixSettings();
const { theme } = useTheme();
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [switchLabel, setSwitchLabel] = useState(""); const [switchLabel, setSwitchLabel] = useState("");
const [promptPrefix, setPromptPrefix] = useState(""); const [promptPrefix, setPromptPrefix] = useState("");
@@ -35,12 +57,17 @@ export default function PromptPrefixSettingsPage() {
}, [data]); }, [data]);
const handleSave = async () => { const handleSave = async () => {
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
if (enabled && !switchLabel.trim()) { if (enabled && !switchLabel.trim()) {
toast.error("开启时必须填写开关名称"); toast.error("开启时必须填写开关名称",darkToastOpts.error);
return; return;
} }
if (enabled && !promptPrefix.trim()) { if (enabled && !promptPrefix.trim()) {
toast.error("开启时必须填写拼接的前缀内容"); toast.error("开启时必须填写拼接的前缀内容",darkToastOpts.error);
return; return;
} }
try { try {
@@ -49,9 +76,9 @@ export default function PromptPrefixSettingsPage() {
switch_label: switchLabel, switch_label: switchLabel,
prompt_prefix: promptPrefix, prompt_prefix: promptPrefix,
}); });
toast.success("已保存"); toast.success("已保存",darkToastOpts.success);
} catch (e) { } 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} 加载失败:{error.message}
</div> </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 className="flex items-center justify-between gap-4">
<div> <div>
<div className="text-sm font-medium">向用户暴露开关</div> <div className="text-sm font-medium">向用户暴露开关</div>
@@ -2,6 +2,8 @@
import { useState } from "react"; import { useState } from "react";
import { import {
AlertCircle,
CheckCircle2,
GripVerticalIcon, GripVerticalIcon,
Loader2Icon, Loader2Icon,
PencilIcon, PencilIcon,
@@ -11,6 +13,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTheme } from "@/contexts/ThemeContext";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -34,7 +37,30 @@ type DialogState =
| { mode: "edit"; question: RecommendedQuestion } | { mode: "edit"; question: RecommendedQuestion }
| 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;
export default function RecommendedQuestionsPage() { export default function RecommendedQuestionsPage() {
const { theme } = useTheme();
const { questions, isLoading, error } = useRecommendedQuestions(); const { questions, isLoading, error } = useRecommendedQuestions();
const createMutation = useCreateRecommendedQuestion(); const createMutation = useCreateRecommendedQuestion();
const updateMutation = useUpdateRecommendedQuestion(); const updateMutation = useUpdateRecommendedQuestion();
@@ -70,7 +96,7 @@ export default function RecommendedQuestionsPage() {
const title = formTitle.trim(); const title = formTitle.trim();
const content = formContent.trim(); const content = formContent.trim();
if (!title || !content) { if (!title || !content) {
toast.error("标题和内容不能为空"); toast.error("标题和内容不能为空", darkToastOpts.error);
return; return;
} }
@@ -81,17 +107,20 @@ export default function RecommendedQuestionsPage() {
content, content,
sort_order: questions.length, sort_order: questions.length,
}); });
toast.success("问题已添加"); toast.success("问题已添加", darkToastOpts.success);
} else if (dialog?.mode === "edit") { } else if (dialog?.mode === "edit") {
await updateMutation.mutateAsync({ await updateMutation.mutateAsync({
id: dialog.question.id, id: dialog.question.id,
body: { title, content }, body: { title, content },
}); });
toast.success("问题已更新"); toast.success("问题已更新", darkToastOpts.success);
} }
closeDialog(); closeDialog();
} catch (e) { } 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 { try {
await deleteMutation.mutateAsync(q.id); await deleteMutation.mutateAsync(q.id);
setDeleteTarget(null); setDeleteTarget(null);
toast.success("问题已删除"); toast.success("问题已删除", darkToastOpts.success);
} catch (e) { } 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 { try {
await reorderMutation.mutateAsync(next.map((q) => q.id)); await reorderMutation.mutateAsync(next.map((q) => q.id));
} catch { } catch {
toast.error("排序保存失败"); toast.error("排序保存失败", darkToastOpts.error);
} }
} }
@@ -138,6 +170,11 @@ export default function RecommendedQuestionsPage() {
deleteMutation.isPending || deleteMutation.isPending ||
reorderMutation.isPending; reorderMutation.isPending;
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
return ( return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10"> <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"> <div className="flex items-center justify-between">
@@ -162,15 +199,15 @@ export default function RecommendedQuestionsPage() {
<p>加载失败:{error.message}</p> <p>加载失败:{error.message}</p>
</div> </div>
) : questions.length === 0 ? ( ) : 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> <p>暂无推荐问题</p>
<Button variant="outline" size="sm" onClick={openAdd}> <Button size="sm" onClick={openAdd}>
<PlusIcon className="mr-1.5 size-4" /> <PlusIcon className="mr-1.5 size-4" />
添加第一个问题 添加第一个问题
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2 ">
{questions.map((q, index) => ( {questions.map((q, index) => (
<div <div
key={q.id} key={q.id}
@@ -182,18 +219,18 @@ export default function RecommendedQuestionsPage() {
setDragIndex(null); setDragIndex(null);
setDragOverIndex(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 dragOverIndex === index && dragIndex !== index
? "border-blue-400 bg-blue-50" ? "border-blue-400 bg-blue-50 dark:border-[#2580FF] dark:bg-[#0D2D54]"
: "border-[#e4e4e7]" : "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="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} {q.title}
</div> </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} {q.content}
</div> </div>
</div> </div>
+80 -19
View File
@@ -2,8 +2,10 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
AlertCircle,
BellOff, BellOff,
BellRing, BellRing,
CheckCircle2,
Download, Download,
Edit3, Edit3,
Eye, Eye,
@@ -62,9 +64,32 @@ import {
statusLabel, statusLabel,
statusVariant, statusVariant,
} from "@/components/workspace/scheduled-tasks/schedule-builder"; } from "@/components/workspace/scheduled-tasks/schedule-builder";
import { useTheme } from "@/contexts/ThemeContext";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; 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) { function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无"; return value ? new Date(value).toLocaleString() : "暂无";
} }
@@ -88,6 +113,11 @@ const emptyForm = {
}; };
export default function ScheduledTasksPage() { export default function ScheduledTasksPage() {
const { theme } = useTheme();
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// When returning from a run-detail page the task id is carried in ?task=, // 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, require_markdown: form.require_markdown,
}); });
setSelectedTaskId(task.task_id); setSelectedTaskId(task.task_id);
toast.success("任务已创建"); toast.success("任务已创建", darkToastOpts.success);
} else if (editing) { } else if (editing) {
await actions.update.mutateAsync({ await actions.update.mutateAsync({
taskId: editing.task_id, taskId: editing.task_id,
@@ -221,11 +251,14 @@ export default function ScheduledTasksPage() {
require_markdown: form.require_markdown, require_markdown: form.require_markdown,
}, },
}); });
toast.success("任务已更新"); toast.success("任务已更新", darkToastOpts.success);
} }
closeDialog(); closeDialog();
} catch (error) { } 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 { try {
await downloadScheduledTaskRun(runId); await downloadScheduledTaskRun(runId);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败"); toast.error(
error instanceof Error ? error.message : "下载失败",
darkToastOpts.error,
);
} }
}; };
const handleDeleteRun = async (runId: string) => { const handleDeleteRun = async (runId: string) => {
try { try {
await actions.removeRun.mutateAsync(runId); await actions.removeRun.mutateAsync(runId);
toast.success("执行记录已删除"); toast.success("执行记录已删除", darkToastOpts.success);
} catch (error) { } 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 <Button
variant="outline" variant="outline"
className="dark:bg-[#0D2D54] dark:hover:!bg-[#2580FF] dark:hover:text-white"
onClick={async () => { onClick={async () => {
try { try {
await actions.updateDeliveryProfile.mutateAsync({ await actions.updateDeliveryProfile.mutateAsync({
element_user_id: elementUserId || null, element_user_id: elementUserId || null,
}); });
toast.success("Element 配置已保存"); toast.success("Element 配置已保存", darkToastOpts.success);
} catch (error) { } 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) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
actions.trigger.mutate(task.task_id); actions.trigger.mutate(task.task_id);
toast.success("已触发执行"); toast.success("已触发执行", darkToastOpts.success);
}} }}
> >
<RefreshCw className="size-4" /> <RefreshCw className="size-4" />
@@ -451,13 +494,19 @@ export default function ScheduledTasksPage() {
try { try {
if (task.published) { if (task.published) {
await actions.unpublish.mutateAsync(task.task_id); await actions.unpublish.mutateAsync(task.task_id);
toast.success("已取消发布(已订阅者保留)"); toast.success(
"已取消发布(已订阅者保留)",
darkToastOpts.success,
);
} else { } else {
await actions.publish.mutateAsync(task.task_id); await actions.publish.mutateAsync(task.task_id);
toast.success("已发布到广场"); toast.success("已发布到广场", darkToastOpts.success);
} }
} catch (error) { } 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, taskId: task.task_id,
notifyElement: !notifyOn, notifyElement: !notifyOn,
}); });
toast.success(notifyOn ? "已关闭 Element 通知" : "已开启 Element 通知"); toast.success(
notifyOn ? "已关闭 Element 通知" : "已开启 Element 通知",
darkToastOpts.success,
);
} catch (error) { } 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(); event.stopPropagation();
try { try {
await actions.unsubscribe.mutateAsync(task.task_id); await actions.unsubscribe.mutateAsync(task.task_id);
toast.success("已取消收藏"); toast.success("已取消收藏", darkToastOpts.success);
} catch (error) { } 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"> <SelectTrigger className="h-7 w-28 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部时间</SelectItem> <SelectItem value="all">全部时间</SelectItem>
<SelectItem value="today">今天</SelectItem> <SelectItem value="today">今天</SelectItem>
<SelectItem value="7d">近 7 天</SelectItem> <SelectItem value="7d">近 7 天</SelectItem>
@@ -747,12 +805,15 @@ export default function ScheduledTasksPage() {
taskId: subscribeTarget.task_id, taskId: subscribeTarget.task_id,
notifyElement: subscribeNotify, notifyElement: subscribeNotify,
}); });
toast.success("已收藏到我的任务"); toast.success("已收藏到我的任务", darkToastOpts.success);
setSubscribeTarget(null); setSubscribeTarget(null);
setSubscribeNotify(false); setSubscribeNotify(false);
setActiveTab("mine"); setActiveTab("mine");
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "收藏失败"); toast.error(
error instanceof Error ? error.message : "收藏失败",
darkToastOpts.error,
);
} }
}} }}
> >
+15 -3
View File
@@ -71,15 +71,27 @@ export function WorkspaceLayout({
return ( return (
<SidebarProvider <SidebarProvider
className="h-full w-full overflow-x-hidden [&_[data-slot=sidebar-container]]:left-[var(--page-sidebar-width)]" // 关键:把 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={[
"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={ style={
{ {
"--page-sidebar-width": pageSidebarWidth, "--page-sidebar-width": pageSidebarWidth,
"--header-height": headerHidden ? "0rem" : "4rem", "--header-height": headerHidden ? "0rem" : "4rem",
} as CSSProperties } 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} data-page-sidebar-collapsed={pageSidebarCollapsed}
defaultOpen defaultOpen
> >
@@ -56,12 +56,12 @@ export function MessageBubble({
{senderName} {senderName}
</span> </span>
{badge && ( {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} {badge}
</span> </span>
)} )}
</div> </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> </div>
{children} {children}
</div> </div>
@@ -139,13 +139,13 @@ export const sharedMarkdownComponents = {
const isBlock = /language-/.test(className ?? ""); const isBlock = /language-/.test(className ?? "");
if (isBlock) { if (isBlock) {
return ( 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> <code>{children}</code>
</pre> </pre>
); );
} }
return ( 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} {children}
</code> </code>
); );
@@ -161,7 +161,7 @@ export const sharedMarkdownComponents = {
</a> </a>
), ),
table: ({ children }: { children?: React.ReactNode }) => ( 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: ({ children }: { children?: React.ReactNode }) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left"> <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 <button
type="button" type="button"
onClick={handleToggle} 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")} /> <BrainIcon className={cn("size-3.5", streaming && "text-primary animate-pulse")} />
<span className={cn(streaming && "animate-pulse")}>{headerLabel}</span> <span className={cn(streaming && "animate-pulse")}>{headerLabel}</span>
@@ -265,7 +265,7 @@ export function ReasoningBlock({
{open && ( {open && (
<div <div
className={cn( 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, contentClassName,
)} )}
> >
@@ -86,10 +86,10 @@ export function MessageStepsCard({
const display = getStepDisplay(lastReasoning.key, lastReasoning.label, lastReasoning.args); const display = getStepDisplay(lastReasoning.key, lastReasoning.label, lastReasoning.args);
const Icon = display.icon; const Icon = display.icon;
return ( 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" /> <Icon className="size-3.5 text-slate-400" />
<span>{display.label}</span> <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> </div>
); );
} }
@@ -108,7 +108,7 @@ export function MessageStepsCard({
> >
<ChainOfThoughtStep <ChainOfThoughtStep
label={ label={
<span className="text-[11.5px] opacity-60"> <span className="text-[calc(11.5px*var(--ui-font-scale,1))] opacity-60">
{showAbove ? "隐藏步骤" : `查看其他 ${priorToolSteps.length} 个步骤`} {showAbove ? "隐藏步骤" : `查看其他 ${priorToolSteps.length} 个步骤`}
</span> </span>
} }
@@ -189,9 +189,9 @@ function ToolStep({
: undefined : undefined
} }
label={ 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="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> </span>
} }
> >
@@ -204,10 +204,10 @@ function ReasoningStep({ step, streaming }: { step: BubbleStep; streaming: boole
const display = getStepDisplay(step.key, step.label, step.args); const display = getStepDisplay(step.key, step.label, step.args);
const Icon = display.icon; const Icon = display.icon;
return ( 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")} /> <Icon className={cn("size-3.5", streaming ? "text-primary animate-pulse" : "text-slate-400")} />
<span>{display.label}</span> <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> </div>
); );
} }
@@ -167,7 +167,7 @@ export function Step2Panel(props: Step2PanelProps) {
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} /> <Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div> <div>
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3> <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> </p>
</div> </div>
@@ -202,7 +202,7 @@ export function Step2Panel(props: Step2PanelProps) {
<button <button
type="button" type="button"
onClick={onResumeRoundtable} 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 pauseFlash
? "ring-2 ring-emerald-400 ring-offset-1 animate-pulse" ? "ring-2 ring-emerald-400 ring-offset-1 animate-pulse"
: "" : ""
@@ -215,7 +215,7 @@ export function Step2Panel(props: Step2PanelProps) {
<button <button
type="button" type="button"
onClick={onPauseRoundtable} 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" /> <Pause className="w-3.5 h-3.5" />
暂停会商 暂停会商
@@ -279,13 +279,13 @@ export function Step2Panel(props: Step2PanelProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span> <span className="text-xs font-extrabold text-amber-900">总控协调需要澄清</span>
{pendingClarification.clarificationType && ( {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} {pendingClarification.clarificationType}
</span> </span>
)} )}
</div> </div>
{pendingClarification.context && ( {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"> <p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
{pendingClarification.question} {pendingClarification.question}
@@ -306,7 +306,7 @@ export function Step2Panel(props: Step2PanelProps) {
key={`${idx}-${label}`} key={`${idx}-${label}`}
type="button" type="button"
onClick={() => onSubmitClarification(label)} 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} {label}
</button> </button>
@@ -580,17 +580,17 @@ function PresentFileCard({
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" /> <FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span <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} title={filename}
> >
{filename} {filename}
</span> </span>
</div> </div>
<div className="flex items-center justify-between gap-2"> <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} {typeLabel}
</span> </span>
<span className="text-[10px] text-slate-400">点击预览</span> <span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400">点击预览</span>
</div> </div>
</button> </button>
); );
@@ -57,6 +57,16 @@ export interface UseDraftPersistenceOptions {
* 否则保持原 false 让进入 Step 2 时正常初始化。 * 否则保持原 false 让进入 Step 2 时正常初始化。
*/ */
setStep2InitGate: (initialized: boolean) => void; setStep2InitGate: (initialized: boolean) => void;
/**
* Step 1 是否正在等待任务理解回复。由 true→false 视为「本轮模型回答完」,
* 触发一次自动保存(详见 autoSave 的两个 effect)。
*/
intentSending: boolean;
/**
* Step 2 已收尾(streaming===false)的对话气泡数量。每次递增意味着总控/某个
* 子智能体刚交付完一轮,触发一次自动保存。流式增量不改变该计数,因此不会刷屏式保存。
*/
step2FinalizedCount: number;
} }
export interface UseDraftPersistenceReturn { export interface UseDraftPersistenceReturn {
@@ -96,6 +106,8 @@ export function useDraftPersistence(
hydrateStep2, hydrateStep2,
cancelAllStreams, cancelAllStreams,
setStep2InitGate, setStep2InitGate,
intentSending,
step2FinalizedCount,
} = options; } = options;
const [drafts, setDrafts] = useState<DraftListItem[]>([]); const [drafts, setDrafts] = useState<DraftListItem[]>([]);
@@ -112,6 +124,11 @@ export function useDraftPersistence(
const currentDraftIdRef = useRef<string | null>(null); const currentDraftIdRef = useRef<string | null>(null);
// 串行化所有写操作,避免并发 create。 // 串行化所有写操作,避免并发 create。
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve()); const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
// 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。
const currentStepRef = useRef<number>(currentStep);
useEffect(() => {
currentStepRef.current = currentStep;
}, [currentStep]);
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。 // 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>( const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
@@ -157,9 +174,15 @@ export function useDraftPersistence(
* 串行执行一次保存:有 id 走 update(不带 title,保护重命名);无 id 走 create * 串行执行一次保存:有 id 走 update(不带 title,保护重命名);无 id 走 create
* (写一次派生标题)。create/update 成功后同步 currentDraftIdRef。 * (写一次派生标题)。create/update 成功后同步 currentDraftIdRef。
* 返回 draft id(无可保存内容返回 null)。 * 返回 draft id(无可保存内容返回 null)。
*
* `furthestStep` 可选:
* - 传值(前进切换 / 手动保存 / ensureDraft)→ update 时一并推进 furthest_step;
* - 省略(每轮模型回答完的自动保存)→ update **不带** furthest_step,只回写
* step1/step2 内容,避免把「回到上一步继续研讨」误判成 furthest 回退。create
* 分支用 currentStepRef 兜底建档步骤。
*/ */
const enqueueSave = useCallback( const enqueueSave = useCallback(
(furthestStep: 1 | 2 | 3): Promise<string | null> => { (furthestStep?: 1 | 2 | 3): Promise<string | null> => {
const run = async (): Promise<string | null> => { const run = async (): Promise<string | null> => {
const step1 = getStep1Snapshot(); const step1 = getStep1Snapshot();
const step2 = getStep2Snapshot(); const step2 = getStep2Snapshot();
@@ -167,7 +190,11 @@ export function useDraftPersistence(
const id = currentDraftIdRef.current; const id = currentDraftIdRef.current;
if (id) { 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; if (updated) return updated.id;
// 服务端已不存在(被删/换账号)→ 落回新建。 // 服务端已不存在(被删/换账号)→ 落回新建。
} }
@@ -176,7 +203,7 @@ export function useDraftPersistence(
intentReady: step1.intentReady, intentReady: step1.intentReady,
interactiveMessages: step1.interactiveMessages, interactiveMessages: step1.interactiveMessages,
}), }),
furthestStep, furthestStep: furthestStep ?? ((currentStepRef.current as 1 | 2 | 3) || 1),
step1, step1,
step2, step2,
}); });
@@ -227,6 +254,41 @@ export function useDraftPersistence(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]); }, [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( const handleLoadDraft = useCallback(
(id: string) => { (id: string) => {
void (async () => { void (async () => {
@@ -248,6 +310,9 @@ export function useDraftPersistence(
// 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect // 先置 latch,再 setCurrentStep —— 顺序反过来会让 step1/step2 init effect
// 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。 // 把 loadedFromDraftRef.current 当成 false 处理,导致角色面板卡在「正在上线」。
loadedFromDraftRef.current = true; loadedFromDraftRef.current = true;
// hydrate 会让 step2 已收尾计数从 0 跳到 N,抑制由此触发的「假交付」自动保存,
// 避免刚加载就把同样内容原样回写一遍、并把该草稿顶到列表最前。
suppressStep2AutoSaveRef.current = true;
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot); if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot); if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
@@ -17,11 +17,12 @@
* 1) special 顺序派活,**不要**改回 `Promise.all`(详见 §5.4) * 1) special 顺序派活,**不要**改回 `Promise.all`(详见 §5.4)
* 2) step1InitGenRef / step2InitRef 闸门用于规避 StrictMode 双 mount 副作用 * 2) step1InitGenRef / step2InitRef 闸门用于规避 StrictMode 双 mount 副作用
* 3) 离开 Step 2 不销毁 threadIds,允许返回继续看 * 3) 离开 Step 2 不销毁 threadIds,允许返回继续看
* 4) 草稿自动保存仅在前进切换(1→2、2→3)触发,后退不保存 * 4) 草稿自动保存:前进切换(1→2、2→3)推进 furthestStep;此外每轮模型回答完
* (Step 1 一轮任务理解结束 / Step 2 每个气泡收尾)也增量回写一次,后者不动 furthestStep
* 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效 * 5) Persona 抽屉改的 model 不会立即重启编排,下一轮才生效
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级 * 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Activity, Activity,
Check, Check,
@@ -173,11 +174,21 @@ export default function RoundtablePlanningPage() {
}; };
}, [step1.intentReady, step1.interactiveMessages]); }, [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。 // 草稿 hook —— 同时依赖 step1 + step2 的 snapshot / hydrate。
const drafts = useDraftPersistence({ const drafts = useDraftPersistence({
currentStep, currentStep,
setCurrentStep, setCurrentStep,
triggerToast, triggerToast,
intentSending: step1.isIntentSending,
step2FinalizedCount,
getStep1Snapshot: () => ({ getStep1Snapshot: () => ({
intentThreadId: step1.intentThreadId, intentThreadId: step1.intentThreadId,
intentReady: step1.intentReady, intentReady: step1.intentReady,
@@ -1,4 +1,7 @@
@import "tailwindcss"; /* 注意:不要在这里 `@import "tailwindcss"`。Tailwind 已由全局 globals.css 引入,
* 本文件被打进同一份样式表,再次 import 会把整套 theme/utilities 重复发一遍
* (CSS 体积翻倍,且重复发出的 --text-* 等主题变量会和全局覆盖打架)。
* 本文件只写圆桌页自己的作用域规则即可。 */
/* 圆桌规划 — 浅色红色主题(对齐 docs/浅红色ui修改.md) */ /* 圆桌规划 — 浅色红色主题(对齐 docs/浅红色ui修改.md) */
.roundtable-planning { .roundtable-planning {
@@ -61,6 +61,7 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({ title, count, color,
}`}> }`}>
<ChevronRight className={`w-4 h-4 ${textMuted}`} /> <ChevronRight className={`w-4 h-4 ${textMuted}`} />
</button> */} </button> */}
<iframe></iframe>
<div> <div>
<Pagination <Pagination
total={count} total={count}
+43
View File
@@ -324,6 +324,18 @@
@apply bg-background text-foreground; @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"] { [data-sidebar="menu-button"][data-active="true"] {
box-shadow: inset 3px 0 0 var(--primary); box-shadow: inset 3px 0 0 var(--primary);
font-weight: 600; font-weight: 600;
@@ -820,3 +832,34 @@
border-color: var(--border); border-color: var(--border);
outline-color: var(--ring); 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 版:业务库切换 MySQL 发版说明
适用场景:Linux 服务器上使用 Docker 部署 DeerFlow 后端。 适用场景:Linux 服务器上使用 `offline-backend-20260512/start_backend.sh` 启动 Docker 后端。
推荐一键部署: 原启动脚本保持不变:
```bash
bash deploy.sh
```
或使用兼容脚本:
```bash ```bash
bash start_backend.sh bash start_backend.sh
``` ```
详细步骤见 [DEPLOY_LINUX_DOCKER_ZH.md](./DEPLOY_LINUX_DOCKER_ZH.md)。
单独新增切换脚本: 单独新增切换脚本:
```bash ```bash
@@ -504,6 +504,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception: except Exception:
logger.exception("Skill ownership migration failed (non-fatal)") 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 表。 # 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB # 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低 # SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
@@ -8,16 +8,29 @@
``git clone`` 之后这些目录**不会存在**,直接调对应 endpoint 会因为 agent ``git clone`` 之后这些目录**不会存在**,直接调对应 endpoint 会因为 agent
解析失败抛 500。 解析失败抛 500。
本模块在四个路由入口(``intent.init``、``intent.stream``、``recommend.stream``、 本模块在两个地方被调用:
``multi_agent.init``)被调用,检查磁盘文件是否齐全;缺失则用下方嵌入的标准模板 1. **启动 lifespan**(``app/gateway/app.py``):服务起来就把缺失的目录补齐,
写回去。``_sync_legacy_agents`` 会在下一次 ``GET /api/agents`` 时把目录 upsert 紧接着的 ``_sync_legacy_agents`` 会把它们 upsert 进 DB,新机器一启动
进 DB,所以这里只关心磁盘文件,不直接动 DB。 ``/api/agents`` 列表里就有这三个 agent,**不必先去点圆桌页**。
2. **四个路由入口**(``intent.init``、``intent.stream``、``recommend.stream``、
``multi_agent.init``):防御性兜底——若管理员在启动后手删了目录,下一次请求
也能自愈。
为什么嵌入字符串而不是用 importlib.resources 资源文件 ``_sync_legacy_agents`` 只关心磁盘文件,本模块也只写磁盘文件,不直接动 DB。
------------------------------------------------------
SOUL 体量不大(三个加起来 ~400 行),嵌入到 Python 文件里是**单一可信源**——改 为什么 SOUL/config 用资源文件而不是内嵌 Python 字符串
SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配 ----------------------------------------------------
``package-data`` 导致部署机拿不到。 这三个 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`` 是单例而不是每次会话新建 为什么 ``roundtable-coordinator`` 是单例而不是每次会话新建
------------------------------------------------------------ ------------------------------------------------------------
@@ -36,576 +49,74 @@ SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path
from typing import Iterable from typing import Iterable
import yaml
from deerflow.config.paths import get_paths from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
INTENT_AGENT_ID = "roundtable-intent" 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_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_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 是**通用版本**——只规定协调规则、工具用法、终止条件, # seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
# **不**包含本次会话的席位列表。席位列表由 multi_agent.init 在创建 coordinator _SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
# 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`
- ❌ 已经达成共识后仍然继续派活
"""
# (id, name, description, soul, skills) — skills 为 None 表示不写 skills 字段。 def _ensure_one(agent_id: str) -> bool:
_BUILTIN_FUNCTIONAL_AGENTS: tuple[tuple[str, str, str, str, list[str] | None], ...] = ( """Copy the verified SOUL.md + config.yaml for one functional agent into
(INTENT_AGENT_ID, INTENT_AGENT_NAME, INTENT_AGENT_DESCRIPTION, INTENT_AGENT_SOUL, None), ``.deer-flow/agents/<id>/`` if that directory is missing.
(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"],
),
)
Returns True if the files were just created, False if the directory already
def _ensure_one( existed (we never overwrite — respect any local edits) or the seed assets
agent_id: str, are missing.
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 即可)。
""" """
agent_dir = get_paths().agent_dir(agent_id) agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists(): if agent_dir.exists():
return False 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 部署时与 agent_dir.mkdir(parents=True, exist_ok=True)
# Linux 部署机字节级不一致(功能上 YAML/Markdown 都兼容,但 git diff / 哈希对不齐)。 for fname in _SEED_FILES:
config_payload: dict[str, object] = {"id": agent_id, "name": name, "description": description} src = src_dir / fname
if skills: if src.exists():
# 与 routers/agents.py::_write_agent_config 行为一致:None 不写、空列表也不写, # 字节级复制,保留验证版文件原样(换行/编码),与资源文件 hash 对齐。
# 仅当确有 skill 名时才追加顶层 skills 字段。 (agent_dir / fname).write_bytes(src.read_bytes())
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"))
return True return True
def ensure_roundtable_functional_agents() -> list[str]: def ensure_roundtable_functional_agents() -> list[str]:
"""Seed `roundtable-intent` + `roundtable-recommender` if their directories """Seed the three roundtable functional agents (``roundtable-intent`` /
are missing. Idempotent — fast no-op when both exist. ``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 Called from the startup lifespan and from the entry of intent/init,
so the routes self-heal on fresh internal deployments where `.deer-flow/` intent/stream, recommend/stream, and multi_agent/init so the routes
is gitignored and the agent directories were never shipped. 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 Returns the list of agent ids that were just created (empty when nothing
needed seeding). Errors are logged but never raised — a partial failure 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. actionable error if the agent really cannot be resolved.
""" """
created: list[str] = [] created: list[str] = []
for agent_id, name, description, soul, skills in _BUILTIN_FUNCTIONAL_AGENTS: for agent_id in _FUNCTIONAL_AGENT_IDS:
try: try:
if _ensure_one(agent_id, name, description, soul, skills): if _ensure_one(agent_id):
created.append(agent_id) created.append(agent_id)
except Exception: except Exception:
logger.exception("Failed to seed roundtable functional agent '%s'", agent_id) 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]: def required_agent_ids() -> Iterable[str]:
"""Stable ids of agents this seeder owns — exposed for tests/diagnostics.""" """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: 智能体推荐
-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}"