Merge remote-tracking branch 'origin/master' into ji
This commit is contained in:
commit
89cb5ab2f1
61 files changed
+1531
-1508
No files matched your search
@@ -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/
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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,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;
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
+42
@@ -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`
|
||||||
|
- ❌ 已经达成共识后仍然继续派活
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
description: 圆桌总控协调智能体,基于「任务目标 + 当前会话已选席位列表」决定派活给哪些子智能体、汇总它们的交付,达成共识后输出最终方案。本身不调研、不写文档,只负责协调与综合。
|
||||||
|
id: roundtable-coordinator
|
||||||
|
name: 总控协调
|
||||||
|
skills:
|
||||||
|
- agent_orchestration
|
||||||
+312
@@ -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]` 块**严格按格式**,后端用正则解析,任何变形都会让前端进入降级路径
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
description: 任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的「任务目标+约束+关键假设」,为多智能体圆桌研讨准备输入
|
||||||
|
id: roundtable-intent
|
||||||
|
name: 任务理解
|
||||||
+191
@@ -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` 在选题阶段用不上,**直接不推**,不要凑数
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
description: 智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单
|
||||||
|
id: roundtable-recommender
|
||||||
|
name: 智能体推荐
|
||||||
@@ -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}"
|
|
||||||
Reference in new issue
Block a user