feat(skills/settings): 技能复制 + 设置持久化等会话前改动

- 技能复制:新增 POST 复制接口(_rewrite_skill_frontmatter_name 重写 frontmatter name + 校验 + 去重),前端 skills api/hooks 与技能设置页配套
- 设置持久化:lastAgentId、线程 RAG 模式锁(settings/index、local)
- i18n(en/zh/types)、agent-card/input-box/message-list 等页面文案与交互微调
- scheduler/element.py 小修

(本批为会话前已存在的工作区改动,按要求一并提交;构建产物 backend.zip 未提交)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-02 23:48:19 +08:00
1 parent d9afaca51f
commit f590f672be
18 files changed
+524 -44

No files matched your search

@@ -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) {
<ShieldIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleDuplicate()}
disabled={createAgent.isPending}
title="复制为新智能体"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canEdit && (
<Button
size="icon"
@@ -535,7 +584,7 @@ export function AgentCard({ agent }: AgentCardProps) {
已选 {selectedSkills.length} 个
</span>
</div>
<div className="rounded-md border p-2">
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
@@ -543,13 +592,13 @@ export function AgentCard({ agent }: AgentCardProps) {
当前没有可用技能
</p>
) : (
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
key={skill.name}
className="inline-flex max-w-full items-center"
className="flex w-full min-w-0 items-center"
>
<Button
type="button"
@@ -557,7 +606,7 @@ export function AgentCard({ agent }: AgentCardProps) {
variant={checked ? "default" : "outline"}
onClick={() => 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"
>
<span className="truncate">{desensitize(skill.name)}</span>
</Button>
@@ -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}
@@ -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<typeof useRehypeSplitWordsIntoSpans>;
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 && (
<MessageToolbar
className={cn(
isHuman ? "-bottom-9 justify-end" : "-bottom-8",
"-bottom-8",
"absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100",
)}
>
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{!isHuman && messageMarkdown && (
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
@@ -138,6 +150,125 @@ export function MessageListItem({
);
}
/**
* Inline copy/edit affordances + editor for the user's own question bubble.
*
* Layout: bubble → hover toolbar (copy + edit) → timestamp. The toolbar sits
* in normal flow above the timestamp (not floating) so it never covers the
* following message. Clicking edit turns the bubble into a textarea seeded with
* the question; confirming sends the edited text as a brand-new turn (the
* original question stays in the thread).
*/
function HumanMessageBlock({
text,
filesList,
messageTime,
components,
onEditResubmit,
}: {
text: string;
filesList: React.ReactNode;
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => 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 (
<div className="ml-auto flex w-full max-w-[min(85%,42rem)] flex-col gap-2">
{filesList}
<Textarea
value={draft}
autoFocus
onChange={(event) => 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"
/>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setEditing(false)}
>
{t.common.cancel}
</Button>
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
{t.common.confirm}
</Button>
</div>
</div>
);
}
const messageResponse = text ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
parseIncompleteMarkdown={false}
>
{text}
</AIElementMessageResponse>
) : null;
return (
<div className="ml-auto flex flex-col items-end gap-1">
{filesList}
{messageResponse && (
<AIElementMessageContent className="w-fit">
{messageResponse}
</AIElementMessageContent>
)}
<MessageTimeStamp time={messageTime} align="right" />
{text && (
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
<CopyButton clipboardData={text} className="size-6" />
{onEditResubmit && (
<Tooltip content={t.inputBox.editQuestion}>
<Button
size="icon-sm"
type="button"
variant="ghost"
className="size-6"
onClick={() => setEditing(true)}
>
<PencilIcon size={11} />
</Button>
</Tooltip>
)}
</div>
)}
</div>
);
}
/**
* 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<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const rehypePlugins = useMemo(
@@ -289,26 +422,16 @@ function MessageContent_({
}
if (isHuman) {
const messageResponse = contentToDisplay ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
parseIncompleteMarkdown={false}
>
{contentToDisplay}
</AIElementMessageResponse>
) : null;
// Render HumanMessageBlock directly (no width-constraining wrapper) so the
// inline editor can widen beyond the bubble's fit-content width.
return (
<div className={cn("ml-auto flex flex-col gap-2", className)}>
{filesList}
{messageResponse && (
<AIElementMessageContent className="w-fit">
{messageResponse}
</AIElementMessageContent>
)}
<MessageTimeStamp time={messageTime} align="right" />
</div>
<HumanMessageBlock
text={contentToDisplay}
filesList={filesList}
messageTime={messageTime}
components={components}
onEditResubmit={onEditResubmit}
/>
);
}
@@ -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<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
@@ -241,6 +243,7 @@ export function MessageList({
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
/>
);
});
@@ -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 "<name>-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({
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
>
<CopyIcon className="size-3.5" />
</Button>
{canManage && (
<Button
size="icon"
@@ -600,6 +641,43 @@ export function SkillRow({
)}
</ItemActions>
</Item>
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>复制技能</DialogTitle>
<DialogDescription>
将「{desensitize(skill.name)}」复制为一个属于你的新技能。请输入新名称(仅小写字母、数字、- 或 _)。
</DialogDescription>
</DialogHeader>
<Input
value={duplicateName}
onChange={(e) => setDuplicateName(e.currentTarget.value)}
placeholder="new-skill-name"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleDuplicate();
}
}}
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDuplicateOpen(false)}
disabled={duplicateSkillMutation.isPending}
>
{t.common.cancel}
</Button>
<Button
onClick={() => void handleDuplicate()}
disabled={duplicateSkillMutation.isPending}
>
{duplicateSkillMutation.isPending ? t.common.loading : "复制"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
@@ -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",
@@ -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;
@@ -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: "添加附件",
+6 -1
View File
@@ -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";
+24
View File
@@ -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;
+18
View File
@@ -202,6 +202,24 @@ export async function installSkillUpload(
return response.json() as Promise<InstallSkillResponse>;
}
export async function duplicateSkill(
sourceName: string,
newName: string,
): Promise<InstallSkillResponse> {
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<InstallSkillResponse>;
}
export interface ReconcileCustomSkillsResponse {
adopted: string[];
already_registered: string[];
+17
View File
@@ -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({
+5 -1
View File
@@ -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: [] })}
/>
</div>
+33 -3
View File
@@ -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<string | null>(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: [] })
}
/>
)}
</div>
@@ -305,7 +335,7 @@ export default function ChatPage() {
isLandingState
? {
selectedAgentId,
onAgentChange: setSelectedAgentId,
onAgentChange: handleAgentChange,
}
: undefined
}
+3 -3
View File
@@ -545,13 +545,13 @@ export default function NewAgentPage() {
没有匹配的技能。
</p>
) : (
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
key={skill.name}
className="inline-flex max-w-full items-center"
className="flex w-full min-w-0 items-center"
>
<Button
type="button"
@@ -560,7 +560,7 @@ export default function NewAgentPage() {
onClick={() => 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",
)}
>
@@ -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;
}
@@ -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,
@@ -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