Compare commits

...
10 Commits
11 changed files with 782 additions and 142 deletions

No files matched your search

@@ -2,6 +2,8 @@
import {
BotIcon,
ChevronDownIcon,
ChevronUpIcon,
CopyIcon,
GlobeIcon,
InfoIcon,
@@ -85,6 +87,10 @@ export function AgentCard({ agent }: AgentCardProps) {
agent.skills ?? [],
);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [skillsExpanded, setSkillsExpanded] = useState(false);
const agentSkills = agent.skills ?? [];
const showSkillsToggle = agentSkills.length >= 3;
// The /api/agents list response intentionally omits `soul` (perf optimization
// — SOUL.md is only read on demand). Fetch the detail row when the edit
@@ -111,6 +117,10 @@ export function AgentCard({ agent }: AgentCardProps) {
const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite;
useEffect(() => {
setSkillsExpanded(false);
}, [agent.id, agentSkills.length]);
useEffect(() => {
if (!editOpen) {
return;
@@ -382,11 +392,13 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
</div>
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{desensitize(agent.description)}
</CardDescription>
)}
<div className="mt-2 min-h-10">
{agent.description ? (
<CardDescription className="line-clamp-2 text-sm">
{desensitize(agent.description)}
</CardDescription>
) : null}
</div>
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
@@ -396,36 +408,73 @@ export function AgentCard({ agent }: AgentCardProps) {
)}
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
<CardContent className="pt-0 pb-3">
<div className="flex flex-wrap gap-1">
{agent.tool_groups?.map((group) => (
<Badge
key={`tg:${group}`}
variant="outline"
className="text-xs"
<CardContent className="min-w-0 pt-0 pb-3">
{(agent.tool_groups?.length ?? 0) > 0 && (
<div className="mb-1 flex flex-wrap gap-1">
{agent.tool_groups?.map((group) => (
<Badge
key={`tg:${group}`}
variant="outline"
className="text-xs"
>
{desensitize(group)}
</Badge>
))}
</div>
)}
{agentSkills.length > 0 && (
<div className="relative min-w-0">
{showSkillsToggle && (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground absolute top-0 right-0 z-10 h-6 w-6 shrink-0"
title={skillsExpanded ? "收起技能" : "展开全部技能"}
aria-expanded={skillsExpanded}
onClick={(e) => {
e.stopPropagation();
setSkillsExpanded((open) => !open);
}}
>
{skillsExpanded ? (
<ChevronUpIcon className="h-3.5 w-3.5" />
) : (
<ChevronDownIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
<div
className={cn(
"flex min-w-0 flex-wrap gap-1",
showSkillsToggle && "pr-7",
!skillsExpanded &&
showSkillsToggle &&
"max-h-12 overflow-hidden",
)}
>
{desensitize(group)}
</Badge>
))}
{agent.skills?.map((skill) => (
<Badge
key={`sk:${skill}`}
variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs"
title={t.settings.skills.viewDetailAction}
onClick={(e) => {
e.stopPropagation();
setDetailSkillName(skill);
}}
>
{desensitize(skill)}
</Badge>
))}
</div>
{agentSkills.map((skill) => (
<Badge
key={`sk:${skill}`}
variant="secondary"
className="hover:bg-secondary/70 max-w-full min-w-0 cursor-pointer text-xs"
title={t.settings.skills.viewDetailAction}
onClick={(e) => {
e.stopPropagation();
setDetailSkillName(skill);
}}
>
<span className="truncate">{desensitize(skill)}</span>
</Badge>
))}
</div>
</div>
)}
</CardContent>
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
<div className="flex flex-wrap items-center justify-end gap-1">
{canFavorite && (
@@ -0,0 +1,194 @@
"use client";
import { InfoIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
/** 与 `AgentCard` 编辑弹框同布局,仅查看、不可修改。 */
export function AgentInspectDialog({
agentId,
open,
onOpenChange,
}: {
agentId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { agent, isLoading } = useAgent(open ? agentId : null);
const { skills, isLoading: isSkillsLoading } = useSkills();
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const enabledSkills = useMemo(
() => skills.filter((s) => s.enabled),
[skills],
);
const selectedSkills = useMemo(
() =>
(agent?.skills ?? []).map((name) => {
const meta = enabledSkills.find((s) => s.name === name);
return { name, description: meta?.description };
}),
[agent?.skills, enabledSkills],
);
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
查看智能体
{agent ? `:${desensitize(agent.name)}` : ""}
</DialogTitle>
<DialogDescription>
仅查看名称、描述、技能白名单、SOUL 与发布状态,不可修改。
</DialogDescription>
</DialogHeader>
{isLoading && !agent ? (
<p className="text-muted-foreground py-8 text-center text-sm">
{t.common.loading}
</p>
) : !agent ? (
<p className="text-muted-foreground py-8 text-center text-sm">
无法加载智能体信息
</p>
) : (
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">名称</label>
<Input
readOnly
value={desensitize(agent.name)}
className="bg-muted/30"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">描述</label>
<Textarea
readOnly
value={desensitize(agent.description ?? "")}
placeholder="暂无描述"
className="min-h-20 bg-muted/30"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">技能白名单</label>
<span className="text-muted-foreground text-xs">
已选 {selectedSkills.length} 个
</span>
</div>
<div className="max-h-[240px] overflow-y-auto rounded-md border bg-muted/20 p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : selectedSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">未配置技能</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{selectedSkills.map((skill) => (
<div
key={skill.name}
className="flex w-full min-w-0 items-center"
>
<Badge
variant="secondary"
className="h-8 min-w-0 flex-1 justify-start rounded-r-none px-2 font-normal"
title={desensitize(skill.description ?? "")}
>
<span className="truncate">
{desensitize(skill.name)}
</span>
</Badge>
<Button
type="button"
size="icon"
variant="secondary"
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 shrink-0 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
{selectedSkills.length === 0 && (
<p className="text-muted-foreground text-xs">
不选任何技能会保存为无技能(skills = [])。
</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL</label>
<Textarea
readOnly
value={agent.soul ?? ""}
placeholder="暂无 SOUL 内容"
className="min-h-40 bg-muted/30 font-mono text-xs"
/>
</div>
<div className="flex items-center justify-between rounded-md border bg-muted/20 p-3">
<div>
<p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs">
{agent.is_private
? "已加入私密广场,不可发布。"
: "打开后,该智能体对所有用户可见。"}
</p>
</div>
<span className="text-sm tabular-nums">
{agent.published && !agent.is_private ? "是" : "否"}
</span>
</div>
<div className="flex items-center justify-between rounded-md border bg-muted/20 p-3">
<div>
<p className="text-sm font-medium">加入私密广场</p>
<p className="text-muted-foreground text-xs">
打开后,该智能体仅在私密广场显示,需输入密码才能查看;与「公开发布」互斥。
</p>
</div>
<span className="text-sm tabular-nums">
{agent.is_private ? "是" : "否"}
</span>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
</>
);
}
@@ -1,6 +1,6 @@
"use client";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
@@ -464,13 +464,41 @@ const HISTORY_LABELS: Record<string, string> = {
longTermBackground: "长期背景",
};
const CATEGORY_LABELS: Record<string, Record<string, string>> = {
zh: {
preference: "偏好",
knowledge: "知识",
context: "背景",
behavior: "行为",
goal: "目标",
correction: "纠偏",
},
en: {
preference: "Preference",
knowledge: "Knowledge",
context: "Context",
behavior: "Behavior",
goal: "Goal",
correction: "Correction",
},
};
const CATEGORY_COLORS: Record<string, string> = {
preference: "bg-blue-100 text-blue-700",
knowledge: "bg-green-100 text-green-700",
context: "bg-yellow-100 text-yellow-700",
behavior: "bg-purple-100 text-purple-700",
goal: "bg-orange-100 text-orange-700",
correction: "bg-red-100 text-red-700",
preference: "bg-blue-50 text-blue-700 border border-blue-200/50 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-900/30",
knowledge: "bg-green-50 text-green-700 border border-green-200/50 dark:bg-green-950/30 dark:text-green-400 dark:border-green-900/30",
context: "bg-amber-50 text-amber-700 border border-amber-200/50 dark:bg-amber-950/30 dark:text-amber-400 dark:border-amber-900/30",
behavior: "bg-purple-50 text-purple-700 border border-purple-200/50 dark:bg-purple-950/30 dark:text-purple-400 dark:border-purple-900/30",
goal: "bg-orange-50 text-orange-700 border border-orange-200/50 dark:bg-orange-950/30 dark:text-orange-400 dark:border-orange-900/30",
correction: "bg-red-50 text-red-700 border border-red-200/50 dark:bg-red-950/30 dark:text-red-400 dark:border-red-900/30",
};
const CATEGORY_ICONS: Record<string, React.ElementType> = {
preference: Heart,
knowledge: BookOpen,
context: Map,
behavior: Zap,
goal: Target,
correction: ShieldAlert,
};
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
@@ -484,6 +512,9 @@ function MemoryV1View({
memory_usage: string;
};
}) {
const { locale } = useI18n();
const isZh = locale.startsWith("zh");
const labelMap = isZh ? CATEGORY_LABELS.zh : CATEGORY_LABELS.en;
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
@@ -519,37 +550,63 @@ function MemoryV1View({
暂无事实记录,对话后系统会自动提取。
</div>
) : (
<div className="space-y-2">
{memory.facts.map((fact) => (
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center gap-2">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
}`}
>
{fact.category}
</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pt-2">
{memory.facts.map((fact) => {
const categoryLabel = labelMap[fact.category] ?? fact.category;
const Icon = CATEGORY_ICONS[fact.category] ?? PlusIcon;
return (
<div
key={fact.id}
className="group relative flex flex-col justify-between rounded-xl border border-border/40 bg-gradient-to-br from-card to-card/50 hover:from-card hover:to-card hover:border-primary/30 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 p-5 space-y-4 dark:border-[#213D5E]/40 dark:from-[#022248]/40 dark:to-[#022248]/10 dark:hover:from-[#022248]/60 dark:hover:to-[#022248]/30 overflow-hidden"
>
{/* Subtle top highlight */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium border shadow-sm ${
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700 border-gray-200"
}`}
>
<Icon className="w-3.5 h-3.5" />
{categoryLabel}
</span>
<div className="flex flex-col items-end gap-1">
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider">
{Math.round(fact.confidence * 100)}%
</span>
{/* Confidence Progress Bar */}
<div className="h-1 w-12 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
/>
</div>
</div>
</div>
<p className="text-[14px] text-foreground/90 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]">
{desensitize(fact.content)}
</p>
</div>
<span className="text-xs text-muted-foreground shrink-0">
{Math.round(fact.confidence * 100)}%
</span>
{fact.sourceError && (
<div className="mt-auto pt-2">
<div className="rounded-lg bg-red-50/80 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50 p-3 text-xs text-red-600 dark:text-red-400 shadow-sm">
<div className="flex items-center gap-1.5 font-semibold mb-1">
<ShieldAlert className="w-3.5 h-3.5" />
<span>避免</span>
</div>
<p className="leading-relaxed opacity-90">{desensitize(fact.sourceError)}</p>
</div>
</div>
)}
</div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
{fact.sourceError && (
<p className="rounded bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-[#311629] dark:text-[#EE212C]">
避免:{desensitize(fact.sourceError)}
</p>
)}
</div>
))}
);
})}
</div>
)}
)}
</div>
</TabsContent>
@@ -28,6 +28,13 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -44,6 +51,7 @@ import {
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
Item,
ItemActions,
@@ -322,6 +330,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
{!isLoading && !error && filteredSkills.length === 0 && (
<EmptySkill onCreateSkill={handleCreateSkill} />
)}
{/* —— 原纵向列表布局(保留备查)——
{!isLoading && !error &&
filteredSkills.length > 0 &&
pagedSkills.map((skill) => (
@@ -331,7 +341,21 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
currentUserId={currentUserId}
isAdmin={isAdmin}
/>
))}
))} */}
{/* card */}
{!isLoading && !error && filteredSkills.length > 0 && (
<div className="grid grid-cols-1 items-stretch gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{pagedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
currentUserId={currentUserId}
isAdmin={isAdmin}
className="h-full"
/>
))}
</div>
)}
{!isLoading && !error && totalPages > 1 && (
<div className="flex items-center justify-end gap-2 pt-1">
<Button
@@ -384,10 +408,12 @@ export function SkillRow({
skill,
currentUserId,
isAdmin,
className,
}: {
skill: Skill;
currentUserId: string | null;
isAdmin: boolean;
className?: string;
}) {
const { t } = useI18n();
const setPublished = useSetSkillPublished();
@@ -473,6 +499,8 @@ export function SkillRow({
return (
<>
{/* —— 原 Item 列表行布局(保留备查)——
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
<ItemContent
className="cursor-pointer"
@@ -640,7 +668,205 @@ export function SkillRow({
</Button>
)}
</ItemActions>
</Item>
</Item> */}
<Card
className={cn(
"group flex h-full min-w-0 cursor-pointer flex-col transition-shadow hover:shadow-md dark:!border-[#213D5E] dark:bg-[#02244C] dark:text-white",
className,
)}
onClick={() => setDetailOpen(true)}
>
<CardHeader className="min-w-0 flex flex-col gap-0">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
<SparklesIcon className="h-5 w-5" />
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 overflow-hidden">
<CardTitle
className="min-w-0 flex-1 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(skill.name)}
>
{desensitize(skill.name)}
</CardTitle>
<Badge variant="secondary" className="shrink-0 text-xs">
{isBuiltin
? t.settings.skills.badgeBuiltin
: skill.category}
</Badge>
</div>
</div>
<div className="mt-4 mb-1.5 flex min-h-5 flex-wrap items-center gap-1">
{isOwned && (
<Badge
variant="default"
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
>
{t.settings.skills.badgeMine}
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
title={
skill.owner_user_id
? t.settings.skills.badgeFromOthersTitle.replace(
"{user_id}",
skill.owner_user_id,
)
: undefined
}
>
{t.settings.skills.badgeFromOthers}
</Badge>
)}
{isOwned && skill.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
<GlobeIcon className="size-3" />
{t.settings.skills.badgePublished}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "agent" && (
<Badge
variant="outline"
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
title={t.settings.skills.badgeSourceAgentTitle}
>
{t.settings.skills.badgeSourceAgent}
</Badge>
)}
{isCustom && !isBuiltin && skill.source === "upload" && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
title={t.settings.skills.badgeSourceUploadTitle}
>
{t.settings.skills.badgeSourceUpload}
</Badge>
)}
{skill.pinned && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title={t.settings.skills.badgePinnedTitle}
>
{t.settings.skills.badgePinned}
</Badge>
)}
</div>
<div className="mt-1.5 min-h-10 mb-3">
{skill.description ? (
<CardDescription className="line-clamp-2 text-sm">
{desensitize(skill.description)}
</CardDescription>
) : null}
</div>
<div className="text-muted-foreground mt-0.5 min-h-4 text-xs leading-tight">
{skill.use_count !== undefined && skill.use_count > 0
? t.settings.skills.useCountInline.replace(
"{count}",
String(skill.use_count),
)
: t.settings.skills.useCountInlineZero}
</div>
<div
className={cn(
"min-w-0 max-w-full overflow-hidden pb-0",
(skill.tags?.length ?? 0) > 0 ? "pt-2.5" : "min-h-4",
)}
>
{(skill.tags?.length ?? 0) > 0 && (
<TagChipList tags={skill.tags!} className="w-full" />
)}
</div>
</CardHeader>
<CardFooter
className="cursor-default py-0 pt-1.5"
onClick={(e) => e.stopPropagation()}
>
<div className="flex w-full flex-wrap items-center justify-end gap-1">
{isAdmin && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
)}
{canManage && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPinned.isPending}
onClick={() => void handleTogglePinned()}
title={
skill.pinned
? t.settings.skills.unpinAction
: t.settings.skills.pinAction
}
>
{skill.pinned ? (
<PinOffIcon className="h-3.5 w-3.5" />
) : (
<PinIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
{canTogglePublish && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={setPublished.isPending}
onClick={() => void handleTogglePublished(!skill.published)}
title={
skill.published
? t.settings.skills.unpublishAction
: t.settings.skills.publishAction
}
>
{skill.published ? (
<LockIcon className="h-3.5 w-3.5" />
) : (
<GlobeIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canManage && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardFooter>
</Card>
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent>
<DialogHeader>
@@ -17,20 +17,22 @@ export function TagChip({
className?: string;
}) {
const color = resolveTagColor(tag.color);
const label = desensitize(tag.name);
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
"inline-flex max-w-full min-w-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
color.chip,
className,
)}
title={label}
>
{desensitize(tag.name)}
<span className="min-w-0 truncate">{label}</span>
{onRemove && (
<button
type="button"
onClick={onRemove}
className="hover:opacity-70"
className="shrink-0 hover:opacity-70"
aria-label={`remove ${tag.name}`}
>
<XIcon className="size-3" />
@@ -50,7 +52,7 @@ export function TagChipList({
}) {
if (!tags || tags.length === 0) return null;
return (
<div className={cn("flex flex-wrap gap-1", className)}>
<div className={cn("flex min-w-0 max-w-full flex-wrap gap-1", className)}>
{tags.map((tag) => (
<TagChip key={tag.name} tag={tag} />
))}
+36 -30
View File
@@ -621,10 +621,15 @@ export function useThreadStream({
lockThreadRagMode(threadId);
}
await thread.submit(
{
messages: [
{
const mergedContext = {
...context,
...extraContext,
};
await thread.submit(
{
messages: [
{
type: "human",
content: [
{
@@ -642,32 +647,33 @@ export function useThreadStream({
},
],
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
},
context: {
...extraContext,
...context,
thinking_enabled: context.mode !== "flash",
is_plan_mode: context.mode === "pro" || context.mode === "ultra",
subagent_enabled: context.mode === "ultra",
reasoning_effort:
context.reasoning_effort ??
(context.mode === "ultra"
? "high"
: context.mode === "pro"
? "medium"
: context.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
{
threadId: threadId,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
},
context: {
...mergedContext,
thinking_enabled: mergedContext.mode !== "flash",
is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra",
reasoning_effort:
mergedContext.reasoning_effort ??
(mergedContext.mode === "ultra"
? "high"
: mergedContext.mode === "pro"
? "medium"
: mergedContext.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId,
},
},
);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
@@ -102,17 +102,99 @@ import {
// Panel IDs used as keys in the v4 Layout map
const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft';
// 初始问答阶段(idle)专用的两栏 ID:左=问答助手,右=写作配置表单
const SETUP_CHAT_ID = 'aw-setup-chat';
const SETUP_FORM_ID = 'aw-setup-form';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
// idle 阶段镜像原比例:问答框在左占大头(65),配置表单在右占小头(35)
const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 };
/**
* 右侧面板:未开始写作(idle)时显示「写作助手 Q&A」,帮用户聊清并回填左侧表单;
* 一旦开始写作(status !== idle)就切回草稿预览。
* 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。
*/
function AIWritingRightPanel() {
function WritingFormPanel({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
return (
// 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
</div>
);
}
/**
* 根据写作状态切换两套左右布局:
* - idle(初始问答阶段):左=「写作助手 Q&A」,右=「写作配置选项」表单;
* - 开始写作后:回到原布局——左=写作配置/时间线面板,右=草稿预览。
*/
function AIWritingLayout({ hideAdvancedFields }: { hideAdvancedFields: boolean }) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const { state } = useAIWriting();
return state.status === 'idle' ? <WritingSetupChat /> : <AIWritingDraftPanel />;
if (state.status === 'idle') {
return (
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={SETUP_LAYOUT}
className="h-full"
>
{/* 左侧:写作助手 Q&A */}
<ResizablePanel
id={SETUP_CHAT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<WritingSetupChat />
</ResizablePanel>
<ResizableHandle withHandle />
{/* 右侧:写作配置选项表单 */}
<ResizablePanel
id={SETUP_FORM_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
>
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
</ResizablePanel>
</ResizablePanelGroup>
);
}
return (
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
<ResizablePanel
id={WRITING_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
>
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} />
</ResizablePanel>
<ResizableHandle withHandle />
{/* 右侧:草稿预览面板 */}
<ResizablePanel
id={DRAFT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<AIWritingDraftPanel />
</ResizablePanel>
</ResizablePanelGroup>
);
}
export default function AIWritingPage({
@@ -120,8 +202,6 @@ export default function AIWritingPage({
}: {
hideAdvancedFields?: boolean;
} = {}) {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
<CanvasThreadProvider>
<CanvasGraphProvider>
@@ -131,37 +211,7 @@ export default function AIWritingPage({
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
<ResizablePanel
id={WRITING_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
>
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel hideAdvancedFields={hideAdvancedFields} />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 右侧:草稿预览面板 */}
<ResizablePanel
id={DRAFT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<AIWritingRightPanel />
</ResizablePanel>
</ResizablePanelGroup>
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} />
</div>
</div>
</TooltipProvider>
@@ -220,8 +220,10 @@ export default function IframeChatPage() {
prompt: data.payload?.prompt,
pageContext: data.payload?.pageContext,
screenshot: data.payload?.screenshot,
model: data.payload?.model,
requestId: data.requestId,
filesCount: files.length,
iframeContext,
});
if (!submitText && files.length === 0) return;
@@ -79,3 +79,24 @@ test("keeps screenshot-only iframe submissions in the conversation flow", () =>
assert.equal(result.files[0]?.filename, "shot.jpg");
assert.equal(result.files[0]?.mediaType, "image/jpeg");
});
test("maps iframe model selection into stream context model_name", () => {
const result = parseBrainyChatSubmitMessage({
source: "brainy-extension",
type: "brainy:chat-submit",
requestId: "iframe-456",
payload: {
text: "帮我总结这个页面",
model: {
label: "Qwen3-32B: 效果与速度均衡(推荐)",
value: "OpenAI3",
},
},
});
assert.equal(result.submitText, "帮我总结这个页面");
assert.deepEqual(result.iframeContext, {
brainy_request_id: "iframe-456",
model_name: "OpenAI3",
});
});
+16 -3
View File
@@ -21,6 +21,11 @@ type BrainyScreenshot = {
images?: BrainyScreenshotImage[];
};
type BrainyModelSelection = {
label?: string;
value?: string;
};
export type BrainyChatSubmitMessage = {
source?: string;
type?: string;
@@ -29,6 +34,7 @@ export type BrainyChatSubmitMessage = {
prompt?: string;
pageContext?: BrainyPageContext;
screenshot?: BrainyScreenshot;
model?: BrainyModelSelection;
};
requestId?: string;
};
@@ -261,13 +267,20 @@ export function parseBrainyChatSubmitMessage(
hasAttachedImage: files.length > 0,
});
const requestId = trimToUndefined(data.requestId);
const modelName = trimToUndefined(data.payload?.model?.value);
const iframeContext: Record<string, unknown> = {};
if (requestId) {
iframeContext.brainy_request_id = requestId;
}
if (modelName) {
iframeContext.model_name = modelName;
}
return {
submitText,
files,
...(requestId
? { iframeContext: { brainy_request_id: requestId } }
: {}),
...(Object.keys(iframeContext).length > 0 ? { iframeContext } : {}),
...(promptPrefix ? { additionalKwargs: { prompt_prefix: promptPrefix } } : {}),
};
}
@@ -30,6 +30,7 @@ import {
Bot,
ChevronDown,
ChevronUp,
Eye,
GripVertical,
ListOrdered,
Loader2,
@@ -61,6 +62,7 @@ import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { AgentInspectDialog } from "@/components/workspace/agents/agent-inspect-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { listAgents } from "@/core/agents";
@@ -832,6 +834,7 @@ function SeatStrip({
const avatarType = getAvatarTypeFor(seat.agent_id, index);
const isFirst = index === 0;
const isLast = index === total - 1;
const [viewOpen, setViewOpen] = useState(false);
return (
<div
ref={setNodeRef}
@@ -877,8 +880,19 @@ function SeatStrip({
)}
</div>
{/* 控制:上移 / 下移 / 移除 */}
{/* 控制:查看 / 上移 / 下移 / 移除 */}
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
<button
type="button"
onClick={() => setViewOpen(true)}
title="查看智能体详情"
className={cn(
"p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
R_CTRL,
)}
>
<Eye className="size-3.5" />
</button>
<button
type="button"
onClick={() => onMove(-1)}
@@ -921,6 +935,12 @@ function SeatStrip({
<X className="size-3.5" />
</button>
</div>
<AgentInspectDialog
agentId={seat.agent_id}
open={viewOpen}
onOpenChange={setViewOpen}
/>
</div>
);
}