feat: refresh workspace UI and agent visuals
This commit is contained in:
1 parent
d7ad0624b7
commit
760b88d6f1
25 files changed
+1236
-566
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="title">
|
||||
<title id="title">CM 助手</title>
|
||||
<defs>
|
||||
<linearGradient id="logoTopLeft" x1="7" y1="7" x2="25" y2="20" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5DB5FF"/>
|
||||
<stop offset="1" stop-color="#328AF2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoTopRight" x1="24" y1="3" x2="43" y2="19" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#62B2FF"/>
|
||||
<stop offset="1" stop-color="#4B98F4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoLeft" x1="5" y1="14" x2="24" y2="45" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2C7CF0"/>
|
||||
<stop offset="1" stop-color="#1F5EDA"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoBottom" x1="9" y1="31" x2="43" y2="40" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#276DE3"/>
|
||||
<stop offset="1" stop-color="#4E9DF5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- The mark is drawn as four independent folded faces. Together they form
|
||||
the open C-shaped cube used in the product reference. -->
|
||||
<path d="M5 13.5 24 3v10L14 18.5 5 13.5Z" fill="url(#logoTopLeft)"/>
|
||||
<path d="M24 3 43 13.5 34 18.5 24 13V3Z" fill="url(#logoTopRight)"/>
|
||||
<path d="M5 13.5 14 18.5v11L24 35v10L5 34.5v-21Z" fill="url(#logoLeft)"/>
|
||||
<path d="M14 29.5 24 35l10-5.5 9 5L24 45 5 34.5l9-5Z" fill="url(#logoBottom)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, SparklesIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AgentBrandIconProps = {
|
||||
className?: string;
|
||||
size?: "nav" | "sm" | "md" | "lg";
|
||||
};
|
||||
|
||||
const SIZE_STYLES = {
|
||||
nav: {
|
||||
frame: "size-[22px] rounded-md",
|
||||
bot: "size-3.5",
|
||||
badge: "-right-1 -top-1 size-2.5",
|
||||
sparkle: "size-1.5",
|
||||
},
|
||||
sm: {
|
||||
frame: "size-10 rounded-full",
|
||||
bot: "size-[18px]",
|
||||
badge: "-right-1 -top-1 size-3.5",
|
||||
sparkle: "size-2",
|
||||
},
|
||||
md: {
|
||||
frame: "size-12 rounded-full",
|
||||
bot: "size-6",
|
||||
badge: "-right-1 -top-1 size-4",
|
||||
sparkle: "size-2.5",
|
||||
},
|
||||
lg: {
|
||||
frame: "size-16 rounded-full",
|
||||
bot: "size-7",
|
||||
badge: "-right-1 -top-1 size-[18px]",
|
||||
sparkle: "size-3",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function AgentBrandIcon({
|
||||
className,
|
||||
size = "md",
|
||||
}: AgentBrandIconProps) {
|
||||
const styles = SIZE_STYLES[size];
|
||||
const showBadge = size === "nav";
|
||||
|
||||
if (size !== "nav") {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-agent-brand-icon={size}
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 overflow-hidden rounded-full",
|
||||
styles.frame,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src="/agent-avatar.png"
|
||||
alt=""
|
||||
className="size-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-agent-brand-icon={size}
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 items-center justify-center border border-blue-100 bg-[radial-gradient(circle_at_35%_30%,#ffffff_0%,#edf4ff_46%,#dce9ff_100%)] text-[#1f5fdb] shadow-[0_8px_22px_rgba(37,99,235,0.13),inset_0_1px_0_rgba(255,255,255,0.9)] dark:border-blue-400/30 dark:bg-blue-500/20 dark:text-blue-300",
|
||||
styles.frame,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<BotIcon className={styles.bot} strokeWidth={1.9} />
|
||||
{showBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute flex items-center justify-center rounded-full bg-gradient-to-br from-[#4b91ff] to-[#1858db] text-white shadow-sm ring-2 ring-white dark:ring-slate-950",
|
||||
styles.badge,
|
||||
)}
|
||||
>
|
||||
<SparklesIcon className={styles.sparkle} strokeWidth={2.4} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
import {
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
EyeOffIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
MessageSquareIcon,
|
||||
MoreHorizontalIcon,
|
||||
PinIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -38,14 +38,21 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -59,7 +66,8 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
|
||||
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
@@ -548,10 +556,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
内置
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="border border-emerald-200 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300"
|
||||
>
|
||||
我的
|
||||
</Badge>
|
||||
@@ -559,7 +567,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
{isFromOthers && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||
className="border-blue-200 bg-blue-50/60 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-500/10 dark:text-blue-300"
|
||||
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
||||
>
|
||||
来自他人
|
||||
@@ -568,7 +576,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
className="border-sky-200 bg-sky-50/60 text-xs text-sky-700 dark:border-sky-800 dark:bg-sky-500/10 dark:text-sky-300"
|
||||
title="该智能体已对所有用户可见"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
@@ -663,8 +671,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
const skillBadgeItems = [
|
||||
...(agent.tool_groups ?? []).map((group) => ({
|
||||
key: `tg:${group}`,
|
||||
node: (
|
||||
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
|
||||
node: (
|
||||
<Badge
|
||||
key={`tg:${group}`}
|
||||
variant="outline"
|
||||
className="border-0 bg-[#f1f5ff] px-2.5 py-1 text-xs font-normal text-[#2166e8] hover:bg-[#e9f0ff]"
|
||||
>
|
||||
{desensitize(group)}
|
||||
</Badge>
|
||||
),
|
||||
@@ -674,8 +686,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
node: (
|
||||
<Badge
|
||||
key={`sk:${skill}`}
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
||||
variant="secondary"
|
||||
className="cursor-pointer border-0 bg-[#f1f5ff] px-2.5 py-1 text-xs font-normal text-[#2166e8] hover:bg-[#e9f0ff]"
|
||||
title={t.settings.skills.viewDetailAction}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -823,80 +835,181 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
</>
|
||||
);
|
||||
|
||||
const chatButton = (
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(isList ? "shrink-0" : "w-full")}
|
||||
onClick={handleChat}
|
||||
const chatButton = (
|
||||
<Button
|
||||
variant={isList ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
isList
|
||||
? "shrink-0 rounded-xl bg-[#1f63e9] px-4 font-medium text-white hover:bg-[#1857d4]"
|
||||
: "h-11 w-full rounded-lg border-[#b9d1ff] bg-white font-medium text-[#135ee8] shadow-none hover:border-[#79a8ff] hover:bg-[#f7faff] hover:text-[#135ee8] dark:border-blue-700 dark:bg-slate-900 dark:text-blue-300",
|
||||
)}
|
||||
onClick={handleChat}
|
||||
>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
开始对话
|
||||
</Button>
|
||||
);
|
||||
);
|
||||
|
||||
const cardStatusBadge = agent.is_private ? (
|
||||
<Badge className="border-0 bg-purple-50 px-2.5 py-1 text-xs font-normal text-purple-600 hover:bg-purple-50 dark:bg-purple-500/15 dark:text-purple-300">
|
||||
<span className="mr-1.5 size-1.5 rounded-full bg-purple-500" />
|
||||
私密
|
||||
</Badge>
|
||||
) : agent.published || isBuiltin ? (
|
||||
<Badge className="border-0 bg-blue-50 px-2.5 py-1 text-xs font-normal text-blue-600 hover:bg-blue-50 dark:bg-blue-500/15 dark:text-blue-300">
|
||||
<span className="mr-1.5 size-1.5 rounded-full bg-blue-500" />
|
||||
已发布
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="border-0 bg-emerald-50 px-2.5 py-1 text-xs font-normal text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300">
|
||||
<span className="mr-1.5 size-1.5 rounded-full bg-emerald-500" />
|
||||
运行中
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const updatedAtText = formatDateTime(agent.updated_at ?? agent.created_at);
|
||||
const cardActionsMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
title="更多操作"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{canFavorite && (
|
||||
<DropdownMenuItem onSelect={() => void handleToggleFavorite()}>
|
||||
<StarIcon className={cn("size-4", isFavorite && "fill-current text-amber-500")} />
|
||||
{isFavorite ? "取消收藏" : "收藏到我的"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void handleTogglePin()}>
|
||||
<PinIcon className={cn("size-4", isPinned && "fill-current text-emerald-600")} />
|
||||
{isPinned ? "取消置顶" : "置顶"}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onSelect={() => void handleToggleFeatured()}>
|
||||
<SparklesIcon className="size-4" />
|
||||
{agent.featured_order != null ? "取消推荐" : "设为推荐"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => setTagPickerOpen(true)}>
|
||||
<TagsIcon className="size-4" />
|
||||
{t.adminTags.manageTags}
|
||||
</DropdownMenuItem>
|
||||
{isOwned && (
|
||||
<DropdownMenuItem onSelect={() => void handleTogglePublished()}>
|
||||
{agent.published ? <LockIcon className="size-4" /> : <GlobeIcon className="size-4" />}
|
||||
{agent.published ? "取消发布" : "发布"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(isOwned || isAdmin) && (
|
||||
<DropdownMenuItem onSelect={() => void handleTogglePrivate()}>
|
||||
<ShieldIcon className="size-4" />
|
||||
{agent.is_private ? "移出私密广场" : "放入私密广场"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void handleDuplicate()}>
|
||||
<CopyIcon className="size-4" />
|
||||
复制智能体
|
||||
</DropdownMenuItem>
|
||||
{canEdit && (
|
||||
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
|
||||
<SquarePenIcon className="size-4" />
|
||||
编辑智能体
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
{t.agents.delete}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const cardLayout = (
|
||||
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="min-w-0 pb-2">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<CardTitle
|
||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||
title={desensitize(agent.name)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.model && (
|
||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||
{desensitize(agent.model)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
||||
{statusBadges}
|
||||
</div>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
{publisherNode && <div className="mt-2">{publisherNode}</div>}
|
||||
{createdAtNode && <div className="mt-1">{createdAtNode}</div>}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
const cardLayout = (
|
||||
<Card
|
||||
className={cn(
|
||||
"group flex min-h-[366px] min-w-0 flex-col gap-0 overflow-hidden rounded-2xl border-slate-200/90 bg-white py-0 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] duration-200 hover:!border-[#b8d1ff] hover:!bg-[linear-gradient(135deg,rgba(239,246,255,0.92)_0%,rgba(255,255,255,0.98)_48%,#fff_100%)] hover:!ring-1 hover:!ring-[#e2edff] hover:shadow-[0_8px_24px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900",
|
||||
agent.featured_order != null && "border-blue-300 ring-1 ring-blue-100 dark:border-blue-700 dark:ring-blue-900/40",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
|
||||
{agent.featured_order != null ? (
|
||||
<>
|
||||
<div className="mb-1 flex h-7 items-center justify-between">
|
||||
<Badge className="border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 dark:border-blue-800 dark:bg-blue-500/15 dark:text-blue-300">
|
||||
<SparklesIcon className="mr-1 size-3.5 fill-current" />
|
||||
推荐
|
||||
</Badge>
|
||||
{cardActionsMenu}
|
||||
</div>
|
||||
<AgentBrandIcon size="lg" />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-start justify-between">
|
||||
<AgentBrandIcon size="lg" />
|
||||
{cardActionsMenu}
|
||||
</div>
|
||||
)}
|
||||
<CardTitle
|
||||
className="mt-3 block w-full min-w-0 overflow-hidden text-[18px] leading-6 font-semibold tracking-[-0.015em] text-ellipsis whitespace-nowrap text-slate-950 dark:text-white"
|
||||
title={desensitize(agent.name)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-1.5 min-h-11 line-clamp-2 text-[14px] leading-[22px] text-slate-600 dark:text-slate-400">
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="px-5 pt-0 pb-1.5">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{hasSkillsOrTools && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<ClampedBadgeRow items={skillBadgeItems} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
|
||||
{showActionRow && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{actionButtons}
|
||||
</div>
|
||||
)}
|
||||
{chatButton}
|
||||
</CardFooter>
|
||||
<CardContent className="px-5 pt-0 pb-2">
|
||||
<ClampedBadgeRow items={skillBadgeItems} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-10 px-5 pt-2 pb-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{cardStatusBadge}
|
||||
<span
|
||||
className="min-w-0 truncate text-xs text-slate-400"
|
||||
title={updatedAtText ? `更新于 ${updatedAtText}` : undefined}
|
||||
>
|
||||
{updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"}
|
||||
{publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{chatButton}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const listLayout = (
|
||||
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="agent-avatar bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="group flex min-w-0 items-center gap-4 rounded-2xl border border-slate-200/90 bg-white p-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:border-blue-200 hover:shadow-[0_12px_32px_rgba(37,99,235,0.09)] dark:border-slate-800 dark:bg-slate-900">
|
||||
<AgentBrandIcon className="agent-avatar" size="md" />
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
@@ -43,6 +42,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
|
||||
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||
@@ -520,9 +520,7 @@ export function AgentDetailPanel({
|
||||
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
|
||||
<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" />
|
||||
</div>
|
||||
<AgentBrandIcon className="agent-avatar" size="lg" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{editing && draft ? (
|
||||
<Input
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDownUpIcon,
|
||||
BotIcon,
|
||||
Loader2,
|
||||
LockIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -10,17 +19,17 @@ import { Input } from "@/components/ui/input";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
import { usePrivateSquareStatus, usePublishSquares } from "@/core/system-settings/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
type AgentStatusFilter = "all" | "published" | "draft" | "private" | "builtin";
|
||||
|
||||
// Within a single tab the verified password is kept in sessionStorage so a
|
||||
// refresh / route navigation does not re-prompt. Cleared on tab close, so the
|
||||
@@ -32,11 +41,10 @@ export function AgentGallery() {
|
||||
const router = useRouter();
|
||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [viewMode, setViewMode] = useViewMode("agent");
|
||||
const [activeTab, setActiveTab] = useState<string>("mine");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<AgentSort>("time_desc");
|
||||
const [statusFilter, setStatusFilter] = useState<AgentStatusFilter>("all");
|
||||
const { data: publishSquares } = usePublishSquares();
|
||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||
// backend always returns at least one (the built-in default).
|
||||
@@ -53,10 +61,9 @@ export function AgentGallery() {
|
||||
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
|
||||
// "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
|
||||
// filters change, every tab returns to its first page.
|
||||
const tagKey = tagIds.join(",");
|
||||
useEffect(() => {
|
||||
setPages({});
|
||||
}, [search, tagKey, sort, publisher?.id]);
|
||||
}, [search, sort, publisher?.id, statusFilter]);
|
||||
|
||||
// If the active square tab disappears (admin removed that square), fall back
|
||||
// to "我的" so the gallery never points at a dead tab.
|
||||
@@ -108,7 +115,6 @@ export function AgentGallery() {
|
||||
} = useAgentsPaginated({
|
||||
scope: mainScope,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page: mainPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
@@ -126,7 +132,6 @@ export function AgentGallery() {
|
||||
error: privateError,
|
||||
} = usePrivateAgentsPaginated(privatePassword, {
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page: pages.private ?? 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
@@ -177,21 +182,22 @@ export function AgentGallery() {
|
||||
const handlePublisherClick = (p: { id: string; label: string }) =>
|
||||
setPublisher(p);
|
||||
|
||||
const renderGrid = (list: Agent[]) =>
|
||||
viewMode === "list" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{list.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
layout="list"
|
||||
onPublisherClick={handlePublisherClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{list.map((agent) => (
|
||||
const filterByStatus = (agent: Agent) => {
|
||||
if (statusFilter === "published") return agent.published && !agent.is_private;
|
||||
if (statusFilter === "draft") return !agent.published && !agent.is_private && !agent.builtin;
|
||||
if (statusFilter === "private") return agent.is_private;
|
||||
if (statusFilter === "builtin") return agent.builtin;
|
||||
return true;
|
||||
};
|
||||
|
||||
const renderGrid = (list: Agent[]) => {
|
||||
const visibleAgents = list.filter(filterByStatus);
|
||||
if (visibleAgents.length === 0 && list.length > 0) {
|
||||
return renderEmpty("当前状态筛选下没有智能体。");
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{visibleAgents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
@@ -200,6 +206,7 @@ export function AgentGallery() {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
||||
const renderListBody = (opts: {
|
||||
@@ -264,10 +271,8 @@ export function AgentGallery() {
|
||||
};
|
||||
|
||||
const renderEmpty = (description: string, showNewButton = false) => (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
|
||||
</div>
|
||||
<div className="flex h-72 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-blue-200 bg-white/70 text-center shadow-[0_12px_36px_rgba(37,99,235,0.05)] dark:border-blue-900 dark:bg-slate-900/70">
|
||||
<AgentBrandIcon size="lg" />
|
||||
<div>
|
||||
<p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]">
|
||||
@@ -276,8 +281,7 @@ export function AgentGallery() {
|
||||
</div>
|
||||
{showNewButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2 dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
|
||||
className="mt-2 rounded-xl bg-[#1f63e9] px-5 shadow-[0_8px_20px_rgba(31,99,233,0.22)] hover:bg-[#1857d4] dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
|
||||
onClick={handleNewAgent}
|
||||
>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
@@ -364,17 +368,22 @@ export function AgentGallery() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden border-y border-r border-slate-200/80 bg-white dark:border-slate-800 dark:bg-slate-950">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div className="flex shrink-0 items-start justify-between px-[38px] pt-[45px] pb-[38px]">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t.agents.title}</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
{t.agents.description}
|
||||
<h1 className="text-[28px] leading-none font-semibold tracking-[-0.03em] text-slate-950 dark:text-white">
|
||||
{t.agents.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-[15px] text-slate-500 dark:text-slate-400">
|
||||
创建并管理专属的 AI 智能体,助力更高效的工作与决策。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
<Button
|
||||
onClick={handleNewAgent}
|
||||
className="h-12 w-[154px] rounded-lg bg-gradient-to-r from-[#2467ea] to-[#0f54ed] px-0 text-[15px] font-medium shadow-[0_9px_22px_rgba(31,99,233,0.22)] hover:brightness-105"
|
||||
>
|
||||
<PlusIcon className="mr-2 h-[18px] w-[18px]" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -384,43 +393,99 @@ export function AgentGallery() {
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v);
|
||||
// Each tab starts from a clean slate: switching tabs clears the
|
||||
// keyword / tag / sort / publisher filters instead of carrying them
|
||||
// over. The TagSearchBar's own input box is reset via its key below.
|
||||
// Each tab starts from a clean slate instead of carrying filters over.
|
||||
setSearch("");
|
||||
setTagIds([]);
|
||||
setSort("time_desc");
|
||||
setStatusFilter("all");
|
||||
setPublisher(null);
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList className="w-auto max-w-full flex-wrap justify-start self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<div className="shrink-0 px-[38px]">
|
||||
<div className="flex min-h-14 flex-col gap-3 border-b border-slate-200/90 lg:flex-row lg:items-end lg:justify-between dark:border-slate-800">
|
||||
<TabsList variant="line" className="h-auto w-auto max-w-full flex-wrap justify-start self-start rounded-none border-0 bg-transparent p-0">
|
||||
<TabsTrigger
|
||||
value="mine"
|
||||
className="h-14 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-3 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
|
||||
>
|
||||
我的
|
||||
</TabsTrigger>
|
||||
{squareTabs.map((sq) => (
|
||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||
<TabsTrigger
|
||||
key={sq.id}
|
||||
value={sq.id}
|
||||
title={sq.description || sq.name}
|
||||
className="h-14 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
|
||||
>
|
||||
{sq.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{privateFeatureAvailable && (
|
||||
<TabsTrigger value="private">
|
||||
<TabsTrigger
|
||||
value="private"
|
||||
className="h-14 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
|
||||
>
|
||||
<LockIcon className="mr-1 h-3.5 w-3.5" />
|
||||
私密广场
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)">
|
||||
<TabsTrigger
|
||||
value="builtin"
|
||||
title="内置/功能型智能体(仅管理员可见)"
|
||||
className="h-14 rounded-none border-x-0 border-t-0 border-b-2 border-transparent bg-transparent px-5 text-[15px] shadow-none after:hidden data-[state=active]:border-x-0 data-[state=active]:border-t-0 data-[state=active]:!border-b-[#2166e8] data-[state=active]:bg-transparent data-[state=active]:text-[#2166e8] data-[state=active]:!shadow-none"
|
||||
>
|
||||
<BotIcon className="mr-1 h-3.5 w-3.5" />
|
||||
内置智能体
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
共 {activeTotal} 个
|
||||
</span>
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||
{showSearchBar && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-5 pb-2.5">
|
||||
<div className="relative w-[350px] max-w-full">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-3.5 size-[18px] -translate-y-1/2 text-slate-500" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="搜索智能体名称或描述"
|
||||
className="h-12 rounded-xl border-slate-200 bg-white pl-11 text-sm shadow-none placeholder:text-slate-400 focus-visible:border-blue-400 focus-visible:ring-blue-100 dark:border-slate-700 dark:bg-slate-900"
|
||||
/>
|
||||
{(activeTab === "private" ? isPrivateFetching : mainFetching) && (
|
||||
<Loader2 className="absolute top-1/2 right-3.5 size-4 -translate-y-1/2 animate-spin text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => setStatusFilter(value as AgentStatusFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-12 w-[156px] rounded-xl border-slate-200 px-4 text-sm shadow-none dark:border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="published">已发布</SelectItem>
|
||||
<SelectItem value="draft">未发布</SelectItem>
|
||||
<SelectItem value="private">私密</SelectItem>
|
||||
<SelectItem value="builtin">内置</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
|
||||
<SelectTrigger
|
||||
className="h-12 w-[156px] rounded-xl border-slate-200 px-4 text-sm shadow-none dark:border-slate-700"
|
||||
title="排序"
|
||||
>
|
||||
<ArrowDownUpIcon className="mr-1 size-4 text-slate-500" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value="time_desc">更新时间</SelectItem>
|
||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||
<SelectItem value="updated_desc">最近更新</SelectItem>
|
||||
<SelectItem value="updated_asc">最早更新</SelectItem>
|
||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{activeTab === "private" && privatePassword && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -432,48 +497,16 @@ export function AgentGallery() {
|
||||
锁定
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showSearchBar && (
|
||||
<TagSearchBar
|
||||
// Remount per tab so the internal keyword input / 展开 state also
|
||||
// resets when the parent-owned filters are cleared on tab switch.
|
||||
key={activeTab}
|
||||
targetType="agent"
|
||||
searching={
|
||||
activeTab === "private" ? isPrivateFetching : mainFetching
|
||||
}
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
actions={
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
|
||||
<SelectTrigger
|
||||
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
||||
title="排序"
|
||||
>
|
||||
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value="time_desc">最新创建</SelectItem>
|
||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||
<SelectItem value="updated_desc">最近更新</SelectItem>
|
||||
<SelectItem value="updated_asc">最早更新</SelectItem>
|
||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publisher && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">筛选发布者:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPublisher(null)}
|
||||
className="bg-primary/10 text-primary hover:bg-primary/20 inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors"
|
||||
className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:bg-blue-500/15 dark:text-blue-300"
|
||||
title="点击清除发布者筛选"
|
||||
>
|
||||
{publisher.label}
|
||||
@@ -481,13 +514,16 @@ export function AgentGallery() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="py-5 text-[15px] text-slate-600 dark:text-slate-300">
|
||||
共 {activeTotal} 个智能体
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{squareTabs.map((sq) => (
|
||||
<TabsContent
|
||||
key={sq.id}
|
||||
value={sq.id}
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8"
|
||||
>
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
@@ -500,7 +536,7 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
<TabsContent value="mine" className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
@@ -513,7 +549,7 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
|
||||
{isAdmin && (
|
||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
<TabsContent value="builtin" className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
@@ -528,7 +564,7 @@ export function AgentGallery() {
|
||||
{privateFeatureAvailable && (
|
||||
<TabsContent
|
||||
value="private"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
className="mt-0 flex-1 overflow-y-auto px-[38px] pb-8"
|
||||
>
|
||||
{renderPrivateTab()}
|
||||
</TabsContent>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BookOpenCheck,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
PanelLeftClose,
|
||||
PenLine,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AgentBrandIcon } from "./agents/agent-brand-icon";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
const NEW_CHAT_PATH = "/page/workspace/chats/new";
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: typeof MessageSquare;
|
||||
match: string;
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ label: "新建对话", href: NEW_CHAT_PATH, icon: MessageSquarePlus, match: NEW_CHAT_PATH },
|
||||
{ label: "对话", href: "/page/workspace/chats", icon: MessageSquare, match: "/page/workspace/chats" },
|
||||
{ label: "智能体", href: "/page/workspace/agents", icon: Sparkles, match: "/page/workspace/agents" },
|
||||
{ label: "技能", href: "/page/workspace/skills", icon: Sparkles, match: "/page/workspace/skills" },
|
||||
{ label: "记忆", href: "/page/workspace/memory", icon: Brain, match: "/page/workspace/memory" },
|
||||
{ label: "AI 写作", href: "/page/workspace/ai-writing", icon: PenLine, match: "/page/workspace/ai-writing" },
|
||||
{
|
||||
label: "多智能体规划",
|
||||
href: "/page/workspace/roundtable/planning",
|
||||
icon: UsersRound,
|
||||
match: "/page/workspace/roundtable",
|
||||
},
|
||||
{
|
||||
label: "定时任务",
|
||||
href: "/page/workspace/scheduled/tasks",
|
||||
icon: CalendarClock,
|
||||
match: "/page/workspace/scheduled",
|
||||
},
|
||||
{ label: "问答梳理", href: "/page/workspace/knowledge", icon: BookOpenCheck, match: "/page/workspace/knowledge" },
|
||||
] as const;
|
||||
|
||||
export function CmChatSidebar({
|
||||
onCollapse,
|
||||
collapsed = false,
|
||||
}: {
|
||||
onCollapse?: () => void;
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNewChat = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
||||
event.preventDefault();
|
||||
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="flex h-full min-h-0 w-14 flex-col border-r border-slate-200/80 bg-white text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
aria-label="展开侧边栏"
|
||||
title="展开侧边栏"
|
||||
className="mx-auto mt-2 flex size-11 shrink-0 items-center justify-center rounded-xl transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 dark:hover:bg-slate-900"
|
||||
>
|
||||
<img
|
||||
src="/cm-assistant-logo.png"
|
||||
alt="CM 助手"
|
||||
className="size-10 object-contain"
|
||||
/>
|
||||
</button>
|
||||
<div className="mx-2 mt-2 h-px shrink-0 bg-slate-200/80 dark:bg-slate-800" />
|
||||
|
||||
<nav className="flex shrink-0 flex-col items-center gap-1 px-2 py-3" aria-label="工作台导航">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = item.href === NEW_CHAT_PATH
|
||||
? pathname === NEW_CHAT_PATH
|
||||
: item.href === "/page/workspace/chats"
|
||||
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
|
||||
: pathname.startsWith(item.match);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-label={item.label}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
"flex size-10 items-center justify-center rounded-xl transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40",
|
||||
active
|
||||
? "bg-[#edf4ff] text-[#165ee8] dark:bg-blue-500/15 dark:text-blue-300"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.href === "/page/workspace/agents" ? (
|
||||
<AgentBrandIcon size="nav" />
|
||||
) : (
|
||||
<Icon className="size-[19px]" strokeWidth={1.8} />
|
||||
)}
|
||||
<span className="sr-only">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex shrink-0 justify-center border-t border-slate-200/80 py-3 dark:border-slate-800">
|
||||
<Link
|
||||
href="/page/workspace/settings"
|
||||
aria-label="设置和更多"
|
||||
title="设置和更多"
|
||||
className="flex size-10 items-center justify-center rounded-xl text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white"
|
||||
>
|
||||
<Settings className="size-[19px]" strokeWidth={1.8} />
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden border-r border-slate-200/80 bg-white text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100">
|
||||
<div className="flex shrink-0 items-center gap-2 px-6 pt-4 pb-3">
|
||||
<Link href={NEW_CHAT_PATH} className="flex min-w-0 flex-1 items-center gap-3.5 outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40">
|
||||
<img
|
||||
src="/cm-assistant-logo.png"
|
||||
alt="CM 助手"
|
||||
className="size-10 shrink-0 object-contain"
|
||||
/>
|
||||
<span className="truncate text-[20px] font-semibold tracking-[-0.025em]">CM 助手</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
aria-label="收起侧边栏"
|
||||
title="收起侧边栏"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 dark:hover:bg-slate-900 dark:hover:text-white"
|
||||
>
|
||||
<PanelLeftClose className="size-[18px]" strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="shrink-0 px-4 pt-1" aria-label="工作台导航">
|
||||
<div className="space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = item.href === NEW_CHAT_PATH
|
||||
? pathname === NEW_CHAT_PATH
|
||||
: item.href === "/page/workspace/chats"
|
||||
? pathname.startsWith(item.match) && pathname !== NEW_CHAT_PATH
|
||||
: pathname.startsWith(item.match);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={item.href === NEW_CHAT_PATH ? handleNewChat : undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex h-11 items-center gap-3 rounded-xl px-4 text-[14px] font-medium transition-colors",
|
||||
active
|
||||
? "bg-[#edf4ff] text-[#165ee8] dark:bg-blue-500/15 dark:text-blue-300"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.href === "/page/workspace/agents" ? (
|
||||
<AgentBrandIcon size="nav" />
|
||||
) : (
|
||||
<Icon className="size-[18px] shrink-0" strokeWidth={1.8} />
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-1">
|
||||
<RecentChatList compact limit={5} moreHref="/page/workspace/chats" />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-slate-200/80 px-3 py-3 dark:border-slate-800">
|
||||
<WorkspaceNavMenu />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,38 +11,15 @@ export function CompactWelcome({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full flex-col items-center justify-center text-center select-none",
|
||||
"mx-auto flex w-full flex-col items-center justify-center text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */}
|
||||
<div
|
||||
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"rgb(var(--legacy-workspace-brand-rgb) / 5%)",
|
||||
border:
|
||||
"1px solid rgb(var(--legacy-workspace-brand-rgb) / 12%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to top right, var(--workspace-brand), var(--brand-200))",
|
||||
}}
|
||||
>
|
||||
<span className="text-2xl font-black tracking-wide text-white italic">
|
||||
CM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="flex items-center justify-center text-2xl font-bold tracking-tight text-foreground/90">
|
||||
欢迎使用CM助手
|
||||
<h1 className="flex items-center justify-center text-[34px] font-semibold tracking-[-0.035em] text-slate-950 dark:text-slate-50">
|
||||
你好,欢迎使用 CM 助手
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
|
||||
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
|
||||
<p className="mx-auto mt-2.5 max-w-xl text-[15px] leading-6 text-slate-500 dark:text-slate-400">
|
||||
我可以帮你写作、问答、分析和规划各类内容,让工作更高效
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,10 +46,12 @@ export function CustomPromptBar({
|
||||
onInsert,
|
||||
className,
|
||||
currentAgentId,
|
||||
compact = false,
|
||||
}: {
|
||||
onInsert: (content: string) => void;
|
||||
className?: string;
|
||||
currentAgentId?: string | null;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const allPrompts = useCustomPrompts();
|
||||
const prompts = allPrompts.filter(
|
||||
@@ -81,7 +83,13 @@ export function CustomPromptBar({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex min-w-0 items-center gap-1", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1",
|
||||
compact && "min-h-9 justify-end",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{prompts.length > 0 && (
|
||||
<>
|
||||
{overflowing && (
|
||||
@@ -111,6 +119,7 @@ export function CustomPromptBar({
|
||||
title={prompt.content}
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
|
||||
compact && "border-slate-200 bg-white px-3.5 py-1.5 text-[12px] text-slate-600 shadow-none hover:border-blue-200 hover:bg-blue-50/60 hover:text-blue-700",
|
||||
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
@@ -139,7 +148,11 @@ export function CustomPromptBar({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full",
|
||||
compact &&
|
||||
"h-9 w-9 rounded-xl border border-slate-200 bg-white text-slate-600 shadow-[0_6px_18px_-14px_rgba(15,23,42,0.4)] hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700 dark:border-slate-700 dark:bg-slate-900",
|
||||
)}
|
||||
title="管理常用提示词"
|
||||
aria-label="管理常用提示词"
|
||||
onClick={() => setManageOpen(true)}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
ShrinkIcon,
|
||||
import {
|
||||
BarChart3Icon,
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
ClipboardCheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ShrinkIcon,
|
||||
SparklesIcon,
|
||||
RocketIcon,
|
||||
XIcon,
|
||||
@@ -102,7 +106,58 @@ import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
const COMPACT_QUICK_STARTS = [
|
||||
{
|
||||
id: "report",
|
||||
title: "撰写报告",
|
||||
content: "请帮我撰写一份结构清晰的专业报告,先向我确认主题、受众和篇幅要求。",
|
||||
icon: FileTextIcon,
|
||||
},
|
||||
{
|
||||
id: "knowledge",
|
||||
title: "知识问答",
|
||||
content: "请基于已有资料回答我的问题,并明确区分已知事实、推断和需要补充的信息。",
|
||||
icon: MessageCircleQuestionIcon,
|
||||
},
|
||||
{
|
||||
id: "planning",
|
||||
title: "任务规划",
|
||||
content: "请把我的目标拆解为可执行的任务计划,列出优先级、里程碑和风险。",
|
||||
icon: ClipboardCheckIcon,
|
||||
},
|
||||
{
|
||||
id: "analysis",
|
||||
title: "数据分析",
|
||||
content: "请帮我分析数据中的关键趋势、异常和可能原因,并给出下一步验证建议。",
|
||||
icon: BarChart3Icon,
|
||||
},
|
||||
{
|
||||
id: "solution",
|
||||
title: "方案策划",
|
||||
content: "请围绕我的目标设计一套完整方案,包含背景、目标、实施步骤、资源需求和风险预案。",
|
||||
icon: LightbulbIcon,
|
||||
},
|
||||
{
|
||||
id: "summary",
|
||||
title: "内容总结",
|
||||
content: "请提炼我提供内容的核心观点、关键结论和待办事项,并按重要程度整理。",
|
||||
icon: BookMarkedIcon,
|
||||
},
|
||||
{
|
||||
id: "research",
|
||||
title: "调研提纲",
|
||||
content: "请根据我的调研主题生成一份结构完整的调研提纲,覆盖对象、问题、方法和输出形式。",
|
||||
icon: GraduationCapIcon,
|
||||
},
|
||||
{
|
||||
id: "polish",
|
||||
title: "文案润色",
|
||||
content: "请在不改变原意的前提下润色我提供的内容,使表达更清晰、专业、简洁。",
|
||||
icon: SparklesIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
||||
function getResolvedMode(
|
||||
@@ -210,17 +265,41 @@ export function InputBox({
|
||||
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
||||
const wasStreamingRef = useRef(false);
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [compactPromptOrder, setCompactPromptOrder] = useState(0);
|
||||
const [pendingSuggestion, setPendingSuggestion] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
recommendedQuestions.length > 0;
|
||||
const compactQuickStarts = useMemo(() => {
|
||||
if (!hideAdminRecommendedQuestions && recommendedQuestions.length > 0) {
|
||||
return recommendedQuestions.map((configured, index) => {
|
||||
const fallback =
|
||||
COMPACT_QUICK_STARTS[index % COMPACT_QUICK_STARTS.length]!;
|
||||
return {
|
||||
...fallback,
|
||||
id: configured.id,
|
||||
title: desensitize(configured.title) || `提示词 ${index + 1}`,
|
||||
content: configured.content || fallback.content,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [...COMPACT_QUICK_STARTS];
|
||||
}, [hideAdminRecommendedQuestions, recommendedQuestions]);
|
||||
const orderedCompactQuickStarts = useMemo(() => {
|
||||
if (compactQuickStarts.length < 2) return compactQuickStarts;
|
||||
const offset = compactPromptOrder % compactQuickStarts.length;
|
||||
return [
|
||||
...compactQuickStarts.slice(offset),
|
||||
...compactQuickStarts.slice(0, offset),
|
||||
];
|
||||
}, [compactPromptOrder, compactQuickStarts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
@@ -252,10 +331,11 @@ export function InputBox({
|
||||
|
||||
const resolvedModelName = selectedModel?.name;
|
||||
|
||||
const supportThinking = useMemo(
|
||||
() => selectedModel?.supports_thinking ?? false,
|
||||
[selectedModel],
|
||||
);
|
||||
const supportThinking = useMemo(
|
||||
() => selectedModel?.supports_thinking ?? false,
|
||||
[selectedModel],
|
||||
);
|
||||
const effectiveMode = getResolvedMode(context.mode, supportThinking);
|
||||
|
||||
const supportReasoningEffort = useMemo(
|
||||
() => selectedModel?.supports_reasoning_effort ?? false,
|
||||
@@ -529,14 +609,68 @@ export function InputBox({
|
||||
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
<div
|
||||
ref={promptRootRef}
|
||||
className={cn(
|
||||
"relative flex flex-col",
|
||||
compact ? "gap-[clamp(32px,6vh,58px)]" : "gap-6",
|
||||
)}
|
||||
>
|
||||
{extraHeader && (
|
||||
<div className="flex items-center justify-center">
|
||||
{extraHeader}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && compact && (
|
||||
<section className="px-2" aria-labelledby="recommended-prompts-title">
|
||||
<div className="mb-3 flex items-center justify-center gap-1.5">
|
||||
<h2
|
||||
id="recommended-prompts-title"
|
||||
className="text-[14px] font-normal text-slate-400 dark:text-slate-500"
|
||||
>
|
||||
你可以这样问我
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
data-prompt-order-refresh="true"
|
||||
title="调整提示词顺序"
|
||||
aria-label="调整提示词顺序"
|
||||
onClick={() => setCompactPromptOrder((current) => current + 1)}
|
||||
className="flex size-7 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-blue-600 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-blue-500/15 dark:hover:bg-slate-800 dark:hover:text-blue-300"
|
||||
>
|
||||
<RefreshCwIcon className="size-3.5" strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-[900px] flex-wrap justify-center gap-x-3 gap-y-3">
|
||||
{orderedCompactQuickStarts.map((item) => {
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-quick-prompt={item.id}
|
||||
title={item.content}
|
||||
className="inline-flex min-h-11 max-w-full items-center justify-center rounded-xl border border-[#e5e7eb] bg-white px-4 py-2 text-center text-[14px] font-normal leading-5 text-slate-700 shadow-[0_2px_6px_rgba(15,23,42,0.025)] transition-[background-color,border-color,box-shadow] hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 hover:shadow-[0_4px_10px_rgba(15,23,42,0.04)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-blue-500/15 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
textInput.setInput(item.content);
|
||||
requestAnimationFrame(() =>
|
||||
document
|
||||
.querySelector<HTMLTextAreaElement>(
|
||||
"textarea[name='message']",
|
||||
)
|
||||
?.focus(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="max-w-full whitespace-normal">{item.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && !compact && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
@@ -587,9 +721,10 @@ export function InputBox({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{isNewThread && (
|
||||
<CustomPromptBar
|
||||
className="px-1"
|
||||
{isNewThread && (
|
||||
<CustomPromptBar
|
||||
compact={compact}
|
||||
className={cn(compact ? "px-2" : "px-1")}
|
||||
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
|
||||
onInsert={(content) => {
|
||||
const current = textInput.value?.trim() ?? "";
|
||||
@@ -612,8 +747,8 @@ export function InputBox({
|
||||
? "*:data-[slot='input-group']:rounded-2xl *:data-[slot='input-group']:border-0 *:data-[slot='input-group']:bg-transparent *:data-[slot='input-group']:shadow-none"
|
||||
: "*:data-[slot='input-group']:rounded-3xl",
|
||||
// 紧凑落地态(CM 参考图):白卡 + 细边 + 轻阴影 + 主色聚焦环(无旋转彩边)
|
||||
compact && isNewThread
|
||||
? "rounded-2xl border border-gray-200/70 bg-white overflow-hidden shadow-[0_4.5px_22px_rgba(0,0,0,0.025)] transition-all duration-300 focus-within:border-[rgb(var(--legacy-primary-rgb)/35%)] focus-within:ring-4 focus-within:ring-[rgb(var(--legacy-primary-rgb)/5%)] focus-within:shadow-[0_10px_35px_rgb(var(--legacy-primary-rgb)/4%)] dark:border-slate-700 dark:bg-slate-800"
|
||||
compact && isNewThread
|
||||
? "overflow-hidden rounded-[24px] border border-slate-200/90 bg-white shadow-[0_18px_50px_-32px_rgba(37,99,235,0.6),0_8px_24px_-20px_rgba(15,23,42,0.35)] transition-all duration-300 focus-within:border-blue-300 focus-within:ring-4 focus-within:ring-blue-500/10 dark:border-slate-700 dark:bg-slate-800"
|
||||
: // landing 态:透明,让外层 gemini-gradient-border 提供白卡 + 旋转描边
|
||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||
isNewThread
|
||||
@@ -637,7 +772,8 @@ export function InputBox({
|
||||
className={cn(
|
||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-muted-foreground/60",
|
||||
isNewThread && "min-h-[110px]!",
|
||||
compact && "min-h-[84px]! px-3 pt-3 pb-1 text-[14px]",
|
||||
compact &&
|
||||
"min-h-[clamp(150px,18vh,176px)]! px-7 pt-7 pb-2 text-[15px]",
|
||||
)}
|
||||
disabled={disabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
@@ -648,7 +784,7 @@ export function InputBox({
|
||||
<PromptInputFooter
|
||||
className={cn(
|
||||
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
|
||||
compact && "gap-2 px-3 pt-1.5 pb-3",
|
||||
compact && "gap-2 px-6 pt-2 pb-5",
|
||||
)}
|
||||
>
|
||||
<PromptInputTools
|
||||
@@ -783,52 +919,47 @@ export function InputBox({
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
context.mode === "flash" ||
|
||||
context.mode === "thinking" ||
|
||||
context.mode === "pro" ||
|
||||
context.mode === "ultra"
|
||||
? context.mode
|
||||
: "flash"
|
||||
}
|
||||
mode={
|
||||
effectiveMode
|
||||
}
|
||||
>
|
||||
<PromptInputActionMenuTrigger
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||
compact &&
|
||||
(context.mode === "thinking"
|
||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
||||
: context.mode === "pro"
|
||||
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
|
||||
: context.mode === "ultra"
|
||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
(effectiveMode === "thinking"
|
||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
||||
: effectiveMode === "pro"
|
||||
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
|
||||
: effectiveMode === "ultra"
|
||||
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
||||
{context.mode === "thinking" && (
|
||||
<LightbulbIcon className="size-3" />
|
||||
)}
|
||||
{context.mode === "pro" && (
|
||||
<GraduationCapIcon className="size-3" />
|
||||
)}
|
||||
{context.mode === "ultra" && (
|
||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||
)}
|
||||
{effectiveMode === "flash" && <ZapIcon className="size-3" />}
|
||||
{effectiveMode === "thinking" && (
|
||||
<LightbulbIcon className="size-3" />
|
||||
)}
|
||||
{effectiveMode === "pro" && (
|
||||
<GraduationCapIcon className="size-3" />
|
||||
)}
|
||||
{effectiveMode === "ultra" && (
|
||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
context.mode === "ultra" ? "golden-text" : "",
|
||||
effectiveMode === "ultra" ? "golden-text" : "",
|
||||
)}
|
||||
>
|
||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
||||
(context.mode === "thinking" &&
|
||||
t.inputBox.reasoningMode) ||
|
||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
||||
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
||||
{(effectiveMode === "flash" && t.inputBox.flashMode) ||
|
||||
(effectiveMode === "thinking" &&
|
||||
t.inputBox.reasoningMode) ||
|
||||
(effectiveMode === "pro" && t.inputBox.proMode) ||
|
||||
(effectiveMode === "ultra" && t.inputBox.ultraMode)}
|
||||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
</ModeHoverGuide>
|
||||
@@ -1124,8 +1255,10 @@ export function InputBox({
|
||||
onOpenChange={setModelDialogOpen}
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
<PromptInputButton
|
||||
disabled={models.length === 0}
|
||||
aria-label="模型选择"
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent px-3 shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact &&
|
||||
"rounded-lg border border-transparent bg-gray-50/60 px-2.5! py-1! font-mono text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
@@ -1138,7 +1271,7 @@ export function InputBox({
|
||||
compact && "text-[11px]",
|
||||
)}
|
||||
>
|
||||
{selectedModel?.display_name}
|
||||
{selectedModel?.display_name || "默认模型"}
|
||||
</ModelSelectorName>
|
||||
</div>
|
||||
</PromptInputButton>
|
||||
@@ -1171,8 +1304,8 @@ export function InputBox({
|
||||
<PromptInputSubmit
|
||||
className={cn(
|
||||
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
|
||||
compact &&
|
||||
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--primary-foreground)] dark:hover:opacity-90 dark:disabled:bg-muted",
|
||||
compact &&
|
||||
"size-10 rounded-full border-0 bg-blue-600 text-white shadow-[0_8px_18px_-10px_rgba(37,99,235,0.9)] hover:bg-blue-700 disabled:bg-muted disabled:text-muted-foreground/40 dark:border-0 dark:bg-blue-500 dark:text-white dark:hover:bg-blue-400 dark:disabled:bg-muted",
|
||||
)}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
|
||||
@@ -65,7 +65,10 @@ import { cn } from "@/lib/utils";
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
import {
|
||||
isWeKnoraSourcePreviewUrl,
|
||||
SourceCard,
|
||||
} from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
@@ -639,19 +642,43 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||||
let sourceLabel: string | undefined = item.source;
|
||||
if (item.url) {
|
||||
try {
|
||||
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
sourceLabel = sourceLabel ?? item.url;
|
||||
}
|
||||
}
|
||||
return (
|
||||
} catch {
|
||||
sourceLabel = sourceLabel ?? item.url;
|
||||
}
|
||||
}
|
||||
|
||||
// MCP retrieval tools without a configured display adapter fall back to this
|
||||
// generic result card. Preserve the normal web-search UI, but route our
|
||||
// credential-free WeKnora pointer through SourceCard so its dialog exposes a
|
||||
// deliberate “查看原文” action rather than rendering the internal route text.
|
||||
if (isWeKnoraSourcePreviewUrl(item.url)) {
|
||||
const weknoraSource: RuntimeDisplaySource = {
|
||||
id: item.id ?? item.uuid ?? item.docId,
|
||||
title: item.title ?? "检索结果",
|
||||
type: item.source ?? "document",
|
||||
snippet: item.snippet ?? item.content ?? "",
|
||||
content,
|
||||
source: item.source,
|
||||
author: item.author,
|
||||
time: date,
|
||||
url: item.url,
|
||||
index: 0,
|
||||
skillName: "weknora-knowledge-search",
|
||||
mode: "list",
|
||||
raw: { ...item },
|
||||
};
|
||||
return <SourceCard source={weknoraSource} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -23,10 +23,11 @@ import {
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -50,13 +51,6 @@ import {
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemTitle,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -742,9 +736,9 @@ export function SkillRow({
|
||||
}
|
||||
}
|
||||
|
||||
const badgeGroup = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn(isCard && "truncate font-medium")}>
|
||||
const badgeGroup = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 truncate text-[15px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-100">
|
||||
{skill.name_zh?.trim() ? skill.name_zh : desensitize(skill.name)}
|
||||
</span>
|
||||
{skill.name_zh?.trim() && (
|
||||
@@ -902,12 +896,12 @@ export function SkillRow({
|
||||
</>
|
||||
);
|
||||
|
||||
const actionButtons = (
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
@@ -917,8 +911,8 @@ export function SkillRow({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
|
||||
skill.featured && "text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
disabled={setFeatured.isPending}
|
||||
@@ -938,8 +932,8 @@ export function SkillRow({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
|
||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||
)}
|
||||
disabled={setPinned.isPending}
|
||||
@@ -959,8 +953,8 @@ export function SkillRow({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white",
|
||||
skill.always_on && "text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
disabled={setAlwaysOn.isPending}
|
||||
@@ -997,9 +991,9 @@ export function SkillRow({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
disabled={duplicateSkillMutation.isPending}
|
||||
onClick={handleOpenDuplicate}
|
||||
title="复制为新技能"
|
||||
@@ -1008,9 +1002,9 @@ export function SkillRow({
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0 rounded-lg hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
title={t.settings.skills.deleteAction}
|
||||
>
|
||||
@@ -1018,41 +1012,124 @@ export function SkillRow({
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
);
|
||||
|
||||
const iconStyle = skill.name.includes("web") || skill.name.includes("search")
|
||||
? {
|
||||
Icon: GlobeIcon,
|
||||
className: "bg-emerald-600 text-white shadow-[0_6px_16px_rgba(22,163,74,0.22)]",
|
||||
}
|
||||
: skill.name.includes("doc") || skill.name.includes("file")
|
||||
? {
|
||||
Icon: FileArchive,
|
||||
className: "bg-violet-600 text-white shadow-[0_6px_16px_rgba(124,58,237,0.22)]",
|
||||
}
|
||||
: skill.source === "agent"
|
||||
? {
|
||||
Icon: WorkflowIcon,
|
||||
className: "bg-[#2166e8] text-white shadow-[0_6px_16px_rgba(37,99,235,0.22)]",
|
||||
}
|
||||
: {
|
||||
Icon: Upload,
|
||||
className: "bg-amber-500 text-white shadow-[0_6px_16px_rgba(245,158,11,0.22)]",
|
||||
};
|
||||
const SkillIcon = iconStyle.Icon;
|
||||
const activityAt = formatDateTime(skill.last_activity_at ?? skill.created_at);
|
||||
const actionArea = (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1" onClick={(event) => event.stopPropagation()}>
|
||||
{actionButtons}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCard ? (
|
||||
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
{badgeGroup}
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
{metaNodes}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<ItemTitle>{badgeGroup}</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</ItemDescription>
|
||||
{metaNodes}
|
||||
</ItemContent>
|
||||
<ItemActions>{actionButtons}</ItemActions>
|
||||
</Item>
|
||||
)}
|
||||
{isCard ? (
|
||||
<div className="flex h-full min-w-0 flex-col rounded-xl border border-slate-200 bg-white p-5 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className={cn("flex size-12 shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
|
||||
<SkillIcon className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{badgeGroup}
|
||||
<p className="mt-1.5 line-clamp-3 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
|
||||
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
|
||||
{skill.enabled ? "已启用" : "未启用"}
|
||||
</Badge>
|
||||
<span className="text-xs text-slate-400">{skill.use_count ? `调用 ${skill.use_count} 次` : "暂无调用"}</span>
|
||||
{activityAt && <span className="text-xs text-slate-400">更新于 {activityAt}</span>}
|
||||
</div>
|
||||
{(skill.tags?.length ?? 0) > 0 && <TagChipList tags={skill.tags!} className="mt-3" />}
|
||||
</div>
|
||||
<div className="mt-4 border-t border-slate-100 pt-3 dark:border-slate-800">
|
||||
{actionArea}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group/skill-card w-full rounded-xl border border-slate-200 bg-white px-5 py-4 shadow-[0_5px_18px_rgba(15,23,42,0.03)] transition-[border-color,box-shadow] hover:border-blue-200 hover:shadow-[0_10px_28px_rgba(37,99,235,0.07)] dark:border-slate-800 dark:bg-slate-900 dark:text-white">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.65fr)_minmax(150px,0.72fr)_minmax(100px,0.42fr)_minmax(132px,0.58fr)_auto] xl:items-center xl:gap-x-6">
|
||||
<div className="flex min-w-0 cursor-pointer items-start gap-4" onClick={() => setDetailOpen(true)}>
|
||||
<div className={cn("flex size-[60px] shrink-0 items-center justify-center rounded-full", iconStyle.className)}>
|
||||
<SkillIcon className="size-7" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{badgeGroup}
|
||||
<p className="mt-1.5 line-clamp-2 text-sm leading-5 text-slate-600 dark:text-slate-400">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
{!isBuiltin && skill.owner_username && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!skill.owner_user_id || !onPublisherClick}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!skill.owner_user_id || !onPublisherClick) return;
|
||||
onPublisherClick({ id: skill.owner_user_id, label: desensitize(skill.owner_username!) });
|
||||
}}
|
||||
className={cn(
|
||||
"mt-2 flex max-w-full items-center gap-1 text-xs text-slate-400",
|
||||
skill.owner_user_id && onPublisherClick && "cursor-pointer hover:text-blue-600 hover:underline",
|
||||
)}
|
||||
>
|
||||
<UserIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">发布者:{desensitize(skill.owner_username)}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 xl:border-l xl:border-slate-100 xl:pl-5 xl:dark:border-slate-800">
|
||||
{(skill.tags?.length ?? 0) > 0 ? (
|
||||
<TagChipList tags={skill.tags!} />
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">未设置标签</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-sm text-slate-600 dark:text-slate-300">
|
||||
<span className="block text-xs text-slate-400 xl:hidden">使用次数</span>
|
||||
{skill.use_count ? `${skill.use_count} 次` : "暂无调用"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Badge className={cn("border-0 px-2.5 py-1 text-xs font-normal", skill.enabled ? "bg-emerald-50 text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300" : "bg-slate-100 text-slate-500 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-400")}>
|
||||
<span className={cn("mr-1.5 size-1.5 rounded-full", skill.enabled ? "bg-emerald-500" : "bg-slate-400")} />
|
||||
{skill.enabled ? "已启用" : "未启用"}
|
||||
</Badge>
|
||||
{activityAt && <p className="mt-2 truncate text-xs text-slate-400" title={activityAt}>更新于 {activityAt}</p>}
|
||||
</div>
|
||||
<div className="min-w-0 border-t border-slate-100 pt-3 xl:border-t-0 xl:pt-0 dark:border-slate-800">
|
||||
{actionArea}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -53,6 +53,7 @@ export function SourceCard({
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
const [context, setContext] = useState<WeKnoraSourceContext | null>(null);
|
||||
const [contextLoading, setContextLoading] = useState(false);
|
||||
const [contextError, setContextError] = useState<string | null>(null);
|
||||
@@ -81,7 +82,7 @@ export function SourceCard({
|
||||
const previewTarget = parseWeKnoraPreviewUrl(source.url);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !previewTarget) {
|
||||
if (!open || !showOriginal || !previewTarget) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
@@ -115,7 +116,7 @@ export function SourceCard({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
||||
}, [open, showOriginal, previewTarget?.chunkId, previewTarget?.knowledgeBaseId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -128,7 +129,10 @@ export function SourceCard({
|
||||
highlighted &&
|
||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => {
|
||||
setShowOriginal(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{showReferenceNumber && (
|
||||
@@ -168,7 +172,15 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setShowOriginal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
@@ -205,7 +217,27 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{previewTarget ? (
|
||||
{previewTarget && (
|
||||
<div className="border-b pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{showOriginal
|
||||
? "正在查看命中文段及其前后上下文"
|
||||
: "当前为检索命中的片段,可继续查看原文上下文"}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={showOriginal ? "outline" : "default"}
|
||||
onClick={() => setShowOriginal((current) => !current)}
|
||||
>
|
||||
<FileTextIcon className="size-3.5" />
|
||||
{showOriginal ? "返回检索片段" : "查看原文"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{previewTarget && showOriginal ? (
|
||||
<WeKnoraSourceContextPanel
|
||||
context={context}
|
||||
loading={contextLoading}
|
||||
@@ -249,12 +281,27 @@ type WeKnoraSourceContext = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export function isWeKnoraSourcePreviewUrl(url?: string): boolean {
|
||||
return parseWeKnoraPreviewUrl(url) !== null;
|
||||
}
|
||||
|
||||
function parseWeKnoraPreviewUrl(url?: string): WeKnoraPreviewTarget | null {
|
||||
if (!url?.startsWith("/weknora-source-preview?")) {
|
||||
const normalizedUrl = url
|
||||
?.trim()
|
||||
.replace(/^[\u200B-\u200D\uFEFF]+/, "");
|
||||
const marker = "/weknora-source-preview?";
|
||||
const start = normalizedUrl?.indexOf(marker) ?? -1;
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
const queryIndex = url.indexOf("?");
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1));
|
||||
// Some MCP tool results wrap the relative pointer in formatting characters
|
||||
// or append a newline. Keep accepting that safe internal path, but never
|
||||
// treat a normal external URL as an original-document preview.
|
||||
const previewUrl = normalizedUrl!
|
||||
.slice(start)
|
||||
.split(/[\r\n\s]/, 1)[0];
|
||||
const queryIndex = previewUrl.indexOf("?");
|
||||
const params = new URLSearchParams(previewUrl.slice(queryIndex + 1));
|
||||
const knowledgeBaseId = params.get("knowledge_base_id")?.trim();
|
||||
const chunkId = params.get("chunk_id")?.trim();
|
||||
return knowledgeBaseId && chunkId ? { knowledgeBaseId, chunkId } : null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { User } from "lucide-react";
|
||||
import { UserRound } from "lucide-react";
|
||||
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useMyPosition } from "@/core/positions";
|
||||
@@ -15,7 +15,13 @@ import { cn } from "@/lib/utils";
|
||||
* 配色按当前内容区前景色(foreground / muted-foreground),适配聊天页浅底顶栏,
|
||||
* 而非 Header 的白字贴 banner 方案。
|
||||
*/
|
||||
export function UserIdentityBadge({ className }: { className?: string }) {
|
||||
export function UserIdentityBadge({
|
||||
className,
|
||||
variant = "plain",
|
||||
}: {
|
||||
className?: string;
|
||||
variant?: "plain" | "topbar";
|
||||
}) {
|
||||
// userInfo belongs to external-app route parameters and can legitimately
|
||||
// outlive a login switch. The visible account name must instead match the
|
||||
// settings page's current auth identity.
|
||||
@@ -26,13 +32,33 @@ export function UserIdentityBadge({ className }: { className?: string }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex shrink-0 items-center gap-2", className)}
|
||||
data-workspace-user={variant}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2",
|
||||
variant === "topbar" &&
|
||||
"h-10 max-w-[240px] rounded-lg px-1.5 py-1 text-slate-700 dark:text-slate-200",
|
||||
className,
|
||||
)}
|
||||
title={positionName ? `${displayName}·${positionName}` : displayName}
|
||||
>
|
||||
<div className="bg-muted text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full">
|
||||
<User className="size-3.5" />
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground relative flex size-7 shrink-0 items-center justify-center rounded-full",
|
||||
variant === "topbar" &&
|
||||
"size-8 bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-[0_4px_12px_-6px_rgba(37,99,235,0.8)] ring-1 ring-blue-500/10 dark:from-blue-400 dark:to-indigo-500",
|
||||
)}
|
||||
>
|
||||
<UserRound className={cn("size-3.5", variant === "topbar" && "size-4")} />
|
||||
{variant === "topbar" && (
|
||||
<span className="absolute right-0 bottom-0 size-2.5 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-950" />
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden min-w-0 text-left sm:block">
|
||||
<div
|
||||
className={cn(
|
||||
"hidden min-w-0 text-left sm:block",
|
||||
variant === "topbar" && "max-w-[165px]",
|
||||
)}
|
||||
>
|
||||
<p className="text-foreground truncate text-sm font-medium leading-tight">
|
||||
{displayName}
|
||||
</p>
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeSwitcher } from "@/components/workspace/appearance-controls";
|
||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||
|
||||
export function WorkspaceContainer({
|
||||
className,
|
||||
@@ -93,9 +95,15 @@ export function WorkspaceHeader({
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{rightActions ? (
|
||||
<div className="flex items-center gap-2 px-4">{rightActions}</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
{rightActions}
|
||||
<ThemeSwitcher tone="content" />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700"
|
||||
/>
|
||||
<UserIdentityBadge variant="topbar" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -123,7 +131,8 @@ function nameOfSegment(
|
||||
t: ReturnType<typeof useI18n>["t"],
|
||||
) {
|
||||
if (!segment) return t.common.home;
|
||||
if (segment === "workspace") return t.breadcrumb.workspace;
|
||||
if (segment === "chats") return t.breadcrumb.chats;
|
||||
return segment[0]?.toUpperCase() + segment.slice(1);
|
||||
}
|
||||
if (segment === "workspace") return t.breadcrumb.workspace;
|
||||
if (segment === "chats") return t.breadcrumb.chats;
|
||||
if (segment === "agents") return t.agents.title;
|
||||
return segment[0]?.toUpperCase() + segment.slice(1);
|
||||
}
|
||||
@@ -1,118 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarHeader,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { GeneralQaSidebar } from "./general-qa-sidebar";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
||||
if (compactChrome) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isSidebarOpen}
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-(--sidebar-width) border-r border-border",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
</>
|
||||
)}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
|
||||
// 非紧凑沿用 icon。
|
||||
collapsible={compactChrome ? "none" : "icon"}
|
||||
className={cn(
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!compactChrome && (
|
||||
<SidebarHeader className="py-0">
|
||||
<WorkspaceHeader />
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList
|
||||
showCollapseTrigger={compactChrome}
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
<WorkspaceNavMenu />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CmChatSidebar } from "./cm-chat-sidebar";
|
||||
|
||||
export function WorkspaceSidebar({
|
||||
className,
|
||||
compactChrome: _compactChrome,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
|
||||
const { open: isSidebarOpen, toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={false}
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full border-r border-border",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-14",
|
||||
"[&_[data-slot=sidebar-container]]:bg-white [&_[data-slot=sidebar-inner]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<CmChatSidebar onCollapse={toggleSidebar} collapsed={!isSidebarOpen} />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
||||
|
||||
export default function AgentsPage() {
|
||||
return <AgentGallery />;
|
||||
}
|
||||
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<WorkspaceContainer className="bg-white dark:bg-slate-950">
|
||||
<WorkspaceBody>
|
||||
<AgentGallery />
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
|
||||
import { UserIdentityBadge } from "@/components/workspace/user-identity-badge";
|
||||
import { Welcome } from "@/components/workspace/welcome";
|
||||
import { CompactWelcome } from "@/components/workspace/compact-welcome";
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -51,7 +50,6 @@ import {
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
@@ -103,8 +101,6 @@ export default function ChatPage() {
|
||||
const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } =
|
||||
useSidebar();
|
||||
const { t } = useI18n();
|
||||
// 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled)
|
||||
const [effectiveLayoutMode] = useLayoutMode();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
@@ -444,20 +440,8 @@ export default function ChatPage() {
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
// 紧凑模式(正常 App,有外层页面侧栏)落地态:用 CM 参考图样式重画;
|
||||
// 判定与 PageRoutes 的 compactWorkspaceSidebar(pageSidebarAllowed) 保持一致 ——
|
||||
// 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式
|
||||
// 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝
|
||||
// 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。
|
||||
const layoutMode = effectiveLayoutMode;
|
||||
const chromeByPreference =
|
||||
layoutMode === "full"
|
||||
? true
|
||||
: layoutMode === "compact"
|
||||
? false
|
||||
: readSidebarEnabled();
|
||||
const compactChrome =
|
||||
chromeByPreference && !isChatsIframeWorkspacePath(pathname);
|
||||
// 正常工作区统一使用新版侧栏和落地页;iframe 专用路由继续沿用嵌入布局。
|
||||
const compactChrome = !isChatsIframeWorkspacePath(pathname);
|
||||
const compactLanding = isLandingState && compactChrome;
|
||||
|
||||
return (
|
||||
@@ -470,16 +454,12 @@ export default function ChatPage() {
|
||||
compactChrome && "workspace-chat-bg",
|
||||
)}
|
||||
>
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center border-b px-4",
|
||||
isLandingState
|
||||
? "border-transparent bg-transparent"
|
||||
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
{(!isLandingState || compactChrome) && (
|
||||
<header className="bg-background border-border/80 absolute top-0 right-0 left-0 z-30 flex h-14 shrink-0 items-center border-b px-5">
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
{!isLandingState && (
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||
@@ -507,7 +487,16 @@ export default function ChatPage() {
|
||||
与全量模式 Header 右上角保持一致。全量模式已有顶栏则不重复展示。 */}
|
||||
{/* 简洁模式无全局顶栏:把换肤 + 界面模式切换搬到页面级顶栏右上角,
|
||||
与全量模式 Header 右上角保持一致(content 配色贴浅底顶栏)。 */}
|
||||
{!compactChrome && (
|
||||
{compactChrome ? (
|
||||
<>
|
||||
<ThemeSwitcher tone="content" />
|
||||
<div
|
||||
className="bg-border/70 mx-1 h-6 w-px shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<UserIdentityBadge variant="topbar" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
@@ -521,6 +510,7 @@ export default function ChatPage() {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
{/*
|
||||
@@ -556,7 +546,7 @@ export default function ChatPage() {
|
||||
"pointer-events-none absolute z-30 flex justify-center px-4",
|
||||
// 紧凑落地态:整块垂直+水平居中于对话区
|
||||
compactLanding
|
||||
? "inset-0 items-center"
|
||||
? "inset-0 items-start overflow-y-auto pt-[clamp(82px,16vh,146px)] pb-12"
|
||||
: "right-0 bottom-0 left-0",
|
||||
)}
|
||||
>
|
||||
@@ -568,7 +558,7 @@ export default function ChatPage() {
|
||||
!compactLanding &&
|
||||
"-translate-y-[calc(50vh-96px)]",
|
||||
compactLanding
|
||||
? "max-w-(--container-width-xl)"
|
||||
? "max-w-[1024px]"
|
||||
: isLandingState
|
||||
? "max-w-(--container-width-md)"
|
||||
: "max-w-(--container-width-lg)",
|
||||
@@ -587,7 +577,7 @@ export default function ChatPage() {
|
||||
</div>
|
||||
{isLandingState && mounted &&
|
||||
(compactLanding ? (
|
||||
<CompactWelcome className="mb-6 -translate-y-4" />
|
||||
<CompactWelcome className="mb-14" />
|
||||
) : (
|
||||
<Welcome
|
||||
mode={settings.context.mode}
|
||||
@@ -595,7 +585,7 @@ export default function ChatPage() {
|
||||
/>
|
||||
))}
|
||||
{mounted ? (
|
||||
<div className="w-full -translate-y-4">
|
||||
<div className={cn("w-full", !compactLanding && "-translate-y-4")}>
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { ImportConversationsDialog } from "@/components/workspace/thread-share-d
|
||||
import {
|
||||
CompactShortcutButtons,
|
||||
LayoutModeSwitcher,
|
||||
ThemeSwitcher,
|
||||
} from "@/components/workspace/appearance-controls";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
@@ -185,7 +184,6 @@ export default function ChatsPage({
|
||||
compactChrome ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
</div>
|
||||
) : null
|
||||
|
||||
@@ -61,8 +61,10 @@ function PageLayout() {
|
||||
// (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome**
|
||||
// (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。
|
||||
const forceChromeForDashboardAgent = location.pathname.includes("/strategy/dashboard-agent");
|
||||
const isWorkspacePage = location.pathname.startsWith("/page/workspace");
|
||||
const pageSidebarAllowed =
|
||||
(forceChromeForDashboardAgent || chromeByPreference) &&
|
||||
!isWorkspacePage &&
|
||||
!isChatsIframeWorkspacePath(location.pathname) &&
|
||||
!embedActive &&
|
||||
// `?hideChrome=1` 显式去 chrome:优先级高于上面对 dashboard-agent 的强制全局 chrome。
|
||||
@@ -114,7 +116,7 @@ function PageLayout() {
|
||||
pageSidebarCollapsed={collapsed}
|
||||
pageSidebarHidden={!pageSidebarAllowed}
|
||||
headerHidden={!pageSidebarAllowed}
|
||||
compactWorkspaceSidebar={pageSidebarAllowed}
|
||||
compactWorkspaceSidebar={pageSidebarAllowed || isWorkspacePage}
|
||||
pageSidebarDark={isDark}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ThemeSwitcher,
|
||||
} from '@/components/workspace/appearance-controls';
|
||||
import { WorkspaceSidebar } from '@/components/workspace/workspace-sidebar';
|
||||
import { UserIdentityBadge } from '@/components/workspace/user-identity-badge';
|
||||
import { CommandPalette } from '@/components/workspace/command-palette';
|
||||
import { Toaster } from 'sonner';
|
||||
import { SubtasksProvider } from '@/core/tasks/context';
|
||||
@@ -79,28 +78,6 @@ function isInIframe(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowCompactTopActions(pathname: string): boolean {
|
||||
return (
|
||||
pathname === '/page/workspace/agents' ||
|
||||
pathname === '/page/workspace/skills' ||
|
||||
pathname === '/page/workspace/memory' ||
|
||||
pathname === '/page/workspace/knowledge' ||
|
||||
pathname.startsWith('/page/workspace/knowledge/')
|
||||
);
|
||||
}
|
||||
|
||||
function CompactWorkspaceTopActions() {
|
||||
return (
|
||||
<div className="z-[60] flex h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 bg-card/90 px-4 shadow-sm backdrop-blur-md">
|
||||
<CompactShortcutButtons tone="content" />
|
||||
<ThemeSwitcher tone="content" />
|
||||
<LayoutModeSwitcher tone="content" />
|
||||
<div className="bg-border/70 mx-1 h-6 w-px shrink-0" aria-hidden />
|
||||
<UserIdentityBadge />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactAppearanceActions() {
|
||||
return (
|
||||
<>
|
||||
@@ -137,11 +114,6 @@ export function WorkspaceLayout({
|
||||
}) {
|
||||
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
|
||||
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
|
||||
const pathname = useLocation().pathname;
|
||||
const isGeneralQa = pathname.startsWith('/page/workspace/chats');
|
||||
const showCompactTopActions =
|
||||
headerHidden && shouldShowCompactTopActions(pathname);
|
||||
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? '0rem'
|
||||
: pageSidebarDark
|
||||
@@ -164,12 +136,13 @@ export function WorkspaceLayout({
|
||||
{
|
||||
'--page-sidebar-width': pageSidebarWidth,
|
||||
'--header-height': headerHidden ? '0rem' : '4rem',
|
||||
'--sidebar-width': '18.5rem',
|
||||
} as CSSProperties
|
||||
}
|
||||
key={String(compactWorkspaceSidebar)}
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
defaultOpen={
|
||||
defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar)
|
||||
defaultHistorySidebarOpen ?? true
|
||||
}
|
||||
>
|
||||
{!workspaceSidebarHidden && (
|
||||
@@ -178,14 +151,7 @@ export function WorkspaceLayout({
|
||||
<SidebarInset
|
||||
className={cn('min-w-0', insetOverflowHidden && 'overflow-hidden')}
|
||||
>
|
||||
{showCompactTopActions ? (
|
||||
<>
|
||||
<CompactWorkspaceTopActions />
|
||||
<div className="min-h-0 flex-1">{children}</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
<div className="min-h-0 flex-1">{children}</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
|
||||
@@ -1290,9 +1290,9 @@ html.lightblue .tiptap-writing-editor .ProseMirror ::selection {
|
||||
* agent surfaces on classic rgba()/hex colours instead.
|
||||
* -------------------------------------------------------------------------- */
|
||||
.agent-avatar {
|
||||
background-color: #feeded;
|
||||
color: #cf1322;
|
||||
box-shadow: inset 0 0 0 1px rgba(190, 35, 35, 0.15);
|
||||
background-color: #edf4ff;
|
||||
color: #1f5fdb;
|
||||
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.16);
|
||||
}
|
||||
|
||||
.dark .agent-avatar {
|
||||
|
||||
@@ -846,7 +846,7 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
|
||||
|
||||
### WeKnora Retrieval Skill
|
||||
|
||||
`skills/custom/weknora-knowledge-search/` is a read-only integration for a WeKnora tenant. The Skill itself only directs the model to two MCP tools, `weknora_search` and `weknora_list_knowledge_bases`; the credential remains inside the stdio MCP subprocess (`scripts/weknora_retrieval_mcp.py`). Its retrieval core groups document/FAQ knowledge bases by `embedding_model_id` and calls WeKnora's multi-KB `hybrid-search` API once per group, preventing invalid cross-embedding score comparisons. It does not discard a KB merely because the legacy list API reports `chunk_count=0`; hybrid-search decides its actual readiness. Wiki pages are opt-in and use their dedicated Wiki search route. In explicit DeerFlow sharing mode, `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE` selects WeKnora's narrow `/api/v1/deerflow/*` capability API: only owner-published document/FAQ knowledge bases are discoverable and searchable, while Wiki is skipped so the skill cannot fall back to a user bearer-token endpoint. Shared document hits include a credential-free source-preview URL. The frontend source card recognizes it and opens a context dialog; its call to authenticated Gateway route `/api/weknora/source-context` is proxied to WeKnora's token-gated `/api/v1/deerflow/knowledge-bases/:id/chunks/:chunk_id/context`, returning only the matching chunk and two neighbours on either side. Never expose the share token to the browser or add it to a URL. Configure through the bundled `templates/extensions_config.fragment.json`; otherwise prefer `WEKNORA_API_KEY_FILE` for scoped-key deployments and `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments. `WEKNORA_ALLOWED_KB_IDS` remains an optional allow-list. Tests: `tests/test_weknora_knowledge_search_skill.py`, `tests/test_weknora_source_preview.py`.
|
||||
`skills/custom/weknora-knowledge-search/` is a read-only integration for a WeKnora tenant. The Skill itself only directs the model to two MCP tools, `weknora_search` and `weknora_list_knowledge_bases`; the credential remains inside the stdio MCP subprocess (`scripts/weknora_retrieval_mcp.py`). Its retrieval core groups document/FAQ knowledge bases by `embedding_model_id` and calls WeKnora's multi-KB `hybrid-search` API once per group, preventing invalid cross-embedding score comparisons. It does not discard a KB merely because the legacy list API reports `chunk_count=0`; hybrid-search decides its actual readiness. Wiki pages are opt-in and use their dedicated Wiki search route. In explicit DeerFlow sharing mode, `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE` selects WeKnora's narrow `/api/v1/deerflow/*` capability API: only owner-published document/FAQ knowledge bases are discoverable and searchable, while Wiki is skipped so the skill cannot fall back to a user bearer-token endpoint. Shared document hits include a credential-free source-preview URL. Both the configured Skill source-card path and the generic MCP-result fallback recognize this URL; the source-information dialog never renders it as text and instead exposes a “查看原文” button. The button makes the authenticated Gateway call to `/api/weknora/source-context`, which then proxies to WeKnora's token-gated `/api/v1/deerflow/knowledge-bases/:id/chunks/:chunk_id/context` and returns only the matching chunk and two neighbours on either side. Never expose the share token to the browser or add it to a URL. Configure through the bundled `templates/extensions_config.fragment.json`; otherwise prefer `WEKNORA_API_KEY_FILE` for scoped-key deployments and `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments. `WEKNORA_ALLOWED_KB_IDS` remains an optional allow-list. Tests: `tests/test_weknora_knowledge_search_skill.py`, `tests/test_weknora_source_preview.py`.
|
||||
|
||||
### Embedded Client (`packages/harness/deerflow/client.py`)
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ Per-thread isolated execution with virtual path translation:
|
||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
- **WeKnora retrieval skill**: `skills/custom/weknora-knowledge-search` pairs a concise Skill with a stdio MCP server. It retrieves tenant-scoped WeKnora document/FAQ passages through the public API, groups multi-KB calls by embedding model, optionally searches Wiki pages, and can use the local reranker. It does not reject a KB solely because the legacy list API reports `chunk_count=0`; hybrid-search remains the source of truth. For WeKnora's explicit DeerFlow sharing mode, configure `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE`: the MCP uses the restricted `/api/v1/deerflow/*` capability API and can only list/search knowledge bases whose owner enabled sharing; it never receives a user bearer token. Shared document results now contain a credential-free `url` pointer; the source card's “查看原文” dialog calls DeerFlow's authenticated `/api/weknora/source-context` proxy, which retrieves the hit plus two adjacent chunks while keeping the share token server-side. Shared mode currently excludes Wiki pages. Otherwise use `WEKNORA_API_KEY_FILE` for scoped-key deployments or `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments; never put any credential in `SKILL.md` or a chat prompt.
|
||||
- **WeKnora retrieval skill**: `skills/custom/weknora-knowledge-search` pairs a concise Skill with a stdio MCP server. It retrieves tenant-scoped WeKnora document/FAQ passages through the public API, groups multi-KB calls by embedding model, optionally searches Wiki pages, and can use the local reranker. It does not reject a KB solely because the legacy list API reports `chunk_count=0`; hybrid-search remains the source of truth. For WeKnora's explicit DeerFlow sharing mode, configure `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE`: the MCP uses the restricted `/api/v1/deerflow/*` capability API and can only list/search knowledge bases whose owner enabled sharing; it never receives a user bearer token. Shared document results contain a credential-free `url` pointer, which is never rendered as text: both configured Skill source cards and the generic MCP-result fallback recognize it and provide a “查看原文” button. Selecting it calls DeerFlow's authenticated `/api/weknora/source-context` proxy and displays the hit plus two adjacent chunks while keeping the share token server-side. Shared mode currently excludes Wiki pages. Otherwise use `WEKNORA_API_KEY_FILE` for scoped-key deployments or `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments; never put any credential in `SKILL.md` or a chat prompt.
|
||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||
|
||||
|
||||
Reference in new issue
Block a user