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) { )} + void handleDuplicate()} + disabled={createAgent.isPending} + title="复制为新智能体" + > + + {canEdit && ( - + {isSkillsLoading ? ( 技能加载中... ) : enabledSkills.length === 0 ? ( @@ -543,13 +592,13 @@ export function AgentCard({ agent }: AgentCardProps) { 当前没有可用技能 ) : ( - + {enabledSkills.map((skill) => { const checked = selectedSkills.includes(skill.name); return ( toggleSkill(skill.name)} title={desensitize(skill.description ?? "")} - className="h-8 max-w-[200px] rounded-r-none" + className="h-8 min-w-0 flex-1 rounded-r-none" > {desensitize(skill.name)} 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} + setDraft(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + confirmEdit(); + } + if (event.key === "Escape") { + event.preventDefault(); + setEditing(false); + } + }} + className="min-h-20 resize-none text-[15px] leading-7" + /> + + setEditing(false)} + > + {t.common.cancel} + + + {t.common.confirm} + + + + ); + } + + const messageResponse = text ? ( + + {text} + + ) : null; + + return ( + + {filesList} + {messageResponse && ( + + {messageResponse} + + )} + + {text && ( + + + {onEditResubmit && ( + + setEditing(true)} + > + + + + )} + + )} + + ); +} + /** * Custom image component that handles artifact URLs */ @@ -177,6 +308,7 @@ function MessageContent_({ tokenUsageEnabled = false, displaySources = [], extraRehypePlugins, + onEditResubmit, }: { className?: string; message: Message; @@ -185,6 +317,7 @@ function MessageContent_({ tokenUsageEnabled?: boolean; displaySources?: RuntimeDisplaySource[]; extraRehypePlugins?: ReturnType; + onEditResubmit?: (text: string) => void; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useMemo( @@ -289,26 +422,16 @@ function MessageContent_({ } if (isHuman) { - const messageResponse = contentToDisplay ? ( - - {contentToDisplay} - - ) : null; + // Render HumanMessageBlock directly (no width-constraining wrapper) so the + // inline editor can widen beyond the bubble's fit-content width. return ( - - {filesList} - {messageResponse && ( - - {messageResponse} - - )} - - + ); } diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 33463bf9..0356b59d 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -58,6 +58,7 @@ export function MessageList({ tokenUsageEnabled = false, extraRehypePlugins, onClarificationSubmit, + onEditResubmit, onSetupAgentApprove, onSetupAgentReject, }: { @@ -68,6 +69,7 @@ export function MessageList({ tokenUsageEnabled?: boolean; extraRehypePlugins?: ReturnType; onClarificationSubmit?: (answer: string) => void | Promise; + onEditResubmit?: (text: string) => void; onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; }) { @@ -241,6 +243,7 @@ export function MessageList({ tokenUsageEnabled={tokenUsageEnabled} displaySources={displaySources} extraRehypePlugins={extraRehypePlugins} + onEditResubmit={onEditResubmit} /> ); }); diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index 2e20e074..460d6794 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -7,6 +7,7 @@ import { ChevronLeftIcon, ChevronRightIcon, ClockIcon, + CopyIcon, FileArchive, GlobeIcon, InfoIcon, @@ -50,6 +51,7 @@ import { ItemContent, ItemDescription, } from "@/components/ui/item"; +import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { TagChipList } from "@/components/workspace/tags/tag-chip"; @@ -61,6 +63,7 @@ import { useI18n } from "@/core/i18n/hooks"; import type { ValidateSkillUploadResponse } from "@/core/skills/api"; import { useDeleteCustomSkill, + useDuplicateSkill, useInstallSkillUpload, useReconcileCustomSkills, useSetSkillPinned, @@ -390,9 +393,12 @@ export function SkillRow({ const setPublished = useSetSkillPublished(); const setPinned = useSetSkillPinned(); const deleteSkill = useDeleteCustomSkill(); + const duplicateSkillMutation = useDuplicateSkill(); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [tagPickerOpen, setTagPickerOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false); + const [duplicateOpen, setDuplicateOpen] = useState(false); + const [duplicateName, setDuplicateName] = useState(""); const isCustom = skill.category === "custom"; // Built-in flag falls back to "no DB owner" so legacy responses without @@ -428,6 +434,31 @@ export function SkillRow({ } } + function handleOpenDuplicate() { + // Suggest a "-copy" default that already satisfies the backend's + // lowercase/digit/-/_ skill-name rule. The user can edit it before saving. + setDuplicateName(`${skill.name}-copy`); + setDuplicateOpen(true); + } + + async function handleDuplicate() { + const target = duplicateName.trim(); + if (!target) { + toast.error("请输入新技能名称"); + return; + } + try { + const result = await duplicateSkillMutation.mutateAsync({ + sourceName: skill.name, + newName: target, + }); + toast.success(`已复制为「${result.skill_name}」`); + setDuplicateOpen(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + async function handleTogglePinned() { const next = !skill.pinned; try { @@ -587,6 +618,16 @@ export function SkillRow({ )} )} + + + {canManage && ( + + + + 复制技能 + + 将「{desensitize(skill.name)}」复制为一个属于你的新技能。请输入新名称(仅小写字母、数字、- 或 _)。 + + + setDuplicateName(e.currentTarget.value)} + placeholder="new-skill-name" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleDuplicate(); + } + }} + /> + + setDuplicateOpen(false)} + disabled={duplicateSkillMutation.isPending} + > + {t.common.cancel} + + void handleDuplicate()} + disabled={duplicateSkillMutation.isPending} + > + {duplicateSkillMutation.isPending ? t.common.loading : "复制"} + + + + diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index 8b602073..22fccf76 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -41,6 +41,7 @@ export const enUS: Translations = { code: "Code", preview: "Preview", cancel: "Cancel", + confirm: "Confirm", save: "Save", install: "Install", create: "Create", @@ -80,6 +81,7 @@ export const enUS: Translations = { // Input Box inputBox: { placeholder: "How can I assist you today?", + editQuestion: "Edit question", createSkillPrompt: "We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?", addAttachments: "Add attachments", diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index 5051292c..5545fa3b 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -42,6 +42,7 @@ export interface Translations { code: string; preview: string; cancel: string; + confirm: string; save: string; install: string; create: string; @@ -77,6 +78,7 @@ export interface Translations { // Input Box inputBox: { placeholder: string; + editQuestion: string; createSkillPrompt: string; addAttachments: string; mode: string; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index b58d38b8..9d605ce7 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -41,6 +41,7 @@ export const zhCN: Translations = { code: "代码", preview: "预览", cancel: "取消", + confirm: "确定", save: "保存", install: "安装", create: "创建", @@ -80,6 +81,7 @@ export const zhCN: Translations = { // Input Box inputBox: { placeholder: "今天我能为你做些什么?", + editQuestion: "编辑问题", createSkillPrompt: "我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。", addAttachments: "添加附件", diff --git a/frontend-web/src/core/settings/index.ts b/frontend-web/src/core/settings/index.ts index dc15f8cd..94da911f 100644 --- a/frontend-web/src/core/settings/index.ts +++ b/frontend-web/src/core/settings/index.ts @@ -1,3 +1,8 @@ export { useLocalSettings, useThreadSettings } from "./hooks"; -export { isThreadRagModeLocked, lockThreadRagMode } from "./local"; +export { + getLastAgentId, + isThreadRagModeLocked, + lockThreadRagMode, + saveLastAgentId, +} from "./local"; export type { LocalSettings } from "./local"; diff --git a/frontend-web/src/core/settings/local.ts b/frontend-web/src/core/settings/local.ts index 7dbab5e5..f5db4fa1 100644 --- a/frontend-web/src/core/settings/local.ts +++ b/frontend-web/src/core/settings/local.ts @@ -21,6 +21,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = { export const LOCAL_SETTINGS_KEY = "deerflow.local-settings"; export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model."; export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode."; +export const LAST_AGENT_ID_KEY = "deerflow.last-agent-id"; function isBrowser(): boolean { return typeof window !== "undefined"; @@ -148,6 +149,29 @@ export function applyThreadModelOverride( }; } +/** + * The id of the agent the user last started a conversation with. New-chat + * landing screens pre-select this agent so the user doesn't have to re-pick it + * every time. Returns ``null`` when nothing was stored yet. + */ +export function getLastAgentId(): string | null { + if (!isBrowser()) { + return null; + } + return localStorage.getItem(LAST_AGENT_ID_KEY) || null; +} + +export function saveLastAgentId(agentId: string | null | undefined) { + if (!isBrowser()) { + return; + } + if (!agentId) { + localStorage.removeItem(LAST_AGENT_ID_KEY); + return; + } + localStorage.setItem(LAST_AGENT_ID_KEY, agentId); +} + export function getLocalSettings(): LocalSettings { if (!isBrowser()) { return DEFAULT_LOCAL_SETTINGS; diff --git a/frontend-web/src/core/skills/api.ts b/frontend-web/src/core/skills/api.ts index c098d44f..5a0bad35 100644 --- a/frontend-web/src/core/skills/api.ts +++ b/frontend-web/src/core/skills/api.ts @@ -202,6 +202,24 @@ export async function installSkillUpload( return response.json() as Promise; } +export async function duplicateSkill( + sourceName: string, + newName: string, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(sourceName)}/duplicate`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ new_name: newName }), + }, + ); + if (!response.ok) { + throw await parseDetailedError(response, "Failed to duplicate skill"); + } + return response.json() as Promise; +} + export interface ReconcileCustomSkillsResponse { adopted: string[]; already_registered: string[]; diff --git a/frontend-web/src/core/skills/hooks.ts b/frontend-web/src/core/skills/hooks.ts index 658124bd..ac3c2c34 100644 --- a/frontend-web/src/core/skills/hooks.ts +++ b/frontend-web/src/core/skills/hooks.ts @@ -7,6 +7,7 @@ import { import { deleteCustomSkill, + duplicateSkill, enableSkill, getSkill, getSkillContent, @@ -192,6 +193,22 @@ export function useSetSkillPinned() { }); } +export function useDuplicateSkill() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + sourceName, + newName, + }: { + sourceName: string; + newName: string; + }) => duplicateSkill(sourceName, newName), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["skills"] }); + }, + }); +} + export function useDeleteCustomSkill() { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 8c76a13c..2f146723 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -26,7 +26,7 @@ import { useAgent } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; -import { useThreadSettings } from "@/core/settings"; +import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { useThreadStream } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; @@ -114,6 +114,9 @@ export default function AgentChatPage() { const handleSubmit = useCallback( (message: PromptInputMessage) => { setHasStartedConversation(true); + // Remember this agent so the new-chat landing screen pre-selects it next + // time (covers both direct agent chats and the ChatPage handoff replay). + saveLastAgentId(agent_id); window.dispatchEvent( new CustomEvent("workspace:sandbox-resume-autoscroll"), ); @@ -216,6 +219,7 @@ export default function AgentChatPage() { paddingBottom={messageListPaddingBottom} tokenUsageEnabled={tokenUsageEnabled} onClarificationSubmit={handleClarificationSubmit} + onEditResubmit={(text) => handleSubmit({ text, files: [] })} /> diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 6a9ef79f..39b3da9a 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -23,10 +23,15 @@ import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { Welcome } from "@/components/workspace/welcome"; +import { useAgentSelector } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; -import { useThreadSettings } from "@/core/settings"; +import { + getLastAgentId, + saveLastAgentId, + useThreadSettings, +} from "@/core/settings"; import { useThreadStream } from "@/core/threads/hooks"; import { useRouteBase } from "@/core/route-base"; import { textOfMessage } from "@/core/threads/utils"; @@ -47,6 +52,8 @@ export default function ChatPage() { const { data: promptPrefixSettings } = usePromptPrefixSettings(); const [promptPrefixOn, setPromptPrefixOn] = useState(false); const [selectedAgentId, setSelectedAgentId] = useState(null); + const { agents: selectorAgents, isLoading: selectorLoading } = + useAgentSelector(); const { selectedArtifact } = useArtifacts(); useSpecificChatMode(); @@ -58,10 +65,22 @@ export default function ChatPage() { if (isNewThread) { setHasStartedConversation(false); setPromptPrefixOn(false); - setSelectedAgentId(null); + // Pre-select the agent the user last started a conversation with so a + // fresh chat defaults to it. Validated against the selector list below. + setSelectedAgentId(getLastAgentId()); } }, [isNewThread, threadId]); + // Drop a restored last-used agent that is no longer in the selector list + // (deleted / unpublished / no longer curated), so we never hand off to a + // dead agent. Wait for the list to load before deciding. + useEffect(() => { + if (selectorLoading || !selectedAgentId) return; + if (!selectorAgents.some((a) => a.id === selectedAgentId)) { + setSelectedAgentId(null); + } + }, [selectedAgentId, selectorAgents, selectorLoading]); + const { showNotification } = useNotification(); const [thread, sendMessage, isUploading] = useThreadStream({ @@ -184,6 +203,14 @@ export default function ChatPage() { }, [sendMessage, threadId], ); + + // Persist the picked agent the moment it changes so the next new chat + // defaults to it — even if the user never sends a message this session. + // Passing null (the ✕ clear) wipes the stored preference. + const handleAgentChange = useCallback((agentId: string | null) => { + setSelectedAgentId(agentId); + saveLastAgentId(agentId); + }, []); const handleStop = useCallback(async () => { await thread.stop(); }, [thread]); @@ -248,6 +275,9 @@ export default function ChatPage() { paddingBottom={messageListPaddingBottom} tokenUsageEnabled={tokenUsageEnabled} onClarificationSubmit={handleClarificationSubmit} + onEditResubmit={(text) => + handleSubmit({ text, files: [] }) + } /> )} @@ -305,7 +335,7 @@ export default function ChatPage() { isLandingState ? { selectedAgentId, - onAgentChange: setSelectedAgentId, + onAgentChange: handleAgentChange, } : undefined } diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 6129a35b..775af146 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -545,13 +545,13 @@ export default function NewAgentPage() { 没有匹配的技能。 ) : ( - + {filteredSkills.map((skill) => { const checked = selectedSkills.includes(skill.name); return ( toggleSkill(skill)} title={skill.description} className={cn( - "h-8 max-w-[200px] rounded-r-none", + "h-8 min-w-0 flex-1 rounded-r-none", !checked && "text-muted-foreground", )} > diff --git a/frontend-web/src/roundtable-planning/lib/clarification.ts b/frontend-web/src/roundtable-planning/lib/clarification.ts index 93fb3ee9..5da8c28a 100644 --- a/frontend-web/src/roundtable-planning/lib/clarification.ts +++ b/frontend-web/src/roundtable-planning/lib/clarification.ts @@ -1,13 +1,15 @@ /** * Resolve whether option cards should allow multi-select. - * Default is single-select (click to send); only multi when explicitly requested. + * + * Product decision: ALL clarification prompts are multi-select. Clicking an + * option only toggles its selection; the conversation continues only after the + * user presses the confirm button. We intentionally ignore the backend + * `allow_multiple` hint (and the old single-select "click to send" fast path) + * so the user is never forced into picking exactly one option. */ export function resolveAllowMultiple( - allowMultiple: boolean | undefined, + _allowMultiple: boolean | undefined, _clarificationType?: string | undefined, ): boolean { - if (typeof allowMultiple === "boolean") { - return allowMultiple; - } - return false; + return true; } diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index 9e6dc8d6..fee8949e 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -30,7 +30,7 @@ from deerflow.skills import Skill from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted from deerflow.skills.installer import SkillAlreadyExistsError from deerflow.skills.security_scanner import scan_skill_content -from deerflow.skills.storage import get_or_new_skill_storage +from deerflow.skills.storage import SkillStorage, get_or_new_skill_storage from deerflow.skills.types import SKILL_MD_FILE, SkillCategory logger = logging.getLogger(__name__) @@ -210,6 +210,12 @@ class CustomSkillUpdateRequest(BaseModel): content: str = Field(..., description="Replacement SKILL.md content") +class SkillDuplicateRequest(BaseModel): + """Request model for duplicating an existing skill into a new custom skill.""" + + new_name: str = Field(..., description="Name for the new custom skill copy (lowercase letters/digits, '-'/'_' separators)") + + class CustomSkillHistoryResponse(BaseModel): history: list[dict] @@ -950,6 +956,121 @@ async def install_skill( raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}") +def _rewrite_skill_frontmatter_name(content: str, new_name: str) -> str: + """Return *content* with the YAML-frontmatter ``name:`` field set to *new_name*. + + A duplicated skill must declare the new name in its SKILL.md frontmatter or + ``validate_skill_markdown_content`` will reject it (frontmatter name must + match the directory name). We rewrite only the first ``name:`` line inside + the leading ``---`` block, leaving every other field byte-for-byte intact. + """ + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + if not match: + raise ValueError("SKILL.md is missing the leading YAML frontmatter block") + fm = match.group(1) + new_fm, replaced = re.subn(r"(?m)^(name:\s*).*$", rf"\g<1>{new_name}", fm, count=1) + if replaced == 0: + new_fm = f"name: {new_name}\n{fm}" + return content[: match.start(1)] + new_fm + content[match.end(1) :] + + +@router.post( + "/skills/custom/{source_name}/duplicate", + response_model=SkillInstallResponse, + summary="Duplicate Skill", + description=( + "Copy an existing skill (built-in/public OR custom) into a brand-new custom " + "skill named ``new_name``, owned by the caller and unpublished. All support " + "files are copied verbatim; only the SKILL.md frontmatter name is rewritten. " + "The source already passed the install-time security scan, so no re-scan is run." + ), +) +async def duplicate_skill( + source_name: str, + body: SkillDuplicateRequest, + request: Request, + config: AppConfig = Depends(get_config), + skill_store: SkillStore = Depends(get_skill_store), +) -> SkillInstallResponse: + import shutil + + try: + user_id = _current_user_id(request) + is_admin = _is_admin_user(request) + storage = get_or_new_skill_storage(app_config=config) + + source_name = SkillStorage.validate_skill_name(source_name.replace("\r\n", "").replace("\n", "")) + new_name = SkillStorage.validate_skill_name(body.new_name.replace("\r\n", "").replace("\n", "")) + + # Resolve the source directory (custom takes precedence over public, so a + # user-shadowed copy is the one duplicated when both exist). + if storage.custom_skill_exists(source_name): + source_dir = storage.get_custom_skill_dir(source_name) + elif storage.public_skill_exists(source_name): + source_dir = storage.get_skills_root_path() / SkillCategory.PUBLIC.value / source_name + else: + raise HTTPException(status_code=404, detail=f"Skill '{source_name}' not found") + + if new_name == source_name or storage.custom_skill_exists(new_name) or storage.public_skill_exists(new_name): + raise HTTPException(status_code=409, detail=f"A skill named '{new_name}' already exists") + if not is_admin: + existing = await skill_store.get_any(new_name) + if existing and existing.get("owner_user_id") not in (None, user_id): + raise HTTPException(status_code=409, detail=f"Skill '{new_name}' already exists and is owned by another user") + + dest_dir = storage.get_custom_skill_dir(new_name) + # copytree refuses to overwrite, which is the behaviour we want — the + # existence checks above already guarantee a clean target. + shutil.copytree(source_dir, dest_dir) + try: + source_content = (dest_dir / SKILL_MD_FILE).read_text(encoding="utf-8") + new_content = _rewrite_skill_frontmatter_name(source_content, new_name) + SkillStorage.validate_skill_markdown_content(new_name, new_content) + storage.write_custom_skill(new_name, SKILL_MD_FILE, new_content) + except Exception: + # Roll back the half-created directory so a failed copy never leaves + # an unparseable skill on disk. + shutil.rmtree(dest_dir, ignore_errors=True) + raise + + await skill_store.ensure_legacy({"name": new_name, "owner_user_id": user_id, "published": False}) + try: + storage.append_history( + new_name, + { + "action": "duplicate", + "author": "human", + "thread_id": None, + "file_path": SKILL_MD_FILE, + "prev_content": None, + "new_content": new_content, + "source_skill": source_name, + }, + ) + except Exception: + pass + try: + from deerflow.skills.usage import mark_uploaded + mark_uploaded(new_name) + except Exception: + pass + await refresh_skills_system_prompt_cache_async() + return SkillInstallResponse( + success=True, + skill_name=new_name, + message=f"Skill '{source_name}' duplicated as '{new_name}'", + overwritten=False, + category=SkillCategory.CUSTOM.value, + ) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Failed to duplicate skill %s: %s", source_name, e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to duplicate skill: {str(e)}") + + @router.post( "/skills/reconcile-custom", response_model=SkillReconcileResponse, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/element.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/element.py index 79241f9c..9b3a3d37 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/element.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/element.py @@ -18,7 +18,7 @@ class ElementDeliveryClient: self.access_token = access_token @classmethod - def from_env(cls) -> "ElementDeliveryClient | None": + def from_env(cls) -> ElementDeliveryClient | None: enabled = os.environ.get("ELEMENT_ENABLED", "true").strip().lower() if enabled in {"0", "false", "no", "off"}: return None
技能加载中...