Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd
This commit is contained in:
commit
48ddbf0412
423 files changed
+117115
-3214
No files matched your search
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||
import { TagChip } from "@/components/workspace/tags/tag-chip";
|
||||
import { TAG_COLORS } from "@/components/workspace/tags/tag-colors";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useCreateTag,
|
||||
useDeleteTag,
|
||||
useTags,
|
||||
useUpdateTag,
|
||||
type Tag,
|
||||
type TagType,
|
||||
} from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TagFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
tagType,
|
||||
editing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tagType: TagType;
|
||||
editing: Tag | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const createTag = useCreateTag();
|
||||
const updateTag = useUpdateTag();
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("gray");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// Sync form state whenever the dialog (re)opens.
|
||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||
const syncKey = `${open}:${editing?.id ?? "new"}`;
|
||||
if (open && syncedFor !== syncKey) {
|
||||
setName(editing?.name ?? "");
|
||||
setColor(editing?.color ?? "gray");
|
||||
setDescription(editing?.description ?? "");
|
||||
setSyncedFor(syncKey);
|
||||
}
|
||||
if (!open && syncedFor !== null) setSyncedFor(null);
|
||||
|
||||
const pending = createTag.isPending || updateTag.isPending;
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
if (editing) {
|
||||
await updateTag.mutateAsync({
|
||||
id: editing.id,
|
||||
input: { name: trimmed, color, description },
|
||||
});
|
||||
} else {
|
||||
await createTag.mutateAsync({
|
||||
name: trimmed,
|
||||
tag_type: tagType,
|
||||
color,
|
||||
description,
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
toast.error(String(e instanceof Error ? e.message : e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? t.adminTags.editTitle : t.adminTags.createTitle}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{t.adminTags.name}</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t.adminTags.namePlaceholder}
|
||||
maxLength={256}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{t.adminTags.color}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TAG_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
onClick={() => setColor(c.name)}
|
||||
aria-label={c.name}
|
||||
className={cn(
|
||||
"size-6 rounded-full border-2",
|
||||
c.swatch,
|
||||
color === c.name
|
||||
? "border-foreground"
|
||||
: "border-transparent",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t.adminTags.tagDescription}
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t.adminTags.descriptionPlaceholder}
|
||||
maxLength={1024}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
{name.trim() && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">Preview:</span>
|
||||
<TagChip tag={{ name: name.trim(), color }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t.adminTags.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={pending || !name.trim()}>
|
||||
{pending && <Loader2 className="size-4 animate-spin" />}
|
||||
{t.adminTags.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagManagementPage() {
|
||||
const { t } = useI18n();
|
||||
const [activeType, setActiveType] = useState<TagType>("agent");
|
||||
const { data: tags, isLoading, error } = useTags(activeType);
|
||||
const deleteTag = useDeleteTag();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Tag | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const openEdit = (tag: Tag) => {
|
||||
setEditing(tag);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const handleDelete = async (tag: Tag) => {
|
||||
if (!window.confirm(t.adminTags.deleteConfirm)) return;
|
||||
try {
|
||||
await deleteTag.mutateAsync(tag.id);
|
||||
} catch (e) {
|
||||
toast.error(String(e instanceof Error ? e.message : e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<SettingsSection
|
||||
title={t.adminTags.title}
|
||||
description={t.adminTags.description}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Tabs
|
||||
value={activeType}
|
||||
onValueChange={(v) => setActiveType(v as TagType)}
|
||||
>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
|
||||
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
|
||||
<TabsTrigger value="scheduled_task">
|
||||
{t.adminTags.typeScheduledTask}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />
|
||||
{t.adminTags.createButton}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-destructive py-4 text-sm">{String(error)}</div>
|
||||
)}
|
||||
{!isLoading && !error && tags && tags.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
{t.adminTags.empty}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && tags && tags.length > 0 && (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
|
||||
{t.adminTags.name}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
|
||||
{t.adminTags.tagDescription}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
|
||||
{t.adminTags.usageCount}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
|
||||
{t.adminTags.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags.map((tag, i) => (
|
||||
<tr
|
||||
key={tag.id}
|
||||
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<TagChip tag={tag} />
|
||||
</td>
|
||||
<td className="text-muted-foreground px-4 py-3">
|
||||
{tag.description || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">
|
||||
{tag.usage_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(tag)}
|
||||
aria-label={t.adminTags.edit}
|
||||
>
|
||||
<PencilIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(tag)}
|
||||
aria-label={t.adminTags.delete}
|
||||
>
|
||||
<Trash2Icon className="text-destructive size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<TagFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
tagType={activeType}
|
||||
editing={editing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EyeOffIcon,
|
||||
MessageSquareIcon,
|
||||
SquarePenIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -51,6 +54,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
const [editName, setEditName] = useState(agent.name);
|
||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||
@@ -212,6 +216,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -242,8 +252,19 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
{(canEdit || canDelete || isOwned) && (
|
||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||
<div className="flex gap-1">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -414,6 +435,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isAdmin && (
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="agent"
|
||||
targetId={agent.id}
|
||||
currentTags={agent.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -14,9 +15,12 @@ import { AgentCard } from "./agent-card";
|
||||
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const { agents, isLoading } = useAgents();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
||||
const { agents, isLoading } = useAgents({ search, tagId });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -83,10 +87,19 @@ export function AgentGallery() {
|
||||
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="mx-6 mt-4 w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
</TabsList>
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
className="w-[420px] max-w-[50%]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
PencilIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
@@ -34,16 +33,16 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
openArtifactInNewTab,
|
||||
} from "@/core/artifacts/download";
|
||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
openArtifactInNewTab,
|
||||
} from "@/core/artifacts/download";
|
||||
import {
|
||||
useArtifactContent,
|
||||
useUpdateArtifactContent,
|
||||
} from "@/core/artifacts/hooks";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
@@ -118,33 +117,19 @@ export function ArtifactFileDetail({
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return language === "html" || language === "markdown";
|
||||
}, [language]);
|
||||
const { content } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
|
||||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(null);
|
||||
const [editedContent, setEditedContent] = useState<string | null>(null);
|
||||
// Tracks last manually-saved content for write-file: artifacts (whose displayContent
|
||||
// always reflects the original tool-call output, not the on-disk file after save).
|
||||
const [savedContent, setSavedContent] = useState<string | null>(null);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const displayContent = (isWriteFile && savedContent !== null ? savedContent : null) ?? content ?? "";
|
||||
const { content, isApplyingEdit } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const canEditArtifact =
|
||||
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
||||
const isHtmlStreaming = language === "html" && thread.isLoading;
|
||||
const canEdit = isCodeFile && !isSkillFile && !thread.isLoading;
|
||||
const activeContent = editedContent ?? displayContent;
|
||||
const isDirty = editedContent !== null && editedContent !== displayContent;
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
if (language === "html") return "HTML";
|
||||
@@ -154,12 +139,34 @@ export function ArtifactFileDetail({
|
||||
return "FILE";
|
||||
}, [isSkillFile, language]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
|
||||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftContent(content ?? "");
|
||||
setHasLocalEdits(false);
|
||||
}, [filepathFromProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEditArtifact || hasLocalEdits) {
|
||||
return;
|
||||
}
|
||||
setDraftContent(content ?? "");
|
||||
}, [canEditArtifact, content, hasLocalEdits]);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowAutoViewSwitch(true);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setEditedContent(null);
|
||||
setSavedContent(null);
|
||||
|
||||
if (language === "html") {
|
||||
setViewMode(thread.isLoading ? "code" : "preview");
|
||||
@@ -322,41 +329,7 @@ export function ArtifactFileDetail({
|
||||
}, 1600);
|
||||
}, [scrollArtifactToBottom]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!isDirty || isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const pathWithoutLeadingSlash = filepath.replace(/^\//, "");
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/artifacts/${pathWithoutLeadingSlash}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: activeContent }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail ?? "保存失败");
|
||||
}
|
||||
if (isWriteFile) {
|
||||
setSavedContent(activeContent);
|
||||
} else {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["artifact", filepathFromProps, threadId],
|
||||
});
|
||||
}
|
||||
setEditedContent(null);
|
||||
toast.success("已保存");
|
||||
} catch (error) {
|
||||
console.error("Save artifact failed:", error);
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [isDirty, isSaving, filepath, threadId, activeContent, queryClient, filepathFromProps, isWriteFile]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
@@ -376,7 +349,33 @@ export function ArtifactFileDetail({
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
|
||||
const handleSaveArtifact = useCallback(async () => {
|
||||
if (!canEditArtifact || updateArtifact.isPending) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateArtifact.mutateAsync({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
content: draftContent,
|
||||
});
|
||||
setHasLocalEdits(false);
|
||||
toast.success("Markdown 已保存");
|
||||
} catch (error) {
|
||||
console.error("Failed to save artifact:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
|
||||
}
|
||||
}, [
|
||||
canEditArtifact,
|
||||
draftContent,
|
||||
filepath,
|
||||
isMock,
|
||||
threadId,
|
||||
updateArtifact,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
@@ -453,30 +452,19 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{canEdit && viewMode === "code" && !isDirty && (
|
||||
<span className="text-muted-foreground bg-muted/65 inline-flex items-center gap-1.5 rounded-full border border-black/6 px-2.5 py-1 text-[11px] dark:border-white/8">
|
||||
<PencilIcon className="size-3" />
|
||||
可编辑
|
||||
</span>
|
||||
)}
|
||||
{canEdit && viewMode === "code" && isDirty && (
|
||||
<span className="text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 inline-flex items-center rounded-full border border-amber-200 dark:border-amber-800 px-2.5 py-1 text-[11px]">
|
||||
未保存
|
||||
</span>
|
||||
)}
|
||||
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||
{canEdit && viewMode === "code" && (
|
||||
<ArtifactAction
|
||||
icon={isSaving ? LoaderIcon : SaveIcon}
|
||||
label="保存"
|
||||
tooltip={isDirty ? "保存修改 (Ctrl+S)" : "无修改"}
|
||||
disabled={isSaving || !isDirty}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||
{canEditArtifact && (
|
||||
<ArtifactAction
|
||||
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
|
||||
label="保存 Markdown"
|
||||
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
|
||||
disabled={!hasLocalEdits || updateArtifact.isPending}
|
||||
onClick={handleSaveArtifact}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||
label={t.common.install}
|
||||
@@ -562,13 +550,19 @@ export function ArtifactFileDetail({
|
||||
</ArtifactActions>
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent
|
||||
ref={artifactContentRef}
|
||||
className="relative p-0"
|
||||
onWheelCapture={handlePauseAutoScroll}
|
||||
>
|
||||
{showResumeAutoScroll && (
|
||||
<button
|
||||
<ArtifactContent
|
||||
ref={artifactContentRef}
|
||||
className="relative p-0"
|
||||
onWheelCapture={handlePauseAutoScroll}
|
||||
>
|
||||
{isApplyingEdit && (
|
||||
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||||
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
正在改写 Markdown
|
||||
</div>
|
||||
)}
|
||||
{showResumeAutoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResumeAutoScroll}
|
||||
className="animate-[sandbox-scroll-button-in_220ms_ease-out] absolute inset-x-0 top-1/2 z-20 mx-auto flex w-fit -translate-y-1/2 items-center gap-2 rounded-full border border-white/35 bg-black/28 px-4 py-2 text-sm font-medium text-white shadow-[0_20px_40px_-24px_rgba(15,23,42,0.7)] backdrop-blur-md transition hover:bg-black/40"
|
||||
@@ -580,30 +574,31 @@ export function ArtifactFileDetail({
|
||||
{isSupportPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={activeContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
/>
|
||||
)}
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<div
|
||||
className="size-full"
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={activeContent}
|
||||
readonly={!canEdit}
|
||||
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
readonly={!canEditArtifact}
|
||||
onChange={
|
||||
canEditArtifact
|
||||
? (value) => {
|
||||
setDraftContent(value);
|
||||
setHasLocalEdits(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
streamingReadonlyFallback={!canEditArtifact}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isCodeFile &&
|
||||
(binaryPreviewUrl ? (
|
||||
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
|
||||
@@ -618,15 +613,17 @@ export function ArtifactFileDetail({
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
animated = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
animated?: boolean;
|
||||
}) {
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
animated = false,
|
||||
activeCursor = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -657,12 +654,15 @@ export function ArtifactFilePreview({
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{ a: ArtifactLink }}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
{activeCursor && (
|
||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (language === "html") {
|
||||
return (
|
||||
|
||||
@@ -38,20 +38,22 @@ export function CodeEditor({
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
readonly,
|
||||
disabled,
|
||||
autoFocus,
|
||||
onChange,
|
||||
settings,
|
||||
streamingReadonlyFallback = true,
|
||||
}: {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
settings?: unknown;
|
||||
streamingReadonlyFallback?: boolean;
|
||||
}) {
|
||||
const {
|
||||
thread: { isLoading },
|
||||
@@ -79,7 +81,7 @@ export function CodeEditor({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isLoading && streamingReadonlyFallback ? (
|
||||
<Textarea
|
||||
className={cn(
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
PaperclipIcon,
|
||||
@@ -129,6 +130,7 @@ export function InputBox({
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -141,6 +143,7 @@ export function InputBox({
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
@@ -263,6 +266,13 @@ export function InputBox({
|
||||
[onContextChange, context],
|
||||
);
|
||||
|
||||
const handleWritingModeToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
writing_mode: !context.writing_mode,
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
@@ -541,6 +551,26 @@ export function InputBox({
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu> */}
|
||||
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700" />
|
||||
<Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none",
|
||||
context.writing_mode
|
||||
? "border-[#d8b878] bg-[#fff3cf] text-[#6d5625] hover:bg-[#ffedbd]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white",
|
||||
)}
|
||||
onClick={handleWritingModeToggle}
|
||||
>
|
||||
<FileTextIcon className="size-3" />
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
|
||||
@@ -329,41 +329,43 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "write_file" || name === "str_replace") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
|
||||
setTimeout(() => {
|
||||
const url = new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString();
|
||||
if (selectedArtifact === url) {
|
||||
return;
|
||||
}
|
||||
select(url, true);
|
||||
setOpen(true);
|
||||
}, 100);
|
||||
}
|
||||
} else if (name === "write_file" || name === "str_replace") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
const artifactPath =
|
||||
name === "write_file" && path
|
||||
? new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString()
|
||||
: path;
|
||||
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
|
||||
setTimeout(() => {
|
||||
if (!artifactPath || selectedArtifact === artifactPath) {
|
||||
return;
|
||||
}
|
||||
select(artifactPath, true);
|
||||
setOpen(true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
select(
|
||||
new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString(),
|
||||
);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath) {
|
||||
return;
|
||||
}
|
||||
select(artifactPath);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{path}
|
||||
|
||||
@@ -358,9 +358,9 @@ function SetupAgentApprovalCard({
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
创建专家审批
|
||||
创建智能体审批
|
||||
</div>
|
||||
<div className="text-sm font-medium">请确认是否写入这个专家配置。</div>
|
||||
<div className="text-sm font-medium">请确认是否写入这个智能体配置。</div>
|
||||
</div>
|
||||
|
||||
{approvalPayload.name ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
@@ -46,6 +47,9 @@ import {
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
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";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
@@ -67,36 +71,27 @@ import { SkillStateSheet } from "./skill-state-sheet";
|
||||
|
||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const { skills, isLoading, error } = useSkills();
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.skills.title}
|
||||
description={t.settings.skills.description}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
) : error ? (
|
||||
<div>Error: {error.message}</div>
|
||||
) : (
|
||||
<SkillSettingsList skills={skills} onClose={onClose} />
|
||||
)}
|
||||
<SkillSettingsList onClose={onClose} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillSettingsList({
|
||||
skills,
|
||||
onClose,
|
||||
}: {
|
||||
skills: Skill[];
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("mine");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagId });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -210,10 +205,22 @@ function SkillSettingsList({
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
{filteredSkills.length === 0 && (
|
||||
<TagSearchBar
|
||||
targetType="skill"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
className="max-w-xl"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
)}
|
||||
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<EmptySkill onCreateSkill={handleCreateSkill} />
|
||||
)}
|
||||
{filteredSkills.length > 0 &&
|
||||
{!isLoading && !error &&
|
||||
filteredSkills.length > 0 &&
|
||||
filteredSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
@@ -257,6 +264,7 @@ function SkillRow({
|
||||
const setPinned = useSetSkillPinned();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
@@ -395,8 +403,22 @@ function SkillRow({
|
||||
: t.settings.skills.useCountInlineZero}
|
||||
</div>
|
||||
)}
|
||||
{(skill.tags?.length ?? 0) > 0 && (
|
||||
<TagChipList tags={skill.tags!} className="mt-2" />
|
||||
)}
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -476,6 +498,15 @@ function SkillRow({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isAdmin && (
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="skill"
|
||||
targetId={skill.name}
|
||||
currentTags={skill.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
<SkillDetailDialog
|
||||
skill={skill}
|
||||
open={detailOpen}
|
||||
|
||||
@@ -21,6 +21,13 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useCuratorConfig,
|
||||
@@ -31,6 +38,7 @@ import {
|
||||
} from "@/core/curator/hooks";
|
||||
import type { CuratorConfig } from "@/core/curator/types";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SkillEvolutionPanel() {
|
||||
@@ -68,6 +76,7 @@ function StatusCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.status;
|
||||
const { status, isLoading } = useCuratorStatus();
|
||||
const { config } = useCuratorConfig();
|
||||
const runCurator = useRunCurator();
|
||||
|
||||
const lastRunDisplay = useMemo(() => {
|
||||
@@ -79,10 +88,25 @@ function StatusCard() {
|
||||
}
|
||||
}, [status?.last_run_at, tt.lastRunNever]);
|
||||
|
||||
// 总开关或 curator 子开关任一关闭时,手动触发跑不起来 —— 直接禁用按钮。
|
||||
const runDisabled = !config || !config.enabled || !config.curator.enabled;
|
||||
|
||||
const handleRun = async () => {
|
||||
try {
|
||||
await runCurator.mutateAsync();
|
||||
toast.success(tt.runStarted);
|
||||
const res = await runCurator.mutateAsync();
|
||||
if (res.triggered) {
|
||||
toast.success(tt.runStarted);
|
||||
} else {
|
||||
// 后端预检拦下了(开关未开 / 已有任务在跑)—— 给出对应原因,而不是误报"已开始"。
|
||||
const reasonMsg: Record<string, string> = {
|
||||
evolution_disabled: tt.runBlockedEvolutionDisabled,
|
||||
curator_disabled: tt.runBlockedCuratorDisabled,
|
||||
already_running: tt.runBlockedAlreadyRunning,
|
||||
};
|
||||
toast.warning(
|
||||
reasonMsg[res.reason ?? ""] ?? tt.runBlockedEvolutionDisabled,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -107,7 +131,8 @@ function StatusCard() {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={runCurator.isPending}
|
||||
disabled={runCurator.isPending || runDisabled}
|
||||
title={runDisabled ? tt.runDisabledHint : undefined}
|
||||
>
|
||||
{runCurator.isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
@@ -154,6 +179,73 @@ function StatusCard() {
|
||||
</span>
|
||||
</span>
|
||||
</StatRow>
|
||||
<StatRow label={tt.lastActions} className="sm:col-span-2">
|
||||
<div className="flex flex-col gap-2 text-xs">
|
||||
<div>
|
||||
<div className="font-medium">{tt.llmSuggested}</div>
|
||||
{status.last_consolidation_suggestions.consolidations.length ===
|
||||
0 &&
|
||||
status.last_consolidation_suggestions.prunings.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{tt.noActions}
|
||||
</span>
|
||||
) : (
|
||||
<ul className="text-muted-foreground mt-0.5 list-disc pl-4">
|
||||
{status.last_consolidation_suggestions.consolidations.map(
|
||||
(c, i) => (
|
||||
<li key={`c${i}`}>
|
||||
{(c.from ?? "?") + " → " + (c.into ?? "?")}
|
||||
{c.reason ? `(${c.reason})` : ""}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
{status.last_consolidation_suggestions.prunings.map(
|
||||
(p, i) => (
|
||||
<li key={`p${i}`}>
|
||||
{p.name ?? "?"}
|
||||
{p.reason ? `(${p.reason})` : ""}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{tt.actuallyDid}</div>
|
||||
{status.last_actual_actions.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{tt.noActions}
|
||||
</span>
|
||||
) : (
|
||||
<ul className="mt-0.5 flex flex-col gap-1">
|
||||
{status.last_actual_actions.map((a, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<span className="font-mono">{a.tool ?? "?"}</span>
|
||||
{typeof a.args?.name === "string" && (
|
||||
<span className="text-muted-foreground">
|
||||
{String(a.args.name)}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant={
|
||||
a.status === "error"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="px-1 text-[10px]"
|
||||
>
|
||||
{a.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StatRow>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
@@ -178,11 +270,15 @@ function StatRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Select 不允许空字符串 value,用此哨兵代表"用主模型"(对应 model_name=null)。 */
|
||||
const DEFAULT_MODEL_VALUE = "__default__";
|
||||
|
||||
function ConfigCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.config;
|
||||
const { config, isLoading } = useCuratorConfig();
|
||||
const updateConfig = useUpdateCuratorConfig();
|
||||
const { models } = useModels();
|
||||
|
||||
const [draft, setDraft] = useState<CuratorConfig | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -311,22 +407,35 @@ function ConfigCard() {
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.moderationModelLabel}
|
||||
helper={tt.moderationModelHelper}
|
||||
label={tt.curatorModelLabel}
|
||||
helper={tt.curatorModelHelper}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
className="max-w-[20rem]"
|
||||
value={draft.moderation_model_name ?? ""}
|
||||
placeholder=""
|
||||
onChange={(e) =>
|
||||
<Select
|
||||
value={draft.curator.model_name ?? DEFAULT_MODEL_VALUE}
|
||||
onValueChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
moderation_model_name:
|
||||
e.target.value.trim() === "" ? null : e.target.value,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
model_name: v === DEFAULT_MODEL_VALUE ? null : v,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="max-w-[20rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_MODEL_VALUE}>
|
||||
{tt.curatorModelDefault}
|
||||
</SelectItem>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldRow>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
import { resolveTagColor } from "./tag-colors";
|
||||
|
||||
/** A read-only (or removable) coloured chip for a single tag. */
|
||||
export function TagChip({
|
||||
tag,
|
||||
onRemove,
|
||||
className,
|
||||
}: {
|
||||
tag: Pick<Tag, "name" | "color">;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
color.chip,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tag.name}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="hover:opacity-70"
|
||||
aria-label={`remove ${tag.name}`}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a horizontal list of tag chips; nothing when the list is empty. */
|
||||
export function TagChipList({
|
||||
tags,
|
||||
className,
|
||||
}: {
|
||||
tags: Pick<Tag, "name" | "color">[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-1", className)}>
|
||||
{tags.map((tag) => (
|
||||
<TagChip key={tag.name} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Fixed tag color palette. The backend only stores the color *name*; the
|
||||
// Tailwind classes are resolved here so the class strings stay static and
|
||||
// survive the production purge.
|
||||
|
||||
export interface TagColorDef {
|
||||
name: string;
|
||||
/** Classes for a rendered chip (background + text + border). */
|
||||
chip: string;
|
||||
/** A solid swatch used in the colour picker. */
|
||||
swatch: string;
|
||||
}
|
||||
|
||||
export const TAG_COLORS: TagColorDef[] = [
|
||||
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
|
||||
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
|
||||
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
|
||||
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
|
||||
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
|
||||
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
|
||||
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
|
||||
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
|
||||
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
|
||||
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
|
||||
];
|
||||
|
||||
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
|
||||
|
||||
export function resolveTagColor(name: string | undefined | null): TagColorDef {
|
||||
return (name && BY_NAME.get(name)) || TAG_COLORS[0];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSetResourceTags, useTags, type Tag, type TagType } from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { resolveTagColor } from "./tag-colors";
|
||||
|
||||
/**
|
||||
* Admin-only dialog to set the tags on one resource. Clicking a tag binds or
|
||||
* unbinds it immediately (no separate save step). Only tags of ``targetType``
|
||||
* are offered, enforcing "a tag may only tag its own type".
|
||||
*/
|
||||
export function TagPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetType,
|
||||
targetId,
|
||||
currentTags,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targetType: TagType;
|
||||
targetId: string;
|
||||
currentTags: Tag[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: allTags, isLoading } = useTags(targetType);
|
||||
const setResourceTags = useSetResourceTags();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||
const syncKey = `${open}:${targetId}`;
|
||||
if (open && syncedFor !== syncKey) {
|
||||
setSelected(new Set(currentTags.map((tg) => tg.id)));
|
||||
setSyncedFor(syncKey);
|
||||
}
|
||||
if (!open && syncedFor !== null) setSyncedFor(null);
|
||||
|
||||
// Clicking a tag mutates immediately. Local state updates optimistically and
|
||||
// rolls back if the request fails.
|
||||
const toggle = (tagId: string) => {
|
||||
const prev = selected;
|
||||
const next = new Set(prev);
|
||||
if (next.has(tagId)) next.delete(tagId);
|
||||
else next.add(tagId);
|
||||
setSelected(next);
|
||||
setResourceTags.mutate(
|
||||
{ targetType, targetId, tagIds: [...next] },
|
||||
{
|
||||
onError: (e) => {
|
||||
setSelected(prev);
|
||||
toast.error(String(e instanceof Error ? e.message : e));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.adminTags.pickerTitle}</DialogTitle>
|
||||
<DialogDescription>{t.adminTags.pickerHint}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-[120px]">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (!allTags || allTags.length === 0) && (
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t.adminTags.pickerEmpty}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && allTags && allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => {
|
||||
const checked = selected.has(tag.id);
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggle(tag.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all",
|
||||
color.chip,
|
||||
checked
|
||||
? "ring-2 ring-offset-1 ring-foreground/40"
|
||||
: "opacity-45 hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3.5" />}
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="items-center sm:justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{setResourceTags.isPending ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{t.adminTags.saving}
|
||||
</span>
|
||||
) : (
|
||||
t.adminTags.pickerSelectedCount.replace(
|
||||
"{count}",
|
||||
String(selected.size),
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t.adminTags.done}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useTags, type TagType } from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
/**
|
||||
* Reusable name-search + tag-filter toolbar for resource list pages.
|
||||
*
|
||||
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
|
||||
* caller can feed the value straight into a server-side query without a
|
||||
* request per keystroke. Filtering is open to all users.
|
||||
*/
|
||||
export function TagSearchBar({
|
||||
targetType,
|
||||
onSearchChange,
|
||||
tagId,
|
||||
onTagIdChange,
|
||||
className,
|
||||
}: {
|
||||
targetType: TagType;
|
||||
onSearchChange: (value: string) => void;
|
||||
tagId: string | null;
|
||||
onTagIdChange: (value: string | null) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: tags } = useTags(targetType);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const cbRef = useRef(onSearchChange);
|
||||
cbRef.current = onSearchChange;
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => cbRef.current(input.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t.adminTags.searchPlaceholder}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={tagId ?? ALL}
|
||||
onValueChange={(v) => onTagIdChange(v === ALL ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder={t.adminTags.filterByTag} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t.adminTags.allTags}</SelectItem>
|
||||
{(tags ?? []).map((tag) => (
|
||||
<SelectItem key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MessageSquarePlusIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
TagsIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -148,6 +149,14 @@ export function WorkspaceNavMenu() {
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
|
||||
Reference in new issue
Block a user