ji
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user