This commit is contained in:
fengxiangdebaozi committed 2026-06-12 17:54:10 +08:00
commit 3ad920457a
10 files changed
+1062 -380

No files matched your search

@@ -23,7 +23,7 @@ function classifySpeed(tps: number): { color: string; label: string } {
return { color: "#ef4444", label: "缓慢" };
}
function formatDuration(ms: number): string {
export function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const mins = Math.floor(ms / 60_000);
@@ -31,7 +31,7 @@ function formatDuration(ms: number): string {
return `${mins}m ${secs}s`;
}
function SpeedPill({ metric }: { metric: MessageMetric }) {
export function SpeedPill({ metric }: { metric: MessageMetric }) {
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
+477 -343
View File
@@ -1,16 +1,8 @@
/**
* 应用管理页 —— 注册 / 编辑 / 删除轻应用(全局共享,写操作仅 admin)。
*
* 两种类型:
* - window_open:填地址 + 路由参数,列表页以卡片展示、点击新开页跳转。
* - iframe:填地址 + 路由参数 + 选择挂载菜单,注册后动态注入侧边栏对应父菜单下。
*
* 路由参数构造器:参数名(key) + 类型(登录/主题)。登录参数右侧选 accessToken/yUserId/
* userName/其他(自填值),运行时从 localStorage.userInfo 解析;主题参数填 dark-blue/red/blue。
*/
import { useMemo, useState } from "react";
import {
ExternalLink,
HelpCircle,
Info,
Layers,
Pencil,
Plus,
@@ -20,25 +12,9 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useTheme } from "@/contexts/ThemeContext";
import {
useLightAppActions,
useLightApps,
@@ -47,11 +23,9 @@ import {
type LightAppType,
} from "@/strategy-components/api/light-apps";
import {
buildLightAppUrl,
LOGIN_SOURCE_OPTIONS,
MOUNT_OPTIONS,
mountLabel,
THEME_EXAMPLE_OPTIONS,
} from "@/strategy-components/lib/light-app";
interface FormState {
@@ -100,10 +74,19 @@ function formFromApp(app: LightApp): FormState {
};
}
export default function LightAppManagementPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
function getParamSourceLabel(param: LightAppRouteParam): string {
if (param.paramType === "theme") {
return `主题参数: ${param.themeValue || ""}`;
}
const source = param.source || "accessToken";
const option = LOGIN_SOURCE_OPTIONS.find((o) => o.value === source);
if (source === "other") {
return `登录参数: ${param.customValue || ""}`;
}
return `登录参数: ${option?.label || source}`;
}
export default function LightAppManagementPage() {
const [search, setSearch] = useState("");
const { data: apps = [], isLoading } = useLightApps({ search });
const actions = useLightAppActions();
@@ -111,6 +94,7 @@ export default function LightAppManagementPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LightApp | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [activeParamPopover, setActiveParamPopover] = useState<string | null>(null);
const saving = actions.create.isPending || actions.update.isPending;
@@ -126,7 +110,8 @@ export default function LightAppManagementPage() {
setDialogOpen(true);
};
const patchForm = (patch: Partial<FormState>) => setForm((f) => ({ ...f, ...patch }));
const patchForm = (patch: Partial<FormState>) =>
setForm((f) => ({ ...f, ...patch }));
const patchParam = (idx: number, patch: Partial<LightAppRouteParam>) =>
setForm((f) => ({
@@ -135,26 +120,27 @@ export default function LightAppManagementPage() {
}));
const addParam = () => patchForm({ routeParams: [...form.routeParams, newParam()] });
const removeParam = (idx: number) =>
patchForm({ routeParams: form.routeParams.filter((_, i) => i !== idx) });
const previewUrl = useMemo(() => {
if (!form.url) return "";
try {
return buildLightAppUrl({ url: form.url, routeParams: cleanParams(form.routeParams) });
} catch {
return form.url;
}
}, [form.url, form.routeParams]);
const handleSave = async () => {
const name = form.name.trim();
const url = form.url.trim();
if (!name) return toast.error("请填写应用名称");
if (!url) return toast.error("请填写应用地址");
if (form.appType === "iframe" && !form.mountLocation) return toast.error("iframe 类型请选择挂载菜单");
if (!name) return toast.error("请输入应用名称");
if (!url) return toast.error("请输入应用地址");
if (form.appType === "iframe" && !form.mountLocation) {
return toast.error("iframe 类型请选择挂载菜单");
}
for (const p of form.routeParams) {
if (!p.key.trim()) return toast.error("路由参数的「参数名」不能为空");
if (!p.key.trim()) return toast.error("路由参数键名不可为空");
if (p.paramType === "login" && p.source === "other" && !p.customValue?.trim()) {
return toast.error("登录参数自定义值不可为空");
}
if (p.paramType === "theme" && !p.themeValue?.trim()) {
return toast.error("主题参数值不可为空");
}
}
const payload = {
@@ -182,7 +168,7 @@ export default function LightAppManagementPage() {
};
const handleDelete = async (app: LightApp) => {
if (!window.confirm(`确定删除应用「${app.name}」吗?`)) return;
if (!window.confirm(`是否确定要删除「${app.name}」?该操作不可逆。`)) return;
try {
await actions.remove.mutateAsync(app.appId);
toast.success(`已删除「${app.name}」`);
@@ -191,235 +177,487 @@ export default function LightAppManagementPage() {
}
};
// ── theme classes(与配置管理页一致) ──────────────────────────────────
const shellClass = isDark
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
const cardClass = isDark
? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]"
: "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]";
const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]";
const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]";
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
const typeBadge = (t: LightAppType) =>
t === "iframe"
? isDark
? "bg-[rgba(168,85,247,0.16)] text-[#D6B4FF]"
: "bg-[#F9F0FF] text-[#722ED1]"
: isDark
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
: "bg-[#FFF1F0] text-[#CF1322]";
const tableApps = useMemo(() => apps, [apps]);
return (
<div className={shellClass}>
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="h-full overflow-y-auto bg-[#FAFAFA]">
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col space-y-6 px-6 py-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
应用管理
</h1>
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧边栏。
<h1 className="text-[20px] font-bold tracking-tight text-[#222222]">应用管理</h1>
<p className="max-w-4xl text-[13px] leading-relaxed text-[#575757]">
注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。
</p>
</div>
<Button onClick={openCreate} className="gap-1.5">
<Plus className="h-4 w-4" />
注册应用
</Button>
<button
onClick={openCreate}
className="flex h-9 cursor-pointer items-center space-x-1.5 rounded-[2px] bg-[#A8071A] px-4 text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-all hover:bg-[#BC0D1E] active:bg-[#820014]"
>
<span>+ 注册应用</span>
</button>
</div>
<div className="relative max-w-sm">
<Search className={`pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${mutedText}`} />
<Input
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[#C1C1C1]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索应用名称 / 描述"
className="pl-9"
className="h-9 w-full rounded-[2px] border border-[#E8E8E8] bg-white pl-10 pr-4 text-[14px] text-[#222222] placeholder-[#C1C1C1] shadow-sm transition-all focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-[12px] text-[#A6A6A6] hover:text-[#F5222D]"
>
清除
</button>
)}
</div>
<div className={`overflow-hidden rounded-3xl ${cardClass}`}>
<table className="w-full border-collapse text-sm">
<div className="overflow-visible rounded-[4px] border border-[#E8E8E8] bg-white shadow-[0_2px_8px_rgba(34,34,34,0.08)]">
<table className="w-full table-fixed border-collapse text-left">
<thead>
<tr className={`text-left text-xs uppercase tracking-widest ${headRowClass}`}>
<th className="px-6 py-4 font-semibold">名称</th>
<th className="px-6 py-4 font-semibold">类型</th>
<th className="px-6 py-4 font-semibold">地址 / 挂载</th>
<th className="px-6 py-4 font-semibold">参数</th>
<th className="px-6 py-4 font-semibold">启用</th>
<th className="px-6 py-4 text-right font-semibold">操作</th>
<tr className="border-b border-[#E8E8E8] bg-[#FAFAFA] text-[12px] font-bold uppercase tracking-wider text-[#A6A6A6]">
<th className="w-[18%] px-6 py-3.5">名称</th>
<th className="w-[12%] px-4 py-3.5">类型</th>
<th className="w-[34%] px-4 py-3.5">地址 / 挂载</th>
<th className="w-[12%] px-4 py-3.5">参数</th>
<th className="w-[12%] px-4 py-3.5">启用</th>
<th className="w-[12%] px-4 py-3.5 pr-6 text-right">操作</th>
</tr>
</thead>
<tbody>
{apps.length === 0 ? (
<tr>
<td colSpan={6} className={`px-6 py-12 text-center ${mutedText}`}>
{isLoading ? "加载中…" : "暂无应用,点击右上角「注册应用」开始"}
<tbody className="divide-y divide-[#E8E8E8]">
{tableApps.map((app) => (
<tr key={app.appId} className="group align-top transition-colors hover:bg-[#FEFAFA]">
<td className="px-6 py-[18px]">
<div className="flex flex-col space-y-1">
<span className="text-[14px] font-bold text-[#222222] transition-colors group-hover:text-[#F5222D]">
{app.name}
</span>
{app.description ? (
<span className="line-clamp-2 text-[12px] text-[#575757]">{app.description}</span>
) : (
<span className="text-[11px] italic text-[#C1C1C1]">无描述说明</span>
)}
</div>
</td>
<td className="px-4 py-[18px]">
{app.appType === "window_open" ? (
<span className="inline-flex select-none items-center space-x-1 rounded-[2px] border border-[#FDDADC] bg-[#FEEDED] px-2 py-0.5 text-[11px] font-medium text-[#F5222D] shadow-sm">
<ExternalLink className="h-3 w-3" />
<span>新页跳转</span>
</span>
) : (
<span className="inline-flex select-none items-center space-x-1 rounded-[2px] border border-[#D4E9FF] bg-[#EAF4FF] px-2 py-0.5 text-[11px] font-medium text-[#0079FE] shadow-sm">
<Layers className="h-3 w-3" />
<span>iframe 嵌套</span>
</span>
)}
</td>
<td className="overflow-hidden px-4 py-[18px]">
<div className="flex max-w-full flex-col space-y-1">
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-1.5 truncate font-mono text-[13px] text-[#222222] hover:text-[#0079FE] hover:underline"
>
<span className="truncate">{app.url}</span>
</a>
{app.appType === "iframe" && app.mountLocation && (
<div className="flex items-center space-x-1 text-[11px] text-[#A6A6A6]">
<span className="h-1.5 w-1.5 rounded-full bg-[#0079FE]" />
<span>挂载: {mountLabel(app.mountLocation)}</span>
</div>
)}
</div>
</td>
<td className="relative px-4 py-[18px]">
<div className="flex items-center select-none">
{app.routeParams.length > 0 ? (
<button
onClick={() =>
setActiveParamPopover(
activeParamPopover === app.appId ? null : app.appId
)
}
className="group/param inline-flex cursor-pointer items-center space-x-1 text-[14px] text-[#222222] transition-colors hover:text-[#0079FE]"
>
<span className="font-medium">{app.routeParams.length}个</span>
<span className="text-[10px] text-[#A6A6A6] transition-colors group-hover/param:text-[#0079FE]">
▼
</span>
</button>
) : (
<span className="text-[14px] text-[#A6A6A6]">0个</span>
)}
{activeParamPopover === app.appId && app.routeParams.length > 0 && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setActiveParamPopover(null)}
/>
<div className="absolute left-4 top-11 z-50 mt-1 w-[280px] animate-in fade-in rounded-[4px] border border-[#E8E8E8] bg-white p-3 text-[12px] shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="mb-2 flex items-center justify-between border-b border-[#E8E8E8] pb-2 font-semibold text-[#3D3D3D]">
<span>路由参数明细</span>
<span className="rounded-sm bg-[#EAF4FF] px-1.5 py-0.5 text-[10px] text-[#0079FE]">
URL 拼接
</span>
</div>
<div className="max-h-[160px] space-y-1.5 overflow-y-auto">
{app.routeParams.map((p, idx) => (
<div
key={`${app.appId}-${idx}`}
className="flex items-center justify-between rounded-sm bg-[#FAFAFA] p-1.5 font-mono hover:bg-[#FEF6F6]"
>
<span className="font-bold text-[#F5222D]">{p.key}</span>
<span className="text-[11px] text-[#A6A6A6]">
({getParamSourceLabel(p)})
</span>
</div>
))}
</div>
</div>
</>
)}
</div>
</td>
<td className="px-4 py-[18px]">
<div className="flex select-none items-center space-x-2">
{app.enabled ? (
<>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#2FD69A] opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#2FD69A]" />
</span>
<span className="text-[13px] font-semibold text-[#189D72]">已启用</span>
</>
) : (
<>
<span className="h-2 w-2 rounded-full bg-[#A6A6A6]" />
<span className="text-[13px] text-[#A6A6A6]">已禁用</span>
</>
)}
</div>
</td>
<td className="px-4 py-[18px] pr-6 text-right">
<div className="inline-flex items-center justify-end space-x-2.5">
<button
onClick={() => openEdit(app)}
className="group/btn flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[4px] border border-[#D4E9FF] bg-[#EAF4FF] text-[#0079FE] transition-all duration-200 hover:scale-105 hover:border-[#0079FE] hover:bg-[#0079FE] hover:text-white active:scale-95"
title="编辑配置"
>
<Pencil className="h-3.5 w-3.5 transition-transform duration-200 group-hover/btn:rotate-12" />
</button>
<button
onClick={() => handleDelete(app)}
className="group/btn flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[4px] border border-[#FDDADC] bg-[#FEEDED] text-[#F5222D] transition-all duration-200 hover:scale-105 hover:border-[#F5222D] hover:bg-[#F5222D] hover:text-white active:scale-95"
title="删除配置"
>
<Trash2 className="h-3.5 w-3.5 transition-transform duration-200 group-hover/btn:scale-110" />
</button>
</div>
</td>
</tr>
) : (
apps.map((app) => (
<tr key={app.appId} className={`border-t ${rowBorder}`}>
<td className="px-6 py-4 align-middle">
<div className={`text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
{app.name}
))}
{tableApps.length === 0 && (
<tr>
<td colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#F5F5F5] text-[#C1C1C1]">
<Info className="h-6 w-6" />
</div>
{app.description && (
<div className={`mt-0.5 line-clamp-1 text-xs ${mutedText}`}>{app.description}</div>
)}
</td>
<td className="px-6 py-4 align-middle">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${typeBadge(app.appType)}`}>
{app.appType === "iframe" ? <Layers className="h-3 w-3" /> : <ExternalLink className="h-3 w-3" />}
{app.appType === "iframe" ? "iframe 嵌套" : "新页跳转"}
</span>
</td>
<td className="px-6 py-4 align-middle">
<div className="max-w-[320px] truncate font-mono text-xs">{app.url}</div>
{app.appType === "iframe" && app.mountLocation && (
<div className={`mt-0.5 text-xs ${mutedText}`}>挂载:{mountLabel(app.mountLocation)}</div>
)}
</td>
<td className="px-6 py-4 align-middle">
<span className={mutedText}>{app.routeParams.length} 个</span>
</td>
<td className="px-6 py-4 align-middle">
<span className={app.enabled ? "text-emerald-500" : mutedText}>
{app.enabled ? "已启用" : "已停用"}
</span>
</td>
<td className="px-6 py-4 text-right align-middle">
<div className="inline-flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-1" onClick={() => openEdit(app)}>
<Pencil className="h-3.5 w-3.5" />
编辑
</Button>
<Button variant="ghost" size="sm" className="gap-1 text-red-500 hover:text-red-600" onClick={() => handleDelete(app)}>
<Trash2 className="h-3.5 w-3.5" />
删除
</Button>
<div className="space-y-1">
<p className="text-[14px] font-semibold text-[#A6A6A6]">
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
</p>
{!isLoading && (
<p className="text-[12px] text-[#C1C1C1]">
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
</p>
)}
</div>
</td>
</tr>
))
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex max-w-full select-none items-start space-x-3.5 rounded-[4px] border border-[#FFBB55] bg-[#FFF6EA] p-4 text-[13px] text-[#575757]">
<Info className="mt-0.5 h-5 w-5 flex-shrink-0 text-[#FF9900]" />
<div className="space-y-1">
<p className="font-bold text-[#222222]">📌 智能多维挂载规则:</p>
<ul className="list-inside list-disc space-y-1 pl-1">
<li>
当您注册 <strong>iframe 嵌套</strong>{" "}
应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。
</li>
<li>
当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "}
<strong>真实 Iframe 预览器</strong>
,并自动将参数以加密/标准的QueryString传递过去。
</li>
</ul>
</div>
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[88vh] overflow-y-auto sm:max-w-[640px]">
<DialogHeader>
<DialogTitle>{editing ? "编辑应用" : "注册应用"}</DialogTitle>
</DialogHeader>
{dialogOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center overflow-y-auto select-none">
<div
className="fixed inset-0 bg-[#3D3D3D]/40 backdrop-blur-[1px]"
onClick={() => setDialogOpen(false)}
/>
<div className="space-y-4 py-2">
{/* 类型 */}
<Field label="应用类型">
<Select value={form.appType} onValueChange={(v) => patchForm({ appType: v as LightAppType })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="window_open">新页跳转(window.open)</SelectItem>
<SelectItem value="iframe">iframe 嵌套(挂载到菜单)</SelectItem>
</SelectContent>
</Select>
</Field>
<div className="relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[4px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
<h2 className="text-[17px] font-bold text-[#222222]">
{editing ? "修改轻应用" : "注册应用"}
</h2>
<button
onClick={() => setDialogOpen(false)}
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FAFAFA] hover:text-[#F5222D]"
>
<X className="h-5 w-5" />
</button>
</div>
<Field label="应用名称">
<Input value={form.name} onChange={(e) => patchForm({ name: e.target.value })} placeholder="如:舆情看板" />
</Field>
<Field label="应用地址">
<Input
value={form.url}
onChange={(e) => patchForm({ url: e.target.value })}
placeholder="https://example.com/page 或 https://x#/route"
/>
</Field>
{form.appType === "iframe" && (
<Field label="挂载菜单">
<Select value={form.mountLocation || undefined} onValueChange={(v) => patchForm({ mountLocation: v })}>
<SelectTrigger>
<SelectValue placeholder="选择挂载到哪个菜单下" />
</SelectTrigger>
<SelectContent>
{MOUNT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
<Field label="应用描述(可选)">
<Textarea
value={form.description}
onChange={(e) => patchForm({ description: e.target.value })}
placeholder="简要说明该应用的用途"
rows={2}
/>
</Field>
{/* 路由参数构造器 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">路由参数</span>
<Button variant="outline" size="sm" className="gap-1" onClick={addParam}>
<Plus className="h-3.5 w-3.5" />
添加路由参数
</Button>
<div className="flex-1 space-y-4 overflow-y-auto py-4 pr-1 text-[#222222]">
<div className="space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<span>应用类型</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<select
value={form.appType}
onChange={(e) => patchForm({ appType: e.target.value as LightAppType })}
className="h-9 w-full cursor-pointer rounded-[2px] border border-[#DBDBDB] bg-white px-3 text-[14px] focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
>
<option value="iframe">iframe 嵌套 (挂载到菜单)</option>
<option value="window_open">window.open (新页跳转)</option>
</select>
</div>
{form.routeParams.length === 0 && (
<p className={`text-xs ${mutedText}`}>暂无参数。登录参数运行时从 userInfo 取值,主题参数用固定值。</p>
<div className="space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<span>应用名称</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<Input
value={form.name}
onChange={(e) => patchForm({ name: e.target.value })}
placeholder="如: 舆情看板"
className="h-9 rounded-[2px] border-[#DBDBDB] text-[14px]"
/>
</div>
<div className="space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<span>应用地址</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<Input
value={form.url}
onChange={(e) => patchForm({ url: e.target.value })}
placeholder="https://example.com/page 或 http://47.88.25.99:3000/"
className="h-9 rounded-[2px] border-[#DBDBDB] font-mono text-[14px]"
/>
</div>
{form.appType === "iframe" && (
<div className="animate-in fade-in space-y-1.5">
<label className="flex items-center text-[14px] font-medium">
<span>挂载菜单</span>
<span className="ml-1 text-[#F5222D]">*</span>
</label>
<select
value={form.mountLocation}
onChange={(e) => patchForm({ mountLocation: e.target.value })}
className="h-9 w-full cursor-pointer rounded-[2px] border border-[#DBDBDB] bg-white px-3 text-[14px] focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]"
>
<option value="">请选择挂载菜单</option>
{MOUNT_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
)}
<div className="space-y-2">
{form.routeParams.map((p, idx) => (
<ParamRow
key={idx}
param={p}
isDark={isDark}
onChange={(patch) => patchParam(idx, patch)}
onRemove={() => removeParam(idx)}
<div className="space-y-1.5">
<label className="text-[14px] font-medium">应用描述 (可选)</label>
<Textarea
value={form.description}
onChange={(e) => patchForm({ description: e.target.value })}
placeholder="简要说明该应用的用途"
rows={2}
className="resize-none rounded-[2px] border-[#DBDBDB] text-[14px]"
/>
</div>
<div className="space-y-2.5 pt-1.5">
<div className="flex items-center justify-between">
<span className="flex items-center text-[14px] font-medium">
<span>路由参数</span>
<HelpCircle
className="ml-1 h-3.5 w-3.5 text-[#A6A6A6]"
title="参数会在加载此应用时作为 query 参数拼接在 URL 的尾部"
/>
</span>
<button
type="button"
onClick={addParam}
className="flex h-7 cursor-pointer items-center space-x-1 rounded-[2px] border border-[#DBDBDB] bg-white px-2.5 text-[12px] font-bold text-[#575757] transition-colors hover:border-[#F5222D] hover:text-[#F5222D]"
>
<Plus className="h-3.5 w-3.5" />
<span>+ 添加路由参数</span>
</button>
</div>
{form.routeParams.length > 0 ? (
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-sm border border-[#E8E8E8] bg-[#FAFAFA] p-1">
{form.routeParams.map((p, idx) => (
<div
key={`${p.key}-${idx}`}
className="relative flex items-center space-x-2 rounded-sm border border-[#E8E8E8] bg-white p-2"
>
<div className="flex-1 space-y-1">
<input
type="text"
value={p.key}
onChange={(e) => patchParam(idx, { key: e.target.value })}
placeholder="参数名 key"
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
<div className="w-[110px]">
<select
value={p.paramType}
onChange={(e) =>
patchParam(idx, {
paramType: e.target.value as "login" | "theme",
source:
e.target.value === "login" ? (p.source || "accessToken") : undefined,
})
}
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-[#FAFAFA] px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
>
<option value="login">登录参数</option>
<option value="theme">主题参数</option>
</select>
</div>
<div className="flex-[1.2] text-[11px]">
{p.paramType === "login" ? (
<div className="flex items-center gap-2">
<select
value={p.source || "accessToken"}
onChange={(e) => patchParam(idx, { source: e.target.value })}
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-1 text-[12px] focus:border-[#F5222D] focus:outline-none"
>
{LOGIN_SOURCE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{p.source === "other" && (
<input
type="text"
value={p.customValue || ""}
onChange={(e) =>
patchParam(idx, { customValue: e.target.value })
}
placeholder="自定义值"
className="h-8 w-[120px] rounded-[2px] border border-[#DBDBDB] px-2 text-[12px] focus:border-[#F5222D] focus:outline-none"
/>
)}
</div>
) : (
<input
type="text"
value={p.themeValue || ""}
onChange={(e) => patchParam(idx, { themeValue: e.target.value })}
placeholder="主题值,如 dark-blue"
className="h-8 w-full rounded-[2px] border border-[#DBDBDB] bg-white px-2 text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
)}
</div>
<button
type="button"
onClick={() => removeParam(idx)}
className="cursor-pointer rounded-sm p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
) : (
<div className="rounded-sm border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
暂无注入的路由参数,加载地址时不传递多余拼接。
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-[#E8E8E8] py-2.5">
<span className="text-[14px] font-medium text-[#222222]">立即启用该应用</span>
<button
type="button"
onClick={() => patchForm({ enabled: !form.enabled })}
className={`relative h-[22px] w-10 cursor-pointer rounded-full p-0.5 transition-colors duration-200 ${
form.enabled ? "bg-[#F5222D]" : "bg-[#DBDBDB]"
}`}
>
<div
className={`h-[18px] w-[18px] rounded-full bg-white shadow-sm transition-transform duration-200 ${
form.enabled ? "translate-x-[18px]" : "translate-x-0"
}`}
/>
))}
</button>
</div>
</div>
{previewUrl && (
<div className={`rounded-lg border px-3 py-2 ${isDark ? "border-[rgba(67,89,124,0.4)] bg-[rgba(8,19,34,0.6)]" : "border-[#F0F0F0] bg-[#FAFAFA]"}`}>
<div className={`text-xs ${mutedText}`}>访问预览(登录参数取自当前 userInfo)</div>
<div className="mt-1 break-all font-mono text-xs">{previewUrl}</div>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">启用</span>
<Switch checked={form.enabled} onCheckedChange={(v) => patchForm({ enabled: v })} />
<div className="flex flex-shrink-0 items-center justify-end space-x-3 border-t border-[#E8E8E8] pt-4">
<Button
variant="outline"
disabled={saving}
onClick={() => setDialogOpen(false)}
className="h-9 rounded-[2px] border-[#DBDBDB] px-[18px] text-[14px] font-semibold"
>
取消
</Button>
<button
type="button"
disabled={saving}
onClick={handleSave}
className="h-9 cursor-pointer rounded-[2px] bg-[#F5222D] px-[18px] text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-colors hover:bg-[#CF1322] active:bg-[#A8071A] disabled:cursor-not-allowed disabled:opacity-70"
>
{saving ? "保存中..." : editing ? "保存修改" : "立即注册"}
</button>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "保存中…" : editing ? "保存" : "注册"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</div>
);
}
/** 去掉与当前参数类型无关的字段,保持载荷干净。 */
function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
return params
.filter((p) => p.key.trim())
@@ -427,116 +665,12 @@ function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
const base: LightAppRouteParam = { key: p.key.trim(), paramType: p.paramType };
if (p.paramType === "login") {
base.source = p.source || "accessToken";
if (base.source === "other") base.customValue = p.customValue ?? "";
if (base.source === "other") {
base.customValue = (p.customValue ?? "").trim();
}
} else {
base.themeValue = p.themeValue ?? "";
base.themeValue = (p.themeValue ?? "").trim();
}
return base;
});
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block space-y-1.5">
<span className="text-sm font-medium">{label}</span>
{children}
</label>
);
}
function ParamRow({
param,
isDark,
onChange,
onRemove,
}: {
param: LightAppRouteParam;
isDark: boolean;
onChange: (patch: Partial<LightAppRouteParam>) => void;
onRemove: () => void;
}) {
const rowClass = isDark
? "border border-[rgba(67,89,124,0.4)] bg-[rgba(8,19,34,0.5)]"
: "border border-[#F0F0F0] bg-[#FAFAFA]";
return (
<div className={`rounded-lg p-2.5 ${rowClass}`}>
<div className="flex flex-wrap items-center gap-2">
<Input
value={param.key}
onChange={(e) => onChange({ key: e.target.value })}
placeholder="参数名 key"
className="h-8 w-[140px]"
/>
<Select value={param.paramType} onValueChange={(v) => onChange({ paramType: v as "login" | "theme" })}>
<SelectTrigger className="h-8 w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem>
</SelectContent>
</Select>
{param.paramType === "login" ? (
<>
<Select value={param.source || "accessToken"} onValueChange={(v) => onChange({ source: v })}>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOGIN_SOURCE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{param.source === "other" && (
<Input
value={param.customValue ?? ""}
onChange={(e) => onChange({ customValue: e.target.value })}
placeholder="自定义值"
className="h-8 w-[150px]"
/>
)}
</>
) : (
<Input
value={param.themeValue ?? ""}
onChange={(e) => onChange({ themeValue: e.target.value })}
placeholder="主题值,如 dark-blue"
className="h-8 w-[160px]"
/>
)}
<button
type="button"
onClick={onRemove}
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md text-red-500 hover:bg-red-500/10"
title="删除该参数"
>
<X className="h-4 w-4" />
</button>
</div>
{param.paramType === "theme" && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pl-1">
<span className="text-[11px] text-muted-foreground">示例(点击填入,可再修改):</span>
{THEME_EXAMPLE_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
onClick={() => onChange({ themeValue: o.value })}
className={`rounded px-1.5 py-0.5 text-[11px] ${
param.themeValue === o.value
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground hover:bg-muted/70"
}`}
>
{o.label}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,27 @@
/**
* 圆桌会商气泡底部的「回答速度指示器」—— 复用主聊天的 `SpeedPill`(同款图标 +
* tok/s 数值 + 绿→红渐变条)与 `formatDuration`,外壳套圆桌的细分隔线 + 小字号,
* 让多智能体会商页的每条模型发言与主页面观感一致。
*
* 仅展示用:是否渲染由调用方按 `streamSpeedMeterEnabled` 开关 + 是否有度量决定
* (与主页面共用同一个全局开关)。`durationMs <= 0`(尚无可计时数据)时不渲染。
*/
import {
SpeedPill,
formatDuration,
} from "@/components/workspace/messages/message-token-usage";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
export function DialogueSpeedMeter({ metric }: { metric: MessageMetric }) {
if (metric.durationMs <= 0) {
return null;
}
return (
<div className="roundtable-chat-meta mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-slate-150 pt-1.5 text-slate-400">
<SpeedPill metric={metric} />
<span className="font-mono tabular-nums">
{formatDuration(metric.durationMs)}
</span>
</div>
);
}
@@ -35,7 +35,10 @@ function formatFileSize(bytes: number): string {
}
import { Button } from "@/components/ui/button";
import { useLocalSettings } from "@/core/settings";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
import { Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter";
import {
clarificationKey,
IntentClarificationCard,
@@ -51,6 +54,7 @@ import {
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
@@ -108,6 +112,11 @@ export function Step1Panel(props: Step1PanelProps) {
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
useAutoScroll(step1ScrollRef, [interactiveMessages]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const messageMetrics = useDialogueSpeedMetrics(interactiveMessages);
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
const [inputText, setInputText] = useState<string>("");
// 待发送的本地附件(点发送时连同文本一起上传)。
@@ -165,6 +174,7 @@ export function Step1Panel(props: Step1PanelProps) {
<Step1Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections}
@@ -338,6 +348,11 @@ export function Step1Panel(props: Step1PanelProps) {
*/
interface Step1MessageProps {
msg: InteractiveMessage;
/**
* 本条气泡的「回答速度」度量(tok/s + 时长)。仅对流式过的智能体发言有值;
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
*/
metric?: MessageMetric;
intentReady: object | null;
pendingIntentClarificationMsgId: number | null;
intentClarificationSelections: Map<string, IntentClarificationSelection>;
@@ -354,6 +369,7 @@ interface Step1MessageProps {
function Step1Message({
msg,
metric,
intentReady,
pendingIntentClarificationMsgId,
intentClarificationSelections,
@@ -520,6 +536,8 @@ function Step1Message({
)}
</div>
)}
{metric && <DialogueSpeedMeter metric={metric} />}
</MessageBubble>
);
}
@@ -47,6 +47,11 @@ import {
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
const SHOW_BACKGROUND_SUSPEND = true;
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter";
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -62,6 +67,13 @@ import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsC
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import {
seatFilterForSender,
seatFilterLabel,
senderMatchesSeatFilter,
type SeatFilter,
} from "@/roundtable-planning/lib/seat-filter";
import type {
AgentConfig,
ParallelSeatView,
@@ -94,6 +106,10 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
/** 当前筛选标识;null = 显示全部。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
// ─── 并行显示(§5)────────────────────────────────────────────
parallelBatch: ParallelSeatView[];
selectedParallelSeats: string[];
@@ -155,6 +171,8 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs,
selectedAgentId,
roles,
seatFilter,
setSeatFilter,
parallelBatch,
selectedParallelSeats,
isParallelRunning,
@@ -189,6 +207,42 @@ export function Step2Panel(props: Step2PanelProps) {
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
// 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues);
// ─── 按席位筛选会商记录 ──────────────────────────────────────────────────
// 筛选状态在页面层(与左侧「参与角色 / 人工参与」卡片共用)。点卡片或点气泡头像
// 都把目标归一化成同一 SeatFilter 标识(见 lib/seat-filter),命中 = 标识相等。
// 仅在单列(非并行)视图启用;并行进行时主区另有「头像条选席位」机制,互不干扰。
const toggleSeatFilter = useCallback(
(filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter)),
[setSeatFilter],
);
const visibleDialogues = useMemo(
() =>
seatFilter
? step2RoundtableDialogues.filter((d) =>
senderMatchesSeatFilter(d.sender, seatFilter),
)
: step2RoundtableDialogues,
[seatFilter, step2RoundtableDialogues],
);
// 选中的席位若已不在对话里(切换草稿 / 重置后),自动清除筛选,避免卡在空列表。
useEffect(() => {
if (
seatFilter &&
!step2RoundtableDialogues.some((d) =>
senderMatchesSeatFilter(d.sender, seatFilter),
)
) {
setSeatFilter(null);
}
}, [seatFilter, step2RoundtableDialogues, setSeatFilter]);
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
@@ -456,6 +510,7 @@ export function Step2Panel(props: Step2PanelProps) {
{msg ? (
<Step2Message
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
@@ -472,19 +527,55 @@ export function Step2Panel(props: Step2PanelProps) {
})}
</div>
) : (
step2RoundtableDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
openArtifactPreview(msg.agentThreadId, path);
}
}}
/>
))
<>
{/* 席位筛选提示条:点卡片 / 头像只看某席位发言时出现,可一键显示全部。 */}
{seatFilter && (
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between gap-2 rounded-lg border border-primary/20 bg-[var(--brand-200)]/20 px-3 py-1.5 backdrop-blur-sm">
<span className="roundtable-chat-meta font-medium text-slate-600">
仅显示{" "}
<span className="font-bold text-primary">
{seatFilterLabel(seatFilter)}
</span>{" "}
的发言
</span>
<button
type="button"
onClick={() => setSeatFilter(null)}
className="roundtable-chat-meta inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 font-semibold text-primary transition hover:bg-primary/10"
>
<X className="size-3.5" />
显示全部
</button>
</div>
)}
{seatFilter && visibleDialogues.length === 0 ? (
<div className="py-10 text-center text-xs text-slate-400">
没有匹配「{seatFilterLabel(seatFilter)}」的消息
</div>
) : (
visibleDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
isAvatarSelected={
seatFilter !== null &&
senderMatchesSeatFilter(msg.sender, seatFilter)
}
onAvatarClick={() =>
toggleSeatFilter(seatFilterForSender(msg.sender))
}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
openArtifactPreview(msg.agentThreadId, path);
}
}}
/>
))
)}
</>
)}
</div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
@@ -687,6 +778,18 @@ export function Step2Panel(props: Step2PanelProps) {
*/
export interface Step2MessageProps {
msg: Step2Dialogue;
/**
* 本条气泡的「回答速度」度量(tok/s + 时长)。仅对流式过的模型发言有值;
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
*/
metric?: MessageMetric;
/**
* 点击头像的回调 —— 传入则头像变为可点的「按席位筛选」开关。不传(并行两列等
* 场景)则头像为纯展示,保持原样。
*/
onAvatarClick?: () => void;
/** 当前筛选是否正命中本气泡的席位(命中则头像高亮)。 */
isAvatarSelected?: boolean;
/** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string;
onArtifactStepClick?: (
@@ -698,6 +801,9 @@ export interface Step2MessageProps {
export function Step2Message({
msg,
metric,
onAvatarClick,
isAvatarSelected = false,
resolveAgentName,
onArtifactStepClick,
onFileCardClick,
@@ -712,9 +818,32 @@ export function Step2Message({
const hasContent = content.length > 0;
const presentedFiles = collectPresentedFiles(msg.steps);
const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0");
// 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。
const avatar = onAvatarClick ? (
<button
type="button"
onClick={onAvatarClick}
aria-pressed={isAvatarSelected}
title={
isAvatarSelected
? "取消筛选,显示全部发言"
: `只看 ${msg.sender} 的发言`
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
isAvatarSelected && "ring-2 ring-primary ring-offset-1 ring-offset-white",
)}
>
{avatarInner}
</button>
) : (
avatarInner
);
return (
<MessageBubble
avatar={getAvatar(msg.avatarType, "h-9 w-9 shrink-0")}
avatar={avatar}
senderName={msg.sender}
badge={msg.confidence}
time={msg.time}
@@ -749,6 +878,8 @@ export function Step2Message({
disabled={!msg.agentThreadId}
/>
)}
{metric && <DialogueSpeedMeter metric={metric} />}
</MessageBubble>
);
}
@@ -30,6 +30,7 @@ import {
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
@@ -45,6 +46,7 @@ import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2Sandbo
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import {
useStep3Report,
type ReportIntent,
@@ -480,6 +482,10 @@ function AgentProgressPanel({
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [revisionText, setRevisionText] = useState("");
const handleRevisionSubmit = (e: FormEvent<HTMLFormElement>) => {
@@ -548,6 +554,7 @@ function AgentProgressPanel({
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
@@ -31,6 +31,7 @@ import {
} from "lucide-react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
@@ -54,6 +55,7 @@ import { markdownComponents } from "@/roundtable-planning/components/markdown-co
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
@@ -573,6 +575,10 @@ function AgentProgressPanel({
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [askText, setAskText] = useState("");
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
const [askFiles, setAskFiles] = useState<File[]>([]);
@@ -655,6 +661,7 @@ function AgentProgressPanel({
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
@@ -0,0 +1,153 @@
/**
* 圆桌各步消息气泡的「回答速度指示器」度量 —— 对齐主聊天
* `components/workspace/messages/use-message-metrics.ts` 的算法。Step 1 的
* `InteractiveMessage`、Step 2 / Step 3 的 `Step2Dialogue` 在「id + content +
* streaming」上同形,故本 hook 只依赖这三字段的最小形状,三步通用:
*
* - 圆桌气泡没有 `usage_metadata`,统一走字符估算(≈ 2 字符 / token)。
* - 以「这条气泡是否 **流式过**」作为「它是不是真正的模型生成」的判据:
* 只有 `streaming === true` 出现过的气泡才建度量项。用户提问 / 系统播报 /
* 澄清回复这类一次性整条推入、从不流式的气泡天然不会有速度胶囊。
* - 流式中用滑动窗口算实时 tok/s;流式结束(streaming 翻 false)冻结时长与速率。
*
* 返回 `Map<id, MessageMetric>`,复用主聊天的 `MessageMetric` 形状,
* 以便直接喂给同一个 `SpeedPill`。
*/
import { useEffect, useRef, useState } from "react";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
/** 本 hook 需要的最小气泡形状(Step 1/2/3 的消息类型都满足)。 */
export interface SpeedTrackableMessage {
id: number;
content?: string;
streaming?: boolean;
}
/** 两字符 ≈ 一 token —— 与主聊天 / 站内其它处保持一致的粗估。 */
const CHARS_PER_TOKEN = 2;
/** 流式期间的采样节流。 */
const SAMPLE_THROTTLE_MS = 60;
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastChars: number;
};
function contentLength(dialogue: SpeedTrackableMessage): number {
return (dialogue.content ?? "").length;
}
/**
* 返回按 dialogue id 索引的实时度量快照:流式中实时刷新,结束后冻结。
*/
export function useDialogueSpeedMetrics(
dialogues: SpeedTrackableMessage[],
): Map<number, MessageMetric> {
const timingRef = useRef<Map<number, TimingEntry>>(new Map());
const lastSampleAtRef = useRef(0);
// 采样推入时强制重算 —— 与主聊天同理:timing map 是 ref,直接改不会触发渲染。
const [, setTick] = useState(0);
useEffect(() => {
const now = performance.now();
for (const dialogue of dialogues) {
const streaming = dialogue.streaming === true;
let entry = timingRef.current.get(dialogue.id);
// 仅当气泡 **正在流式** 时才开始跟踪 —— 这是「真模型生成」的信号;
// 已完成才出现(系统播报 / 插话 / 澄清回复)的气泡不建项,也就不出胶囊。
if (!entry) {
if (!streaming) continue;
const chars = contentLength(dialogue);
entry = {
firstSeenAt: now,
endedAt: null,
samples: [{ at: now, chars }],
lastChars: chars,
};
timingRef.current.set(dialogue.id, entry);
continue;
}
const chars = contentLength(dialogue);
if (streaming) {
const grew = chars !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars });
entry.lastChars = chars;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
}
lastSampleAtRef.current = now;
} else if (grew) {
entry.lastChars = chars;
}
} else if (entry.endedAt === null) {
// 流式结束 → 冻结时长,并以最终内容长度收口。
entry.endedAt = now;
entry.lastChars = chars;
}
}
setTick((v) => v + 1);
}, [dialogues]);
// 仍有气泡在流式时慢速 tick,让实时时长 / 速率平滑刷新。
const anyStreaming = dialogues.some((d) => d.streaming === true);
useEffect(() => {
if (!anyStreaming) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
return () => window.clearInterval(interval);
}, [anyStreaming]);
const out = new Map<number, MessageMetric>();
const now = performance.now();
for (const dialogue of dialogues) {
const entry = timingRef.current.get(dialogue.id);
if (!entry) continue;
const isActive = entry.endedAt === null && dialogue.streaming === true;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
let tokensPerSec = 0;
if (isActive && entry.samples.length >= 2) {
// 滑动窗口:取最近 ~1.5s 的样本,反映当前速度而非全程平均。
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = ((chars / elapsedMs) * 1000) / CHARS_PER_TOKEN;
}
}
} else if (durationMs > 0) {
// 已完成:按全程平均。
tokensPerSec = ((entry.lastChars / durationMs) * 1000) / CHARS_PER_TOKEN;
}
out.set(dialogue.id, {
tokensPerSec,
durationMs,
isActive,
// 圆桌气泡无 usage_metadata,速率永远是字符估算。
fromUsage: false,
// 圆桌每条气泡就是一位席位的一次发言,逐条都展示(无需「turn-final」收敛)。
isTurnFinal: true,
});
}
return out;
}
@@ -0,0 +1,63 @@
/**
* 「按席位筛选会商记录」的匹配逻辑 —— 第二步左侧「参与角色 / 人工参与」卡片
* 与消息气泡头像共用同一套标识。
*
* 设计要点:把**任意一条气泡的 sender** 和**任意一张卡片**都归一化成同一个
* `SeatFilter` 字符串标识,于是「某气泡命中当前筛选」就等价于「两边标识相等」,
* 卡片点击与头像点击不再各写一套匹配规则。
*
* 三类标识:
* - `"coordinator"` —— 总控协调 / 系统 调度播报(对应「指挥员 / 主持人」卡片)
* - `"user"` —— 用户插话 / 澄清回复(对应「人工参与 · 用户」卡片)
* - `"agent:<显示名>"` —— 某 AI 席位的发言(对应该席位卡片)
*/
import { COMMANDER_SEAT_ID, type RoleEntry } from "./roundtable-constants";
export type SeatFilter = string;
/** 总控协调气泡固定使用的 sender(见 useStep2Orchestration 的 appendStreamingDialogue)。 */
export const COORDINATOR_SENDER = "总控协调";
export const COORDINATOR_SEAT_FILTER: SeatFilter = "coordinator";
export const USER_SEAT_FILTER: SeatFilter = "user";
const AGENT_PREFIX = "agent:";
/** 把一条气泡的 sender 归一化成筛选标识。 */
export function seatFilterForSender(sender: string): SeatFilter {
if (sender === COORDINATOR_SENDER || sender === "系统") {
return COORDINATOR_SEAT_FILTER;
}
if (sender.startsWith("用户")) {
return USER_SEAT_FILTER;
}
// 去掉「方案设计 (deepseek-…)」这种「模型偏好已保存」播报的模型后缀,回到席位基名,
// 让它与该席位的正常发言归到同一标识。
const base = sender.replace(/\s*\([^)]*\)\s*$/, "").trim();
return `${AGENT_PREFIX}${base}`;
}
/** 把「参与角色」里的一行(席位)映射成筛选标识。 */
export function seatFilterForRole(role: RoleEntry): SeatFilter {
// 指挥员 / 主持人(COMMANDER_SEAT_ID,human):对应总控协调 / 系统 调度播报流。
if (role.type === "human" || role.id === COMMANDER_SEAT_ID) {
return COORDINATOR_SEAT_FILTER;
}
return `${AGENT_PREFIX}${role.name}`;
}
/** 某 sender 是否命中当前筛选。 */
export function senderMatchesSeatFilter(
sender: string,
filter: SeatFilter,
): boolean {
return seatFilterForSender(sender) === filter;
}
/** 筛选提示条用的人类可读标签。 */
export function seatFilterLabel(filter: SeatFilter): string {
if (filter === COORDINATOR_SEAT_FILTER) return "指挥员 / 主持人";
if (filter === USER_SEAT_FILTER) return "用户";
if (filter.startsWith(AGENT_PREFIX)) return filter.slice(AGENT_PREFIX.length);
return filter;
}
@@ -86,6 +86,11 @@ import {
type RoleEntry,
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
import {
seatFilterForRole,
USER_SEAT_FILTER,
type SeatFilter,
} from "@/roundtable-planning/lib/seat-filter";
import {
buildChainSeatSelection,
type SeatSelectionResult,
@@ -215,6 +220,8 @@ export default function RoundtablePlanningPage() {
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
// 第二步「按席位筛选会商记录」—— 左侧角色卡片与中间消息头像共用的筛选标识。
const [seatFilter, setSeatFilter] = useState<SeatFilter | null>(null);
// Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入;
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
@@ -1153,6 +1160,8 @@ export default function RoundtablePlanningPage() {
step2Runs={step2Runs}
activeStep2RunId={activeStep2RunId}
onSelectStep2Run={handleSelectStep2Run}
seatFilter={seatFilter}
setSeatFilter={setSeatFilter}
/>
{/* MIDDLE VIEWPORT */}
@@ -1216,6 +1225,8 @@ export default function RoundtablePlanningPage() {
agentConfigs={step2.agentConfigs}
selectedAgentId={step2.selectedAgentId}
roles={roles}
seatFilter={seatFilter}
setSeatFilter={setSeatFilter}
parallelBatch={step2.parallelBatch}
selectedParallelSeats={step2.selectedParallelSeats}
isParallelRunning={step2.isParallelRunning}
@@ -1623,6 +1634,9 @@ interface LeftSidebarProps {
step2Runs: DraftStep2RunSnapshot[];
activeStep2RunId: string | null;
onSelectStep2Run: (runId: string) => void;
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
}
function Step2SandboxSidebarSync({
@@ -1662,8 +1676,13 @@ function LeftSidebar({
step2Runs,
activeStep2RunId,
onSelectStep2Run,
seatFilter,
setSeatFilter,
}: LeftSidebarProps) {
const isStep2 = currentStep === 2;
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
const toggleSeatFilter = (filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter));
// Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应
// 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。
@@ -1708,13 +1727,25 @@ function LeftSidebar({
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
</div>
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{roles.map((role) => (
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} />
))}
{roles.map((role) => {
const filter = seatFilterForRole(role);
return (
<RoleRow
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div>
</div>
<HumanParticipationCard />
<HumanParticipationCard
active={seatFilter === USER_SEAT_FILTER}
onSelect={() => toggleSeatFilter(USER_SEAT_FILTER)}
/>
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
@@ -1730,20 +1761,41 @@ function LeftSidebar({
<CollapsedSidebarSection title="参与角色">
<div className="flex flex-col items-center gap-2">
{roles.map((role) => (
<RoleAvatarBadge
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
size="h-8 w-8"
showSpeakingAnimation
/>
))}
{roles.map((role) => {
const filter = seatFilterForRole(role);
return (
<RoleAvatarBadge
key={role.id}
role={role}
agentNameToRole={agentNameToRole}
size="h-8 w-8"
showSpeakingAnimation
active={seatFilter === filter}
onSelect={() => toggleSeatFilter(filter)}
/>
);
})}
</div>
</CollapsedSidebarSection>
<CollapsedSidebarSection title="人工参与">
<Man1Avatar size="h-8 w-8" />
<button
type="button"
onClick={() => toggleSeatFilter(USER_SEAT_FILTER)}
aria-pressed={seatFilter === USER_SEAT_FILTER}
title={
seatFilter === USER_SEAT_FILTER
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
seatFilter === USER_SEAT_FILTER &&
"ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
<Man1Avatar size="h-8 w-8" />
</button>
</CollapsedSidebarSection>
<CollapsedSidebarSection title="任务概览">
@@ -1897,14 +1949,50 @@ function TaskOverviewCard({ currentStep }: { currentStep: number }) {
);
}
function HumanParticipationCard() {
function HumanParticipationCard({
active = false,
onSelect,
}: {
/** 当前筛选是否命中「用户」(命中则高亮)。 */
active?: boolean;
/** 传入则可点击,用于筛选出用户的插话 / 澄清回复。 */
onSelect?: () => void;
} = {}) {
const clickable = Boolean(onSelect);
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
</div>
<div className="flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3">
<div
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: "只看 用户 的发言"
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3 transition",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active && "ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40",
)}
>
<div className="flex min-w-0 items-center gap-3">
<Man1Avatar size="h-10 w-10" />
<span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span>
@@ -2318,6 +2406,8 @@ function RoleAvatarBadge({
agentNameToRole,
size = "h-7 w-7",
showSpeakingAnimation = false,
active = false,
onSelect,
}: {
role: RoleEntry;
agentNameToRole: Record<
@@ -2326,16 +2416,21 @@ function RoleAvatarBadge({
>;
size?: string;
showSpeakingAnimation?: boolean;
/** 当前筛选是否命中本席位(命中则头像高亮)。 */
active?: boolean;
/** 传入则头像可点击,用于「按席位筛选会商记录」(收起态)。 */
onSelect?: () => void;
}) {
const isOngoing = role.status === "研讨中";
const animate = showSpeakingAnimation && isOngoing;
const clickable = Boolean(onSelect);
return (
const inner = (
<div
title={`${role.name} · ${role.status}`}
className={cn(
"relative shrink-0 rounded-full",
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
active && "ring-2 ring-primary ring-offset-2 ring-offset-white",
)}
>
{animate && (
@@ -2346,33 +2441,80 @@ function RoleAvatarBadge({
</div>
</div>
);
if (!clickable) {
return <div title={`${role.name} · ${role.status}`}>{inner}</div>;
}
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
title={active ? "取消筛选,显示全部发言" : `只看 ${role.name} 的发言`}
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
>
{inner}
</button>
);
}
function RoleRow({
role,
agentNameToRole,
active = false,
onSelect,
}: {
role: RoleEntry;
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
/** 当前筛选是否命中本席位(命中则整卡高亮)。 */
active?: boolean;
/** 传入则整卡可点击,用于「按席位筛选会商记录」。 */
onSelect?: () => void;
}) {
const isActive =
role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
const isOngoing = role.status === "研讨中";
const isBootingUp = role.status === "正在上线";
const clickable = Boolean(onSelect);
return (
<div
id={`role-item-${role.id}`}
className={`flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition ${
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
aria-pressed={clickable ? active : undefined}
title={
clickable
? active
? "取消筛选,显示全部发言"
: `只看 ${role.name} 的发言`
: undefined
}
onClick={onSelect}
onKeyDown={
clickable
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect?.();
}
}
: undefined
}
className={cn(
"flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition",
isActive
? "bg-slate-50 border border-slate-100"
: isBootingUp
? "bg-amber-50/60 border border-amber-100"
: "opacity-60"
}`}
: "opacity-60",
clickable && "cursor-pointer hover:border-primary/30 hover:bg-primary/5",
active &&
"ring-2 ring-primary ring-offset-1 ring-offset-white !border-primary/40 !opacity-100",
)}
>
<div className="flex min-w-0 items-center gap-3">
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />