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 082b4607..2827977d 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'; @@ -130,6 +134,7 @@ export function MessageListItem({ tokenUsageEnabled = false, displaySources = [], extraRehypePlugins, + onEditResubmit, }: { className?: string; fitRowToContent?: boolean; @@ -139,6 +144,7 @@ export function MessageListItem({ tokenUsageEnabled?: boolean; displaySources?: RuntimeDisplaySource[]; extraRehypePlugins?: ReturnType; + onEditResubmit?: (text: string) => void; }) { const isHuman = message.type === 'human'; const messageMarkdown = @@ -163,17 +169,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} +