diff --git a/frontend-web/src/components/workspace/agent-info-sidebar.tsx b/frontend-web/src/components/workspace/agent-info-sidebar.tsx new file mode 100644 index 00000000..27bd7640 --- /dev/null +++ b/frontend-web/src/components/workspace/agent-info-sidebar.tsx @@ -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(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(); + 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([]); + + 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 ( +
+ + + +
+ ); + } + + return ( + + ); +} diff --git a/frontend-web/src/core/agents/types.ts b/frontend-web/src/core/agents/types.ts index 21b4ceb7..1709fddf 100644 --- a/frontend-web/src/core/agents/types.ts +++ b/frontend-web/src/core/agents/types.ts @@ -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; } diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 3a834120..e26bc612 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -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() { + + handleSubmit({ text, files: [] })} + /> diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 7848dc8c..2a9f8fc6 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py index 3fddcd22..b8be6e01 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/agents_config.py @@ -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: diff --git a/offline-backend-20260512/backend/tests/test_agents_backend.py b/offline-backend-20260512/backend/tests/test_agents_backend.py index d835484a..e59e847e 100644 --- a/offline-backend-20260512/backend/tests/test_agents_backend.py +++ b/offline-backend-20260512/backend/tests/test_agents_backend.py @@ -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: