Compare commits

..
1 Commits
Author SHA1 Message Date
董宏凯andCursor e939dc4548 feat(frontend): 岗位会商页面布局与意图理解交互优化
完善 PositionRoundtable 样式与输入组件,补充 SVG 资源,并同步 Step1 意图相关逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:54 +08:00
42 changed files with 2747 additions and 2391 deletions

No files matched your search

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M24.036,10l-.012,9.333" transform="translate(-16.016 -6.667)"/><path class="b" d="M10,24h9.333" transform="translate(-6.667 -16)"/></svg>

After

Width:  |  Height:  |  Size: 401 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="36" height="36" viewBox="0 0 36 36"><defs><style>.a,.d{fill:#fff;}.a{opacity:0;}.b{fill:#da2331;}.c{fill:#0a54cf;}.e{fill:#1677ff;}.f{fill:#1e2433;}.g{fill:#10d6ff;}.h{fill:#ff8181;}.i{fill:none;stroke:#10d6ff;stroke-linecap:round;stroke-linejoin:round;}.j{filter:url(#d);}.k{filter:url(#a);}</style><filter id="a"><feOffset dx="4" input="SourceAlpha"/><feGaussianBlur stdDeviation="1" result="b"/><feFlood flood-opacity="0.161" result="c"/><feComposite operator="out" in="SourceGraphic" in2="b"/><feComposite operator="in" in="c"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="d"><feOffset dx="-4" input="SourceAlpha"/><feGaussianBlur stdDeviation="1" result="e"/><feFlood flood-opacity="0.161" result="f"/><feComposite operator="out" in="SourceGraphic" in2="e"/><feComposite operator="in" in="f"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(0 -1)"><rect class="a" width="36" height="36" transform="translate(0 1)"/><path class="b" d="M23.5,14.75a1.5,1.5,0,0,1-1.5-1.5V9.5a1.5,1.5,0,1,1,3,0v3.75A1.5,1.5,0,0,1,23.5,14.75Z" transform="translate(-5.5 -1)"/><g data-type="innerShadowGroup"><path class="c" d="M-2-2H1C3.206-2,5-.532,5,1.273V3.727C5,5.532,3.206,7,1,7H-2Z" transform="translate(28.66 19.541)"/><g class="k" transform="matrix(1, 0, 0, 1, 0, 1)"><path class="d" d="M-2-2H1C3.206-2,5-.532,5,1.273V3.727C5,5.532,3.206,7,1,7H-2Z" transform="translate(28.66 18.54)"/></g></g><g data-type="innerShadowGroup"><path class="c" d="M5-2H2C-.206-2-2-.532-2,1.273V3.727C-2,5.532-.206,7,2,7H5Z" transform="translate(4.34 19.541)"/><g class="j" transform="matrix(1, 0, 0, 1, 0, 1)"><path class="d" d="M5-2H2C-.206-2-2-.532-2,1.273V3.727C-2,5.532-.206,7,2,7H5Z" transform="translate(4.34 18.54)"/></g></g><path class="e" d="M5.5-2H16a7.508,7.508,0,0,1,7.5,7.5V13A7.508,7.508,0,0,1,16,20.5H5.5A7.508,7.508,0,0,1-2,13V5.5A7.508,7.508,0,0,1,5.5-2Z" transform="translate(7.25 13.5)"/><path class="f" d="M5.25,0h9A5.25,5.25,0,0,1,19.5,5.25V7.5a5.25,5.25,0,0,1-5.25,5.25h-9A5.25,5.25,0,0,1,0,7.5V5.25A5.25,5.25,0,0,1,5.25,0Z" transform="translate(8.25 15.25)"/><circle class="g" cx="1.5" cy="1.5" r="1.5" transform="translate(21 19)"/><circle class="g" cx="1.5" cy="1.5" r="1.5" transform="translate(12 19)"/><path class="d" d="M24,10v3.75" transform="translate(-6 -1.5)"/><circle class="h" cx="3" cy="3" r="3" transform="translate(15 4)"/><path class="i" d="M44,28.667a3.367,3.367,0,0,0,1.922.412,2.841,2.841,0,0,0,1.828-.412" transform="translate(-27.875 -5.017)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#006efe;stroke-width:6px;}.b{stroke-linecap:round;stroke-linejoin:round;}</style></defs><g transform="translate(0 1)"><rect class="a" width="96" height="96" transform="translate(0 -1)"/><path class="b" d="M47,67H38L18,77V67H4V7H84V29" transform="translate(4 8)"/><circle class="c" cx="12" cy="12" r="12" transform="translate(56 45)"/><path class="b" d="M39,32l10,8" transform="translate(39 33)"/><path class="b" d="M12,15H24" transform="translate(12 16)"/><path class="b" d="M12,21H36" transform="translate(12 22)"/></g></svg>

After

Width:  |  Height:  |  Size: 670 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13" height="11" viewBox="0 0 13 11"><defs><style>.a{stroke:rgba(0,0,0,0);stroke-miterlimit:10;fill:url(#a);}</style><linearGradient id="a" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><path class="a" d="M1,10A1,1,0,1,1,1,8H6a1,1,0,0,1,0,2Zm9.8-.482L7.848,7.361A.445.445,0,0,1,7.661,7a.46.46,0,0,1,.187-.366L10.8,4.481a.45.45,0,0,1,.716.365V9.152a.44.44,0,0,1-.131.317.445.445,0,0,1-.322.136A.454.454,0,0,1,10.8,9.518ZM1,6A1,1,0,1,1,1,4H6A1,1,0,0,1,6,6ZM1,2A1,1,0,1,1,1,0H11a1,1,0,1,1,0,2Z" transform="translate(0.5 0.5)"/></svg>

After

Width:  |  Height:  |  Size: 753 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13" height="11" viewBox="0 0 13 11"><defs><style>.a{fill:url(#a);}.b{fill:url(#b);}</style><linearGradient id="a" x1="0.023" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff" stop-opacity="0.302"/><stop offset="1" stop-color="#00c7ff" stop-opacity="0.302"/></linearGradient><linearGradient id="b" x1="0.023" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><path class="a" d="M5.5,0,11,8H0Z" transform="translate(13) rotate(90)"/><path class="b" d="M5.5,0,11,8H0Z" transform="translate(8) rotate(90)"/></svg>

After

Width:  |  Height:  |  Size: 735 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:rgba(255,255,255,0.8);}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M10,1.5A1.5,1.5,0,1,1,11.5,3,1.5,1.5,0,0,1,10,1.5Zm-5,0A1.5,1.5,0,1,1,6.5,3,1.5,1.5,0,0,1,5,1.5Zm-5,0A1.5,1.5,0,1,1,1.5,3,1.5,1.5,0,0,1,0,1.5Z" transform="translate(1.498 6.5)"/></svg>

After

Width:  |  Height:  |  Size: 410 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:rgba(85,166,254,0.6);}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M12.715,13.421,9.588,10.294a5.841,5.841,0,1,1,.707-.707l3.128,3.128a.5.5,0,0,1-.708.706ZM1,5.833A4.833,4.833,0,1,0,5.834,1,4.838,4.838,0,0,0,1,5.833Z" transform="translate(1.334 1.216)"/></svg>

After

Width:  |  Height:  |  Size: 418 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;}</style></defs><path class="a" d="M14.488.053.824,4.256a1.167,1.167,0,0,0-.285,2.1L4.576,8.936a1.167,1.167,0,0,0,1.382-.092L11,4.593A.292.292,0,0,1,11.408,5L7.156,10.043a1.167,1.167,0,0,0-.091,1.382l2.58,4.036a1.168,1.168,0,0,0,2.1-.286l4.2-13.663A1.167,1.167,0,0,0,14.488.053Z" transform="translate(0)"/></svg>

After

Width:  |  Height:  |  Size: 422 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16.131" height="16" viewBox="0 0 16.131 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#97edff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M6,30V34.12H18.361V30" transform="translate(-2.73 -19.994)"/><path class="b" d="M21.181,9.09,18.09,6,15,9.09" transform="translate(-8.64 -4.235)"/><path class="b" d="M23.992,14.241V6" transform="translate(-14.544 -4.235)"/></svg>

After

Width:  |  Height:  |  Size: 504 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#346fb1;stroke-linejoin:round;}.c{stroke-linecap:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M10.667,17.333A6.667,6.667,0,1,0,4,10.667,6.667,6.667,0,0,0,10.667,17.333Z" transform="translate(-2.667 -2.667)"/><path class="c" d="M24.008,12v4l2.826,2.826" transform="translate(-16.005 -8)"/></svg>

After

Width:  |  Height:  |  Size: 473 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(212,120,0,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 320 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(207,19,34,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 320 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(29,171,123,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(132,85,230,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(201,181,0,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 320 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(69,195,243,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#346fb1;stroke-linecap:round;stroke-linejoin:round;}</style></defs><g transform="translate(0 -2)"><rect class="a" width="16" height="16" transform="translate(0 2)"/><path class="b" d="M17.333,8H4V18H9l1.667,1.667L12.333,18h5Z" transform="translate(-2.667 -3.667)"/><circle class="b" cx="1.667" cy="1.667" r="1.667" transform="translate(6.333 6.333)"/><path class="b" d="M21,26.667A2.848,2.848,0,0,0,18,24a2.848,2.848,0,0,0-3,2.667" transform="translate(-10 -14.333)"/></g></svg>

After

Width:  |  Height:  |  Size: 618 B

@@ -10,7 +10,6 @@ import {
Gauge,
ListTree,
MousePointerClick,
FileText,
BriefcaseBusiness,
Settings,
ShieldAlert,
@@ -237,14 +236,6 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/notification-templates")}
>
<FileText className="size-4" />
通知模板管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/position-roles")}
@@ -558,14 +549,6 @@ function LegacyAccordionSidebar({
按钮管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/notification-templates")}
>
<FileText className="size-4" />
通知模板管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
<BriefcaseBusiness className="size-4" />
@@ -100,15 +100,17 @@ function Section({
icon,
title,
extra,
className,
children,
}: {
icon?: ReactNode;
title: string;
extra?: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<section className="bg-card/60 rounded-xl border p-4">
<section className={cn("bg-card/60 rounded-xl border p-4", className)}>
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
{icon}
@@ -139,6 +141,15 @@ export function AgentDetailPanel({
defaultCollapsed,
showRecentChats,
compactAppearanceActions,
avatarSrc,
positionName,
skillsIcon,
skillsTitle,
showCommonQuestions = false,
panelTitle,
embedded,
hideStatusBadges = false,
editIcon,
}: {
agent: Agent | null | undefined;
agentName: string;
@@ -150,6 +161,19 @@ export function AgentDetailPanel({
showRecentChats?: boolean;
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
compactAppearanceActions?: ReactNode;
avatarSrc?: string;
positionName?: string;
skillsIcon?: ReactNode;
skillsTitle?: string;
showCommonQuestions?: boolean;
/** Overrides the standard panel heading for an embedded consumer. */
panelTitle?: string;
/** Renders one fixed panel instead of the responsive collapsible rail. */
embedded?: boolean;
/** Keeps deployment/status badges out of an embedded product surface. */
hideStatusBadges?: boolean;
/** Optional icon used by the compact embedded edit action. */
editIcon?: ReactNode;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
@@ -472,11 +496,25 @@ export function AgentDetailPanel({
</div>
) : (
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
{canTogglePublish && (
{embedded ? (
<Tooltip content="编辑智能体">
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={beginEdit}
aria-label="编辑智能体"
>
{editIcon ?? <SquarePenIcon className="size-3.5" />}
</Button>
</Tooltip>
) : (
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
)}
{!embedded && canTogglePublish && (
<Tooltip content={agent?.published ? "下架" : "发布"}>
<Button
size="icon-sm"
@@ -493,17 +531,19 @@ export function AgentDetailPanel({
</Button>
</Tooltip>
)}
<Tooltip content="删除智能体">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
aria-label="删除智能体"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
{!embedded && (
<Tooltip content="删除智能体">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
aria-label="删除智能体"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
)}
</div>
)
) : null;
@@ -521,7 +561,11 @@ export function AgentDetailPanel({
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start gap-3">
<div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
<BotIcon className="text-primary size-6" />
{avatarSrc ? (
<img src={avatarSrc} alt="" aria-hidden="true" className="size-9" />
) : (
<BotIcon className="text-primary size-6" />
)}
</div>
<div className="min-w-0 flex-1 space-y-1.5">
{editing && draft ? (
@@ -541,9 +585,16 @@ export function AgentDetailPanel({
{displayName}
</div>
)}
<div className="flex flex-wrap items-center gap-1">
{statusBadges}
</div>
{!editing && positionName && (
<div className="position-agent-role text-sm">
岗位:{desensitize(positionName)}
</div>
)}
{!hideStatusBadges && (
<div className="flex flex-wrap items-center gap-1">
{statusBadges}
</div>
)}
{!editing && agent.owner_username && !agent.builtin && (
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<UserRoundIcon className="size-3 shrink-0" />
@@ -646,7 +697,7 @@ export function AgentDetailPanel({
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(editing || questions.length > 0 || canEdit) && (
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
<Section
icon={<MessageCircleQuestionIcon className="size-3.5" />}
title="常用问题"
@@ -787,8 +838,9 @@ export function AgentDetailPanel({
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
<Section
icon={<SparklesIcon className="size-3.5" />}
title={`已启用技能 ${skillNamesShown.length} 个`}
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
className="position-roundtable-skills-section"
extra={
editing ? (
<Button
@@ -830,7 +882,9 @@ export function AgentDetailPanel({
const panelChrome = (collapseButton: ReactNode) => (
<>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">智能体信息</span>
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">
{panelTitle ?? "智能体信息"}
</span>
<div className="flex min-w-0 items-center gap-1">
{headerActions}
{compactAppearanceActions}
@@ -844,7 +898,11 @@ export function AgentDetailPanel({
return (
<>
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
{collapsed ? (
{embedded ? (
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
{panelChrome(null)}
</aside>
) : collapsed ? (
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
<div className="flex items-center gap-1">
{compactAppearanceActions}
@@ -877,30 +935,34 @@ export function AgentDetailPanel({
</aside>
)}
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setMobileOpen(true)}
aria-label="打开智能体信息"
>
<InfoIcon className="size-4" />
</Button>
</Tooltip>
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 pb-3 pt-4">
<SheetTitle className="flex items-center justify-between text-sm">
智能体信息
<span className="mr-6">{headerActions}</span>
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</SheetContent>
</Sheet>
{!embedded && (
<>
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setMobileOpen(true)}
aria-label="打开智能体信息"
>
<InfoIcon className="size-4" />
</Button>
</Tooltip>
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 pb-3 pt-4">
<SheetTitle className="flex items-center justify-between text-sm">
智能体信息
<span className="mr-6">{headerActions}</span>
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</SheetContent>
</Sheet>
</>
)}
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
<SkillPickerSheet
@@ -1,706 +0,0 @@
"use client";
/**
* 通知模板管理(仅 admin):创建/维护消息通知模板,支持变量校验与实时预览、
* 乐观锁冲突对比(重新加载 / 覆盖保存)。
*/
import { useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import {
AlertTriangle,
Copy,
FileText,
Loader2,
Pencil,
Plus,
Search,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import { cn } from "@/lib/utils";
import {
NOTIFICATION_TEMPLATE_VARIABLES,
NotificationTemplateConflictError,
previewNotificationTemplate,
useNotificationTemplateActions,
useNotificationTemplates,
type NotificationTemplate,
type NotificationTemplateInput,
type PreviewResult,
} from "@/strategy-components/api/notification-templates";
interface FormState {
name: string;
titleTemplate: string;
contentTemplate: string;
tagsText: string;
enabled: boolean;
}
interface ConflictState {
server: NotificationTemplate;
mine: FormState;
}
const emptyForm = (): FormState => ({
name: "",
titleTemplate: "",
contentTemplate: "",
tagsText: "",
enabled: true,
});
function formFromTemplate(t: NotificationTemplate): FormState {
return {
name: t.name,
titleTemplate: t.titleTemplate,
contentTemplate: t.contentTemplate,
tagsText: (t.tags ?? []).join(", "),
enabled: t.enabled,
};
}
function parseTags(text: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const part of text.split(/[,,]/)) {
const tag = part.trim();
if (!tag || seen.has(tag)) continue;
seen.add(tag);
out.push(tag);
}
return out;
}
function toInput(form: FormState): NotificationTemplateInput {
return {
name: form.name,
titleTemplate: form.titleTemplate,
contentTemplate: form.contentTemplate,
tags: parseTags(form.tagsText),
enabled: form.enabled,
};
}
const DEFAULT_PREVIEW_VARS: Record<string, string> = {
userName: "张三",
taskName: "示例任务",
taskId: "T-001",
dueDate: "2026-08-10",
};
export default function NotificationTemplatesPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const actions = useNotificationTemplateActions();
const [keyword, setKeyword] = useState("");
const [keywordApplied, setKeywordApplied] = useState("");
const [enabledFilter, setEnabledFilter] = useState<"all" | "true" | "false">("all");
const [tagFilter, setTagFilter] = useState("");
const [page, setPage] = useState(1);
const pageSize = 10;
const listParams = useMemo(
() => ({
keyword: keywordApplied || undefined,
enabled:
enabledFilter === "all" ? null : enabledFilter === "true" ? true : false,
tag: tagFilter.trim() || undefined,
page,
pageSize,
}),
[keywordApplied, enabledFilter, tagFilter, page, pageSize],
);
const { data, isLoading, isError, error, refetch, isFetching } =
useNotificationTemplates(listParams);
const items = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<NotificationTemplate | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [saveError, setSaveError] = useState<string | null>(null);
const [conflict, setConflict] = useState<ConflictState | null>(null);
const [deleteTarget, setDeleteTarget] = useState<NotificationTemplate | null>(null);
const [previewVars, setPreviewVars] = useState(DEFAULT_PREVIEW_VARS);
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const saving =
actions.create.isPending || actions.update.isPending || actions.duplicate.isPending;
// 实时预览(编辑弹窗打开时)
useEffect(() => {
if (!dialogOpen) return;
let cancelled = false;
const timer = window.setTimeout(() => {
setPreviewLoading(true);
void previewNotificationTemplate({
titleTemplate: form.titleTemplate,
contentTemplate: form.contentTemplate,
variables: previewVars,
})
.then((r) => {
if (!cancelled) setPreview(r);
})
.catch(() => {
if (!cancelled) setPreview(null);
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
}, 280);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [dialogOpen, form.titleTemplate, form.contentTemplate, previewVars]);
if (!isAdmin) {
return <Navigate to="/page/workspace/chats/new" replace />;
}
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setSaveError(null);
setConflict(null);
setDialogOpen(true);
};
const openEdit = (t: NotificationTemplate) => {
setEditing(t);
setForm(formFromTemplate(t));
setSaveError(null);
setConflict(null);
setDialogOpen(true);
};
const applySearch = () => {
setPage(1);
setKeywordApplied(keyword.trim());
};
const handleSave = async (opts?: { forceVersion?: number }) => {
setSaveError(null);
const input = toInput(form);
try {
if (editing) {
const version = opts?.forceVersion ?? editing.version;
const updated = await actions.update.mutateAsync({
id: editing.id,
input: { ...input, expectedVersion: version },
});
setEditing(updated);
setConflict(null);
toast.success("模板已保存");
setDialogOpen(false);
} else {
await actions.create.mutateAsync(input);
toast.success("模板已创建");
setDialogOpen(false);
}
} catch (e) {
if (e instanceof NotificationTemplateConflictError) {
setConflict({ server: e.server, mine: form });
setSaveError("版本冲突:服务器上的模板已被他人更新,请选择重新加载或覆盖保存。");
return;
}
const msg = e instanceof Error ? e.message : "保存失败";
setSaveError(msg);
toast.error(msg);
}
};
const handleReloadFromServer = () => {
if (!conflict) return;
setEditing(conflict.server);
setForm(formFromTemplate(conflict.server));
setConflict(null);
setSaveError(null);
toast.info("已加载服务器最新版本");
};
const handleForceOverwrite = async () => {
if (!conflict) return;
await handleSave({ forceVersion: conflict.server.version });
};
const handleToggle = async (t: NotificationTemplate) => {
try {
await actions.update.mutateAsync({
id: t.id,
input: {
name: t.name,
titleTemplate: t.titleTemplate,
contentTemplate: t.contentTemplate,
tags: t.tags,
enabled: !t.enabled,
expectedVersion: t.version,
},
});
toast.success(t.enabled ? "已停用" : "已启用");
} catch (e) {
if (e instanceof NotificationTemplateConflictError) {
toast.error("版本冲突,请刷新列表后重试");
void refetch();
return;
}
toast.error(e instanceof Error ? e.message : "启停失败");
}
};
const handleDuplicate = async (t: NotificationTemplate) => {
try {
await actions.duplicate.mutateAsync(t.id);
toast.success("已复制");
} catch (e) {
toast.error(e instanceof Error ? e.message : "复制失败");
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await actions.remove.mutateAsync(deleteTarget.id);
toast.success("已删除");
setDeleteTarget(null);
if (items.length <= 1 && page > 1) setPage((p) => p - 1);
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
}
};
const insertVariable = (name: string, field: "titleTemplate" | "contentTemplate") => {
const token = `{{${name}}}`;
setForm((f) => ({ ...f, [field]: `${f[field]}${token}` }));
};
return (
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-4 p-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
<FileText className="size-5" />
通知模板管理
</h1>
<p className="mt-1 text-sm text-muted-foreground">
维护消息通知的标题/正文模板。支持变量:
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<code key={v} className="mx-1 rounded bg-muted px-1.5 py-0.5 text-xs">
{`{{${v}}}`}
</code>
))}
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
新建模板
</Button>
</header>
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
<div className="min-w-[200px] flex-1">
<label className="mb-1 block text-xs text-muted-foreground">关键字</label>
<div className="flex gap-2">
<Input
value={keyword}
placeholder="搜索名称 / 标题 / 正文"
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") applySearch();
}}
/>
<Button variant="secondary" onClick={applySearch}>
<Search className="size-4" />
</Button>
</div>
</div>
<div className="w-36">
<label className="mb-1 block text-xs text-muted-foreground">启用状态</label>
<Select
value={enabledFilter}
onValueChange={(v) => {
setEnabledFilter(v as "all" | "true" | "false");
setPage(1);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="true">已启用</SelectItem>
<SelectItem value="false">已停用</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-40">
<label className="mb-1 block text-xs text-muted-foreground">标签</label>
<Input
value={tagFilter}
placeholder="精确匹配标签"
onChange={(e) => {
setTagFilter(e.target.value);
setPage(1);
}}
/>
</div>
</div>
{isError ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-12 text-center">
<AlertTriangle className="size-8 text-destructive" />
<p className="text-sm text-destructive">
{error instanceof Error && /403|仅限管理员|Forbidden/i.test(error.message)
? "无权限访问通知模板管理"
: error instanceof Error
? error.message
: "加载失败"}
</p>
<Button variant="outline" onClick={() => void refetch()}>
重试
</Button>
</div>
) : isLoading ? (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
加载中…
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
<FileText className="size-10 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">暂无通知模板</p>
<Button onClick={openCreate}>
<Plus className="size-4" />
创建第一个模板
</Button>
</div>
) : (
<div className={cn("overflow-hidden rounded-lg border", isFetching && "opacity-70")}>
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">名称</th>
<th className="px-3 py-2 font-medium">标题模板</th>
<th className="px-3 py-2 font-medium">标签</th>
<th className="px-3 py-2 font-medium">版本</th>
<th className="px-3 py-2 font-medium">启用</th>
<th className="px-3 py-2 font-medium text-right">操作</th>
</tr>
</thead>
<tbody>
{items.map((t) => (
<tr key={t.id} className="border-t">
<td className="px-3 py-2.5 font-medium">{t.name}</td>
<td className="max-w-[240px] truncate px-3 py-2.5 text-muted-foreground">
{t.titleTemplate}
</td>
<td className="px-3 py-2.5">
<div className="flex flex-wrap gap-1">
{t.tags.length === 0 ? (
<span className="text-xs text-muted-foreground">—</span>
) : (
t.tags.map((tag) => (
<span
key={tag}
className="rounded bg-muted px-1.5 py-0.5 text-xs"
>
{tag}
</span>
))
)}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground">v{t.version}</td>
<td className="px-3 py-2.5">
<Switch checked={t.enabled} onCheckedChange={() => void handleToggle(t)} />
</td>
<td className="px-3 py-2.5">
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => openEdit(t)}
title="编辑"
>
<Pencil className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void handleDuplicate(t)}
title="复制"
>
<Copy className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => setDeleteTarget(t)}
title="删除"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground">
<span>
共 {total} 条 · 第 {page}/{totalPages} 页
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
上一页
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
)}
{/* 新建 / 编辑 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[92vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? "编辑通知模板" : "新建通知模板"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">名称</label>
<Input
value={form.name}
maxLength={50}
placeholder="2~50 个字符"
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">标题模板</label>
<Input
value={form.titleTemplate}
maxLength={120}
placeholder="如:任务 {{taskName}} 即将到期"
onChange={(e) => setForm((f) => ({ ...f, titleTemplate: e.target.value }))}
/>
<div className="mt-1 flex flex-wrap gap-1">
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<button
key={v}
type="button"
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
onClick={() => insertVariable(v, "titleTemplate")}
>
{`{{${v}}}`}
</button>
))}
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">正文模板</label>
<Textarea
value={form.contentTemplate}
rows={7}
maxLength={5000}
placeholder="支持 {{userName}} / {{taskName}} / {{taskId}} / {{dueDate}}"
onChange={(e) => setForm((f) => ({ ...f, contentTemplate: e.target.value }))}
/>
<div className="mt-1 flex flex-wrap gap-1">
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<button
key={v}
type="button"
className="rounded border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted"
onClick={() => insertVariable(v, "contentTemplate")}
>
{`{{${v}}}`}
</button>
))}
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
标签(逗号分隔,最多 5 个)
</label>
<Input
value={form.tagsText}
placeholder="提醒, 任务"
onChange={(e) => setForm((f) => ({ ...f, tagsText: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={form.enabled}
onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
/>
<span className="text-sm">启用</span>
{editing && (
<span className="ml-auto text-xs text-muted-foreground">
当前编辑版本 v{editing.version}
</span>
)}
</div>
</div>
<div className="space-y-3 rounded-lg border bg-muted/20 p-3">
<div className="text-sm font-medium">实时预览</div>
<div className="grid grid-cols-2 gap-2">
{NOTIFICATION_TEMPLATE_VARIABLES.map((v) => (
<div key={v}>
<label className="mb-0.5 block text-[11px] text-muted-foreground">{v}</label>
<Input
value={previewVars[v] ?? ""}
onChange={(e) =>
setPreviewVars((prev) => ({ ...prev, [v]: e.target.value }))
}
/>
</div>
))}
</div>
<div className="rounded border bg-background p-3 text-sm">
{previewLoading && !preview ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
预览中…
</div>
) : (
<>
<div className="mb-2 font-medium">{preview?.title || "(标题预览)"}</div>
<div className="whitespace-pre-wrap text-muted-foreground">
{preview?.content || "(正文预览)"}
</div>
{preview && preview.missingVariables.length > 0 && (
<p className="mt-2 text-xs text-amber-600">
缺少变量:{preview.missingVariables.join(", ")}(保留原占位符)
</p>
)}
</>
)}
</div>
</div>
</div>
{saveError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{saveError}
</div>
)}
{conflict && (
<div className="space-y-3 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-400">
<AlertTriangle className="size-4" />
版本冲突 — 请选择如何处理
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded border bg-background p-2 text-xs">
<div className="mb-1 font-medium">服务器最新版本 (v{conflict.server.version})</div>
<div className="text-muted-foreground">名称:{conflict.server.name}</div>
<div className="text-muted-foreground">标题:{conflict.server.titleTemplate}</div>
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
{conflict.server.contentTemplate}
</div>
</div>
<div className="rounded border bg-background p-2 text-xs">
<div className="mb-1 font-medium">我的编辑内容</div>
<div className="text-muted-foreground">名称:{conflict.mine.name}</div>
<div className="text-muted-foreground">标题:{conflict.mine.titleTemplate}</div>
<div className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap text-muted-foreground">
{conflict.mine.contentTemplate}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={handleReloadFromServer}>
重新加载
</Button>
<Button variant="destructive" disabled={saving} onClick={() => void handleForceOverwrite()}>
覆盖保存
</Button>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button disabled={saving || !!conflict} onClick={() => void handleSave()}>
{saving && <Loader2 className="size-4 animate-spin" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
确定删除模板「{deleteTarget?.name}」?此操作不可恢复。
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
取消
</Button>
<Button
variant="destructive"
disabled={actions.remove.isPending}
onClick={() => void handleDelete()}
>
{actions.remove.isPending && <Loader2 className="size-4 animate-spin" />}
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
-11
View File
@@ -45,7 +45,6 @@ import LoginWhitelistPage from "./LoginWhitelistPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage";
import NotificationTemplatesPage from "./NotificationTemplatesPage";
import PositionRolesPage from "./PositionRolesPage";
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
@@ -439,16 +438,6 @@ export default function StrategyRoutes() {
)
}
/>
<Route
path="admin/notification-templates"
element={
getStoredAuth()?.system_role === "admin" ? (
<NotificationTemplatesPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/position-roles"
element={
@@ -1,10 +1,30 @@
import { useEffect, useRef, type ComponentProps } from "react";
import { useEffect, useMemo, useRef, type ComponentProps } from "react";
import { InputBox } from "@/components/workspace/input-box";
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
import { cn } from "@/lib/utils";
import {
usePromptInputController,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import type { InputBox } from "@/components/workspace/input-box";
import { useModels } from "@/core/models/hooks";
import type { AgentQuestion } from "@/core/agents";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
type InputBoxProps = ComponentProps<typeof InputBox>;
type PositionAgentInputProps = Pick<
InputBoxProps,
| "className"
| "assistantId"
| "status"
| "disabled"
| "context"
| "onContextChange"
| "onSubmit"
| "onStop"
| "isNewThread"
| "threadId"
| "hideAdminRecommendedQuestions"
> & {
commonQuestions?: (string | AgentQuestion)[] | null;
/** Stable node/builtin key used to restore the matching SQL-backed draft. */
draftKey?: string;
draftValue?: string;
@@ -18,18 +38,36 @@ type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
* 只改这个组件即可,不会影响普通对话、工作区或其他业务页面。
*/
export function PositionAgentInput({
className,
context,
disabled = false,
status = "ready",
onContextChange,
onSubmit,
onStop,
commonQuestions,
draftKey = "",
draftValue = "",
onDraftChange,
...props
}: PositionAgentInputProps) {
const { textInput } = usePromptInputController();
const { textInput, attachments } = usePromptInputController();
const { models } = useModels();
const activeDraftKeyRef = useRef("");
const restoringValueRef = useRef<string | null>(null);
const textInputRef = useRef(textInput);
textInputRef.current = textInput;
const modelOptions = useMemo(
() => models.map((model) => ({ label: model.display_name ?? model.name, value: model.name })),
[models],
);
const attachedFiles = useMemo(
() =>
attachments.files
.map((attachment) => attachment.file)
.filter((file): file is File => file instanceof File),
[attachments.files],
);
useEffect(() => {
activeDraftKeyRef.current = draftKey;
if (textInputRef.current.value !== draftValue) {
@@ -51,13 +89,60 @@ export function PositionAgentInput({
onDraftChange?.(textInput.value);
}, [draftKey, onDraftChange, textInput.value]);
// Keep the page-specific class on the wrapper rather than adding a variant
// to InputBox. Node chat and built-in chat can evolve together while every
// other workspace keeps the global composer untouched.
const handleSend = () => {
if (status === "streaming") {
onStop?.();
return;
}
const text = textInput.value.trim();
if (!text && attachedFiles.length === 0) return;
const message: PromptInputMessage = {
text: textInput.value,
files: attachments.files,
};
void onSubmit?.(message);
textInput.clear();
attachments.clear();
};
const handleModelChange = (modelName: string) => {
const model = models.find((candidate) => candidate.name === modelName);
if (!model) return;
onContextChange?.({ ...context, model_name: model.name });
};
const selectedModel =
typeof context.model_name === "string"
? context.model_name
: modelOptions[0]?.value;
return (
<InputBox
{...props}
className={cn("position-roundtable-agent-input w-full", className)}
<PositionIntentInput
value={textInput.value}
onValueChange={textInput.setInput}
attachedFiles={attachedFiles}
onAddFiles={attachments.add}
onRemoveFile={(index) => {
const attachment = attachments.files[index];
if (attachment) attachments.remove(attachment.id);
}}
charactersLeft={textInput.value.length}
isSending={status === "streaming"}
canSend={!disabled && (Boolean(textInput.value.trim()) || attachedFiles.length > 0)}
pendingClarification={false}
onSend={handleSend}
onStop={() => onStop?.()}
onFollowUp={() => undefined}
onSkip={() => undefined}
onEnterRoundtable={() => undefined}
enterButtonLabel=""
intentReady={null}
hideEnterRoundtable
onManualSave={() => undefined}
onTriggerToast={() => undefined}
modelOptions={modelOptions}
selectedModel={selectedModel}
onSelectedModelChange={handleModelChange}
commonQuestions={commonQuestions}
/>
);
}
@@ -716,7 +716,7 @@ export function PositionBuiltinAgentChat({
</div>
)}
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<div className="position-roundtable-agent-composer-bar position-roundtable-node-composer shrink-0">
<PositionAgentInput
className="w-full"
isNewThread={!boundThreadId}
@@ -1,12 +1,9 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import {
FileText,
Loader2,
Paperclip,
Pause,
Repeat,
Save,
Send,
Smile,
Sparkles,
X,
@@ -14,7 +11,11 @@ import {
import { Select as TSelect } from "tdesign-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents";
import type { Step1InputAreaProps } from "@/roundtable-planning/components/Step1Panel";
import moreTaskIcon from "@/assets/position-roundtable/more-task.svg";
import uploadFileIcon from "@/assets/position-roundtable/upload-file.svg";
import sendMessageIcon from "@/assets/position-roundtable/send-message.svg";
interface PositionIntentModelOption {
label: string;
@@ -25,6 +26,7 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
modelOptions?: PositionIntentModelOption[];
selectedModel?: string;
onSelectedModelChange?: (model: string) => void;
commonQuestions?: (string | AgentQuestion)[] | null;
}
/** Keep attachment metadata presentation local to this position-only composer. */
@@ -64,40 +66,25 @@ export function PositionIntentInput({
modelOptions = [],
selectedModel,
onSelectedModelChange,
commonQuestions,
}: PositionIntentInputProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and
// forwards its selection through onAddFiles, so replacement layouts do not
// accidentally create a second attachment state.
const fileInputRef = useRef<HTMLInputElement>(null);
const [showMoreQuestions, setShowMoreQuestions] = useState(false);
const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions);
const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2);
const extraCommonQuestions = normalizedCommonQuestions.slice(2);
const pickCommonQuestion = (question: AgentQuestion) => {
onValueChange(question.prompt);
setShowMoreQuestions(false);
};
return (
<div className="position-roundtable-intent-input position-roundtable-intent-composer shrink-0 border-t border-slate-200 bg-[#f7faff] px-4 py-3 dark:border-[#2b8ffe]/35 dark:bg-[#071a38]">
<div className="position-roundtable-intent-composer-frame relative overflow-hidden rounded-[4px] border border-slate-300 bg-white shadow-[0_2px_10px_rgba(15,23,42,0.08)] transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/45 dark:border-[#2b8ffe]/55 dark:bg-[#0d2855] dark:shadow-none dark:focus-within:border-[#56baff] dark:focus-within:ring-[#2b8ffe]/25">
{attachedFiles.length > 0 && (
<div className="position-roundtable-intent-attachments mx-3 mb-2 flex flex-wrap gap-1.5 border-b border-slate-100 pb-2 pt-2.5 dark:border-[#2b8ffe]/25">
{attachedFiles.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-[4px] border border-slate-200 bg-slate-50 py-1 pl-2 pr-1 text-[11px] text-slate-600 dark:border-[#2b8ffe]/30 dark:bg-[#102f63] dark:text-[#c4dcff]"
>
<FileText className="size-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 rounded-[3px] p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600 dark:text-[#8aaee0] dark:hover:bg-[#21447c] dark:hover:text-white"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<AutoGrowTextarea
id="input-text-area"
value={value}
@@ -144,7 +131,8 @@ export function PositionIntentInput({
title="上传参考文件(智能体将读取文件内容辅助理解任务)"
className="rounded-[3px] p-1.5 transition hover:bg-white hover:text-primary dark:hover:bg-[#16386e] dark:hover:text-[#73c8ff]"
>
<Paperclip className="size-4" />
<img src={uploadFileIcon} alt="" aria-hidden="true" />
<span>上传附件</span>
</button>
</div>
<span className="font-mono text-[10px] text-slate-400">{charactersLeft}/500</span>
@@ -154,8 +142,79 @@ export function PositionIntentInput({
Parent pages can still hide optional actions by their stable ids
without changing this component's layout or global input styles.
*/}
<div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 bg-white px-3 py-2.5 dark:border-[#2b8ffe]/25 dark:bg-[#0b234c]">
<div className="flex shrink-0 items-center">
<div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 px-3 py-2.5 dark:border-[#2b8ffe]/25">
<div className="position-roundtable-intent-common-slot">
{visibleCommonQuestions.length > 0 && (
<div className="position-roundtable-intent-common-questions">
{visibleCommonQuestions.map((question, index) => (
<button
key={`${question.prompt}-${index}`}
type="button"
className="position-roundtable-intent-common-question"
title={question.prompt}
onClick={() => pickCommonQuestion(question)}
>
<span aria-hidden="true" />
<span>{question.title}</span>
</button>
))}
{extraCommonQuestions.length > 0 && (
<div className="position-roundtable-intent-common-more">
<button
type="button"
className="position-roundtable-intent-common-more-button"
aria-label="更多常用问题"
aria-expanded={showMoreQuestions}
title="更多常用问题"
onClick={() => setShowMoreQuestions((visible) => !visible)}
>
<img src={moreTaskIcon} alt="" aria-hidden="true" />
</button>
{showMoreQuestions && (
<div className="position-roundtable-intent-common-more-menu">
{extraCommonQuestions.map((question, index) => (
<button
key={`${question.prompt}-${index}`}
type="button"
title={question.prompt}
onClick={() => pickCommonQuestion(question)}
>
{question.title}
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
{attachedFiles.length > 0 && (
<div className="position-roundtable-intent-file-cards flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{attachedFiles.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[220px] shrink-0 items-center gap-1.5 rounded-[4px] border border-[#2b8ffe]/45 bg-[#0b3168] py-1 pl-2 pr-1 text-[11px] text-[#c4dcff]"
>
<FileText className="size-3 shrink-0 text-[#73c8ff]" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-[#8aaee0]">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 cursor-pointer rounded-[3px] p-0.5 text-[#8aaee0] transition hover:bg-[#21447c] hover:text-white"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
<div className="position-roundtable-intent-upload-wrap flex shrink-0 items-center">
<input
ref={fileInputRef}
type="file"
@@ -169,28 +228,28 @@ export function PositionIntentInput({
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件"
aria-label="上传参考文件"
className="position-roundtable-intent-upload-btn flex size-10 items-center justify-center rounded-[4px] border border-slate-200 bg-white text-slate-500 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b] dark:hover:text-[#73c8ff]"
title="上传附件"
aria-label="上传附件"
className="position-roundtable-intent-upload-btn flex items-center justify-center rounded-[24px] border-0"
>
<Paperclip className="size-4" />
<img src={uploadFileIcon} alt="" aria-hidden="true" />
<span>上传附件</span>
</button>
</div>
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
{modelOptions.length > 0 && (
<div
className="position-roundtable-intent-model-switch flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white pl-2 text-xs font-bold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7]"
title="切换意图理解模型"
className="position-roundtable-intent-model-switch flex h-8 shrink-0 items-center gap-1.5 rounded-[20px] border border-[#2b8ffe]/55 bg-[#0b3168] px-2.5 text-xs font-bold text-[#dceaff]"
title="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
>
<Sparkles className="size-3.5 shrink-0 text-primary dark:text-[#73c8ff]" />
<Sparkles className="size-3.5 shrink-0 text-[#73c8ff]" />
<TSelect
size="small"
aria-label="切换意图理解模型"
aria-label="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
value={selectedModel}
options={modelOptions}
disabled={isSending}
onChange={(value) => onSelectedModelChange?.(String(value))}
className="w-[150px] shrink-0 [&_.t-input]:!h-8 [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!shadow-none dark:[&_.t-input]:!text-[#dceaff]"
className="w-[150px] shrink-0 [&_.t-input]:!h-7 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!text-[#dceaff] [&_.t-input]:!shadow-none"
popupProps={{ overlayClassName: "z-[80]" }}
/>
</div>
@@ -198,26 +257,20 @@ export function PositionIntentInput({
<button
id="send-message-btn"
type="button"
onClick={onSend}
disabled={!canSend}
className="position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]"
onClick={isSending ? onStop : onSend}
disabled={isSending ? false : !canSend}
aria-label={isSending ? "暂停生成" : "发送"}
title={isSending ? "暂停生成" : "发送"}
className={`position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]${isSending ? " position-roundtable-intent-send-btn--stopping" : ""}`}
>
{isSending ? <Loader2 className="size-3 animate-spin" /> : <Send className="size-3" />}
{isSending ? "发送中" : "发送"}
{isSending ? (
<Pause className="size-4" aria-hidden="true" />
) : (
<img src={sendMessageIcon} alt="" aria-hidden="true" />
)}
<span className="sr-only">{isSending ? "暂停生成" : "发送"}</span>
</button>
{isSending && (
<button
id="pause-intent-btn"
type="button"
onClick={onStop}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-amber-200/40 bg-amber-100 px-3 text-xs font-bold text-amber-800 shadow-sm transition active:scale-[0.98] hover:bg-amber-200 dark:border-amber-300/30 dark:bg-amber-400/15 dark:text-amber-200 dark:hover:bg-amber-400/25"
>
<Pause className="size-3" />
暂停
</button>
)}
<button
id="suggest-followup-btn"
type="button"
@@ -811,7 +811,7 @@ export function PositionNodeChat({
</div>
)}
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<div className="position-roundtable-agent-composer-bar position-roundtable-node-composer shrink-0">
<PositionAgentInput
className="w-full"
isNewThread={!boundThreadId}
@@ -825,6 +825,7 @@ export function PositionNodeChat({
onDraftChange={setInputDraft}
hideAdminRecommendedQuestions
onContextChange={(context) => setSettings("context", context)}
commonQuestions={agent?.common_questions}
onSubmit={(message) => void handleSubmit(message)}
onStop={() => void thread.stop()}
/>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -7,6 +7,9 @@ import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
export type { ClarificationPayload };
/** Stable built-in agent used by the multi-agent roundtable Step 1 flow. */
export const INTENT_AGENT_ID = "roundtable-intent";
/**
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
* built-in agent and no per-call coordinator: one /init creates a thread
@@ -103,6 +103,12 @@ export interface Step1PanelProps {
*/
headerLeftSlot?: React.ReactNode;
headerRightSlot?: React.ReactNode;
/** Optional side content rendered below the shared header, beside the dialogue area. */
rightSidebar?: React.ReactNode;
/** Hide the shared question-count badge when a workspace supplies its own header actions. */
hideQuestionCount?: boolean;
/** Optional empty state rendered in the centre of an otherwise blank dialogue list. */
emptyState?: React.ReactNode;
interactiveMessages: InteractiveMessage[];
intentThreadId: string | null;
intentReady: object | null;
@@ -146,6 +152,9 @@ export function Step1Panel(props: Step1PanelProps) {
const {
headerLeftSlot,
headerRightSlot,
rightSidebar,
hideQuestionCount = false,
emptyState,
rootClassName,
interactiveMessages,
intentThreadId,
@@ -230,6 +239,7 @@ export function Step1Panel(props: Step1PanelProps) {
// 顶部标题栏整片脱敏(消息列表正文在下方,不在此容器内,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
const showEmptyState = interactiveMessages.length === 0 && Boolean(emptyState);
return (
<div className={`flex-1 flex flex-col min-h-0 ${rootClassName ?? ""}`}>
@@ -244,34 +254,45 @@ export function Step1Panel(props: Step1PanelProps) {
</div>
)}
<div className="flex shrink-0 items-center gap-2">
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
</span>
{!hideQuestionCount && (
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
</span>
)}
{headerRightSlot}
</div>
</div>
<div className={rightSidebar ? "flex min-h-0 flex-1" : "contents"}>
<div className={rightSidebar ? "flex min-w-0 flex-1 flex-col" : "contents"}>
{/* Chat list */}
<div className="relative flex-1 min-h-0 flex flex-col font-normal">
<div
ref={step1ScrollRef}
// overflow-x-hidden:消息列表只竖向滚(见 Step2Panel 同处注释),避免误触横向拖动抢滑块。
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white"
className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white"
>
<div ref={step1ContentRef} className="space-y-4 p-6">
{interactiveMessages.map((msg) => (
<Step1Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections}
setIntentClarificationSelections={setIntentClarificationSelections}
isIntentSending={isIntentSending}
submitIntentClarifications={submitIntentClarifications}
/>
))}
<div
ref={step1ContentRef}
className={showEmptyState ? "flex min-h-full flex-1 p-6" : "space-y-4 p-6"}
>
{showEmptyState ? (
<div className="flex min-h-full w-full flex-1 items-center justify-center">{emptyState}</div>
) : (
interactiveMessages.map((msg) => (
<Step1Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections}
setIntentClarificationSelections={setIntentClarificationSelections}
isIntentSending={isIntentSending}
submitIntentClarifications={submitIntentClarifications}
/>
))
)}
</div>
</div>
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
@@ -453,6 +474,9 @@ export function Step1Panel(props: Step1PanelProps) {
</div>
</div>
)}
</div>
{rightSidebar}
</div>
</div>
);
}
@@ -94,6 +94,8 @@ export interface UseStep1IntentOptions {
taskContext?: Record<string, unknown> | null;
/** Optional task-aware welcome bubble text before the first user turn. */
initialWelcomeText?: string;
/** Keep the dialogue visually empty while its intent thread is initialising. */
showInitialWelcome?: boolean;
/** 主页面统一的 toast 入口。 */
triggerToast: (msg: string) => void;
}
@@ -104,6 +106,7 @@ export interface UseStep1IntentReturn {
intentReady: IntentSummary | null;
intentInitError: string | null;
intentInitErrorDetail: IntentErrorDetail | null;
isIntentInitializing: boolean;
isIntentSending: boolean;
pendingIntentClarificationMsgId: number | null;
/** Per-question selections, keyed by `clarificationKey(msgId, clarId, index)`. */
@@ -172,13 +175,21 @@ function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
}
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
const { currentStep, selectedModel, taskContext, initialWelcomeText, triggerToast } = options;
const {
currentStep,
selectedModel,
taskContext,
initialWelcomeText,
showInitialWelcome = true,
triggerToast,
} = options;
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
const [intentInitError, setIntentInitError] = useState<string | null>(null);
const [intentInitErrorDetail, setIntentInitErrorDetail] =
useState<IntentErrorDetail | null>(null);
const [isIntentInitializing, setIsIntentInitializing] = useState<boolean>(false);
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] =
useState<number | null>(null);
@@ -230,18 +241,23 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
setIntentInitError(null);
setIntentInitErrorDetail(null);
setIntentThreadId(null);
setIsIntentInitializing(true);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setTimelineEntries([]);
setInteractiveMessages([
{
id: 1,
sender: "agent",
senderName: "任务理解智能体",
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
time: nowTime(),
},
]);
setInteractiveMessages(
showInitialWelcome
? [
{
id: 1,
sender: "agent",
senderName: "任务理解智能体",
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
time: nowTime(),
},
]
: [],
);
(async () => {
try {
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
@@ -252,6 +268,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
);
if (step1InitGenRef.current !== myGen) return;
setIntentThreadId(res.thread_id);
setIsIntentInitializing(false);
setTimelineEntries([
{
id: 1,
@@ -262,6 +279,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
]);
} catch (err) {
if (step1InitGenRef.current !== myGen) return;
setIsIntentInitializing(false);
console.error("init intent failed:", err);
if (err instanceof IntentInitError) {
setIntentInitError(err.detail?.message || err.message);
@@ -272,13 +290,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
}
}
})();
}, []);
}, [showInitialWelcome]);
const resetIntent = useCallback(() => {
stopIntentStream();
step1InitRef.current = false;
step1InitGenRef.current += 1;
setIntentThreadId(null);
setIsIntentInitializing(false);
setIntentReady(null);
setIntentInitError(null);
setIntentInitErrorDetail(null);
@@ -654,6 +673,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
// 或继续显示旧会话的澄清选择。
stopIntentStream();
setIsIntentSending(false);
setIsIntentInitializing(false);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setIntentThreadId(snapshot.intentThreadId);
@@ -690,6 +710,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
intentReady,
intentInitError,
intentInitErrorDetail,
isIntentInitializing,
isIntentSending,
pendingIntentClarificationMsgId,
intentClarificationSelections,
@@ -1,338 +0,0 @@
/**
* 通知模板管理 API 客户端 + React Query hooks。
*
* 后端:`/api/notification-templates`(见 app/gateway/routers/notification_templates.py)。
* 全局共享;读/写均仅 admin。snake_case ⇄ camelCase 在此边界收敛。
*/
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
export const NOTIFICATION_TEMPLATE_VARIABLES = [
"userName",
"taskName",
"taskId",
"dueDate",
] as const;
export type NotificationTemplateVariable =
(typeof NOTIFICATION_TEMPLATE_VARIABLES)[number];
export interface NotificationTemplate {
id: string;
name: string;
titleTemplate: string;
contentTemplate: string;
tags: string[];
enabled: boolean;
version: number;
createdBy: string | null;
createdAt: string | null;
updatedAt: string | null;
}
export interface NotificationTemplateInput {
name: string;
titleTemplate: string;
contentTemplate: string;
tags: string[];
enabled: boolean;
}
export interface NotificationTemplateUpdateInput extends NotificationTemplateInput {
expectedVersion: number;
}
export interface ListNotificationTemplatesParams {
keyword?: string;
enabled?: boolean | null;
tag?: string;
page?: number;
pageSize?: number;
}
export interface NotificationTemplateListResult {
items: NotificationTemplate[];
total: number;
page: number;
pageSize: number;
}
export interface PreviewResult {
title: string;
content: string;
missingVariables: string[];
}
/** 409 乐观锁冲突:携带服务端最新完整记录。 */
export class NotificationTemplateConflictError extends Error {
readonly server: NotificationTemplate;
constructor(server: NotificationTemplate, message = "版本冲突") {
super(message);
this.name = "NotificationTemplateConflictError";
this.server = server;
}
}
const BASE = () => `${getBackendBaseURL()}/api/notification-templates`;
interface ServerTemplate {
id: string;
name: string;
title_template: string;
content_template: string;
tags: string[];
enabled: boolean;
version: number;
created_by?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
function toTemplate(s: ServerTemplate): NotificationTemplate {
return {
id: s.id,
name: s.name,
titleTemplate: s.title_template,
contentTemplate: s.content_template,
tags: s.tags ?? [],
enabled: s.enabled ?? true,
version: s.version ?? 1,
createdBy: s.created_by ?? null,
createdAt: s.created_at ?? null,
updatedAt: s.updated_at ?? null,
};
}
function toBody(input: NotificationTemplateInput & { expectedVersion?: number }) {
const body: Record<string, unknown> = {
name: input.name,
title_template: input.titleTemplate,
content_template: input.contentTemplate,
tags: input.tags,
enabled: input.enabled,
};
if (input.expectedVersion !== undefined) {
body.expected_version = input.expectedVersion;
}
return body;
}
function detailMessage(data: unknown, fallback: string): string {
if (!data || typeof data !== "object" || !("detail" in data)) return fallback;
const detail = (data as { detail: unknown }).detail;
if (typeof detail === "string") return detail;
if (detail && typeof detail === "object" && "message" in detail) {
const msg = (detail as { message: unknown }).message;
if (typeof msg === "string") return msg;
}
return fallback;
}
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (res.status === 204) return undefined as T;
const data = await res.json().catch(() => null as unknown);
if (!res.ok) {
if (res.status === 409 && data && typeof data === "object" && "detail" in data) {
const detail = (data as { detail: unknown }).detail;
if (detail && typeof detail === "object" && "server" in detail) {
const server = (detail as { server: ServerTemplate }).server;
if (server && typeof server === "object" && "id" in server) {
throw new NotificationTemplateConflictError(
toTemplate(server),
detailMessage(data, "版本冲突"),
);
}
}
}
throw new Error(detailMessage(data, `${ctx} failed (${res.status})`));
}
return data as T;
}
export async function listNotificationTemplates(
params: ListNotificationTemplatesParams = {},
): Promise<NotificationTemplateListResult> {
const qs = new URLSearchParams();
if (params.keyword) qs.set("keyword", params.keyword);
if (params.enabled === true || params.enabled === false) {
qs.set("enabled", String(params.enabled));
}
if (params.tag) qs.set("tag", params.tag);
if (params.page) qs.set("page", String(params.page));
if (params.pageSize) qs.set("page_size", String(params.pageSize));
const query = qs.toString();
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
const data = await readJson<{
items: ServerTemplate[];
total: number;
page: number;
page_size: number;
}>(res, "list notification templates");
const result: NotificationTemplateListResult = {
items: (data.items ?? []).map(toTemplate),
total: data.total ?? 0,
page: data.page ?? 1,
pageSize: data.page_size ?? params.pageSize ?? 20,
};
writeYLog({
optType: OptType.search,
optDetail: "CM助手-全量-通知模板-列表",
url: `${BASE()}${query ? `?${query}` : ""}`,
reqParam: params,
returnParam: { total: result.total, page: result.page },
});
return result;
}
export async function getNotificationTemplate(id: string): Promise<NotificationTemplate> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
const tpl = toTemplate(await readJson<ServerTemplate>(res, "get notification template"));
writeYLog({
optType: OptType.view,
optDetail: "CM助手-全量-通知模板-查看",
url: `${BASE()}/${encodeURIComponent(id)}`,
reqParam: { id },
returnParam: tpl,
});
return tpl;
}
export async function createNotificationTemplate(
input: NotificationTemplateInput,
): Promise<NotificationTemplate> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toBody(input)),
});
const tpl = toTemplate(await readJson<ServerTemplate>(res, "create notification template"));
writeYLog({
optType: OptType.create,
optDetail: "CM助手-全量-通知模板-创建",
url: BASE(),
reqParam: input,
returnParam: tpl,
});
return tpl;
}
export async function updateNotificationTemplate(
id: string,
input: NotificationTemplateUpdateInput,
): Promise<NotificationTemplate> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toBody(input)),
});
const tpl = toTemplate(await readJson<ServerTemplate>(res, "update notification template"));
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-通知模板-修改",
url: `${BASE()}/${encodeURIComponent(id)}`,
reqParam: { id, input },
returnParam: tpl,
});
return tpl;
}
export async function deleteNotificationTemplate(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
await readJson<void>(res, "delete notification template");
writeYLog({
optType: OptType.delete,
optDetail: "CM助手-全量-通知模板-删除",
url: `${BASE()}/${encodeURIComponent(id)}`,
reqParam: { id },
});
}
export async function duplicateNotificationTemplate(
id: string,
): Promise<NotificationTemplate> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}/duplicate`, {
method: "POST",
});
const tpl = toTemplate(await readJson<ServerTemplate>(res, "duplicate notification template"));
writeYLog({
optType: OptType.create,
optDetail: "CM助手-全量-通知模板-复制",
url: `${BASE()}/${encodeURIComponent(id)}/duplicate`,
reqParam: { id },
returnParam: tpl,
});
return tpl;
}
export async function previewNotificationTemplate(input: {
titleTemplate: string;
contentTemplate: string;
variables: Record<string, string>;
}): Promise<PreviewResult> {
const res = await apiFetch(`${BASE()}/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title_template: input.titleTemplate,
content_template: input.contentTemplate,
variables: input.variables,
}),
});
const data = await readJson<{
title: string;
content: string;
missing_variables: string[];
}>(res, "preview notification template");
return {
title: data.title,
content: data.content,
missingVariables: data.missing_variables ?? [],
};
}
const KEY = "notification-templates";
export function useNotificationTemplates(params: ListNotificationTemplatesParams) {
return useQuery({
queryKey: [
KEY,
"list",
params.keyword ?? "",
params.enabled === true ? "1" : params.enabled === false ? "0" : "",
params.tag ?? "",
params.page ?? 1,
params.pageSize ?? 20,
],
queryFn: () => listNotificationTemplates(params),
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
export function useNotificationTemplateActions() {
const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: [KEY] });
return {
create: useMutation({ mutationFn: createNotificationTemplate, onSuccess: invalidate }),
update: useMutation({
mutationFn: ({ id, input }: { id: string; input: NotificationTemplateUpdateInput }) =>
updateNotificationTemplate(id, input),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteNotificationTemplate, onSuccess: invalidate }),
duplicate: useMutation({
mutationFn: duplicateNotificationTemplate,
onSuccess: invalidate,
}),
};
}
+1 -1
View File
@@ -746,7 +746,7 @@
/* 岗位会商左侧总面板:仅收窄滚动条宽度,沿用全局既有的滑块颜色、圆角和轨道样式。 */
.position-roundtable-sidebar-scrollbar::-webkit-scrollbar {
width: 8px;
width: 5px;
}
.workspace-shell {
@@ -51,14 +51,13 @@ from app.gateway.routers import (
models,
multi_agent,
notifications,
notification_templates,
open_3qfx,
open_chat,
parallel_agent_tasks,
parallel_agents,
position_roundtable,
positions,
position_roles,
parallel_agent_tasks,
parallel_agents,
position_roundtable,
positions,
position_roles,
public_config,
public_embed,
public_leaderboard,
@@ -859,9 +858,9 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED")
return
store = getattr(app.state, "concurrency_sample_store", None)
run_mgr = getattr(app.state, "run_manager", None)
concurrency_gate = getattr(app.state, "concurrency_gate", None)
store = getattr(app.state, "concurrency_sample_store", None)
run_mgr = getattr(app.state, "run_manager", None)
concurrency_gate = getattr(app.state, "concurrency_gate", None)
if store is None or run_mgr is None:
logger.info("Concurrency sampler not started (no store / run manager — memory backend?)")
return
@@ -882,16 +881,16 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None:
try:
# Runtime toggle (admin can turn sampling off without a restart).
# mtime-cached read, so this is cheap every tick.
from deerflow.config.system_settings import get_concurrency_monitor_settings
if get_concurrency_monitor_settings().enabled:
active_count = None
if concurrency_gate is not None:
active_count = await concurrency_gate.count_active()
if active_count is None:
active = await run_mgr.list_active()
active_count = len(active)
await store.record(active_count)
from deerflow.config.system_settings import get_concurrency_monitor_settings
if get_concurrency_monitor_settings().enabled:
active_count = None
if concurrency_gate is not None:
active_count = await concurrency_gate.count_active()
if active_count is None:
active = await run_mgr.list_active()
active_count = len(active)
await store.record(active_count)
ticks += 1
if ticks % purge_every_ticks == 0:
from datetime import UTC, datetime, timedelta
@@ -1339,18 +1338,18 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
v = os.environ.get("GATEWAY_CORS_ALLOW_ALL", "").strip().lower()
return v in ("1", "true", "yes", "on") or is_auth_disabled()
# The legacy TaskCOP Vue application uses Vite's default development port.
# Keep these origins available even when deployments configure additional
# origins through GATEWAY_CORS_ORIGINS.
taskcop_vite_origins = [
"http://localhost:8080",
"http://127.0.0.1:8080",
]
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
if cors_origins:
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
# The legacy TaskCOP Vue application uses Vite's default development port.
# Keep these origins available even when deployments configure additional
# origins through GATEWAY_CORS_ORIGINS.
taskcop_vite_origins = [
"http://localhost:8080",
"http://127.0.0.1:8080",
]
cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "").strip()
cors_origins: list[str] = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else []
if cors_origins:
cors_origins = list(dict.fromkeys([*cors_origins, *taskcop_vite_origins]))
# Headers the LangGraph SDK / browser clients need to read from cross-origin
# responses. Without exposing them, the SDK's `response.headers.get(...)` returns
@@ -1409,10 +1408,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:3000",
"http://127.0.0.1:3000",
*taskcop_vite_origins,
]
"http://localhost:3000",
"http://127.0.0.1:3000",
*taskcop_vite_origins,
]
logger.warning(
"No CORS configuration found; enabling local dev origins only: %s. "
"Set GATEWAY_CORS_ORIGINS or GATEWAY_CORS_ALLOW_ALL for deployment.",
@@ -1472,25 +1471,25 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
app.include_router(flow_extract.router)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
# Canonical position-workspace persistence API.
app.include_router(position_roundtable.router, prefix="/api")
# Compatibility route for intranet gateways whose allowlist only exposes
# the established `/api/multi-agent/*` namespace.
app.include_router(
position_roundtable.router,
prefix="/api/multi-agent",
include_in_schema=False,
)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
# Canonical position-workspace persistence API.
app.include_router(position_roundtable.router, prefix="/api")
# Compatibility route for intranet gateways whose allowlist only exposes
# the established `/api/multi-agent/*` namespace.
app.include_router(
position_roundtable.router,
prefix="/api/multi-agent",
include_in_schema=False,
)
# Global business ? agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router)
# Position-roundtable work-view role configuration at /api/position-roles.
app.include_router(position_roles.router)
app.include_router(business_mapping.router)
# Position-roundtable work-view role configuration at /api/position-roles.
app.include_router(position_roles.router)
# Light-app registry (?????) at /api/light-apps
app.include_router(light_apps.router)
@@ -1509,7 +1508,6 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
app.include_router(task_buttons.router)
app.include_router(notification_templates.router)
# Temporary compatibility bridge for the TaskCOP situation-overview frontend.
app.include_router(taskcop_tasks.router)
@@ -255,23 +255,23 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
from deerflow.persistence.position_roles import make_position_role_store
app.state.position_role_store = make_position_role_store(sf)
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
from deerflow.persistence.position_roles import make_position_role_store
app.state.position_role_store = make_position_role_store(sf)
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
from deerflow.persistence.business_mapping import make_business_mapping_store
@@ -295,21 +295,17 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.task_buttons import make_task_button_store
app.state.task_button_store = make_task_button_store(sf)
from deerflow.persistence.notification_templates import make_notification_template_store
app.state.notification_template_store = make_notification_template_store(sf)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
app.state.taskcop_task_store = make_taskcop_task_store(sf)
from deerflow.persistence.task_reports import make_task_report_store
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.task_button_store = make_task_button_store(sf)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
app.state.taskcop_task_store = make_taskcop_task_store(sf)
from deerflow.persistence.task_reports import make_task_report_store
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
@@ -393,11 +389,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
# RunManager with store backing for persistence
app.state.run_manager = RunManager(store=app.state.run_store)
from deerflow.runtime import make_redis_concurrency_gate
app.state.concurrency_gate = make_redis_concurrency_gate(config)
# RunManager with store backing for persistence
app.state.run_manager = RunManager(store=app.state.run_store)
from deerflow.runtime import make_redis_concurrency_gate
app.state.concurrency_gate = make_redis_concurrency_gate(config)
try:
yield
@@ -411,10 +407,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
await queue.stop()
set_auto_ingest_queue(None)
await close_engine()
gate = getattr(app.state, "concurrency_gate", None)
if gate is not None:
await gate.aclose()
await close_engine()
gate = getattr(app.state, "concurrency_gate", None)
if gate is not None:
await gate.aclose()
# ---------------------------------------------------------------------------
@@ -440,12 +436,12 @@ get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run
get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
def get_concurrency_gate(request: Request):
"""Return the optional cross-worker concurrency gate."""
return getattr(request.app.state, "concurrency_gate", None)
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
def get_concurrency_gate(request: Request):
"""Return the optional cross-worker concurrency gate."""
return getattr(request.app.state, "concurrency_gate", None)
def get_store(request: Request):
@@ -1,307 +0,0 @@
"""Notification template management API (通知模板管理).
Global / shared templates for admin-authored notification messages. Every
endpoint (including reads) requires ``system_role == "admin"``.
Routes (prefix ``/api/notification-templates``):
GET "" list (keyword / enabled / tag / page / page_size)
POST "" create
GET "/{id}" read one
PUT "/{id}" update (optimistic lock via expected_version)
DELETE "/{id}" delete
POST "/{id}/duplicate" duplicate
POST "/preview" render title/content with variables
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.notification_templates import (
DuplicateNotificationTemplateNameError,
NotificationTemplateStore,
TemplateVariableError,
VersionConflictError,
preview_templates,
validate_templates,
)
router = APIRouter(prefix="/api/notification-templates", tags=["notification-templates"])
# ── schemas ──────────────────────────────────────────────────────────────
class NotificationTemplateResponse(BaseModel):
id: str
name: str
title_template: str
content_template: str
tags: list[str] = Field(default_factory=list)
enabled: bool = True
version: int = 1
created_by: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class NotificationTemplateListResponse(BaseModel):
items: list[NotificationTemplateResponse]
total: int
page: int
page_size: int
class NotificationTemplateCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
title_template: str = Field(..., min_length=1, max_length=120)
content_template: str = Field(..., min_length=1, max_length=5000)
tags: list[str] = Field(default_factory=list)
enabled: bool = True
class NotificationTemplateUpdateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=80)
title_template: str = Field(..., min_length=1, max_length=120)
content_template: str = Field(..., min_length=1, max_length=5000)
tags: list[str] = Field(default_factory=list)
enabled: bool = True
expected_version: int = Field(..., ge=1)
class PreviewRequest(BaseModel):
title_template: str = Field(..., min_length=0, max_length=120)
content_template: str = Field(..., min_length=0, max_length=5000)
variables: dict[str, str] = Field(default_factory=dict)
class PreviewResponse(BaseModel):
title: str
content: str
missing_variables: list[str] = Field(default_factory=list)
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> NotificationTemplateStore:
store = getattr(request.app.state, "notification_template_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Notification template store not available")
return store
async def _require_admin(request: Request) -> None:
"""Reject non-admins. Lets the call through when auth is disabled."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="通知模板管理仅限管理员")
def _normalize_name(name: str) -> str:
cleaned = (name or "").strip()
if not (2 <= len(cleaned) <= 50):
raise HTTPException(status_code=422, detail="名称去除首尾空格后长度须为 2~50")
return cleaned
def _normalize_tags(tags: list[str] | None) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for raw in tags or []:
tag = (raw or "").strip()
if not tag:
continue
if not (1 <= len(tag) <= 20):
raise HTTPException(status_code=422, detail="单个标签长度须为 1~20")
if tag in seen:
continue
seen.add(tag)
out.append(tag)
if len(out) > 5:
raise HTTPException(status_code=422, detail="标签最多 5 个")
return out
def _validate_lengths(title: str, content: str) -> None:
if not (1 <= len(title) <= 120):
raise HTTPException(status_code=422, detail="标题模板长度须为 1~120")
if not (1 <= len(content) <= 5000):
raise HTTPException(status_code=422, detail="正文模板长度须为 1~5000")
def _validate_variables(title: str, content: str) -> None:
try:
validate_templates(title, content)
except TemplateVariableError as exc:
raise HTTPException(status_code=422, detail=exc.message) from exc
def _to_response(row: dict) -> NotificationTemplateResponse:
return NotificationTemplateResponse(**row)
async def _unique_duplicate_name(store: NotificationTemplateStore, original: str) -> str:
base = f"{original} - 副本"
if not await store.name_exists(base):
return base
n = 2
while True:
candidate = f"{original} - 副本 {n}"
if not await store.name_exists(candidate):
return candidate
n += 1
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=NotificationTemplateListResponse)
async def list_notification_templates(
request: Request,
keyword: str | None = Query(default=None),
enabled: bool | None = Query(default=None),
tag: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> NotificationTemplateListResponse:
await _require_admin(request)
store = _get_store(request)
items, total = await store.list_templates(
keyword=keyword,
enabled=enabled,
tag=tag,
page=page,
page_size=page_size,
)
return NotificationTemplateListResponse(
items=[_to_response(r) for r in items],
total=total,
page=page,
page_size=page_size,
)
@router.post("/preview", response_model=PreviewResponse)
async def preview_notification_template(request: Request, body: PreviewRequest) -> PreviewResponse:
await _require_admin(request)
result = preview_templates(body.title_template, body.content_template, body.variables)
return PreviewResponse(**result) # type: ignore[arg-type]
@router.post("", response_model=NotificationTemplateResponse, status_code=201)
async def create_notification_template(
request: Request,
body: NotificationTemplateCreateRequest,
) -> NotificationTemplateResponse:
await _require_admin(request)
name = _normalize_name(body.name)
tags = _normalize_tags(body.tags)
_validate_lengths(body.title_template, body.content_template)
_validate_variables(body.title_template, body.content_template)
store = _get_store(request)
user_id = await get_current_user(request)
try:
row = await store.create_template(
{
"name": name,
"title_template": body.title_template,
"content_template": body.content_template,
"tags": tags,
"enabled": body.enabled,
"created_by": user_id,
}
)
except DuplicateNotificationTemplateNameError:
raise HTTPException(status_code=409, detail="模板名称已存在")
return _to_response(row)
@router.get("/{template_id}", response_model=NotificationTemplateResponse)
async def get_notification_template(request: Request, template_id: str) -> NotificationTemplateResponse:
await _require_admin(request)
store = _get_store(request)
row = await store.get_template(template_id)
if row is None:
raise HTTPException(status_code=404, detail="模板不存在")
return _to_response(row)
@router.put("/{template_id}", response_model=NotificationTemplateResponse)
async def update_notification_template(
request: Request,
template_id: str,
body: NotificationTemplateUpdateRequest,
) -> NotificationTemplateResponse:
await _require_admin(request)
name = _normalize_name(body.name)
tags = _normalize_tags(body.tags)
_validate_lengths(body.title_template, body.content_template)
_validate_variables(body.title_template, body.content_template)
store = _get_store(request)
try:
row = await store.update_template(
template_id,
{
"name": name,
"title_template": body.title_template,
"content_template": body.content_template,
"tags": tags,
"enabled": body.enabled,
},
expected_version=body.expected_version,
)
except VersionConflictError as exc:
raise HTTPException(
status_code=409,
detail={"message": "版本冲突,请重新加载或覆盖保存", "server": exc.server},
) from exc
except DuplicateNotificationTemplateNameError:
raise HTTPException(status_code=409, detail="模板名称已存在")
if row is None:
raise HTTPException(status_code=404, detail="模板不存在")
return _to_response(row)
@router.delete("/{template_id}", status_code=204)
async def delete_notification_template(request: Request, template_id: str) -> Response:
await _require_admin(request)
store = _get_store(request)
deleted = await store.delete_template(template_id)
if not deleted:
raise HTTPException(status_code=404, detail="模板不存在")
return Response(status_code=204)
@router.post("/{template_id}/duplicate", response_model=NotificationTemplateResponse, status_code=201)
async def duplicate_notification_template(
request: Request,
template_id: str,
) -> NotificationTemplateResponse:
await _require_admin(request)
store = _get_store(request)
source = await store.get_template(template_id)
if source is None:
raise HTTPException(status_code=404, detail="模板不存在")
new_name = await _unique_duplicate_name(store, source["name"])
user_id = await get_current_user(request)
try:
row = await store.create_template(
{
"name": new_name,
"title_template": source["title_template"],
"content_template": source["content_template"],
"tags": list(source.get("tags") or []),
"enabled": source.get("enabled", True),
"created_by": user_id,
}
)
except DuplicateNotificationTemplateNameError:
raise HTTPException(status_code=409, detail="模板名称已存在")
return _to_response(row)
@@ -45,7 +45,6 @@ from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.position_roles.model import PositionRoleRow
@@ -111,7 +110,6 @@ __all__ = [
"LlmCallMetricRow",
"MenuOverrideRow",
"NotificationRow",
"NotificationTemplateRow",
"ParallelAgentTaskRow",
"PositionRoundtableNodeRow",
"PositionRoundtableSessionRow",
@@ -1,38 +0,0 @@
"""Notification template persistence (通知模板管理)."""
from __future__ import annotations
from deerflow.persistence.notification_templates.base import (
DuplicateNotificationTemplateNameError,
NotificationTemplateStore,
VersionConflictError,
)
from deerflow.persistence.notification_templates.memory import MemoryNotificationTemplateStore
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
from deerflow.persistence.notification_templates.sql import NotificationTemplateRepository
from deerflow.persistence.notification_templates.variables import (
ALLOWED_VARIABLES,
TemplateVariableError,
preview_templates,
validate_templates,
)
__all__ = [
"ALLOWED_VARIABLES",
"DuplicateNotificationTemplateNameError",
"MemoryNotificationTemplateStore",
"NotificationTemplateRepository",
"NotificationTemplateRow",
"NotificationTemplateStore",
"TemplateVariableError",
"VersionConflictError",
"make_notification_template_store",
"preview_templates",
"validate_templates",
]
def make_notification_template_store(session_factory):
if session_factory is None:
return MemoryNotificationTemplateStore()
return NotificationTemplateRepository(session_factory)
@@ -1,69 +0,0 @@
"""Abstract notification-template persistence API.
Global / shared store: no ``user_id`` scoping. The router gates every endpoint
behind admin. ``created_by`` is audit-only.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class DuplicateNotificationTemplateNameError(ValueError):
"""Raised when creating/updating would collide on the unique name."""
class VersionConflictError(Exception):
"""Raised when ``expected_version`` does not match the stored version."""
def __init__(self, server: dict[str, Any]) -> None:
super().__init__("version conflict")
self.server = server
class NotificationTemplateStore(ABC):
@abstractmethod
async def list_templates(
self,
*,
keyword: str | None = None,
enabled: bool | None = None,
tag: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[dict[str, Any]], int]:
"""Return ``(items, total)`` newest-first with optional filters."""
raise NotImplementedError
@abstractmethod
async def get_template(self, template_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create_template(self, data: dict[str, Any]) -> dict[str, Any]:
"""Create a template. Raises ``DuplicateNotificationTemplateNameError``."""
raise NotImplementedError
@abstractmethod
async def update_template(
self,
template_id: str,
data: dict[str, Any],
*,
expected_version: int,
) -> dict[str, Any] | None:
"""Update with optimistic lock. Returns None if missing.
Raises ``VersionConflictError`` (with ``.server``) on mismatch, or
``DuplicateNotificationTemplateNameError`` on a name clash.
"""
raise NotImplementedError
@abstractmethod
async def delete_template(self, template_id: str) -> bool:
raise NotImplementedError
@abstractmethod
async def name_exists(self, name: str, *, exclude_id: str | None = None) -> bool:
raise NotImplementedError
@@ -1,135 +0,0 @@
"""In-memory notification-template store (tests / no-DB)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.notification_templates.base import (
DuplicateNotificationTemplateNameError,
NotificationTemplateStore,
VersionConflictError,
)
_WRITABLE = {
"name",
"title_template",
"content_template",
"tags",
"enabled",
}
class MemoryNotificationTemplateStore(NotificationTemplateStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
@staticmethod
def _public(row: dict[str, Any]) -> dict[str, Any]:
out = dict(row)
out["tags"] = list(out.get("tags") or [])
return out
def _name_taken(self, name: str, *, exclude_id: str | None = None) -> bool:
return any(r["name"] == name and r["id"] != exclude_id for r in self._rows.values())
async def name_exists(self, name: str, *, exclude_id: str | None = None) -> bool:
return self._name_taken(name, exclude_id=exclude_id)
def _matches(
self,
row: dict[str, Any],
*,
keyword: str | None,
enabled: bool | None,
tag: str | None,
) -> bool:
if enabled is not None and bool(row.get("enabled")) is not enabled:
return False
if tag is not None and tag not in (row.get("tags") or []):
return False
if keyword:
needle = keyword.strip().lower()
if needle:
hay = " ".join(
[
str(row.get("name") or ""),
str(row.get("title_template") or ""),
str(row.get("content_template") or ""),
]
).lower()
if needle not in hay:
return False
return True
async def list_templates(
self,
*,
keyword: str | None = None,
enabled: bool | None = None,
tag: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[dict[str, Any]], int]:
rows = [
r
for r in self._rows.values()
if self._matches(r, keyword=keyword, enabled=enabled, tag=tag)
]
rows.sort(key=lambda r: r.get("created_at", ""), reverse=True)
total = len(rows)
page = max(1, page)
page_size = max(1, page_size)
start = (page - 1) * page_size
items = [self._public(r) for r in rows[start : start + page_size]]
return items, total
async def get_template(self, template_id: str) -> dict[str, Any] | None:
row = self._rows.get(template_id)
return self._public(row) if row is not None else None
async def create_template(self, data: dict[str, Any]) -> dict[str, Any]:
if self._name_taken(data["name"]):
raise DuplicateNotificationTemplateNameError(data["name"])
now = datetime.now(UTC).isoformat()
template_id = data.get("id") or uuid.uuid4().hex[:12]
row = {
"id": template_id,
"name": data["name"],
"title_template": data["title_template"],
"content_template": data["content_template"],
"tags": list(data.get("tags") or []),
"enabled": data.get("enabled", True),
"version": 1,
"created_by": data.get("created_by"),
"created_at": now,
"updated_at": now,
}
self._rows[template_id] = row
return self._public(row)
async def update_template(
self,
template_id: str,
data: dict[str, Any],
*,
expected_version: int,
) -> dict[str, Any] | None:
row = self._rows.get(template_id)
if row is None:
return None
if int(row["version"]) != int(expected_version):
raise VersionConflictError(self._public(row))
new_name = data.get("name")
if new_name is not None and new_name != row["name"] and self._name_taken(new_name, exclude_id=template_id):
raise DuplicateNotificationTemplateNameError(new_name)
for key, value in data.items():
if key in _WRITABLE:
row[key] = value
row["version"] = int(row["version"]) + 1
row["updated_at"] = datetime.now(UTC).isoformat()
return self._public(row)
async def delete_template(self, template_id: str) -> bool:
return self._rows.pop(template_id, None) is not None
@@ -1,35 +0,0 @@
"""ORM model for notification templates (通知模板管理).
Global / shared — no ``user_id`` scoping. ``created_by`` is audit-only.
All reads/writes are admin-gated at the router layer.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class NotificationTemplateRow(Base):
__tablename__ = "notification_templates"
__table_args__ = (UniqueConstraint("name", name="uq_notification_templates_name"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(50))
title_template: Mapped[str] = mapped_column(String(120))
content_template: Mapped[str] = mapped_column(Text)
tags: Mapped[list] = mapped_column(PortableJSON(), default=list)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
version: Mapped[int] = mapped_column(Integer, default=1)
created_by: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -1,163 +0,0 @@
"""SQLAlchemy-backed notification-template store (global / shared)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.notification_templates.base import (
DuplicateNotificationTemplateNameError,
NotificationTemplateStore,
VersionConflictError,
)
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
_WRITABLE = {
"name",
"title_template",
"content_template",
"tags",
"enabled",
}
def _to_dict(row: NotificationTemplateRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
data["tags"] = list(data.get("tags") or [])
return data
class NotificationTemplateRepository(NotificationTemplateStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def name_exists(self, name: str, *, exclude_id: str | None = None) -> bool:
async with self._sf() as session:
return await self._name_taken(session, name, exclude_id=exclude_id)
async def _name_taken(self, session: AsyncSession, name: str, *, exclude_id: str | None = None) -> bool:
stmt = select(NotificationTemplateRow.id).where(NotificationTemplateRow.name == name)
if exclude_id is not None:
stmt = stmt.where(NotificationTemplateRow.id != exclude_id)
result = await session.execute(stmt.limit(1))
return result.first() is not None
def _filter_stmt(
self,
*,
keyword: str | None,
enabled: bool | None,
tag: str | None,
):
stmt = select(NotificationTemplateRow)
if enabled is not None:
stmt = stmt.where(NotificationTemplateRow.enabled.is_(enabled))
if keyword:
needle = f"%{keyword.strip()}%"
if keyword.strip():
stmt = stmt.where(
or_(
NotificationTemplateRow.name.ilike(needle),
NotificationTemplateRow.title_template.ilike(needle),
NotificationTemplateRow.content_template.ilike(needle),
)
)
# Tag filter is applied in Python (PortableJSON differs across backends).
return stmt
async def list_templates(
self,
*,
keyword: str | None = None,
enabled: bool | None = None,
tag: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[dict[str, Any]], int]:
page = max(1, page)
page_size = max(1, page_size)
stmt = self._filter_stmt(keyword=keyword, enabled=enabled, tag=tag)
stmt = stmt.order_by(NotificationTemplateRow.created_at.desc())
async with self._sf() as session:
result = await session.execute(stmt)
rows = [_to_dict(r) for r in result.scalars()]
if tag is not None:
rows = [r for r in rows if tag in (r.get("tags") or [])]
total = len(rows)
start = (page - 1) * page_size
return rows[start : start + page_size], total
async def get_template(self, template_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(NotificationTemplateRow, template_id)
return _to_dict(row) if row is not None else None
async def create_template(self, data: dict[str, Any]) -> dict[str, Any]:
row = NotificationTemplateRow(
id=data.get("id") or uuid.uuid4().hex[:12],
name=data["name"],
title_template=data["title_template"],
content_template=data["content_template"],
tags=list(data.get("tags") or []),
enabled=data.get("enabled", True),
version=1,
created_by=data.get("created_by"),
)
async with self._sf() as session:
if await self._name_taken(session, row.name):
raise DuplicateNotificationTemplateNameError(row.name)
session.add(row)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise DuplicateNotificationTemplateNameError(row.name) from exc
await session.refresh(row)
return _to_dict(row)
async def update_template(
self,
template_id: str,
data: dict[str, Any],
*,
expected_version: int,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(NotificationTemplateRow, template_id)
if row is None:
return None
if int(row.version) != int(expected_version):
raise VersionConflictError(_to_dict(row))
new_name = data.get("name")
if new_name is not None and new_name != row.name and await self._name_taken(session, new_name, exclude_id=template_id):
raise DuplicateNotificationTemplateNameError(new_name)
for key, value in data.items():
if key in _WRITABLE:
setattr(row, key, value)
row.version = int(row.version) + 1
row.updated_at = datetime.now(UTC)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise DuplicateNotificationTemplateNameError(new_name or row.name) from exc
await session.refresh(row)
return _to_dict(row)
async def delete_template(self, template_id: str) -> bool:
async with self._sf() as session:
row = await session.get(NotificationTemplateRow, template_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
@@ -1,110 +0,0 @@
"""Template variable validation and preview helpers for notification templates.
Supported placeholders (exact names, no spaces):
{{userName}} {{taskName}} {{taskId}} {{dueDate}}
"""
from __future__ import annotations
import re
ALLOWED_VARIABLES: frozenset[str] = frozenset({"userName", "taskName", "taskId", "dueDate"})
# Captures the inner of a well-formed {{...}} pair (no nested braces).
_VAR_RE = re.compile(r"\{\{([^{}]*)\}\}")
# Valid identifier: letters then letters/digits (no spaces / punctuation).
_IDENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
class TemplateVariableError(ValueError):
"""Raised when a template contains an invalid / unsupported / unclosed variable."""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
def validate_template_text(text: str) -> None:
"""Validate all ``{{var}}`` occurrences in *text*.
Raises ``TemplateVariableError`` with a Chinese message pointing at the
offending variable or position.
"""
if not text:
return
i = 0
while True:
start = text.find("{{", i)
if start < 0:
break
end = text.find("}}", start + 2)
if end < 0:
raise TemplateVariableError(f"未闭合的变量(位置 {start})")
inner = text[start + 2 : end]
token = "{{" + inner + "}}"
if not inner or not _IDENT_RE.fullmatch(inner):
raise TemplateVariableError(f"变量格式错误:{token}")
if inner not in ALLOWED_VARIABLES:
raise TemplateVariableError(f"不支持的变量:{token}")
i = end + 2
def validate_templates(*texts: str) -> None:
"""Validate every template string; first error wins."""
for text in texts:
validate_template_text(text or "")
def extract_variables(text: str) -> list[str]:
"""Return ordered unique allowed-looking variable names found in *text*."""
seen: set[str] = set()
out: list[str] = []
for match in _VAR_RE.finditer(text or ""):
name = match.group(1)
if name in seen:
continue
seen.add(name)
out.append(name)
return out
def render_template(text: str, variables: dict[str, str] | None) -> tuple[str, list[str]]:
"""Replace known placeholders; keep missing ones and report their names.
Returns ``(rendered, missing_variables)``. Missing names are unique and
appear in first-seen order.
"""
vars_map = variables or {}
missing: list[str] = []
seen_missing: set[str] = set()
def _repl(match: re.Match[str]) -> str:
name = match.group(1)
if name in vars_map and vars_map[name] is not None:
return str(vars_map[name])
if name not in seen_missing:
seen_missing.add(name)
missing.append(name)
return match.group(0)
rendered = _VAR_RE.sub(_repl, text or "")
return rendered, missing
def preview_templates(
title_template: str,
content_template: str,
variables: dict[str, str] | None = None,
) -> dict[str, object]:
"""Render title + content; merge missing variable lists (order-preserving)."""
title, missing_title = render_template(title_template, variables)
content, missing_content = render_template(content_template, variables)
seen: set[str] = set()
missing: list[str] = []
for name in missing_title + missing_content:
if name in seen:
continue
seen.add(name)
missing.append(name)
return {"title": title, "content": content, "missing_variables": missing}