|
|
@@ -1,11 +1,25 @@
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
|
+import { open, save } from "@tauri-apps/plugin-dialog"
|
|
|
+import { readFile, writeFile } from "@/commands/fs"
|
|
|
import { useWikiStore } from "@/stores/wiki-store"
|
|
|
import {
|
|
|
+ createBlankProjectDeAiSkill,
|
|
|
getAllDeAiSkills,
|
|
|
loadDeAiSkillConfig,
|
|
|
+ normalizeDeAiSkillConfig,
|
|
|
+ saveDeAiSkillConfig,
|
|
|
type DeAiSkill,
|
|
|
} from "@/lib/novel/de-ai-skill-library"
|
|
|
-import { loadUserSkillConfig } from "@/lib/novel/user-skill-store"
|
|
|
+import {
|
|
|
+ createBlankWritingSkill,
|
|
|
+ exportSkillToJson,
|
|
|
+ importLinkedSkill,
|
|
|
+ importSkillFromJson,
|
|
|
+ importWritingSkill,
|
|
|
+ loadUserSkillConfig,
|
|
|
+ normalizeUserSkillConfig,
|
|
|
+ saveUserSkillConfig,
|
|
|
+} from "@/lib/novel/user-skill-store"
|
|
|
import type { SkillKind, UserSkill } from "@/lib/novel/skill-library"
|
|
|
import { SkillLibraryView } from "./skill-library-view"
|
|
|
import { WritingSkillLibraryView } from "./writing-skill-library-view"
|
|
|
@@ -60,28 +74,315 @@ function writingSkillToEntry(skill: UserSkill): UnifiedSkillEntry {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function SkillLibraryTabs({ compact = false }: { compact?: boolean }) {
|
|
|
+function fileBaseName(path: string): string {
|
|
|
+ return path.split(/[\\/]/).pop() || "未命名 Skill"
|
|
|
+}
|
|
|
+
|
|
|
+function stripSkillFileExtension(name: string): string {
|
|
|
+ return name.replace(/\.(json|md|txt)$/i, "")
|
|
|
+}
|
|
|
+
|
|
|
+function parseSkillFrontmatter(content: string): { name?: string; description?: string; body: string } {
|
|
|
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/)
|
|
|
+ if (!match) return { body: content }
|
|
|
+ const yamlBlock = match[1]
|
|
|
+ const nameMatch = yamlBlock.match(/^name:\s*(.+?)\s*$/m)
|
|
|
+ const descMatch = yamlBlock.match(/^description:\s*(.+?)\s*$/m)
|
|
|
+ return {
|
|
|
+ name: nameMatch ? nameMatch[1].trim() : undefined,
|
|
|
+ description: descMatch ? descMatch[1].trim() : undefined,
|
|
|
+ body: content.slice(match[0].length),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function importedDeAiSkillFromContent(path: string, content: string): DeAiSkill | null {
|
|
|
+ const now = Date.now()
|
|
|
+ const nameFromPath = stripSkillFileExtension(fileBaseName(path))
|
|
|
+ if (/\.json$/i.test(path)) {
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(content)
|
|
|
+ if (!parsed || typeof parsed !== "object") return null
|
|
|
+ const raw = parsed as Record<string, unknown>
|
|
|
+ if (typeof raw.name !== "string" || !raw.name.trim()) return null
|
|
|
+ if (typeof raw.content !== "string" || !raw.content.trim()) return null
|
|
|
+ return {
|
|
|
+ id: `project:${now}`,
|
|
|
+ name: raw.name.trim(),
|
|
|
+ description: typeof raw.description === "string" ? raw.description.trim() : "",
|
|
|
+ templateId: typeof raw.templateId === "string" ? raw.templateId : "custom",
|
|
|
+ content: raw.content.trim(),
|
|
|
+ source: "project",
|
|
|
+ createdAt: now,
|
|
|
+ updatedAt: now,
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const parsed = parseSkillFrontmatter(content)
|
|
|
+ const body = parsed.body.trim()
|
|
|
+ if (!body) return null
|
|
|
+ return {
|
|
|
+ id: `project:${now}`,
|
|
|
+ name: parsed.name || nameFromPath || "未命名去AI味 Skill",
|
|
|
+ description: parsed.description || "",
|
|
|
+ templateId: "custom",
|
|
|
+ content: body,
|
|
|
+ source: "project",
|
|
|
+ createdAt: now,
|
|
|
+ updatedAt: now,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function SkillLibraryHeader({ compact = false }: { compact?: boolean }) {
|
|
|
const activeView = useWikiStore((s) => s.activeView)
|
|
|
const setActiveView = useWikiStore((s) => s.setActiveView)
|
|
|
const activeTab = activeView === "writingSkillLibrary" ? "writingSkillLibrary" : "skillLibrary"
|
|
|
|
|
|
return (
|
|
|
- <div className={`flex shrink-0 items-center gap-1 border-b ${compact ? "px-2 py-2" : "px-4 py-3"}`}>
|
|
|
- {skillLibraryTabs.map((tab) => (
|
|
|
- <button
|
|
|
- key={tab.view}
|
|
|
- type="button"
|
|
|
- aria-pressed={activeTab === tab.view}
|
|
|
- onClick={() => setActiveView(tab.view)}
|
|
|
- className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
|
|
- activeTab === tab.view
|
|
|
- ? "bg-primary text-primary-foreground"
|
|
|
- : "text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
|
- }`}
|
|
|
- >
|
|
|
- {tab.label}
|
|
|
- </button>
|
|
|
- ))}
|
|
|
+ <div className={`flex shrink-0 flex-wrap items-center justify-between gap-2 border-b ${compact ? "px-2 py-2" : "px-4 py-3"}`}>
|
|
|
+ <div className="flex items-center gap-1">
|
|
|
+ {skillLibraryTabs.map((tab) => (
|
|
|
+ <button
|
|
|
+ key={tab.view}
|
|
|
+ type="button"
|
|
|
+ aria-pressed={activeTab === tab.view}
|
|
|
+ onClick={() => setActiveView(tab.view)}
|
|
|
+ className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
|
|
+ activeTab === tab.view
|
|
|
+ ? "bg-primary text-primary-foreground"
|
|
|
+ : "text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {tab.label}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ <SkillLibraryHeaderActions activeTab={activeTab} />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function SkillLibraryHeaderActions({ activeTab }: { activeTab: "skillLibrary" | "writingSkillLibrary" }) {
|
|
|
+ const project = useWikiStore((s) => s.project)
|
|
|
+ const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
|
|
|
+ const setActiveView = useWikiStore((s) => s.setActiveView)
|
|
|
+ const selectedWritingSkillId = useWikiStore((s) => s.selectedWritingSkillLibrarySkillId)
|
|
|
+ const setSelectedSkillId = useWikiStore((s) => s.setSelectedSkillLibrarySkillId)
|
|
|
+ const setSelectedWritingSkillId = useWikiStore((s) => s.setSelectedWritingSkillLibrarySkillId)
|
|
|
+ const [message, setMessage] = useState("")
|
|
|
+ const [saving, setSaving] = useState(false)
|
|
|
+
|
|
|
+ async function persistWritingConfig(nextConfig: ReturnType<typeof normalizeUserSkillConfig>, nextSkillId: string | null) {
|
|
|
+ if (!project || saving) return
|
|
|
+ setSaving(true)
|
|
|
+ try {
|
|
|
+ await saveUserSkillConfig(project.path, nextConfig)
|
|
|
+ if (nextSkillId) setSelectedWritingSkillId(nextSkillId)
|
|
|
+ setActiveView("writingSkillLibrary")
|
|
|
+ bumpDataVersion()
|
|
|
+ setMessage("写作 Skill 已保存")
|
|
|
+ } catch {
|
|
|
+ setMessage("写作 Skill 保存失败")
|
|
|
+ } finally {
|
|
|
+ setSaving(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function persistDeAiConfig(nextConfig: ReturnType<typeof normalizeDeAiSkillConfig>, nextSkillId: string) {
|
|
|
+ if (!project || saving) return
|
|
|
+ setSaving(true)
|
|
|
+ try {
|
|
|
+ await saveDeAiSkillConfig(project.path, nextConfig)
|
|
|
+ setSelectedSkillId(nextSkillId)
|
|
|
+ setActiveView("skillLibrary")
|
|
|
+ bumpDataVersion()
|
|
|
+ setMessage("去AI味技能已保存")
|
|
|
+ } catch {
|
|
|
+ setMessage("去AI味技能保存失败")
|
|
|
+ } finally {
|
|
|
+ setSaving(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleCreateDeAiSkill() {
|
|
|
+ if (!project || saving) return
|
|
|
+ const config = await loadDeAiSkillConfig(project.path)
|
|
|
+ const now = Date.now()
|
|
|
+ const next = createBlankProjectDeAiSkill(config, now)
|
|
|
+ await persistDeAiConfig(next, `project:${now}`)
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleImportDeAiSkillFile() {
|
|
|
+ if (!project || saving) return
|
|
|
+ try {
|
|
|
+ const selected = await open({
|
|
|
+ multiple: false,
|
|
|
+ filters: [{ name: "去AI味 Skill 文件", extensions: ["json", "md", "txt"] }],
|
|
|
+ })
|
|
|
+ if (!selected || typeof selected !== "string") return
|
|
|
+ const content = await readFile(selected)
|
|
|
+ const imported = importedDeAiSkillFromContent(selected, content)
|
|
|
+ if (!imported) {
|
|
|
+ setMessage("导入失败:文件内容不是有效的去AI味 Skill")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const config = await loadDeAiSkillConfig(project.path)
|
|
|
+ const next = normalizeDeAiSkillConfig({
|
|
|
+ ...config,
|
|
|
+ defaultSkillId: imported.id,
|
|
|
+ projectSkills: [imported, ...config.projectSkills],
|
|
|
+ })
|
|
|
+ await persistDeAiConfig(next, imported.id)
|
|
|
+ } catch {
|
|
|
+ setMessage("导入去AI味 Skill 失败")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleImportDeAiSkillFolder() {
|
|
|
+ if (!project || saving) return
|
|
|
+ try {
|
|
|
+ const selected = await open({ multiple: false, directory: true })
|
|
|
+ if (!selected || typeof selected !== "string") return
|
|
|
+ const skillPath = `${selected.replace(/[\\/]+$/, "")}/SKILL.md`
|
|
|
+ const content = await readFile(skillPath)
|
|
|
+ const imported = importedDeAiSkillFromContent(skillPath, content)
|
|
|
+ if (!imported) {
|
|
|
+ setMessage("导入失败:文件夹中未找到有效的 SKILL.md")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const config = await loadDeAiSkillConfig(project.path)
|
|
|
+ const next = normalizeDeAiSkillConfig({
|
|
|
+ ...config,
|
|
|
+ defaultSkillId: imported.id,
|
|
|
+ projectSkills: [imported, ...config.projectSkills],
|
|
|
+ })
|
|
|
+ await persistDeAiConfig(next, imported.id)
|
|
|
+ } catch {
|
|
|
+ setMessage("导入失败:文件夹中未找到有效的 SKILL.md")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleCreateWritingSkill() {
|
|
|
+ if (!project || saving) return
|
|
|
+ const config = await loadUserSkillConfig(project.path)
|
|
|
+ const next = createBlankWritingSkill(config)
|
|
|
+ await persistWritingConfig(next, next.selectedSkillId)
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleImportWritingSkill() {
|
|
|
+ if (!project || saving) return
|
|
|
+ try {
|
|
|
+ const selected = await open({
|
|
|
+ multiple: false,
|
|
|
+ filters: [{ name: "Skill 文件", extensions: ["json", "md", "txt"] }],
|
|
|
+ })
|
|
|
+ if (!selected || typeof selected !== "string") return
|
|
|
+ const content = await readFile(selected)
|
|
|
+ const fileName = fileBaseName(selected)
|
|
|
+ const config = await loadUserSkillConfig(project.path)
|
|
|
+ const next = /\.json$/i.test(fileName)
|
|
|
+ ? (() => {
|
|
|
+ const imported = importSkillFromJson(content)
|
|
|
+ if (!imported) return null
|
|
|
+ return normalizeUserSkillConfig({
|
|
|
+ ...config,
|
|
|
+ selectedSkillId: imported.id,
|
|
|
+ skills: [imported, ...config.skills],
|
|
|
+ })
|
|
|
+ })()
|
|
|
+ : importWritingSkill(config, {
|
|
|
+ name: fileName.replace(/\.(md|txt)$/i, ""),
|
|
|
+ content,
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!next) {
|
|
|
+ setMessage("JSON 文件格式不正确,导入失败")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await persistWritingConfig(next, next.selectedSkillId)
|
|
|
+ } catch {
|
|
|
+ setMessage("导入 Skill 失败")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleImportWritingSkillFolder() {
|
|
|
+ if (!project || saving) return
|
|
|
+ try {
|
|
|
+ const selected = await open({ multiple: false, directory: true })
|
|
|
+ if (!selected || typeof selected !== "string") return
|
|
|
+ const config = await loadUserSkillConfig(project.path)
|
|
|
+ const next = await importLinkedSkill(config, selected)
|
|
|
+ if (!next.selectedSkillId) {
|
|
|
+ setMessage("导入失败:文件夹中未找到有效的 Skill 文件")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ await persistWritingConfig(next, next.selectedSkillId)
|
|
|
+ } catch {
|
|
|
+ setMessage("导入失败:文件夹中未找到有效的 Skill 文件")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleExportCurrentWritingSkill() {
|
|
|
+ if (!project || saving) return
|
|
|
+ try {
|
|
|
+ const config = await loadUserSkillConfig(project.path)
|
|
|
+ const selected = selectedWritingSkillId
|
|
|
+ ? config.skills.find((skill) => skill.id === selectedWritingSkillId)
|
|
|
+ : config.skills[0]
|
|
|
+ if (!selected) {
|
|
|
+ setMessage("请先选择写作 Skill")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const filePath = await save({
|
|
|
+ defaultPath: `${selected.name}.json`,
|
|
|
+ filters: [{ name: "JSON 文件", extensions: ["json"] }],
|
|
|
+ })
|
|
|
+ if (!filePath) return
|
|
|
+ await writeFile(filePath, exportSkillToJson(selected))
|
|
|
+ setMessage("导出成功")
|
|
|
+ } catch {
|
|
|
+ setMessage("导出 Skill 失败")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const buttonClass = "rounded-md border px-2.5 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
|
|
+ const disabled = !project || saving
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div data-testid="skill-library-header-actions" className="flex flex-wrap items-center justify-end gap-2">
|
|
|
+ {activeTab === "skillLibrary" ? (
|
|
|
+ <>
|
|
|
+ <button type="button" onClick={() => void handleCreateDeAiSkill()} disabled={disabled} className={buttonClass}>
|
|
|
+ 新建技能
|
|
|
+ </button>
|
|
|
+ <span className="text-xs text-muted-foreground">导入技能</span>
|
|
|
+ <button type="button" onClick={() => void handleImportDeAiSkillFile()} disabled={disabled} className={buttonClass}>
|
|
|
+ 导入文件
|
|
|
+ </button>
|
|
|
+ <button type="button" onClick={() => void handleImportDeAiSkillFolder()} disabled={disabled} className={buttonClass}>
|
|
|
+ 导入文件夹
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <button type="button" onClick={() => void handleCreateWritingSkill()} disabled={disabled} className={buttonClass}>
|
|
|
+ 新建 Skill
|
|
|
+ </button>
|
|
|
+ <span className="text-xs text-muted-foreground">导入 Skill</span>
|
|
|
+ <button type="button" onClick={() => void handleImportWritingSkill()} disabled={disabled} className={buttonClass}>
|
|
|
+ 导入文件
|
|
|
+ </button>
|
|
|
+ <button type="button" onClick={() => void handleImportWritingSkillFolder()} disabled={disabled} className={buttonClass}>
|
|
|
+ 导入文件夹
|
|
|
+ </button>
|
|
|
+ <button type="button" onClick={() => void handleExportCurrentWritingSkill()} disabled={disabled} className={buttonClass}>
|
|
|
+ 导出当前
|
|
|
+ </button>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {message ? <span className="text-xs text-muted-foreground">{message}</span> : null}
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
@@ -92,7 +393,7 @@ export function UnifiedSkillLibraryView() {
|
|
|
|
|
|
return (
|
|
|
<div data-testid="unified-skill-library-view" className="flex h-full flex-col overflow-hidden">
|
|
|
- <SkillLibraryTabs />
|
|
|
+ <SkillLibraryHeader />
|
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
|
{showWritingSkill ? <WritingSkillLibraryView /> : <SkillLibraryView />}
|
|
|
</div>
|