feat(agents): 智能体常用问题——侧边栏展示并一键提问

新增 common_questions(常用问题)字段:后端贯通 agents_config、
config.yaml 读写与 create/update API,前端 Agent 类型补充字段并在
AgentChatPage 新增 AgentInfoSidebar 组件展示常用问题,点击即填充并提交。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-11 08:34:39 +08:00
1 parent 22222308a1
commit e97017b297
6 files changed
+375 -8

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
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>
@@ -53,6 +53,11 @@ class AgentResponse(BaseModel):
tool_groups: list[str] | None = None
skills: list[str] | None = None
common_questions: list[str] | None = Field(
default=None,
description="Frequently-asked questions shown in the agent chat sidebar (常用问题).",
)
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
featured_order: int | None = Field(
@@ -101,6 +106,7 @@ class AgentCreateRequest(BaseModel):
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
common_questions: list[str] | None = Field(default=None, description="Frequently-asked questions (常用问题) persisted to config.yaml")
class AgentUpdateRequest(BaseModel):
@@ -114,6 +120,7 @@ class AgentUpdateRequest(BaseModel):
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
common_questions: list[str] | None = Field(default=None, description="Updated frequently-asked questions (常用问题, config.yaml)")
class AgentSelectorResponse(BaseModel):
@@ -217,6 +224,7 @@ def _write_agent_config(
skills: list[str] | None = None,
model: str | None = None,
tool_groups: list[str] | None = None,
common_questions: list[str] | None = None,
) -> None:
agent_dir = get_paths().agent_dir(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True)
@@ -232,19 +240,27 @@ def _write_agent_config(
config_data["model"] = model
if tool_groups is not None:
config_data["tool_groups"] = tool_groups
if common_questions is not None:
config_data["common_questions"] = common_questions
with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
def _load_config_extras(agent_id: str) -> dict[str, Any]:
"""Read skills/model/tool_groups from the agent's config.yaml, tolerant of missing files."""
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
try:
cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
return {"skills": None, "model": None, "tool_groups": None}
return dict(empty)
if cfg is None:
return {"skills": None, "model": None, "tool_groups": None}
return {"skills": cfg.skills, "model": cfg.model, "tool_groups": cfg.tool_groups}
return dict(empty)
return {
"skills": cfg.skills,
"model": cfg.model,
"tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions,
}
def _write_agent_soul(agent_id: str, soul: str) -> None:
@@ -404,7 +420,7 @@ def _record_to_response(
can happen lazily when the agent is actually opened.
"""
soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None}
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
@@ -425,6 +441,7 @@ def _record_to_response(
model=extras["model"],
tool_groups=extras["tool_groups"],
skills=extras["skills"],
common_questions=extras.get("common_questions"),
featured_order=record.get("featured_order"),
)
@@ -725,6 +742,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
skills=request.skills,
model=request.model,
tool_groups=request.tool_groups,
common_questions=request.common_questions,
)
_write_agent_soul(agent_id, request.soul)
@@ -797,8 +815,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
next_model = request.model if "model" in fields_set else existing_extras["model"]
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups"))
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions"))
if config_dirty:
_write_agent_config(
agent_id,
@@ -807,6 +826,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
skills=next_skills,
model=next_model,
tool_groups=next_tool_groups,
common_questions=next_common_questions,
)
# Keep the listing cache in lock-step with config.yaml on every write.
await store.replace_skills(agent_id, next_skills)
@@ -69,6 +69,9 @@ class AgentConfig(BaseModel):
# - [] (explicit empty list): no skills
# - ["skill1", "skill2"]: load only the specified skills
skills: list[str] | None = None
# Frequently-asked questions ("常用问题") shown in the agent chat sidebar.
# Display/UX metadata only — the runtime never reads these. None = none set.
common_questions: list[str] | None = None
def load_agent_config(agent_id: str | None) -> AgentConfig | None:
@@ -177,12 +177,42 @@ def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> No
)
extras = _load_config_extras(agent_id)
assert extras == {"skills": ["s1"], "model": None, "tool_groups": None}
assert extras == {"skills": ["s1"], "model": None, "tool_groups": None, "common_questions": None}
def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
extras = _load_config_extras("does-not-exist")
assert extras == {"skills": None, "model": None, "tool_groups": None}
assert extras == {"skills": None, "model": None, "tool_groups": None, "common_questions": None}
def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
agent_id = "agent-cq-1"
questions = ["这个智能体能做什么?", "如何上传文件?"]
_write_agent_config(agent_id, name="助手", description="", common_questions=questions)
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert raw["common_questions"] == questions
extras = _load_config_extras(agent_id)
assert extras["common_questions"] == questions
def test_write_agent_config_omits_common_questions_when_none(isolated_paths) -> None:
agent_id = "agent-cq-2"
_write_agent_config(agent_id, name="x", description="")
config_file = paths_module.get_paths().agent_dir(agent_id) / "config.yaml"
raw = yaml.safe_load(config_file.read_text(encoding="utf-8"))
assert "common_questions" not in raw
assert _load_config_extras(agent_id)["common_questions"] is None
def test_write_agent_config_explicit_empty_common_questions_is_persisted(isolated_paths) -> None:
agent_id = "agent-cq-3"
_write_agent_config(agent_id, name="x", description="", common_questions=[])
assert _load_config_extras(agent_id)["common_questions"] == []
def test_write_agent_config_explicit_empty_skills_is_persisted(isolated_paths) -> None: