This commit is contained in:
Administrator committed 2026-06-28 00:23:01 +08:00
1 parent 65449ffd71
commit d522498cc9
8 files changed
+779 -165

No files matched your search

@@ -49,9 +49,8 @@ import {
} from "@/core/artifacts/hooks";
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { streamdownPlugins } from "@/core/streamdown";
import {
checkCodeFile,
getFileName,
@@ -79,7 +78,8 @@ import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
import { SpreadsheetPreview } from "./spreadsheet-preview";
import { SpreadsheetPreview } from "./spreadsheet-preview";
import { SkillInstallDialog } from "./skill-install-dialog";
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
@@ -232,7 +232,7 @@ export function ArtifactFileDetail({
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
const [isInstalling, setIsInstalling] = useState(false);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
@@ -432,29 +432,7 @@ export function ArtifactFileDetail({
}, 1600);
}, [scrollArtifactToBottom]);
const handleInstallSkill = useCallback(async () => {
if (isInstalling) return;
setIsInstalling(true);
try {
const result = await installSkill({
thread_id: threadId,
path: filepath,
});
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message ?? "Failed to install skill");
}
} catch (error) {
console.error("Failed to install skill:", error);
toast.error("Failed to install skill");
} finally {
setIsInstalling(false);
}
}, [threadId, filepath, isInstalling]);
const handleSaveArtifact = useCallback(async () => {
const handleSaveArtifact = useCallback(async () => {
if (!canEditArtifact || updateArtifact.isPending) {
return;
}
@@ -686,18 +664,17 @@ export function ArtifactFileDetail({
)}
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
label={t.common.install}
tooltip={t.common.install}
disabled={
isInstalling ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onClick={handleInstallSkill}
/>
</Tooltip>
)}
<ArtifactAction
icon={PackageIcon}
label={t.common.install}
tooltip={t.common.install}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
onClick={() => setInstallDialogOpen(true)}
/>
</Tooltip>
)}
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
@@ -782,7 +759,7 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent
<ArtifactContent
ref={artifactContentRef}
className="relative p-0"
onWheelCapture={handlePauseAutoScroll}
@@ -859,10 +836,16 @@ export function ArtifactFileDetail({
{t.common.loading}
</div>
))}
</ArtifactContent>
</Artifact>
);
}
</ArtifactContent>
<SkillInstallDialog
open={installDialogOpen}
onOpenChange={setInstallDialogOpen}
threadId={threadId}
filepath={filepath}
/>
</Artifact>
);
}
function SandboxPreviewImage({
src,
@@ -1,6 +1,6 @@
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -10,18 +10,18 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { downloadArtifactFile } from "@/core/artifacts/download";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import {
getFileExtensionDisplayName,
getFileIcon,
getFileName,
} from "@/core/utils/files";
import { downloadArtifactFile } from "@/core/artifacts/download";
import { useI18n } from "@/core/i18n/hooks";
import {
getFileExtensionDisplayName,
getFileIcon,
getFileName,
} from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { useThread } from "../messages/context";
import { useArtifacts } from "./context";
import { useThread } from "../messages/context";
import { useArtifacts } from "./context";
import { SkillInstallDialog } from "./skill-install-dialog";
export function ArtifactFileList({
className,
@@ -32,11 +32,11 @@ export function ArtifactFileList({
files: string[];
threadId: string;
}) {
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
const { select: selectArtifact, setOpen } = useArtifacts();
const [installingFile, setInstallingFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
const { select: selectArtifact, setOpen } = useArtifacts();
const [installFile, setInstallFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const handleClick = useCallback(
(filepath: string) => {
@@ -46,33 +46,14 @@ export function ArtifactFileList({
[selectArtifact, setOpen],
);
const handleInstallSkill = useCallback(
async (e: React.MouseEvent, filepath: string) => {
e.stopPropagation();
e.preventDefault();
if (installingFile) return;
setInstallingFile(filepath);
try {
const result = await installSkill({
thread_id: threadId,
path: filepath,
});
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message || "Failed to install skill");
}
} catch (error) {
console.error("Failed to install skill:", error);
toast.error("Failed to install skill");
} finally {
setInstallingFile(null);
}
},
[threadId, installingFile],
);
const handleInstallSkill = useCallback(
(e: React.MouseEvent, filepath: string) => {
e.stopPropagation();
e.preventDefault();
setInstallFile(filepath);
},
[],
);
const handleDownload = useCallback(
async (e: React.MouseEvent, filepath: string) => {
@@ -113,20 +94,15 @@ export function ArtifactFileList({
{getFileExtensionDisplayName(file)} file
</CardDescription>
<CardAction className="row-span-1 self-center">
{file.endsWith(".skill") && (
<Button
variant="ghost"
disabled={installingFile === file}
onClick={(e) => handleInstallSkill(e, file)}
>
{installingFile === file ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<PackageIcon className="size-4" />
)}
{t.common.install}
</Button>
)}
{file.endsWith(".skill") && (
<Button
variant="ghost"
onClick={(e) => handleInstallSkill(e, file)}
>
<PackageIcon className="size-4" />
{t.common.install}
</Button>
)}
<Button
variant="ghost"
disabled={downloadingFile === file}
@@ -142,7 +118,15 @@ export function ArtifactFileList({
</CardAction>
</CardHeader>
</Card>
))}
</ul>
);
}
))}
<SkillInstallDialog
open={!!installFile}
onOpenChange={(open) => {
if (!open) setInstallFile(null);
}}
threadId={threadId}
filepath={installFile}
/>
</ul>
);
}
@@ -0,0 +1,178 @@
"use client";
import { AlertCircleIcon, Loader2Icon, PackageIcon } from "lucide-react";
import { useEffect, useMemo, 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 { Input } from "@/components/ui/input";
import {
installSkill,
previewSkillInstall,
type PreviewSkillInstallResponse,
} from "@/core/skills/api";
export function SkillInstallDialog({
open,
onOpenChange,
threadId,
filepath,
onInstalled,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
threadId: string;
filepath: string | null;
onInstalled?: (skillName: string) => void;
}) {
const [preview, setPreview] = useState<PreviewSkillInstallResponse | null>(null);
const [installName, setInstallName] = useState("");
const [loading, setLoading] = useState(false);
const [installing, setInstalling] = useState(false);
useEffect(() => {
if (!open || !filepath) {
setPreview(null);
setInstallName("");
setLoading(false);
setInstalling(false);
return;
}
let cancelled = false;
setLoading(true);
setPreview(null);
setInstallName("");
void previewSkillInstall({ thread_id: threadId, path: filepath })
.then((result) => {
if (cancelled) return;
setPreview(result);
setInstallName(result.skill_name);
})
.catch((error) => {
if (cancelled) return;
toast.error(error instanceof Error ? error.message : "Failed to preview skill install");
onOpenChange(false);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [filepath, onOpenChange, open, threadId]);
const trimmedName = installName.trim();
const unchangedConflict = useMemo(() => {
if (!preview) return false;
return trimmedName === preview.skill_name && !preview.can_install_as_is;
}, [preview, trimmedName]);
async function handleInstall() {
if (!filepath || !trimmedName || installing) return;
setInstalling(true);
try {
const result = await installSkill({
thread_id: threadId,
path: filepath,
skill_name: trimmedName,
});
if (!result.success) {
toast.error(result.message || "Failed to install skill");
return;
}
toast.success(result.message);
onInstalled?.(result.skill_name);
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to install skill");
} finally {
setInstalling(false);
}
}
const helperText = preview?.owned_by_other
? `该技能名已被 ${preview.owner_username || "其他用户"} 安装。你可以换个名字继续安装。`
: preview?.owned_by_me
? "你已经安装过这个技能了;如果想保留一份新副本,可以换个名字安装。"
: preview?.builtin_conflict
? "这个名字已被公共技能占用,请换个名字安装。"
: "可以直接安装,也可以先改一个更适合自己的名字。";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>安装技能</DialogTitle>
<DialogDescription>安装前会校验技能归属;如果名字已被占用,可以改名后继续安装。</DialogDescription>
</DialogHeader>
{loading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在检查技能安装权限…
</div>
) : preview ? (
<div className="space-y-4">
<div className="rounded-xl border bg-muted/35 p-3 text-sm">
<div className="font-medium">{preview.skill_name}</div>
{preview.description ? (
<div className="text-muted-foreground mt-1 line-clamp-3">{preview.description}</div>
) : null}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">安装名称</div>
<Input
value={installName}
onChange={(e) => setInstallName(e.currentTarget.value)}
placeholder="请输入技能名称"
/>
<div className="text-muted-foreground text-xs">{helperText}</div>
</div>
{!preview.can_install_as_is && (
<div className="flex gap-2 rounded-xl border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<AlertCircleIcon className="mt-0.5 size-4 shrink-0" />
<div>{preview.message}</div>
</div>
)}
{preview.warnings.length > 0 && (
<div className="text-muted-foreground rounded-xl border px-3 py-2 text-xs">
{preview.warnings.join(" ")}
</div>
)}
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={installing}>
取消
</Button>
<Button
onClick={() => void handleInstall()}
disabled={loading || installing || !trimmedName || unchangedConflict}
>
{installing ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PackageIcon className="size-4" />
)}
安装
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}