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:
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: "添加附件",
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user