diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index a7e2b79b..a3002755 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -2,6 +2,7 @@ import { BotIcon, + CopyIcon, GlobeIcon, InfoIcon, LockIcon, @@ -43,12 +44,14 @@ import { TagChipList } from "@/components/workspace/tags/tag-chip"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { useAgent, + useCreateAgent, useDeleteAgent, useSetAgentFeatured, useSetAgentPrivate, useToggleAgentFavorite, useUpdateAgent, } from "@/core/agents"; +import { getAgent } from "@/core/agents/api"; import type { Agent } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; @@ -65,6 +68,7 @@ export function AgentCard({ agent }: AgentCardProps) { const router = useRouter(); const deleteAgent = useDeleteAgent(); const updateAgent = useUpdateAgent(); + const createAgent = useCreateAgent(); const setFeatured = useSetAgentFeatured(); const setPrivate = useSetAgentPrivate(); const toggleFavorite = useToggleAgentFavorite(); @@ -153,6 +157,41 @@ export function AgentCard({ agent }: AgentCardProps) { } } + async function handleDuplicate() { + if (createAgent.isPending) { + return; + } + try { + // SOUL.md is omitted from the list payload (perf), so fetch the detail + // row to carry the source agent's personality into the copy. Fall back + // to whatever the list happened to include if the detail fetch fails. + let soul = agent.soul ?? ""; + try { + const detail = await getAgent(agent.id); + soul = detail.soul ?? soul; + } catch { + // keep the list fallback + } + const created = await createAgent.mutateAsync({ + name: `${agent.name} 副本`, + description: agent.description ?? "", + soul, + skills: agent.skills ?? [], + model: agent.model ?? null, + tool_groups: agent.tool_groups ?? null, + // A copy starts as a private, unpublished draft the owner can refine + // before sharing — never inherit the source's published/private state. + published: false, + is_private: false, + }); + toast.success( + `已复制为「${desensitize(created.name)}」,可在「我的」中编辑`, + ); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + async function handleSaveEdit() { const trimmedName = editName.trim(); if (!trimmedName) { @@ -471,6 +510,16 @@ export function AgentCard({ agent }: AgentCardProps) { )} + {canEdit && ( diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index f1739d53..da17a139 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -583,7 +583,7 @@ export function InputBox({ // 对话态:常规白卡 + 中性边框 + 轻阴影 isNewThread ? "rounded-3xl bg-transparent border-0 shadow-none ring-0" - : "rounded-3xl bg-background border border-border shadow-sm dark:bg-slate-800 dark:border-slate-700", + : "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700", className, )} disabled={disabled} diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index f7d551ac..d997d415 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -1,9 +1,10 @@ import type { Message } from "@langchain/langgraph-sdk"; -import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react"; +import { ClockIcon, FileIcon, Loader2Icon, PencilIcon } from "lucide-react"; import { memo, useCallback, useContext, + useEffect, useMemo, useState, type AnchorHTMLAttributes, @@ -25,6 +26,8 @@ import { } from "@/components/ai-elements/reasoning"; import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; import { UploadedFilePreviewDialog, useAuthedObjectUrl, @@ -51,6 +54,7 @@ import { cn } from "@/lib/utils"; import { CitationLink } from "../citations/citation-link"; import { CopyButton } from "../copy-button"; +import { Tooltip } from "../tooltip"; import { SkillDisplayBlock } from "../skill-display-block"; import { ThreadContext } from "./context"; import { DownloadWordButton } from "./download-word-button"; @@ -88,6 +92,7 @@ export function MessageListItem({ tokenUsageEnabled = false, displaySources = [], extraRehypePlugins, + onEditResubmit, }: { className?: string; message: Message; @@ -96,6 +101,7 @@ export function MessageListItem({ tokenUsageEnabled?: boolean; displaySources?: RuntimeDisplaySource[]; extraRehypePlugins?: ReturnType; + onEditResubmit?: (text: string) => void; }) { const isHuman = message.type === "human"; const messageMarkdown = @@ -115,17 +121,23 @@ export function MessageListItem({ tokenUsageEnabled={tokenUsageEnabled} displaySources={displaySources} extraRehypePlugins={extraRehypePlugins} + onEditResubmit={onEditResubmit} /> - {!isLoading && ( + {/* + Human messages carry their own inline copy/edit toolbar inside + MessageContent (above the timestamp). Only assistant answers use the + floating bottom toolbar here. + */} + {!isLoading && !isHuman && (
- {!isHuman && messageMarkdown && ( + {messageMarkdown && ( void; +}) { + const { t } = useI18n(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(text); + + useEffect(() => { + if (!editing) { + setDraft(text); + } + }, [text, editing]); + + const confirmEdit = useCallback(() => { + const value = draft.trim(); + if (!value) { + return; + } + onEditResubmit?.(value); + setEditing(false); + }, [draft, onEditResubmit]); + + if (editing) { + return ( +
+ {filesList} +