样式修改

This commit is contained in:
董宏凯 committed 2026-06-03 09:54:11 +08:00
1 parent 3b5525a0a5
commit 6521218308
12 files changed
+1435 -163

No files matched your search

@@ -5,15 +5,16 @@
* - 左栏:候选智能体列表(`listAgents()` + `filterRecommendCandidates` 复用,剔除
* intent/recommender/coordinator),支持**名称检索 + 标签筛选**(`TagSearchBar`,
* 服务端过滤),竖排卡片,可拖到右栏、也可点击快捷加入。
* - 右栏:当前链条的**有序**长条卡片(序号 + 名称 + 描述),可拖拽排序、删除单项;
* 左栏拖入时按落点插入到对应位置。
* - 右栏:当前链条的**有序**长条卡片(序号 + 名称 + 描述),可拖拽排序、上/下移、
* 删除单项;左栏拖入时按落点插入到对应位置。
*
* 拖拽用 `@dnd-kit`:右栏是 `SortableContext`(席位互相排序),左栏卡片是
* `useDraggable`(pool→右栏插入)。落点计算在 `onDragEnd`。
*
* 视觉:直接遵循《浅红色 UI 规范》/《深蓝色 UI 规范》—— 用 app 语义 token(光红暗蓝
* 已内置)+ 规范小圆角(输入/按钮/tag 2px、卡片/分栏 4px)+ 克制阴影。**不**套圆桌页
* 的 `roundtable-planning` scope。
* 视觉:对齐设计稿 `yewuliantiao`(浅红 / 绛红风格)。统一走 app 语义 token
* (`primary` 在浅色主题即设计稿的绛红 #c25555,深色主题自动切蓝),配规范小圆角
* (输入/按钮/tag 2px、卡片/分栏 4px)+ 克制阴影。**不**套圆桌页的
* `roundtable-planning` scope。
*
* 持久化走 `api/chains.ts`(后端 MySQL `/api/roundtable-chains`)。席位数约束
* 2 ≤ N ≤ 8、agent 去重,保存时校验(后端 `_validate_seats` 也兜底)。
@@ -26,14 +27,16 @@ import { toast } from "sonner";
import { nanoid } from "nanoid";
import {
ArrowLeft,
Bot,
ChevronDown,
ChevronUp,
GripVertical,
Layers,
ListOrdered,
Loader2,
Plus,
Save,
Trash2,
Users,
UserPlus,
X,
} from "lucide-react";
import {
@@ -57,7 +60,6 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { listAgents } from "@/core/agents";
@@ -116,7 +118,7 @@ export default function BusinessChainEditorPage() {
const [saving, setSaving] = useState(false);
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const selectValue = currentChainId ?? "";
const [dropdownOpen, setDropdownOpen] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@@ -214,6 +216,14 @@ export default function BusinessChainEditorPage() {
setSeats((prev) => prev.filter((s) => s.agent_id !== agentId));
}, []);
const moveSeat = useCallback((index: number, dir: -1 | 1) => {
setSeats((prev) => {
const target = index + dir;
if (target < 0 || target >= prev.length) return prev;
return arrayMove(prev, index, target);
});
}, []);
// ─── DnD ─────────────────────────────────────────────────────────────
const onDragStart = useCallback(
(e: DragStartEvent) => {
@@ -344,108 +354,196 @@ export default function BusinessChainEditorPage() {
// ─── Render ──────────────────────────────────────────────────────────
const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id);
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
return (
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
{/* 顶栏 */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-5 py-2.5">
<Button
variant="ghost"
size="sm"
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
<button
type="button"
onClick={() => navigate("/page/workspace/roundtable/planning")}
className={cn("h-8 gap-1.5 px-2 text-muted-foreground hover:text-foreground", R_CTRL)}
className={cn(
"flex items-center gap-1.5 border border-border bg-card px-3 py-1.5 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground active:scale-[0.98]",
R_CTRL,
)}
>
<ArrowLeft className="size-4" />
返回圆桌
</Button>
<div className="h-4 w-px bg-border" />
</button>
{/* 品牌标识 */}
<div className="flex items-center gap-2.5">
<span className={cn("flex size-8 items-center justify-center bg-primary/10 text-primary", R_CARD)}>
<Layers className="size-4" />
</span>
<div
className={cn(
"flex size-8 select-none items-center justify-center bg-gradient-to-tr from-primary to-primary/40 text-sm font-bold text-primary-foreground shadow-sm",
R_CARD,
)}
>
🪶
</div>
<div className="leading-tight">
<div className="text-sm font-semibold">业务链条配置</div>
<div className="text-xs text-muted-foreground">编排有序的智能体研讨链路</div>
<div className="text-base font-bold text-foreground">业务链条配置</div>
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
编排有序的智能体研讨链路
</div>
</div>
</div>
{/* 链条选择 + 新建 */}
<div className="ml-2 flex items-center gap-2">
<select
value={selectValue}
onChange={(e) => {
const id = e.target.value;
if (!id) {
startNewChain();
return;
}
const chain = chains.find((c) => c.id === id);
if (chain) loadChain(chain);
}}
{/* 链条选择下拉 */}
<div className="relative ml-2">
<button
type="button"
onClick={() => setDropdownOpen((o) => !o)}
className={cn(
"h-8 border border-input bg-card px-2.5 text-sm text-foreground outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/30",
"flex min-w-[150px] items-center justify-between gap-3 border border-border bg-card px-3 py-1.5 text-sm text-foreground transition hover:border-primary/50 focus:outline-none focus:ring-1 focus:ring-primary/30",
R_CTRL,
)}
>
<option value="">+ 新建链条…</option>
{chains.map((c) => (
<option key={c.id} value={c.id}>
{c.title || "未命名"}({c.seats.length} 席)
</option>
))}
</select>
<Button variant="outline" size="sm" onClick={startNewChain} className={cn("h-8 gap-1.5", R_CTRL)}>
<Plus className="size-4" />
新建
</Button>
<span className="truncate font-medium">{triggerLabel}</span>
<ChevronDown className="size-4 text-muted-foreground" />
</button>
{dropdownOpen && (
<>
<div className="fixed inset-0 z-30" onClick={() => setDropdownOpen(false)} />
<div
className={cn(
"absolute left-0 z-40 mt-1 max-h-[280px] w-[260px] overflow-y-auto border border-border bg-card py-1 shadow-lg",
R_CARD,
)}
>
<div className="border-b border-border px-3 py-1 text-xs font-semibold text-muted-foreground">
切换已有链条
</div>
{chains.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">暂无已保存链条</div>
) : (
chains.map((c) => (
<button
key={c.id}
type="button"
onClick={() => {
loadChain(c);
setDropdownOpen(false);
}}
className={cn(
"flex w-full items-center justify-between px-3 py-2 text-xs transition hover:bg-primary/5",
c.id === currentChainId
? "bg-primary/5 font-bold text-primary"
: "text-muted-foreground",
)}
>
<span className="truncate">
{c.title || "未命名链条"}({c.seats.length} 席)
</span>
{c.id === currentChainId && (
<span className={cn("ml-2 shrink-0 bg-primary/15 px-1.5 py-0.5 text-[10px] text-primary", R_CTRL)}>
当前
</span>
)}
</button>
))
)}
<div className="mt-1 border-t border-border pt-1">
<button
type="button"
onClick={() => {
startNewChain();
setDropdownOpen(false);
}}
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs font-medium text-primary transition hover:bg-primary/5"
>
<Plus className="size-3.5" />
新建另一条链路
</button>
</div>
</div>
</>
)}
</div>
{/* 快捷新建 */}
<button
type="button"
onClick={startNewChain}
className={cn(
"flex items-center gap-1.5 border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground active:scale-[0.98]",
R_CTRL,
)}
>
<Plus className="size-4" />
新建
</button>
{/* 右侧操作 */}
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
<button
type="button"
onClick={handleDelete}
disabled={!currentChainId}
className={cn("h-8 gap-1.5 text-destructive hover:text-destructive", R_CTRL)}
className={cn(
"flex items-center gap-1.5 border border-primary/30 bg-card px-3 py-1.5 text-sm font-medium text-primary transition hover:bg-primary/5 active:scale-[0.98]",
R_CTRL,
)}
>
<Trash2 className="size-4" />
删除
</Button>
<Button
size="sm"
</button>
<button
type="button"
onClick={handleSave}
disabled={!canSave}
className={cn("h-8 gap-1.5", R_CTRL)}
className={cn(
"flex items-center gap-1.5 bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground shadow-sm transition hover:bg-primary/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{saving ? <Loader2 className="size-4 animate-spin" /> : <Save className="size-4" />}
保存
</Button>
</button>
</div>
</header>
{/* 标题 + 描述 */}
<div className="flex shrink-0 flex-col gap-2.5 border-b border-border bg-card px-5 py-3 sm:flex-row sm:items-center">
<div className="flex items-center gap-2 sm:w-80">
<span className="shrink-0 text-xs font-medium text-muted-foreground">标题</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:应急方案三段链"
className={cn("h-8 bg-background", R_CTRL)}
maxLength={120}
/>
<section className="shrink-0 border-b border-border bg-card px-6 py-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
标题
</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:应急方案三段链"
maxLength={120}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
描述
</span>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="链条用途说明(可选)"
maxLength={500}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
</div>
<div className="flex flex-1 items-center gap-2">
<span className="shrink-0 text-xs font-medium text-muted-foreground">描述</span>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="链条用途说明(可选)"
className={cn("h-8 bg-background", R_CTRL)}
maxLength={500}
/>
</div>
</div>
</section>
{/* 双栏拖拽编辑器 */}
<DndContext
@@ -454,20 +552,34 @@ export default function BusinessChainEditorPage() {
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-hidden p-4 md:grid-cols-[360px_1fr]">
<main className="grid min-h-0 flex-1 grid-cols-1 gap-6 overflow-hidden px-6 py-6 lg:grid-cols-12">
{/* 左栏:候选智能体 */}
<section className={cn("flex min-h-0 flex-col overflow-hidden border border-border bg-card", R_CARD, PANEL_SHADOW)}>
<div className="shrink-0 space-y-2.5 border-b border-border px-3.5 py-3">
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<Users className="size-3.5 text-primary" />
候选智能体
<span className={cn("bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary tabular-nums", R_CTRL)}>
<section
className={cn(
"flex min-h-0 flex-col overflow-hidden border border-border bg-card lg:col-span-4",
R_CARD,
PANEL_SHADOW,
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-border bg-muted/40 px-5 py-4">
<div className="flex items-center gap-2">
<span className={cn("flex size-5 items-center justify-center bg-primary/10", R_CTRL)}>
<UserPlus className="size-3.5 text-primary" />
</span>
<h2 className="text-sm font-bold tracking-wide text-foreground">候选智能体</h2>
<span
className={cn(
"min-w-[20px] border border-primary/30 bg-primary/10 px-2 py-0.5 text-center text-xs font-bold text-primary tabular-nums",
"rounded-full",
)}
>
{pool.length}
</span>
<span className="ml-auto text-[11px] font-normal text-muted-foreground">
拖到右侧 / 点击加入
</span>
</div>
<span className="text-[11px] font-medium text-muted-foreground">拖到右侧 / 点击加入</span>
</div>
<div className="shrink-0 border-b border-border px-4 py-3">
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
@@ -475,14 +587,15 @@ export default function BusinessChainEditorPage() {
onTagIdsChange={setFilterTagIds}
/>
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto p-2.5">
<div className="min-h-0 flex-1 space-y-2.5 overflow-y-auto p-4">
{poolLoading ? (
<div className="flex items-center gap-2 px-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载候选智能体…
</div>
) : pool.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
<div className="px-2 py-12 text-center text-sm text-muted-foreground">
{search || filterTagIds.length
? "没有匹配的智能体,换个关键词或标签试试。"
: `候选池为空,请先在「智能体」里创建至少 ${CHAIN_MIN_SEATS} 个智能体。`}
@@ -506,18 +619,29 @@ export default function BusinessChainEditorPage() {
seatItemIds={seatItemIds}
seatCountInvalid={seatCountInvalid}
onRemove={removeSeat}
onMove={moveSeat}
/>
</div>
</main>
<DragOverlay>
{activeDrag ? (
<div className={cn("flex items-center gap-2 border border-primary/50 bg-card px-3 py-2 text-sm font-medium text-foreground shadow-[0_4px_12px_rgba(15,23,42,0.12)]", R_CTRL)}>
<div
className={cn(
"flex items-center gap-2 border border-primary/50 bg-card px-3 py-2 text-sm font-medium text-foreground shadow-[0_4px_12px_rgba(15,23,42,0.12)]",
R_CTRL,
)}
>
<GripVertical className="size-4 text-primary" />
{activeDrag.name}
</div>
) : null}
</DragOverlay>
</DndContext>
{/* 页脚 */}
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
编排有序的业务链条,让智能体决策更有深度。保存后,配置将自动同步至圆桌。
</footer>
</div>
);
}
@@ -540,43 +664,57 @@ function PoolAgentCard({
return (
<div
ref={setNodeRef}
onClick={inChain ? undefined : onAdd}
className={cn(
"group flex items-start gap-2 border p-2.5 transition-colors",
R_CTRL,
"group relative flex items-start gap-3 border p-3.5 transition-all duration-150",
R_CARD,
inChain
? "cursor-not-allowed border-dashed border-border bg-muted/40 opacity-60"
: "cursor-grab border-border bg-card hover:border-primary/50 hover:bg-primary/[0.03] active:cursor-grabbing",
? "cursor-default border-primary/30 bg-primary/[0.03]"
: "cursor-pointer border-border bg-card hover:border-primary hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)]",
isDragging && "opacity-30",
)}
{...(inChain ? {} : listeners)}
{...attributes}
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{agent.name || agent.agent_id}</div>
<div
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center transition-colors",
R_CARD,
inChain
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Bot className="size-3.5" />
</div>
<div className="min-w-0 flex-1 pr-16">
<div className="truncate text-xs font-bold leading-tight text-foreground transition-colors group-hover:text-primary">
{agent.name || agent.agent_id}
</div>
{agent.description && (
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
<div className="mt-1 line-clamp-2 text-[11px] font-medium leading-normal text-muted-foreground">
{agent.description}
</div>
)}
</div>
{inChain ? (
<span className={cn("shrink-0 self-center bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary", R_CTRL)}>
已加入
</span>
) : (
<button
type="button"
onClick={onAdd}
onPointerDown={(e) => e.stopPropagation()}
className={cn(
"shrink-0 self-center p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-primary hover:text-primary-foreground group-hover:opacity-100",
R_CTRL,
)}
title="加入链条"
>
<Plus className="size-4" />
</button>
)}
<div className="absolute right-3.5 top-3.5 shrink-0 select-none">
{inChain ? (
<span className={cn("border border-primary/30 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary", R_CTRL)}>
已加入
</span>
) : (
<span
className={cn(
"border border-transparent bg-muted px-2 py-1 text-[10px] font-semibold text-muted-foreground transition-all group-hover:border-primary/30 group-hover:bg-primary/10 group-hover:text-primary",
R_CTRL,
)}
>
点击加入
</span>
)}
</div>
</div>
);
}
@@ -588,45 +726,72 @@ function SeatsColumn({
seatItemIds,
seatCountInvalid,
onRemove,
onMove,
}: {
seats: ChainSeat[];
seatItemIds: string[];
seatCountInvalid: boolean;
onRemove: (agentId: string) => void;
onMove: (index: number, dir: -1 | 1) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: SEATS_DROPPABLE_ID });
return (
<section className={cn("flex min-h-0 flex-col overflow-hidden border border-border bg-card", R_CARD, PANEL_SHADOW)}>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3 text-xs font-semibold text-foreground">
<ListOrdered className="size-3.5 text-primary" />
链条席位
<span className={cn("bg-muted px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground", R_CTRL)}>
按顺序发言
</span>
<span
className={cn(
"ml-auto px-1.5 py-0.5 text-[11px] font-medium tabular-nums",
R_CTRL,
seatCountInvalid
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
)}
>
{seats.length} / {CHAIN_MAX_SEATS}
{seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
</span>
<section
className={cn(
"flex min-h-0 flex-col overflow-hidden border border-border bg-card lg:col-span-8",
R_CARD,
PANEL_SHADOW,
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-border bg-muted/40 px-5 py-4">
<div className="flex items-center gap-2">
<span className={cn("flex size-5 items-center justify-center bg-primary/10", R_CTRL)}>
<ListOrdered className="size-3.5 text-primary" />
</span>
<h2 className="text-sm font-bold tracking-wide text-foreground">链条席位</h2>
<span className={cn("border border-border bg-muted px-2 py-0.5 text-[10px] font-bold text-muted-foreground", R_CTRL)}>
按顺序发言
</span>
</div>
<div className="flex select-none items-center gap-1 text-xs">
<span className="font-medium text-muted-foreground">配置席位</span>
<span
className={cn(
"border px-2 py-0.5 font-bold tabular-nums",
R_CTRL,
seatCountInvalid
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-primary/30 bg-primary/10 text-primary",
)}
>
{seats.length} / {CHAIN_MAX_SEATS}
{seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
</span>
</div>
</div>
<div
ref={setNodeRef}
className={cn(
"min-h-0 flex-1 space-y-2 overflow-y-auto p-3.5 transition-colors",
"min-h-0 flex-1 space-y-3 overflow-y-auto p-5 transition-colors",
isOver && "bg-primary/[0.03]",
)}
>
{seats.length === 0 ? (
<div className={cn("flex h-full flex-col items-center justify-center gap-2 border border-dashed border-border text-sm text-muted-foreground", R_CARD)}>
<Layers className="size-7 opacity-40" />
从左侧把智能体拖到这里,组成有序业务链条
<div
className={cn(
"m-1 flex h-full flex-col items-center justify-center border border-dashed border-border bg-card/50 px-8 py-16 text-center",
R_CARD,
)}
>
<div className="mb-3 flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ListOrdered className="size-6" />
</div>
<h3 className="mb-1 text-sm font-bold text-foreground">暂无席位</h3>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
该业务链条当前为空。请从左侧「候选智能体」点击或拖拽,将其按顺序编排加入此处。
</p>
</div>
) : (
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
@@ -635,7 +800,9 @@ function SeatsColumn({
key={seat.agent_id}
seat={seat}
index={index}
total={seats.length}
onRemove={() => onRemove(seat.agent_id)}
onMove={(dir) => onMove(index, dir)}
/>
))}
</SortableContext>
@@ -650,59 +817,110 @@ function SeatsColumn({
function SeatStrip({
seat,
index,
total,
onRemove,
onMove,
}: {
seat: ChainSeat;
index: number;
total: number;
onRemove: () => void;
onMove: (dir: -1 | 1) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: SEAT_PREFIX + seat.agent_id });
const avatarType = getAvatarTypeFor(seat.agent_id, index);
const isFirst = index === 0;
const isLast = index === total - 1;
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"flex items-center gap-3 border border-border bg-card p-2.5",
R_CTRL,
"flex items-stretch overflow-hidden border border-border bg-card",
R_CARD,
PANEL_SHADOW,
isDragging && "z-10 opacity-70 shadow-[0_4px_12px_rgba(15,23,42,0.12)]",
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground/60 hover:text-foreground active:cursor-grabbing"
className="flex w-7 shrink-0 cursor-grab touch-none items-center justify-center border-r border-border bg-muted/40 text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
title="拖动排序"
>
<GripVertical className="size-4" />
<GripVertical className="size-3.5" />
</button>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center border text-[12px] font-semibold tabular-nums",
R_CTRL,
chainTagClasses(avatarType),
)}
>
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{seat.name || seat.agent_id}</div>
{/* 序号 */}
<div className="flex shrink-0 select-none items-center pl-2.5">
<span
className={cn(
"flex size-6 items-center justify-center border text-xs font-bold tabular-nums",
R_CTRL,
chainTagClasses(avatarType),
)}
>
{index + 1}
</span>
</div>
{/* 详情 */}
<div className="flex min-w-0 flex-1 flex-col justify-center px-2.5 py-2">
<div className="truncate text-xs font-bold text-foreground">{seat.name || seat.agent_id}</div>
{seat.description && (
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
<div className="mt-0.5 line-clamp-1 text-[11px] font-medium text-muted-foreground">
{seat.description}
</div>
)}
</div>
<button
type="button"
onClick={onRemove}
className={cn("shrink-0 p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive", R_CTRL)}
title="移除"
>
<X className="size-4" />
</button>
{/* 控制:上移 / 下移 / 移除 */}
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
<button
type="button"
onClick={() => onMove(-1)}
disabled={isFirst}
title="上移席位"
className={cn(
"p-1 transition",
R_CTRL,
isFirst
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronUp className="size-3.5" />
</button>
<button
type="button"
onClick={() => onMove(1)}
disabled={isLast}
title="下移席位"
className={cn(
"p-1 transition",
R_CTRL,
isLast
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronDown className="size-3.5" />
</button>
<button
type="button"
onClick={onRemove}
title="移出此席位"
className={cn(
"ml-0.5 p-1 text-muted-foreground/60 transition-colors hover:bg-primary/10 hover:text-primary",
R_CTRL,
)}
>
<X className="size-3.5" />
</button>
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
+20
View File
@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/afcf08ae-640d-4fb2-9eab-4d3094ffaa28
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>业务链条配置</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{
"name": "业务链条配置",
"description": "编排有序的智能体研讨链路配置系统。",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}
+833
View File
@@ -0,0 +1,833 @@
import React, { useState, useEffect, useMemo } from "react";
import {
ArrowLeft,
Trash2,
Save,
Plus,
ChevronDown,
Search,
GripVertical,
X,
ListOrdered,
Languages,
Sparkles,
ChevronUp,
UserPlus,
Bot
} from "lucide-react";
import { INITIAL_AGENTS, Agent } from "./initialAgents";
interface ChainConfig {
id: string;
title: string;
description: string;
seats: string[]; // Holds reference to Agent IDs in order
}
const STORAGE_CHANNELS_KEY = "business_decision_chains";
const STORAGE_CURRENT_KEY = "business_decision_current_id";
const STORAGE_CUSTOM_AGENTS_KEY = "business_decision_custom_agents";
// Preset colors for the seat index badges
const SEAT_COLORS = [
{ bg: "bg-blue-50 border border-blue-200 text-blue-600", label: "蓝色" },
{ bg: "bg-purple-50 border border-purple-200 text-purple-600", label: "紫色" },
{ bg: "bg-emerald-50 border border-emerald-200 text-emerald-600", label: "绿色" },
{ bg: "bg-orange-50 border border-orange-200 text-orange-600", label: "橙色" },
{ bg: "bg-indigo-50 border border-indigo-200 text-indigo-600", label: "靛蓝" },
{ bg: "bg-pink-50 border border-pink-200 text-pink-600", label: "粉色" },
{ bg: "bg-amber-50 border border-amber-200 text-amber-600", label: "琥珀" },
{ bg: "bg-sky-50 border border-sky-200 text-sky-600", label: "天空蓝" },
];
export default function App() {
// 1. Storage & Config states
const [chains, setChains] = useState<ChainConfig[]>([]);
const [currentChainId, setCurrentChainId] = useState<string>("");
const [customAgents, setCustomAgents] = useState<Agent[]>([]);
// 2. Search & UI states
const [searchTerm, setSearchTerm] = useState("");
const [showDropdown, setShowDropdown] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// Custom Agent Form Modal
const [showAgentModal, setShowAgentModal] = useState(false);
const [newAgentName, setNewAgentName] = useState("");
const [newAgentDesc, setNewAgentDesc] = useState("");
// Toast feedback
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
// Initialize data on load
useEffect(() => {
// Load custom agents
const savedCustom = localStorage.getItem(STORAGE_CUSTOM_AGENTS_KEY);
let loadedCustom: Agent[] = [];
if (savedCustom) {
try {
loadedCustom = JSON.parse(savedCustom);
setCustomAgents(loadedCustom);
} catch (e) {
console.error("Failed to parse custom agents", e);
}
}
// Load chains
const savedChains = localStorage.getItem(STORAGE_CHANNELS_KEY);
const savedCurrentId = localStorage.getItem(STORAGE_CURRENT_KEY);
let loadedChains: ChainConfig[] = [];
if (savedChains) {
try {
loadedChains = JSON.parse(savedChains);
} catch (e) {
console.error("Failed to parse chains from localStorage", e);
}
}
// Default configuration if empty
if (loadedChains.length === 0) {
loadedChains = [
{
id: "chain-1",
title: "应急方案三段链",
description: "链条用途说明(可选)",
seats: ["agent-1", "agent-2", "agent-3"],
}
];
}
setChains(loadedChains);
// Set current active id
if (savedCurrentId && loadedChains.some(c => c.id === savedCurrentId)) {
setCurrentChainId(savedCurrentId);
} else {
setCurrentChainId(loadedChains[0].id);
}
}, []);
// Save chains list to storage when it updates
const saveAllToLocalStorage = (newChainsList: ChainConfig[]) => {
localStorage.setItem(STORAGE_CHANNELS_KEY, JSON.stringify(newChainsList));
};
// Toast helper
const triggerToast = (message: string, type: "success" | "error" | "info" = "success") => {
setToast({ message, type });
setTimeout(() => {
setToast(null);
}, 2500);
};
// Complete list of agents (built-in + custom)
const allAgents = useMemo(() => {
return [...INITIAL_AGENTS, ...customAgents];
}, [customAgents]);
const activeChain = useMemo(() => {
return chains.find(c => c.id === currentChainId) || {
id: "fallback",
title: "",
description: "",
seats: []
} as ChainConfig;
}, [chains, currentChainId]);
// Handle text field updates
const handleTitleChange = (val: string) => {
setChains(prev => {
const updated = prev.map(c => c.id === currentChainId ? { ...c, title: val } : c);
saveAllToLocalStorage(updated);
return updated;
});
};
const handleDescChange = (val: string) => {
setChains(prev => {
const updated = prev.map(c => c.id === currentChainId ? { ...c, description: val } : c);
saveAllToLocalStorage(updated);
return updated;
});
};
// Add / Toggle seat inclusion
const handleToggleAgent = (agentId: string) => {
const isAlreadyIn = activeChain.seats.includes(agentId);
if (isAlreadyIn) {
// Remove it
const newSeats = activeChain.seats.filter(id => id !== agentId);
updateActiveSeats(newSeats);
triggerToast("已将智能体移出席位");
} else {
// Add it
if (activeChain.seats.length >= 8) {
triggerToast("席位已满,每个链条最多配置 8 个席位!", "error");
return;
}
const newSeats = [...activeChain.seats, agentId];
updateActiveSeats(newSeats);
triggerToast("成功添加到链条席位");
}
};
const updateActiveSeats = (newSeats: string[]) => {
setChains(prev => {
const updated = prev.map(c => c.id === currentChainId ? { ...c, seats: newSeats } : c);
saveAllToLocalStorage(updated);
return updated;
});
};
// Multi-chain operations
const createNewChain = () => {
const nextId = `chain-${Date.now()}`;
const newChainItem: ChainConfig = {
id: nextId,
title: `空白新链条 ${chains.length + 1}`,
description: "",
seats: []
};
const updated = [...chains, newChainItem];
setChains(updated);
setCurrentChainId(nextId);
localStorage.setItem(STORAGE_CURRENT_KEY, nextId);
saveAllToLocalStorage(updated);
triggerToast("已创建新的空白链条", "success");
setShowDropdown(false);
};
const handleSelectChain = (id: string) => {
setCurrentChainId(id);
localStorage.setItem(STORAGE_CURRENT_KEY, id);
setShowDropdown(false);
triggerToast(`切换到: ${chains.find(c => c.id === id)?.title || "新链条"}`);
};
const handleSaveChain = () => {
saveAllToLocalStorage(chains);
triggerToast("保存配置成功", "success");
};
const handleDeleteChain = () => {
if (chains.length <= 1) {
// Create a blank placeholder instead of locking
const nextId = `chain-${Date.now()}`;
const placeholder: ChainConfig = {
id: nextId,
title: "新配置链条",
description: "",
seats: []
};
setChains([placeholder]);
setCurrentChainId(nextId);
localStorage.setItem(STORAGE_CURRENT_KEY, nextId);
saveAllToLocalStorage([placeholder]);
triggerToast("重置当前链条");
return;
}
const filtered = chains.filter(c => c.id !== currentChainId);
setChains(filtered);
const nextId = filtered[0].id;
setCurrentChainId(nextId);
localStorage.setItem(STORAGE_CURRENT_KEY, nextId);
saveAllToLocalStorage(filtered);
triggerToast("删除成功", "success");
};
// Reorder commands: Drag & Drop
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
};
const handleDrop = (targetIndex: number) => {
if (draggedIndex === null) return;
const reordered = [...activeChain.seats];
const [movedItem] = reordered.splice(draggedIndex, 1);
reordered.splice(targetIndex, 0, movedItem);
updateActiveSeats(reordered);
setDraggedIndex(null);
setDragOverIndex(null);
};
// Fallback reordering commands: Move Up/Down button click
const moveDirection = (index: number, direction: "up" | "down") => {
const targetIdx = direction === "up" ? index - 1 : index + 1;
if (targetIdx < 0 || targetIdx >= activeChain.seats.length) return;
const items = [...activeChain.seats];
const temp = items[index];
items[index] = items[targetIdx];
items[targetIdx] = temp;
updateActiveSeats(items);
};
// Create customized user agents
const handleAddCustomAgent = (e: React.FormEvent) => {
e.preventDefault();
if (!newAgentName.trim()) {
triggerToast("请输入智能体名称", "error");
return;
}
const newAgent: Agent = {
id: `custom-agent-${Date.now()}`,
name: newAgentName,
description: newAgentDesc || "自定义创建的专属智能体角色。",
isCustom: true
};
const updatedCustom = [...customAgents, newAgent];
setCustomAgents(updatedCustom);
localStorage.setItem(STORAGE_CUSTOM_AGENTS_KEY, JSON.stringify(updatedCustom));
setNewAgentName("");
setNewAgentDesc("");
setShowAgentModal(false);
triggerToast(`智能体 '${newAgent.name}' 创建成功!`);
// Optionally auto-add to the chain seats if space available
if (activeChain.seats.length < 8) {
updateActiveSeats([...activeChain.seats, newAgent.id]);
}
};
// Clear a custom agent
const handleDeleteCustomAgent = (e: React.MouseEvent, agentId: string) => {
e.stopPropagation(); // Avoid triggering seat addition
const updatedCustom = customAgents.filter(a => a.id !== agentId);
setCustomAgents(updatedCustom);
localStorage.setItem(STORAGE_CUSTOM_AGENTS_KEY, JSON.stringify(updatedCustom));
// Also remove from any seats as well
if (activeChain.seats.includes(agentId)) {
updateActiveSeats(activeChain.seats.filter(id => id !== agentId));
}
triggerToast("已彻底删除该自定义智能体");
};
// Filter conditions
const filteredCandidates = allAgents.filter(agent => {
const text = (agent.name + " " + agent.description).toLowerCase();
return text.includes(searchTerm.toLowerCase());
});
return (
<div className="min-h-screen bg-[#faf9f9] text-[#1f2937] flex flex-col font-sans transition-all duration-150 antialiased" id="main-container">
{/* 1. Header Toolbar */}
<header className="bg-white border-b border-[#ede9e9] sticky top-0 z-20 px-6 py-3 shrink-0" id="header">
<div className="max-w-[1600px] mx-auto flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
{/* Left Controls */}
<div className="flex flex-wrap items-center gap-4">
{/* Back Button */}
<button
id="back-roundtable-btn"
onClick={() => triggerToast("页面状态已保持。")}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-[#4b5563] border border-[#ede9e9] rounded-md bg-white hover:bg-[#f6f4f4] active:scale-[0.98] transition"
>
<ArrowLeft className="w-4 h-4" />
<span>返回圆桌</span>
</button>
{/* Custom Brand Icon */}
<div className="flex items-center gap-2" id="brand-indicator">
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-[#c25555] to-[#fcdbda] flex items-center justify-center text-white font-bold text-sm shadow-sm select-none">
🪶
</div>
<div className="flex flex-col">
<h1 className="text-base font-bold text-[#1f2937] leading-tight">业务链条配置</h1>
<p className="text-[11px] text-[#9ca3af] tracking-wide font-medium">编排有序的智能体研讨链路</p>
</div>
</div>
{/* Dropdown config selection */}
<div className="relative ml-2" id="chain-selector-dropdown">
<button
onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center justify-between gap-3 min-w-[140px] px-3 py-1.5 text-sm bg-white border border-[#ede9e9] rounded-md text-[#374151] hover:border-[#dbcbcb] focus:outline-none focus:ring-1 focus:ring-[#c25555] transition"
>
<span className="font-medium truncate">{activeChain.title || "选择一个配置"}</span>
<ChevronDown className="w-4 h-4 text-[#9ca3af]" />
</button>
{showDropdown && (
<>
<div className="fixed inset-0 z-30" onClick={() => setShowDropdown(false)} />
<div className="absolute left-0 mt-1 w-[240px] bg-white border border-[#ede9e9] rounded-md shadow-lg py-1 z-40 max-h-[280px] overflow-y-auto animate-in fade-in slide-in-from-top-2 duration-150">
<div className="px-3 py-1 text-xs text-slate-400 font-semibold border-b border-[#f5f2f2]">
切换已有链条
</div>
{chains.map((c) => (
<button
key={c.id}
onClick={() => handleSelectChain(c.id)}
className={`w-full text-left px-3 py-2 text-xs hover:bg-[#fef4f3] flex items-center justify-between transition ${
c.id === currentChainId ? "text-[#c25555] font-bold bg-[#fef4f3]" : "text-[#4b5563]"
}`}
>
<span className="truncate">{c.title || "未命名链条"}</span>
{c.id === currentChainId && <span className="text-[10px] bg-[#fcdbda] px-1.5 py-0.5 rounded text-[#c25555]">当前</span>}
</button>
))}
<div className="border-t border-[#f5f2f2] mt-1 pt-1">
<button
onClick={createNewChain}
className="w-full text-left px-3 py-2 text-xs text-[#c25555] hover:bg-[#fef4f3] font-medium flex items-center gap-1.5 transition"
>
<Plus className="w-3.5 h-3.5" />
<span>新建另一条链路</span>
</button>
</div>
</div>
</>
)}
</div>
{/* Quick New Button */}
<button
id="new-chain-btn"
onClick={createNewChain}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[#4b5563] border border-[#ede9e9] rounded-md bg-white hover:bg-[#f6f4f4] active:scale-[0.98] transition"
>
<Plus className="w-4 h-4 text-[#9ca3af]" />
<span>新建</span>
</button>
</div>
{/* Right Action buttons */}
<div className="flex items-center gap-2 ml-auto sm:ml-0">
<button
id="delete-chain-btn"
onClick={handleDeleteChain}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[#c25555] font-medium border border-[#fcdbda] rounded-md bg-white hover:bg-[#fef4f3] active:scale-[0.98] transition"
>
<Trash2 className="w-4 h-4" />
<span>删除</span>
</button>
<button
id="save-chain-btn"
onClick={handleSaveChain}
className="flex items-center gap-1.5 px-4 py-1.5 text-sm text-white font-medium bg-[#c25555] hover:bg-[#b54545] border border-[#b24848] rounded-md shadow-sm active:scale-[0.98] transition"
>
<Save className="w-4 h-4" />
<span>保存</span>
</button>
</div>
</div>
</header>
{/* 2. Top Config Form Cards (Title, Description) */}
<section className="bg-white border-b border-[#ede9e9] px-6 py-4 shrink-0 shadow-[0_1px_2px_0_rgba(0,0,0,0.02)]" id="form-container">
<div className="max-w-[1600px] mx-auto grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Title input field */}
<div className="flex items-stretch border border-[#ede9e9] rounded-md overflow-hidden bg-white group focus-within:ring-1 focus-within:ring-[#c25555] focus-within:border-[#c25555] transition" id="title-field">
<span className="bg-[#faf9f9] text-[#767e89] font-medium text-sm px-4 flex items-center justify-center border-r border-[#ede9e9] select-none min-w-[70px]">
标题
</span>
<input
type="text"
value={activeChain.title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="如:应急方案三段链"
className="w-full px-4 py-2.5 text-sm text-[#1f2937] focus:outline-none placeholder-[#9ca3af] font-medium"
/>
</div>
{/* Description input field */}
<div className="flex items-stretch border border-[#ede9e9] rounded-md overflow-hidden bg-white group focus-within:ring-1 focus-within:ring-[#c25555] focus-within:border-[#c25555] transition" id="desc-field">
<span className="bg-[#faf9f9] text-[#767e89] font-medium text-sm px-4 flex items-center justify-center border-r border-[#ede9e9] select-none min-w-[70px]">
描述
</span>
<input
type="text"
value={activeChain.description}
onChange={(e) => handleDescChange(e.target.value)}
placeholder="链条用途说明(可选)"
className="w-full px-4 py-2.5 text-sm text-[#1f2937] focus:outline-none placeholder-[#9ca3af] font-medium"
/>
</div>
</div>
</section>
{/* 3. Main Split Workspace Grid */}
<main className="flex-1 max-w-[1600px] w-full mx-auto px-6 py-6 grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-0" id="workspace">
{/* Column Left: 候选智能体 (Grid weight 4 of 12) */}
<section className="lg:col-span-4 bg-white border border-[#ede9e9] rounded-xl flex flex-col overflow-hidden shadow-sm" id="candidate-column">
{/* Candidate Header */}
<div className="px-5 py-4 border-b border-[#ede9e9] flex items-center justify-between shrink-0 bg-[#faf9f9]">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-5 h-5 rounded bg-[#fef4f3]">
<UserPlus className="w-3.5 h-3.5 text-[#c25555]" />
</span>
<h2 className="font-bold text-[#1f2937] text-sm tracking-wide">候选智能体</h2>
<span className="bg-[#fef4f3] text-[#c25555] text-xs font-bold px-2 py-0.5 rounded-full min-w-[20px] text-center select-none border border-[#fcdbda]">
{filteredCandidates.length}
</span>
</div>
<span className="text-[11px] text-[#8c96a3] font-medium tracking-tight">拖到右侧 / 点击加入</span>
</div>
{/* Search Bar with Creation Utility */}
<div className="p-4 border-b border-[#f4f1f1] flex items-center gap-2 shrink-0 bg-[#fdfdfd]">
<div className="relative flex-1">
<span className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search className="w-4 h-4 text-slate-400" />
</span>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="按名称搜索智能体..."
className="w-full pl-9 pr-3 py-2 text-xs border border-[#ede9e9] rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-[#c25555] focus:border-[#c25555] placeholder-slate-400 font-medium"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-slate-300 hover:text-slate-500"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Inline Custom Agent Launcher */}
<button
onClick={() => setShowAgentModal(true)}
title="创建自定义智能体角色"
className="flex items-center justify-center p-2 text-xs border border-dashed border-[#ede9e9] rounded-md hover:border-[#c25555] hover:text-[#c25555] hover:bg-[#fef4f3] transition shrink-0"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Candidates Scroll List */}
<div className="flex-1 overflow-y-auto p-4 space-y-2.5 scrollbar-thin" id="candidates-scroll">
{filteredCandidates.length === 0 ? (
<div className="py-12 px-4 text-center">
<p className="text-xs text-slate-400 font-medium">未找到符合搜索条件的智能体</p>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="mt-2 text-xs font-semibold text-[#c25555] underline hover:text-[#b44545]"
>
清除搜索
</button>
)}
</div>
) : (
filteredCandidates.map((agent) => {
const isAdded = activeChain.seats.includes(agent.id);
return (
<div
key={agent.id}
id={`candidate-card-${agent.id}`}
onClick={() => handleToggleAgent(agent.id)}
className={`group relative flex items-start gap-3 p-3.5 bg-white border rounded-lg cursor-pointer transition-all duration-150 ${
isAdded
? "border-[#fcdbda] bg-[#fdfdfd] shadow-[0_1px_3px_rgba(194,85,85,0.02)]"
: "border-[#ede9e9] hover:border-[#c25555] hover:shadow-[0_2px_8px_rgba(0,0,0,0.03)]"
}`}
>
{/* Left Icon status badge */}
<div className="mt-1 shrink-0">
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
isAdded ? "bg-[#fef4f3] text-[#c25555]" : "bg-slate-50 text-slate-400 group-hover:bg-[#fef4f3] group-hover:text-[#c25555]"
} transition-colors`}>
<Bot className="w-3.5 h-3.5" />
</div>
</div>
{/* Agent Details */}
<div className="flex-1 pr-14 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<h3 className="font-bold text-xs text-[#1f2937] leading-tight truncate group-hover:text-[#c25555] transition-colors">{agent.name}</h3>
{agent.isCustom && (
<span className="text-[10px] scale-95 font-medium px-1 bg-slate-50 text-slate-400 rounded border border-[#ede9e9]">自定义</span>
)}
</div>
<p className="text-[11px] text-[#717b88] leading-normal font-medium line-clamp-2">
{agent.description}
</p>
</div>
{/* Status Badge Action on Far Right */}
<div className="absolute top-3.5 right-3.5 select-none shrink-0 text-right">
{isAdded ? (
<span className="text-[10px] font-bold px-2 py-1 border border-[#fcdbda] bg-[#fef4f3] text-[#c25555] rounded">
已加入
</span>
) : (
<span className="text-[10px] font-semibold px-2 py-1 border border-transparent bg-slate-50 text-[#717b88] group-hover:bg-[#fef4f3] group-hover:text-[#c25555] group-hover:border-[#fcdbda] rounded transition-all">
点击加入
</span>
)}
{/* Custom agents get an extra trash deletion trigger */}
{agent.isCustom && (
<button
onClick={(e) => handleDeleteCustomAgent(e, agent.id)}
title="彻底删除此自定义智能体"
className="mt-1 block opacity-0 group-hover:opacity-100 hover:text-red-500 text-slate-300 transition-opacity p-1 ml-auto"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})
)}
</div>
</section>
{/* Column Right: 链条席位 (Grid weight 8 of 12) */}
<section className="lg:col-span-8 bg-white border border-[#ede9e9] rounded-xl flex flex-col overflow-hidden shadow-sm" id="seats-column">
{/* Seats Header */}
<div className="px-5 py-4 border-b border-[#ede9e9] flex items-center justify-between shrink-0 bg-[#faf9f9]">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-5 h-5 rounded bg-[#fef4f3]">
<ListOrdered className="w-3.5 h-3.5 text-[#c25555]" />
</span>
<h2 className="font-bold text-[#1f2937] text-sm tracking-wide">链条席位</h2>
<span className="inline-block px-2 py-0.5 text-[10px] font-bold bg-[#f5f2f2] text-[#717b88] rounded border border-[#ede9e9]">按顺序发言</span>
</div>
{/* Limit counter badge */}
<div className="flex items-center gap-1 text-xs select-none">
<span className="text-slate-400 font-medium">配置席位</span>
<span className={`px-2 py-0.5 font-bold rounded-md border ${
activeChain.seats.length > 0
? "bg-[#fef4f3] border-[#fcdbda] text-[#c25555]"
: "bg-slate-50 border-slate-200 text-slate-400"
}`}>
{activeChain.seats.length} / 8
</span>
</div>
</div>
{/* Seats Content Canvas */}
<div className="flex-1 overflow-y-auto p-5 space-y-3.5 scrollbar-thin" id="seats-scroll">
{activeChain.seats.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center py-20 px-8 border border-dashed border-[#ede9e9] rounded-xl bg-[#fdfdfd] m-1" id="empty-seats-place">
<div className="w-12 h-12 rounded-full bg-[#fef4f3] flex items-center justify-center text-[#c25555] mb-3">
<ListOrdered className="w-6 h-6" />
</div>
<h3 className="text-sm font-bold text-slate-700 mb-1">暂无席位</h3>
<p className="text-xs text-slate-400 text-center max-w-sm leading-relaxed mb-4">
该业务链条当前为空。请从左侧<b>“候选智能体”</b>列表中点击或者选择,将其按顺序编排加入此处。
</p>
<div className="flex flex-wrap justify-center gap-2 max-w-md">
{allAgents.slice(0, 3).map(agent => (
<button
key={agent.id}
onClick={() => handleToggleAgent(agent.id)}
className="px-2.5 py-1 text-[11px] font-semibold border border-[#ede9e9] hover:border-[#c25555] roundedbg-white hover:bg-slate-50 flex items-center gap-1 transition"
>
<span>💡</span>
<span>加入{agent.name}</span>
</button>
))}
</div>
</div>
) : (
<div className="space-y-3" id="seats-container-list">
{activeChain.seats.map((agentId, index) => {
const agent = allAgents.find(a => a.id === agentId);
if (!agent) return null;
const theme = SEAT_COLORS[index] || { bg: "bg-slate-50 border border-slate-200 text-slate-600" };
const isDragged = index === draggedIndex;
const isHoveredDrop = index === dragOverIndex;
return (
<div
key={`${agentId}-${index}`}
id={`seat-card-${index}`}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={() => { setDraggedIndex(null); setDragOverIndex(null); }}
onDrop={() => handleDrop(index)}
className={`flex items-stretch bg-white border rounded-lg shadow-sm transition-all duration-200 select-none ${
isDragged ? "opacity-35 scale-[0.98] border-dashed border-[#c25555]" : ""
} ${
isHoveredDrop ? "border-2 border-[#c25555] bg-[#fef4f3] scale-[1.01]" : "border-[#ede9e9]"
}`}
>
{/* Left Grip Handle */}
<div className="w-7 shrink-0 bg-[#faf9f9] border-r border-[#ede9e9] flex items-center justify-center rounded-l-lg select-none" id="grip-box">
<div
className="cursor-grab active:cursor-grabbing text-slate-400 hover:text-slate-600 p-1"
title="拖拽排序"
>
<GripVertical className="w-3.5 h-3.5" />
</div>
</div>
{/* Number Indicator Badge - Placed to the LEFT of details */}
<div className="flex items-center justify-center pl-2.5 shrink-0 select-none" id="seat-number-box">
<div className={`w-5.5 h-5.5 rounded flex items-center justify-center font-bold text-xs shadow-[inset_0_1px_1px_rgba(255,255,255,0.15)] select-none ${theme.bg}`}>
{index + 1}
</div>
</div>
{/* Seat info details */}
<div className="flex-1 py-2 pl-2.5 pr-2 min-w-0 flex flex-col justify-center">
<h4 className="font-bold text-xs text-[#1f2937] mb-0.5 flex items-center gap-1.5">
<span>{agent.name}</span>
{agent.isCustom && (
<span className="text-[9px] uppercase font-bold tracking-wider px-1 bg-slate-100 text-slate-400 rounded">Custom</span>
)}
</h4>
<p className="text-[11px] text-[#717b88] leading-tight font-medium line-clamp-1 truncate">
{agent.description}
</p>
</div>
{/* Seat controller Actions (Move up/down & close delete) */}
<div className="px-2 py-1.5 flex items-center gap-0.5 border-l border-[#f5f2f2] shrink-0" id="seat-controls">
{/* Up button */}
<button
onClick={() => moveDirection(index, "up")}
disabled={index === 0}
title="上移席位"
className={`p-1 rounded hover:bg-slate-100 transition ${
index === 0 ? "text-slate-200 pointer-events-none" : "text-slate-400 hover:text-[#1f2937]"
}`}
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
{/* Down button */}
<button
onClick={() => moveDirection(index, "down")}
disabled={index === activeChain.seats.length - 1}
title="下移席位"
className={`p-1 rounded hover:bg-slate-100 transition ${
index === activeChain.seats.length - 1 ? "text-slate-200 pointer-events-none" : "text-slate-400 hover:text-[#1f2937]"
}`}
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
{/* Cross Delete button */}
<button
onClick={() => handleToggleAgent(agent.id)}
title="移出此席位"
className="p-1 text-slate-300 hover:text-[#c25555] hover:bg-[#fef4f3] rounded transition-colors ml-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</section>
</main>
{/* 4. Float Helper Footer decoration */}
<footer className="py-3 px-6 border-t border-[#ede9e9] bg-white text-center text-[11px] text-slate-400 select-none shrink-0" id="footer-section">
编排有序的业务链条,让智能体决策更有深度。保存后,配置将自动同步至圆桌。
</footer>
{/* 5. Custom Agent Modal (Popup Card) */}
{showAgentModal && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-[1px] flex items-center justify-center z-50 animate-in fade-in duration-200" id="custom-agent-modal">
<div className="bg-white border border-[#ede9e9] rounded-xl shadow-xl max-w-md w-full mx-4 overflow-hidden animate-in zoom-in-95 duration-200">
{/* Modal Header */}
<div className="px-5 py-4 bg-[#faf9f9] border-b border-[#ede9e9] flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="text-base">🧠</span>
<h3 className="font-bold text-sm text-[#1f2937]">添加自定义候选智能体</h3>
</div>
<button
onClick={() => setShowAgentModal(false)}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4.5 h-4.5" />
</button>
</div>
{/* Modal Form */}
<form onSubmit={handleAddCustomAgent} className="p-5 space-y-4">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1.5">智能体名称 <span className="text-red-500">*</span></label>
<input
type="text"
required
placeholder="例如:市场公关专家"
value={newAgentName}
onChange={(e) => setNewAgentName(e.target.value)}
className="w-full px-3 py-2 text-xs border border-[#ede9e9] rounded-md focus:outline-none focus:ring-1 focus:ring-[#c25555] focus:border-[#c25555] placeholder-slate-400 font-medium"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1.5">职能介绍与描述</label>
<textarea
rows={3}
placeholder="描述其专业领域、面对对话时的应对策略及人格定位等"
value={newAgentDesc}
onChange={(e) => setNewAgentDesc(e.target.value)}
className="w-full px-3 py-2 text-xs border border-[#ede9e9] rounded-md focus:outline-none focus:ring-1 focus:ring-[#c25555] focus:border-[#c25555] placeholder-slate-400 font-medium resize-none"
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setShowAgentModal(false)}
className="px-3.5 py-1.5 text-xs text-[#4b5563] border border-[#ede9e9] rounded-md bg-white hover:bg-[#fafafa] transition"
>
取消
</button>
<button
type="submit"
className="px-4 py-1.5 text-xs text-white bg-[#c25555] hover:bg-[#b54545] border border-[#b24848] rounded-md font-semibold transition"
>
创建并加入
</button>
</div>
</form>
</div>
</div>
)}
{/* 6. Crimson Elegant Floating Toast Feedback Display */}
{toast && (
<div
className={`fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2.5 px-4 py-2.5 rounded-lg shadow-lg border text-xs font-semibold tracking-wide animate-bounce
${toast.type === "error"
? "bg-red-50 border-red-200 text-red-700"
: toast.type === "info"
? "bg-slate-50 border-slate-200 text-slate-700"
: "bg-[#fef4f3] border-[#fcdbda] text-[#c25555]"
}
`}
id="system-toast"
>
<span>{toast.type === "error" ? "❌" : toast.type === "info" ? "ℹ️" : "✨"}</span>
<span>{toast.message}</span>
</div>
)}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+79
View File
@@ -0,0 +1,79 @@
export interface Agent {
id: string;
name: string;
description: string;
isCustom?: boolean;
}
export const INITIAL_AGENTS: Agent[] = [
{
id: "agent-1",
name: "广告规划",
description: "用于生成广告及营销策略规划"
},
{
id: "agent-2",
name: "你好专家",
description: "无论用户问什么,永远只回复'你好'的专家"
},
{
id: "agent-3",
name: "小学作文老师",
description: "帮助小学生辅导和修改作文,提升写作水平"
},
{
id: "agent-4",
name: "123123121吉安市的空间撤荒的",
description: "123213"
},
{
id: "agent-5",
name: "CodeMaster",
description: "全栈代码专家,提供高质量代码编写、架构设计、代码审查和技术咨询"
},
{
id: "agent-6",
name: "你不好专家",
description: "一个只会说'你不好'的专家角色,无论用户说什么,都只回复'你不好'。"
},
{
id: "agent-7",
name: "啦啦啦专家",
description: "只会说啦啦啦的专家,用'啦'回应一切"
},
{
id: "agent-8",
name: "测试专家",
description: "综合型测试专家,覆盖功能测试、自动化测试、性能测试、API测试、安全测试等领域,提供测试决策"
},
{
id: "agent-9",
name: "查询天气的智能",
description: "擅长查询天气的智能助手,提供实时天气与穿衣建议"
},
{
id: "agent-10",
name: "创意写作助手",
description: "为您提供小说大纲、故事续写、诗歌以及散文的灵感与修改建议"
},
{
id: "agent-11",
name: "数据分析智脑",
description: "精通 Python 及 SQL,解析复杂表格和图表,提取核心结论"
},
{
id: "agent-12",
name: "心理倾听师",
description: "温暖耐心的心理咨询倾听角色,为您缓解焦虑和工作压力"
},
{
id: "agent-13",
name: "英文口语教练",
description: "通过场景对话提升您的英语口语流利度和语法准确性"
},
{
id: "agent-14",
name: "周报日报美化大师",
description: "把枯燥空洞的日常工作记录,精炼美化为重点突出、职业素养爆表的职场汇报"
}
];
+10
View File
@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
+22
View File
@@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});