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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user