This commit is contained in:
董宏凯 committed 2026-05-18 17:47:33 +08:00
commit 49e5aaea6b
194 files changed
+63359 -3567

No files matched your search

@@ -2,8 +2,9 @@
import {
BotIcon,
EyeIcon,
EyeOffIcon,
GlobeIcon,
InfoIcon,
LockIcon,
MessageSquareIcon,
SquarePenIcon,
TagsIcon,
@@ -34,6 +35,7 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
@@ -62,6 +64,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [],
);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -204,6 +207,7 @@ export function AgentCard({ agent }: AgentCardProps) {
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title="该智能体已对所有用户可见"
>
<GlobeIcon className="size-3" />
已发布
</Badge>
)}
@@ -238,7 +242,12 @@ export function AgentCard({ agent }: AgentCardProps) {
<Badge
key={`sk:${skill}`}
variant="secondary"
className="text-xs"
className="hover:bg-secondary/70 cursor-pointer text-xs"
title={t.settings.skills.viewDetailAction}
onClick={(e) => {
e.stopPropagation();
setDetailSkillName(skill);
}}
>
{skill}
</Badge>
@@ -275,9 +284,9 @@ export function AgentCard({ agent }: AgentCardProps) {
title={agent.published ? "取消发布" : "发布"}
>
{agent.published ? (
<EyeOffIcon className="h-3.5 w-3.5" />
<LockIcon className="h-3.5 w-3.5" />
) : (
<EyeIcon className="h-3.5 w-3.5" />
<GlobeIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
@@ -353,17 +362,31 @@ export function AgentCard({ agent }: AgentCardProps) {
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<Button
<div
key={skill.name}
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={skill.description}
className="h-8"
className="inline-flex max-w-full items-center"
>
{skill.name}
</Button>
<Button
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={skill.description}
className="h-8 max-w-[200px] rounded-r-none"
>
<span className="truncate">{skill.name}</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
);
})}
</div>
@@ -445,6 +468,14 @@ export function AgentCard({ agent }: AgentCardProps) {
currentTags={agent.tags ?? []}
/>
)}
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
</>
);
}
@@ -5,10 +5,10 @@ import {
ArchiveIcon,
CheckCircle2,
ClockIcon,
EyeIcon,
EyeOffIcon,
FileArchive,
GlobeIcon,
Loader2,
LockIcon,
PinIcon,
PinOffIcon,
SparklesIcon,
@@ -47,6 +47,7 @@ import {
ItemDescription,
} from "@/components/ui/item";
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";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
@@ -58,7 +59,6 @@ import {
useInstallSkillUpload,
useSetSkillPinned,
useSetSkillPublished,
useSkillContent,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
@@ -358,6 +358,7 @@ function SkillRow({
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
title={t.settings.skills.badgePublishedTitle}
>
<GlobeIcon className="size-3" />
{t.settings.skills.badgePublished}
</Badge>
)}
@@ -453,9 +454,9 @@ function SkillRow({
}
>
{skill.published ? (
<EyeOffIcon className="size-3.5" />
<LockIcon className="size-3.5" />
) : (
<EyeIcon className="size-3.5" />
<GlobeIcon className="size-3.5" />
)}
</Button>
)}
@@ -508,7 +509,7 @@ function SkillRow({
/>
)}
<SkillDetailDialog
skill={skill}
skillName={skill.name}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
@@ -516,46 +517,6 @@ function SkillRow({
);
}
function SkillDetailDialog({
skill,
open,
onOpenChange,
}: {
skill: Skill;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { data, isLoading, error } = useSkillContent(open ? skill.name : null);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogDescription>{skill.description}</DialogDescription>
</DialogHeader>
<div className="bg-muted/40 min-h-0 flex-1 overflow-auto rounded-md border">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : error ? (
<div className="text-destructive p-4 text-sm">
{error instanceof Error ? error.message : String(error)}
</div>
) : (
<pre className="text-foreground/90 whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{data?.content}
</pre>
)}
</div>
</DialogContent>
</Dialog>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
@@ -0,0 +1,213 @@
"use client";
import { Loader2, PencilIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import {
useSetSkillDetail,
useSkill,
useSkillContent,
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown";
/**
* Whether the current user may edit a skill's detail text.
*
* Admins can edit any skill; the publisher (owner of a custom skill) can edit
* their own. Built-in skills have no human owner, so only admins qualify.
* Mirrors the backend authz in `PUT /api/skills/{name}/detail`.
*/
function canEditSkillDetail(skill: Skill | null | undefined): boolean {
if (!skill) return false;
const auth = getStoredAuth();
if (!auth) return false;
if (auth.system_role === "admin") return true;
return (
skill.category === "custom" &&
!!skill.owner_user_id &&
skill.owner_user_id === auth.user_id
);
}
/**
* Reusable skill detail dialog. Given a skill name it fetches the skill
* metadata (including the admin/owner-authored `detail`) and the raw SKILL.md
* source, and lets authorized users edit the detail inline.
*/
export function SkillDetailDialog({
skillName,
open,
onOpenChange,
}: {
skillName: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tx = t.settings.skills;
// Only fetch while the dialog is open to avoid background requests.
const activeName = open ? skillName : null;
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(activeName);
const setDetail = useSetSkillDetail();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [showSource, setShowSource] = useState(false);
// Reset transient UI whenever the dialog closes or switches to another skill.
useEffect(() => {
if (!open) {
setEditing(false);
setShowSource(false);
}
}, [open, skillName]);
const canEdit = useMemo(() => canEditSkillDetail(skill), [skill]);
function startEditing() {
setDraft(skill?.detail ?? "");
setEditing(true);
}
async function handleSave() {
if (!skill) return;
try {
await setDetail.mutateAsync({ skillName: skill.name, detail: draft });
toast.success(tx.detailSaveSuccess);
setEditing(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill?.name ?? skillName ?? ""}</DialogTitle>
<DialogDescription>{skill?.description}</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-auto pr-1">
{/* Detail — human-authored long-form introduction */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{tx.detailSectionTitle}</h3>
{canEdit && !editing && (
<Button
size="sm"
variant="ghost"
className="h-7"
onClick={startEditing}
>
<PencilIcon className="size-3.5" />
{tx.detailEditAction}
</Button>
)}
</div>
{editing ? (
<div className="space-y-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
placeholder={tx.detailPlaceholder}
className="max-h-[320px] min-h-40 resize-y overflow-y-auto text-sm"
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setEditing(false)}
disabled={setDetail.isPending}
>
{t.common.cancel}
</Button>
<Button
size="sm"
onClick={() => void handleSave()}
disabled={setDetail.isPending}
>
{setDetail.isPending && (
<Loader2 className="size-3.5 animate-spin" />
)}
{t.common.save}
</Button>
</div>
</div>
) : isSkillLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : skill?.detail ? (
<div className="bg-muted/40 rounded-md border p-3 text-sm">
<MarkdownContent
content={skill.detail}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</div>
) : (
<p className="text-muted-foreground text-sm">
{tx.detailEmpty}
{!canEdit ? ` ${tx.detailAdminOnlyHint}` : ""}
</p>
)}
</section>
{/* SKILL.md source — collapsed by default */}
<section className="space-y-2">
<button
type="button"
className="text-muted-foreground hover:text-foreground text-sm font-medium"
onClick={() => setShowSource((v) => !v)}
>
{showSource ? "▾ " : "▸ "}
{tx.contentSectionTitle}
</button>
{showSource && (
<div className="bg-muted/40 overflow-auto rounded-md border">
{isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : contentError ? (
<div className="text-destructive p-4 text-sm">
{contentError instanceof Error
? contentError.message
: String(contentError)}
</div>
) : (
<pre className="text-foreground/90 max-h-[320px] overflow-auto whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{contentData?.content}
</pre>
)}
</div>
)}
</section>
</div>
</DialogContent>
</Dialog>
);
}
@@ -474,6 +474,16 @@ export const enUS: Translations = {
deleteConfirm:
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
deleteSuccess: "Skill deleted",
detailSectionTitle: "Details",
detailEmpty: "No details yet.",
detailEditAction: "Edit details",
detailPlaceholder:
"Describe the skill's purpose, use cases and usage notes (Markdown supported).",
detailSaveSuccess: "Details updated",
detailAdminOnlyHint:
"Only an admin or the skill publisher can edit the details.",
contentSectionTitle: "SKILL.md source",
viewDetailAction: "View skill details",
stateSheet: {
archivedTitle: "Archived skills",
archivedDescription:
@@ -405,6 +405,14 @@ export interface Translations {
deleteAction: string;
deleteConfirm: string;
deleteSuccess: string;
detailSectionTitle: string;
detailEmpty: string;
detailEditAction: string;
detailPlaceholder: string;
detailSaveSuccess: string;
detailAdminOnlyHint: string;
contentSectionTitle: string;
viewDetailAction: string;
stateSheet: {
archivedTitle: string;
archivedDescription: string;
@@ -454,6 +454,14 @@ export const zhCN: Translations = {
deleteAction: "删除技能",
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
deleteSuccess: "技能已删除",
detailSectionTitle: "详情",
detailEmpty: "暂无详情说明。",
detailEditAction: "编辑详情",
detailPlaceholder: "用 Markdown 补充技能的用途、适用场景、使用说明等。",
detailSaveSuccess: "详情已更新",
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
contentSectionTitle: "SKILL.md 原文",
viewDetailAction: "查看技能详情",
stateSheet: {
archivedTitle: "归档的技能",
archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。",
+37
View File
@@ -20,6 +20,16 @@ export async function loadSkills(params: ListSkillsParams = {}) {
return json.skills as Skill[];
}
export async function getSkill(skillName: string): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to load skill");
}
return response.json() as Promise<Skill>;
}
export async function enableSkill(skillName: string, enabled: boolean) {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${skillName}`,
@@ -212,3 +222,30 @@ export async function deleteCustomSkill(skillName: string): Promise<void> {
throw await parseDetailedError(response, "Failed to delete skill");
}
}
export interface SetSkillDetailResponse {
name: string;
detail: string | null;
}
/**
* Update a skill's long-form detail text. Owner/admin for custom skills,
* admin-only for built-in skills (backend enforces). An empty string clears it.
*/
export async function setSkillDetail(
skillName: string,
detail: string,
): Promise<SetSkillDetailResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/detail`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ detail }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill detail");
}
return response.json() as Promise<SetSkillDetailResponse>;
}
+22
View File
@@ -8,8 +8,10 @@ import {
import {
deleteCustomSkill,
enableSkill,
getSkill,
getSkillContent,
installSkillUpload,
setSkillDetail,
setSkillPinned,
setSkillPublished,
setSkillState,
@@ -38,6 +40,26 @@ export function useSkillContent(skillName: string | null) {
});
}
export function useSkill(skillName: string | null) {
return useQuery({
queryKey: ["skill", skillName],
queryFn: () => getSkill(skillName!),
enabled: !!skillName,
});
}
export function useSetSkillDetail() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
setSkillDetail(skillName, detail),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useEnableSkill() {
const queryClient = useQueryClient();
return useMutation({
+3
View File
@@ -14,6 +14,9 @@ export interface Skill {
owner_user_id?: string | null;
published?: boolean;
builtin?: boolean;
// Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
detail?: string | null;
// Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead.
state?: SkillState;
+37 -12
View File
@@ -3,6 +3,7 @@
import {
ArrowLeftIcon,
CheckCircleIcon,
InfoIcon,
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -24,6 +25,7 @@ import {
type SetupAgentApprovalPayload,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents";
import {
@@ -73,6 +75,7 @@ export default function NewAgentPage() {
const [isSavingDraft, setIsSavingDraft] = useState(false);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [threadPrepared, setThreadPrepared] = useState(false);
const { skills, isLoading: isSkillsLoading } = useSkills();
const { models, isLoading: isModelsLoading } = useModels();
@@ -448,20 +451,34 @@ export default function NewAgentPage() {
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<Button
<div
key={skill.name}
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
title={skill.description}
className={cn(
"h-8 max-w-full",
!checked && "text-muted-foreground",
)}
className="inline-flex max-w-full items-center"
>
<span className="truncate">{skill.name}</span>
</Button>
<Button
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
title={skill.description}
className={cn(
"h-8 max-w-[200px] rounded-r-none",
!checked && "text-muted-foreground",
)}
>
<span className="truncate">{skill.name}</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
);
})}
</div>
@@ -469,6 +486,14 @@ export default function NewAgentPage() {
</div>
</div>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
{formError ? (
<p className="text-destructive text-sm">{formError}</p>
) : null}