This commit is contained in:
吉鑫升 committed 2026-06-11 09:34:45 +08:00
commit 6efe1b80c5
10 files changed
+603 -67

No files matched your search

@@ -0,0 +1,299 @@
"use client";
import {
BotIcon,
CheckIcon,
InfoIcon,
MessageCircleQuestionIcon,
PanelRightCloseIcon,
PanelRightOpenIcon,
PencilIcon,
PlusIcon,
SparklesIcon,
XIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { Tooltip } from "@/components/workspace/tooltip";
import { type Agent } from "@/core/agents";
import { useUpdateAgent } from "@/core/agents/hooks";
import { getStoredAuth } from "@/core/auth";
import { useSkills } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* Collapsible right-hand panel on the agent chat page that surfaces the agent's
* profile (name / description / skills) plus a list of frequently-asked
* questions ("常用问题"). Clicking a question sends it as a message. The agent's
* owner (or an admin) can edit the question list inline.
*/
export function AgentInfoSidebar({
agent,
agentName,
onAskQuestion,
disabled,
}: {
agent: Agent | null | undefined;
agentName: string;
onAskQuestion: (text: string) => void;
disabled?: boolean;
}) {
const [collapsed, setCollapsed] = useState(true);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const { skills } = useSkills();
const updateAgent = useUpdateAgent();
const displayName = desensitize(agent?.name ?? agentName);
const description = agent?.description
? desensitize(agent.description)
: undefined;
// Owner / admin gate for inline editing of the common-questions list.
const storedAuth = useMemo(() => getStoredAuth(), []);
const canEdit = useMemo(() => {
if (!agent) return false;
if (storedAuth?.system_role === "admin") return true;
return agent.user_id != null && agent.user_id === storedAuth?.user_id;
}, [agent, storedAuth]);
// Map a stored skill name to its Chinese display label (falls back to name).
const skillLabel = useMemo(() => {
const map = new Map<string, string>();
for (const skill of skills) {
map.set(skill.name, skill.name_zh?.trim() ? skill.name_zh : skill.name);
}
return (name: string) => map.get(name) ?? name;
}, [skills]);
const questions = useMemo(
() => (agent?.common_questions ?? []).filter((q) => q.trim().length > 0),
[agent?.common_questions],
);
// --- Inline editing state ---
const [editing, setEditing] = useState(false);
const [drafts, setDrafts] = useState<string[]>([]);
const startEditing = () => {
setDrafts(questions.length > 0 ? [...questions] : [""]);
setEditing(true);
};
const cancelEditing = () => {
setEditing(false);
setDrafts([]);
};
const handleSave = async () => {
if (!agent) return;
const cleaned = drafts.map((q) => q.trim()).filter((q) => q.length > 0);
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { common_questions: cleaned },
});
toast.success("常用问题已保存");
setEditing(false);
setDrafts([]);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
// Leaving edit mode whenever the panel collapses keeps the state tidy.
useEffect(() => {
if (collapsed && editing) cancelEditing();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collapsed]);
if (collapsed) {
return (
<div className="flex shrink-0 flex-col items-center border-l pt-14">
<Tooltip content="智能体简介">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(false)}
aria-label="展开智能体简介"
>
<PanelRightOpenIcon className="h-4 w-4" />
</Button>
</Tooltip>
</div>
);
}
return (
<aside className="bg-card/40 flex w-72 shrink-0 flex-col border-l pt-12">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-semibold">智能体简介</span>
<Tooltip content="收起">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setCollapsed(true)}
aria-label="收起智能体简介"
>
<PanelRightCloseIcon className="h-4 w-4" />
</Button>
</Tooltip>
</div>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
{/* Identity */}
<div className="flex flex-col items-center gap-2 text-center">
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
<BotIcon className="text-primary h-6 w-6" />
</div>
<div className="text-base font-bold" title={displayName}>
{displayName}
</div>
{description && (
<p className="text-muted-foreground text-xs leading-relaxed">
{description}
</p>
)}
</div>
{/* Skills */}
{agent?.skills && agent.skills.length > 0 && (
<div className="space-y-2">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<SparklesIcon className="h-3.5 w-3.5" />
技能
</div>
<div className="flex flex-wrap gap-1.5">
{agent.skills.map((name) => (
<button
key={name}
type="button"
onClick={() => setDetailSkillName(name)}
title="查看技能详情"
className="border-border bg-background hover:bg-accent inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors"
>
<span className="max-w-[150px] truncate">
{skillLabel(name)}
</span>
<InfoIcon className="text-muted-foreground h-3 w-3 shrink-0" />
</button>
))}
</div>
</div>
)}
{/* Common questions */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<MessageCircleQuestionIcon className="h-3.5 w-3.5" />
常用问题
</div>
{canEdit && !editing && (
<Tooltip content="编辑常用问题">
<Button
variant="ghost"
size="icon-sm"
onClick={startEditing}
aria-label="编辑常用问题"
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
</Tooltip>
)}
</div>
{editing ? (
<div className="space-y-2">
{drafts.map((value, index) => (
<div key={index} className="flex items-center gap-1">
<Input
value={value}
placeholder={`问题 ${index + 1}`}
onChange={(e) => {
const next = [...drafts];
next[index] = e.currentTarget.value;
setDrafts(next);
}}
className="h-8 text-xs"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() =>
setDrafts(drafts.filter((_, i) => i !== index))
}
aria-label="删除"
>
<XIcon className="h-3.5 w-3.5" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setDrafts([...drafts, ""])}
>
<PlusIcon className="h-3.5 w-3.5" /> 添加问题
</Button>
<div className="flex gap-2 pt-1">
<Button
size="sm"
className="flex-1"
onClick={() => void handleSave()}
disabled={updateAgent.isPending}
>
<CheckIcon className="h-3.5 w-3.5" /> 保存
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={cancelEditing}
disabled={updateAgent.isPending}
>
取消
</Button>
</div>
</div>
) : questions.length > 0 ? (
<div className="flex flex-col gap-1.5">
{questions.map((question, index) => (
<button
key={index}
type="button"
disabled={disabled}
onClick={() => onAskQuestion(question)}
title={question}
className={cn(
"border-border bg-background hover:bg-accent w-full rounded-md border px-2.5 py-1.5 text-left text-xs transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{question}
</button>
))}
</div>
) : (
<p className="text-muted-foreground text-xs">
{canEdit ? "还没有常用问题,点击上方编辑添加。" : "暂无常用问题。"}
</p>
)}
</div>
</div>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
</aside>
);
}
+4
View File
@@ -16,6 +16,8 @@ export interface Agent {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
/** Frequently-asked questions (常用问题) shown in the agent chat sidebar. */
common_questions?: string[] | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -46,6 +48,7 @@ export interface CreateAgentRequest {
skills?: string[] | null;
model?: string | null;
tool_groups?: string[] | null;
common_questions?: string[] | null;
}
export interface UpdateAgentRequest {
@@ -57,4 +60,5 @@ export interface UpdateAgentRequest {
skills?: string[] | null;
model?: string | null;
tool_groups?: string[] | null;
common_questions?: string[] | null;
}
+11 -1
View File
@@ -434,7 +434,17 @@ export function findToolCallResult(
}
export function isHiddenFromUIMessage(message: Message) {
return message.additional_kwargs?.hide_from_ui === true;
if (message.additional_kwargs?.hide_from_ui === true) {
return true;
}
// Conversation-summary messages are context for the model only — never shown
// to the user. New summaries are injected transiently (never persisted), but
// threads compacted by older builds still carry a persisted `name: "summary"`
// HumanMessage, so hide those too.
if (message.name === "summary") {
return true;
}
return false;
}
/**
+11
View File
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { AgentInfoSidebar } from "@/components/workspace/agent-info-sidebar";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
@@ -285,6 +286,16 @@ export default function AgentChatPage() {
</div>
</div>
</main>
<AgentInfoSidebar
agent={agent}
agentName={agentDisplayName}
disabled={
thread.isLoading ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
/>
</div>
</ChatBox>
</ThreadContext.Provider>