study / web /src /app /chat /components /skill-document-browser.tsx
xiaolongmr
1
6daa87a
Raw History Blame Contribute Delete
3.26 kB
"use client";
import { useEffect, useMemo, useState } from "react";
import { FileText, LoaderCircle } from "lucide-react";
import { AssistantMarkdown } from "@/components/chat/assistant-markdown";
import { readSkillResource, type SkillRuntime } from "@/lib/api";
import { cn } from "@/lib/utils";
const DOCUMENT_EXTENSIONS = /\.(?:md|mdx|txt|json|jsonc|ya?ml|toml|xml|html?|css|csv|tsv|ini|cfg|conf|py|js|jsx|ts|tsx|sh|ps1)$/i;
function documentPaths(runtime: SkillRuntime) {
return runtime.resources
.filter((resource) => resource.readable && DOCUMENT_EXTENSIONS.test(resource.path))
.map((resource) => resource.path)
.sort((left, right) => {
if (left === "SKILL.md") return -1;
if (right === "SKILL.md") return 1;
return left.localeCompare(right);
});
}
export function SkillDocumentBrowser({ skillId, runtime }: { skillId: string; runtime: SkillRuntime }) {
const paths = useMemo(() => documentPaths(runtime), [runtime]);
const [activePath, setActivePath] = useState(paths[0] || "");
const [documents, setDocuments] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
setActivePath(paths[0] || "");
setDocuments({});
setError("");
}, [skillId, paths]);
useEffect(() => {
if (!activePath || documents[activePath]) return;
let cancelled = false;
setLoading(true);
setError("");
void readSkillResource(skillId, activePath)
.then((resource) => {
if (!cancelled) setDocuments((current) => ({ ...current, [activePath]: resource.content }));
})
.catch((reason) => {
if (!cancelled) setError(reason instanceof Error ? reason.message : "读取文档失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [activePath, documents, skillId]);
if (!paths.length) return <p className="text-xs text-stone-500">这个 Skill 没有可展示的文本文件。</p>;
return (
<div className="grid min-h-72 overflow-hidden rounded-xl border border-stone-200 bg-white sm:grid-cols-[220px_minmax(0,1fr)]">
<nav className="max-h-80 overflow-y-auto border-b border-stone-200 bg-stone-50 p-2 sm:border-b-0 sm:border-r" aria-label="Skill 文档列表">
{paths.map((path) => (
<button key={path} type="button" onClick={() => setActivePath(path)} className={cn("flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs", activePath === path ? "bg-white font-medium text-blue-700 shadow-sm" : "text-stone-600 hover:bg-white")}>
<FileText className="size-3.5 shrink-0" /><span className="truncate" title={path}>{path}</span>
</button>
))}
</nav>
<div className="max-h-80 min-w-0 overflow-auto p-4">
{loading && !documents[activePath] ? <div className="flex items-center gap-2 text-sm text-stone-500"><LoaderCircle className="size-4 animate-spin" />正在读取 {activePath}</div> : null}
{error ? <div className="text-sm text-red-600">{error}</div> : null}
{documents[activePath] ? <AssistantMarkdown content={documents[activePath]} /> : null}
</div>
</div>
);
}