Explorar el Código

feat(skill-favorite): 新增技能库收藏功能 + 合并 PR #38 伏笔修复 (v3.0.4)

Mochocyang hace 1 mes
padre
commit
d2305a3cda

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "qmai",
-  "version": "3.0.2",
+  "version": "3.0.3",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "qmai",
-      "version": "3.0.2",
+      "version": "3.0.3",
       "license": "GPL-3.0-or-later",
       "dependencies": {
         "@base-ui/react": "^1.6.0",

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
   "name": "qmai",
   "private": true,
-  "version": "3.0.3",
+  "version": "3.0.4",
   "license": "GPL-3.0-or-later",
   "type": "module",
   "scripts": {

+ 1 - 1
src-tauri/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "qmai"
-version = "3.0.3"
+version = "3.0.4"
 description = "QMAI - AI writing system for long-form novels"
 authors = ["Mochocyang"]
 edition = "2021"

+ 1 - 1
src-tauri/tauri.conf.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://schema.tauri.app/config/2",
   "productName": "QMaiWrite",
-  "version": "3.0.3",
+  "version": "3.0.4",
   "identifier": "com.qingmuai.writer",
   "build": {
     "beforeDevCommand": "npm run dev",

+ 8 - 0
src/App.tsx

@@ -175,6 +175,14 @@ function App() {
     await hydrateScheduledImportAfterOpen(proj)
     if (!isCurrentProject(proj)) return
     await hydrateProjectSideStores(proj)
+    if (!isCurrentProject(proj)) return
+    // v3 新增:加载技能收藏(全局存储,但需 project path 用于 originProjectPath 标记)
+    try {
+      const { useFavoriteSkillStore } = await import("@/stores/favorite-skill-store")
+      await useFavoriteSkillStore.getState().load(proj.path)
+    } catch (err) {
+      console.warn("[startup] 加载技能收藏失败:", err)
+    }
   }
 
   useEffect(() => {

+ 1 - 0
src/components/layout/content-area.tsx

@@ -111,6 +111,7 @@ export function ContentArea() {
         break;
       case "skillLibrary":
       case "writingSkillLibrary":
+      case "skillFavorites":
         content = (
           <Suspense fallback={<LoadingView />}>
             <UnifiedSkillLibraryView />

+ 1 - 1
src/components/layout/sidebar-panel.tsx

@@ -1244,7 +1244,7 @@ export function SidebarPanel() {
     )
   }
 
-  if (activeView === "skillLibrary" || activeView === "writingSkillLibrary") {
+  if (activeView === "skillLibrary" || activeView === "writingSkillLibrary" || activeView === "skillFavorites") {
     return (
       <Suspense fallback={<SidebarPanelLoading />}>
         <UnifiedSkillLibrarySidebarPanel />

+ 181 - 0
src/components/skill-library/favorite-list-view.tsx

@@ -0,0 +1,181 @@
+import { useState } from "react"
+import { Star, Copy, Trash2, Search } from "lucide-react"
+import { useFavoriteSkillStore } from "@/stores/favorite-skill-store"
+import { useWikiStore } from "@/stores/wiki-store"
+import { toast } from "@/lib/toast"
+import type { FavoriteSkillEntry } from "@/lib/novel/skill-favorite"
+
+export function FavoriteListView() {
+  const favorites = useFavoriteSkillStore((s) => s.favorites)
+  const loaded = useFavoriteSkillStore((s) => s.loaded)
+  const removeFavorite = useFavoriteSkillStore((s) => s.removeFavorite)
+  const copyToCurrentProject = useFavoriteSkillStore((s) => s.copyToCurrentProject)
+  const project = useWikiStore((s) => s.project)
+  const [searchQuery, setSearchQuery] = useState("")
+  const [copyingId, setCopyingId] = useState<string | null>(null)
+
+  // 按时间降序排序(最新收藏在前)
+  const sortedFavorites = [...favorites].sort((a, b) => b.favoritedAt - a.favoritedAt)
+
+  // 搜索过滤
+  const filteredFavorites = searchQuery.trim()
+    ? sortedFavorites.filter(
+        (f) =>
+          f.snapshot.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+          f.snapshot.description.toLowerCase().includes(searchQuery.toLowerCase()),
+      )
+    : sortedFavorites
+
+  async function handleCopy(favoriteId: string) {
+    if (copyingId) return
+    setCopyingId(favoriteId)
+    try {
+      const result = await copyToCurrentProject(favoriteId)
+      if (!result.ok && result.reason === "duplicate-name") {
+        toast.error("当前项目已存在同名技能,复制失败")
+      }
+    } finally {
+      setCopyingId(null)
+    }
+  }
+
+  async function handleRemove(favoriteId: string, name: string) {
+    if (!confirm(`确定要删除收藏「${name}」吗?`)) return
+    await removeFavorite(favoriteId)
+    toast.success(`已删除收藏「${name}」`)
+  }
+
+  if (!loaded) {
+    return (
+      <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
+        加载中...
+      </div>
+    )
+  }
+
+  if (favorites.length === 0) {
+    return (
+      <div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
+        <Star className="h-12 w-12 opacity-30" />
+        <p className="text-sm">暂无收藏的技能</p>
+        <p className="text-xs">点击技能卡片上的星标按钮即可收藏</p>
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex h-full flex-col overflow-hidden">
+      {/* 搜索栏 */}
+      <div className="shrink-0 border-b px-4 py-2">
+        <div className="relative">
+          <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+          <input
+            type="text"
+            placeholder="搜索收藏的技能..."
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+            className="w-full rounded-md border bg-background py-1.5 pl-8 pr-3 text-sm outline-none focus:ring-2 focus:ring-ring"
+          />
+        </div>
+      </div>
+
+      {/* 收藏列表 */}
+      <div className="min-h-0 flex-1 overflow-y-auto p-4">
+        <div className="grid gap-3">
+          {filteredFavorites.map((entry) => (
+            <FavoriteCard
+              key={entry.favoriteId}
+              entry={entry}
+              isCurrentProject={!!project && (project.path === entry.originProjectPath || entry.source === "built-in")}
+              hasCurrentProject={!!project}
+              copying={copyingId === entry.favoriteId}
+              onCopy={() => handleCopy(entry.favoriteId)}
+              onRemove={() => handleRemove(entry.favoriteId, entry.snapshot.name)}
+            />
+          ))}
+        </div>
+        {filteredFavorites.length === 0 && (
+          <div className="py-8 text-center text-sm text-muted-foreground">
+            没有匹配的收藏
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+function FavoriteCard({
+  entry,
+  isCurrentProject,
+  hasCurrentProject,
+  copying,
+  onCopy,
+  onRemove,
+}: {
+  entry: FavoriteSkillEntry
+  isCurrentProject: boolean
+  hasCurrentProject: boolean
+  copying: boolean
+  onCopy: () => void
+  onRemove: () => void
+}) {
+  const libraryLabel = entry.library === "writing" ? "写作" : "去AI味"
+  const sourceLabel =
+    entry.source === "built-in"
+      ? "内置"
+      : entry.source === "project"
+      ? "项目"
+      : entry.source === "uploaded"
+      ? "上传"
+      : entry.source === "linked"
+      ? "链接"
+      : "旧版"
+
+  return (
+    <div className="rounded-lg border bg-card p-4">
+      <div className="flex items-start justify-between gap-2">
+        <div className="min-w-0 flex-1">
+          <div className="flex flex-wrap items-center gap-2">
+            <h3 className="truncate font-medium">{entry.snapshot.name}</h3>
+            <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
+              {libraryLabel}
+            </span>
+            <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
+              {sourceLabel}
+            </span>
+          </div>
+          {entry.snapshot.description && (
+            <p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
+              {entry.snapshot.description}
+            </p>
+          )}
+          <p className="mt-2 line-clamp-3 text-xs text-muted-foreground">
+            {entry.snapshot.content}
+          </p>
+          <p className="mt-2 text-xs text-muted-foreground">
+            收藏于 {new Date(entry.favoritedAt).toLocaleString("zh-CN")}
+          </p>
+        </div>
+      </div>
+      <div className="mt-3 flex items-center gap-2">
+        <button
+          type="button"
+          onClick={onCopy}
+          disabled={copying || isCurrentProject || !hasCurrentProject}
+          className="inline-flex items-center gap-1 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
+        >
+          <Copy className="h-3 w-3" />
+          {isCurrentProject ? "已在当前项目" : copying ? "复制中..." : "复制到当前项目"}
+        </button>
+        <button
+          type="button"
+          onClick={onRemove}
+          className="inline-flex items-center gap-1 rounded-md border px-3 py-1 text-xs transition-colors hover:bg-accent"
+        >
+          <Trash2 className="h-3 w-3" />
+          删除
+        </button>
+      </div>
+    </div>
+  )
+}

+ 49 - 6
src/components/skill-library/unified-skill-library-view.tsx

@@ -1,7 +1,9 @@
 import { useEffect, useMemo, useState } from "react"
 import { open } from "@tauri-apps/plugin-dialog"
+import { Star } from "lucide-react"
 import { readFile } from "@/commands/fs"
 import { useWikiStore } from "@/stores/wiki-store"
+import { useFavoriteSkillStore } from "@/stores/favorite-skill-store"
 import {
   createBlankProjectDeAiSkill,
   getAllDeAiSkills,
@@ -21,10 +23,12 @@ import {
 import type { SkillKind, UserSkill } from "@/lib/novel/skill-library"
 import { SkillLibraryView } from "./skill-library-view"
 import { WritingSkillLibraryView } from "./writing-skill-library-view"
+import { FavoriteListView } from "./favorite-list-view"
 
 const skillLibraryTabs = [
   { view: "skillLibrary" as const, label: "去AI味技能" },
   { view: "writingSkillLibrary" as const, label: "写作 Skill" },
+  { view: "skillFavorites" as const, label: "收藏" },
 ]
 
 type UnifiedSkillCategory = "all" | "writing" | "de-ai" | SkillKind
@@ -37,6 +41,8 @@ interface UnifiedSkillEntry {
   description: string
   content: string
   kinds: SkillKind[]
+  /** toggleFavorite 需要原始技能对象(v3 新增) */
+  rawSkill: UserSkill | DeAiSkill
 }
 
 const unifiedSkillCategories: { id: UnifiedSkillCategory; label: string }[] = [
@@ -57,6 +63,7 @@ function deAiSkillToEntry(skill: DeAiSkill): UnifiedSkillEntry {
     description: skill.description,
     content: skill.content,
     kinds: ["rewrite", "style"],
+    rawSkill: skill,
   }
 }
 
@@ -69,6 +76,7 @@ function writingSkillToEntry(skill: UserSkill): UnifiedSkillEntry {
     description: skill.description,
     content: skill.content,
     kinds: skill.kind,
+    rawSkill: skill,
   }
 }
 
@@ -136,7 +144,11 @@ function importedDeAiSkillFromContent(path: string, content: string): DeAiSkill
 function SkillLibraryHeader({ compact = false }: { compact?: boolean }) {
   const activeView = useWikiStore((s) => s.activeView)
   const setActiveView = useWikiStore((s) => s.setActiveView)
-  const activeTab = activeView === "writingSkillLibrary" ? "writingSkillLibrary" : "skillLibrary"
+  const activeTab = activeView === "writingSkillLibrary"
+    ? "writingSkillLibrary"
+    : activeView === "skillFavorites"
+    ? "skillFavorites"
+    : "skillLibrary"
 
   return (
     <div className={`flex shrink-0 flex-wrap items-center justify-between gap-2 border-b ${compact ? "px-2 py-2" : "px-4 py-3"}`}>
@@ -162,7 +174,7 @@ function SkillLibraryHeader({ compact = false }: { compact?: boolean }) {
   )
 }
 
-function SkillLibraryHeaderActions({ activeTab }: { activeTab: "skillLibrary" | "writingSkillLibrary" }) {
+function SkillLibraryHeaderActions({ activeTab }: { activeTab: "skillLibrary" | "writingSkillLibrary" | "skillFavorites" }) {
   const project = useWikiStore((s) => s.project)
   const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
   const setActiveView = useWikiStore((s) => s.setActiveView)
@@ -294,7 +306,7 @@ function SkillLibraryHeaderActions({ activeTab }: { activeTab: "skillLibrary" |
             导入
           </button>
         </>
-      ) : (
+      ) : activeTab === "writingSkillLibrary" ? (
         <>
           <button type="button" onClick={() => void handleCreateWritingSkill()} disabled={disabled} className={buttonClass}>
             新建 Skill
@@ -303,7 +315,7 @@ function SkillLibraryHeaderActions({ activeTab }: { activeTab: "skillLibrary" |
             导入
           </button>
         </>
-      )}
+      ) : null}
       {message ? <span className="text-xs text-muted-foreground">{message}</span> : null}
     </div>
   )
@@ -311,13 +323,18 @@ function SkillLibraryHeaderActions({ activeTab }: { activeTab: "skillLibrary" |
 
 export function UnifiedSkillLibraryView() {
   const activeView = useWikiStore((s) => s.activeView)
-  const showWritingSkill = activeView === "writingSkillLibrary"
 
   return (
     <div data-testid="unified-skill-library-view" className="flex h-full flex-col overflow-hidden">
       <SkillLibraryHeader />
       <div className="min-h-0 flex-1 overflow-hidden">
-        {showWritingSkill ? <WritingSkillLibraryView /> : <SkillLibraryView />}
+        {activeView === "writingSkillLibrary" ? (
+          <WritingSkillLibraryView />
+        ) : activeView === "skillFavorites" ? (
+          <FavoriteListView />
+        ) : (
+          <SkillLibraryView />
+        )}
       </div>
     </div>
   )
@@ -331,6 +348,8 @@ export function UnifiedSkillLibrarySidebarPanel() {
   const selectedWritingSkillId = useWikiStore((s) => s.selectedWritingSkillLibrarySkillId)
   const setSelectedSkillId = useWikiStore((s) => s.setSelectedSkillLibrarySkillId)
   const setSelectedWritingSkillId = useWikiStore((s) => s.setSelectedWritingSkillLibrarySkillId)
+  const toggleFavorite = useFavoriteSkillStore((s) => s.toggleFavorite)
+  const isFavorited = useFavoriteSkillStore((s) => s.isFavorited)
   const [entries, setEntries] = useState<UnifiedSkillEntry[]>([])
   const [query, setQuery] = useState("")
   const [category, setCategory] = useState<UnifiedSkillCategory>("all")
@@ -463,6 +482,30 @@ export function UnifiedSkillLibrarySidebarPanel() {
                 <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
                   {entry.type === "writing" ? "写作" : "去AI味"}
                 </span>
+                <button
+                  type="button"
+                  onClick={(e) => {
+                    e.stopPropagation()
+                    const library = entry.type === "writing" ? "writing" : "de-ai"
+                    void toggleFavorite({
+                      library,
+                      skill: entry.rawSkill,
+                      originProjectPath: project?.path,
+                    })
+                  }}
+                  aria-label={isFavorited(entry.type === "writing" ? "writing" : "de-ai", entry.sourceId) ? "取消收藏" : "收藏"}
+                  title={isFavorited(entry.type === "writing" ? "writing" : "de-ai", entry.sourceId) ? "取消收藏" : "收藏"}
+                  className={`shrink-0 rounded p-0.5 transition-colors ${
+                    isFavorited(entry.type === "writing" ? "writing" : "de-ai", entry.sourceId)
+                      ? "text-yellow-500 hover:text-yellow-600"
+                      : "text-muted-foreground hover:text-foreground"
+                  }`}
+                >
+                  <Star
+                    className="h-3.5 w-3.5"
+                    fill={isFavorited(entry.type === "writing" ? "writing" : "de-ai", entry.sourceId) ? "currentColor" : "none"}
+                  />
+                </button>
               </div>
               <div className="mt-1 truncate text-xs text-muted-foreground">
                 {entry.description || "未填写说明"}

+ 33 - 0
src/lib/changelog.ts

@@ -7,6 +7,35 @@ export interface ChangelogEntry {
   };
 }
 
+const THREE_POINT_ZERO_FOUR_CHANGELOG: ChangelogEntry = {
+  version: "3.0.4",
+  date: "2026-08-02",
+  highlights: {
+    en: [
+      "[Skill Favorites] Star any skill in the sidebar to save it to a global favorites list; access them from the new 'Favorites' tab.",
+      "[Cross-Project Copy] Copy any favorited skill to the current project with one click; works across different projects.",
+      "[Global Storage] Favorites are stored globally via Tauri plugin-store; they persist across project switches.",
+      "[Content Snapshot] Favoriting captures a content snapshot; favorites remain intact even if the original skill is modified or deleted.",
+      "[Foreshadowing Fix] Fixed foreshadowing ingestion: full/half-width colon compatibility, name normalization, deduplication.",
+      "[Abandoned State] Foreshadowing now supports 'abandoned' status; new cleanup maintenance tool added in Settings.",
+      "[Chat Model Dropdown] Fixed dropdown clipping in chat model selector; now expands upward when space is insufficient.",
+      "[Thinking Mode Fix] Fixed reasoning_content propagation in multi-turn tool calls for thinking models.",
+      "[Dismantling Library Unstable] The dismantling library is currently unstable; usage is not recommended until stabilized.",
+    ],
+    zh: [
+      "【技能库收藏】侧边栏技能卡片新增星标按钮,一键收藏到全局收藏列表,通过新增的「收藏」Tab 集中查看",
+      "【跨项目复制】收藏列表支持「复制到当前项目」,一键将收藏的技能复制到当前打开的项目,跨项目复用更便捷",
+      "【全局存储】收藏数据通过 Tauri plugin-store 全局存储,切换项目不丢失",
+      "【内容快照】收藏时固化技能内容快照,原技能被修改或删除后收藏不会失效",
+      "【伏笔摄取修复】修复伏笔摄取全角/半角冒号兼容、name 字段归一化、重复伏笔去重问题",
+      "【已放弃状态】伏笔新增「已放弃」状态,设置中新增伏笔清理维护工具",
+      "【聊天模型下拉修复】修复聊天模型选择下拉框被裁切问题,空间不足时自动向上展开",
+      "【思维模式修复】修复思维模式多轮工具调用时 reasoning_content 未正确回传问题",
+      "【拆书库不稳定】拆书库目前不稳定,建议暂不使用,待后续版本修复稳定后恢复",
+    ],
+  },
+};
+
 const THREE_POINT_ZERO_THREE_CHANGELOG: ChangelogEntry = {
   version: "3.0.3",
   date: "2026-08-01",
@@ -953,6 +982,8 @@ export const CHANGELOG: ChangelogEntry[] = [
 ];
 
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
+  if (version === THREE_POINT_ZERO_FOUR_CHANGELOG.version)
+    return [THREE_POINT_ZERO_FOUR_CHANGELOG];
   if (version === THREE_POINT_ZERO_THREE_CHANGELOG.version)
     return [THREE_POINT_ZERO_THREE_CHANGELOG];
   if (version === THREE_POINT_ZERO_ONE_CHANGELOG.version)
@@ -1030,6 +1061,8 @@ export function currentVersionChangelog(version: string): ChangelogEntry[] {
 
 export function allChangelog(): ChangelogEntry[] {
   return [
+    THREE_POINT_ZERO_FOUR_CHANGELOG,
+    THREE_POINT_ZERO_THREE_CHANGELOG,
     THREE_POINT_ZERO_ONE_CHANGELOG,
     THREE_POINT_ZERO_ZERO_CHANGELOG,
     TWO_POINT_TWO_THIRTY_SEVEN_CHANGELOG,

+ 148 - 0
src/lib/novel/skill-favorite.ts

@@ -0,0 +1,148 @@
+import type { SkillKind, SkillStage, SkillMode, SkillCategory, UserSkill } from "@/lib/novel/skill-library"
+import type { DeAiSkill } from "@/lib/novel/de-ai-skill-library"
+import { getStore } from "@/lib/web-store"
+
+export type FavoriteSkillLibrary = "writing" | "de-ai"
+
+export type FavoriteSkillSource = "built-in" | "project" | "uploaded" | "linked" | "legacy"
+
+/**
+ * 收藏快照:收藏时固化技能的展示信息,避免原技能被修改/删除后收藏失效。
+ * kind/stages/modes 对 de-ai 技能硬编码(DeAiSkill 接口无这些字段)。
+ */
+export interface FavoriteSkillSnapshot {
+  name: string
+  description: string
+  content: string
+  kind: SkillKind[]
+  stages: SkillStage[]
+  modes: SkillMode[]
+  category: string
+  /** de-ai 技能的 templateId;writing 为空字符串 */
+  templateId: string
+}
+
+/**
+ * 收藏条目。
+ * - favoriteId:crypto.randomUUID() 生成,唯一标识。
+ * - skillId:来源技能 id(如 "built-in:comprehensive"、"skill:1700000000000")。
+ * - originProjectPath:"" 表示内置/全局;否则为源项目绝对路径。
+ */
+export interface FavoriteSkillEntry {
+  favoriteId: string
+  library: FavoriteSkillLibrary
+  skillId: string
+  originProjectPath: string
+  source: FavoriteSkillSource
+  snapshot: FavoriteSkillSnapshot
+  favoritedAt: number
+}
+
+export interface FavoriteSkillConfig {
+  version: 1
+  favorites: FavoriteSkillEntry[]
+}
+
+export const EMPTY_FAVORITE_CONFIG: FavoriteSkillConfig = {
+  version: 1,
+  favorites: [],
+}
+
+/**
+ * writing 技能快照映射。
+ * category 从 writingCategories 反查 categoryId 对应 name(参考 v5 设计 4.2 节)。
+ */
+export function buildWritingSnapshot(
+  skill: UserSkill,
+  content: string,
+  categories: SkillCategory[],
+): FavoriteSkillSnapshot {
+  const category = categories.find((c) => c.id === skill.categoryId)
+  return {
+    name: skill.name,
+    description: skill.description,
+    content,
+    kind: skill.kind,
+    stages: skill.stages,
+    modes: skill.modes,
+    category: category?.name ?? "",
+    templateId: "",
+  }
+}
+
+/**
+ * de-ai 技能快照映射。
+ * kind/stages/modes 硬编码,参考 deAiSkillToUserSkill:590-606。
+ * kind 用 ["style"](DeAiSkill→UserSkill 标准转换值,v5 设计 E3 已确认)。
+ */
+export function buildDeAiSnapshot(
+  skill: DeAiSkill,
+  content: string,
+): FavoriteSkillSnapshot {
+  return {
+    name: skill.name,
+    description: skill.description,
+    content,
+    kind: ["style"],
+    stages: ["rewrite", "output"],
+    modes: ["fast", "standard", "strict"],
+    category: "去AI味",
+    templateId: skill.templateId,
+  }
+}
+
+const FAVORITE_SKILL_CONFIG_KEY = "favoriteSkills"
+const configSaveQueues = new Map<string, Promise<void>>()
+
+/**
+ * 从全局 web-store 加载收藏配置。
+ * 失败时返回空配置,不抛异常(参考 project-store.ts 模式)。
+ */
+export async function loadFavorites(): Promise<FavoriteSkillConfig> {
+  try {
+    const store = await getStore()
+    const config = await store.get<FavoriteSkillConfig>(FAVORITE_SKILL_CONFIG_KEY)
+    if (!config || typeof config !== "object") return EMPTY_FAVORITE_CONFIG
+    if (!Array.isArray(config.favorites)) return EMPTY_FAVORITE_CONFIG
+    return {
+      version: 1,
+      favorites: config.favorites.filter(isValidFavoriteEntry),
+    }
+  } catch (err) {
+    console.warn("[skill-favorite] 加载收藏配置失败:", err)
+    return EMPTY_FAVORITE_CONFIG
+  }
+}
+
+function isValidFavoriteEntry(value: unknown): value is FavoriteSkillEntry {
+  if (!value || typeof value !== "object") return false
+  const entry = value as Partial<FavoriteSkillEntry>
+  return (
+    typeof entry.favoriteId === "string" &&
+    typeof entry.library === "string" &&
+    typeof entry.skillId === "string" &&
+    typeof entry.originProjectPath === "string" &&
+    typeof entry.source === "string" &&
+    typeof entry.favoritedAt === "number" &&
+    entry.snapshot !== null &&
+    typeof entry.snapshot === "object"
+  )
+}
+
+/**
+ * 保存收藏配置到全局 web-store(串行化写入,防止竞态)。
+ * 参考 de-ai-skill-library.ts:31 的 configSaveQueues 模式。
+ */
+export async function saveFavorites(config: FavoriteSkillConfig): Promise<void> {
+  const key = FAVORITE_SKILL_CONFIG_KEY
+  const previous = configSaveQueues.get(key) ?? Promise.resolve()
+  const next = previous.then(() => persistFavorites(config)).catch(() => persistFavorites(config))
+  configSaveQueues.set(key, next)
+  await next
+}
+
+async function persistFavorites(config: FavoriteSkillConfig): Promise<void> {
+  const store = await getStore()
+  await store.set(FAVORITE_SKILL_CONFIG_KEY, config)
+  await store.save()
+}

+ 8 - 0
src/lib/reset-project-state.ts

@@ -11,6 +11,7 @@
 import { pauseQueue as pauseIngestQueue } from "@/lib/ingest-queue"
 import { useActivityStore } from "@/stores/activity-store"
 import { useChatStore } from "@/stores/chat-store"
+import { useFavoriteSkillStore } from "@/stores/favorite-skill-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { useReviewStore } from "@/stores/review-store"
 
@@ -40,6 +41,13 @@ export function resetProjectStores(): void {
   useActivityStore.setState({
     items: [],
   })
+
+  // v4 R7:重置收藏 store 状态
+  // 只清空 currentProjectPath(退出项目后无当前项目)
+  // favorites 全局数据保留;loaded 保持 true(数据已加载,无需重新加载,退出项目后仍可查看收藏 Tab)
+  useFavoriteSkillStore.setState({
+    currentProjectPath: "",
+  })
 }
 
 export async function resetProjectState(): Promise<void> {

+ 244 - 0
src/stores/favorite-skill-store.ts

@@ -0,0 +1,244 @@
+import { create } from "zustand"
+import { normalizePath } from "@/lib/path-utils"
+import { toast } from "@/lib/toast"
+import { useWikiStore } from "@/stores/wiki-store"
+import {
+  loadUserSkillConfig,
+  saveUserSkillConfig,
+  loadLinkedSkillContent,
+} from "@/lib/novel/user-skill-store"
+import {
+  loadDeAiSkillConfig,
+  saveDeAiSkillConfig,
+  BUILT_IN_DE_AI_SKILLS,
+} from "@/lib/novel/de-ai-skill-library"
+import { DEFAULT_SKILL_PRIORITY, type UserSkill, type SkillCategory } from "@/lib/novel/skill-library"
+import type { DeAiSkill } from "@/lib/novel/de-ai-skill-library"
+import {
+  type FavoriteSkillEntry,
+  type FavoriteSkillLibrary,
+  type FavoriteSkillConfig,
+  type FavoriteSkillSnapshot,
+  type FavoriteSkillSource,
+  loadFavorites,
+  saveFavorites,
+  buildWritingSnapshot,
+  buildDeAiSnapshot,
+} from "@/lib/novel/skill-favorite"
+
+export interface ToggleFavoriteParams {
+  library: FavoriteSkillLibrary
+  skill: UserSkill | DeAiSkill
+  originProjectPath?: string
+  writingCategories?: SkillCategory[]
+}
+
+interface FavoriteSkillState {
+  favorites: FavoriteSkillEntry[]
+  currentProjectPath: string
+  loaded: boolean
+  loading: boolean
+
+  load: (projectPath: string) => Promise<void>
+  toggleFavorite: (params: ToggleFavoriteParams) => Promise<void>
+  removeFavorite: (favoriteId: string) => Promise<void>
+  isFavorited: (library: FavoriteSkillLibrary, skillId: string) => boolean
+  copyToCurrentProject: (favoriteId: string) => Promise<{
+    ok: boolean
+    reason?: "duplicate-name" | "write-failed" | "empty-content" | "no-project"
+  }>
+}
+
+export const useFavoriteSkillStore = create<FavoriteSkillState>((set, get) => ({
+  favorites: [],
+  currentProjectPath: "",
+  loaded: false,
+  loading: false,
+
+  load: async (projectPath) => {
+    set({ loading: true })
+    try {
+      const config = await loadFavorites()
+      // v5 R4:守卫必须用 normalizePath,与 App.tsx isCurrentProject 一致
+      const current = useWikiStore.getState().project
+      if (!current || normalizePath(current.path) !== normalizePath(projectPath)) return
+      set({ favorites: config.favorites, currentProjectPath: projectPath, loaded: true })
+    } catch {
+      toast.error("收藏加载失败")
+    } finally {
+      set({ loading: false })
+    }
+  },
+
+  toggleFavorite: async ({ library, skill, originProjectPath, writingCategories }) => {
+    const currentProjectPath = get().currentProjectPath
+
+    // 1. 固化 content + v4 R3:内置去AI味整体替换为原版对象
+    let content = skill.content
+    let effectiveSkill: UserSkill | DeAiSkill = skill
+
+    try {
+      if (library === "writing" && skill.source === "linked") {
+        // writing 链接技能需读取真实文件内容(文件可能被删/移动,需捕获异常)
+        content = await loadLinkedSkillContent(skill as UserSkill)
+      }
+    } catch (err) {
+      console.error("[favorite] 读取链接技能内容失败:", err)
+      toast.error("读取链接技能文件失败,无法收藏")
+      return
+    }
+
+    if (library === "de-ai" && skill.source === "built-in") {
+      // v4 R3:name/description/content/templateId 全部用 BUILT_IN_DE_AI_SKILLS 原版
+      const original = BUILT_IN_DE_AI_SKILLS.find((s) => s.id === skill.id)
+      if (original) {
+        effectiveSkill = original
+        content = original.content
+      }
+    }
+
+    // 2. v5 N11:用 if 分支结构 + 必要的 as 断言(library 与 skill 是独立参数,TS 无法自动窄化)
+    let snapshot: FavoriteSkillSnapshot
+    if (library === "writing") {
+      snapshot = buildWritingSnapshot(effectiveSkill as UserSkill, content, writingCategories ?? [])
+    } else {
+      snapshot = buildDeAiSnapshot(effectiveSkill as DeAiSkill, content)
+    }
+
+    // 3. 构造 entry
+    const entry: FavoriteSkillEntry = {
+      favoriteId: crypto.randomUUID(),
+      library,
+      skillId: skill.id,
+      originProjectPath: skill.source === "built-in" ? "" : (originProjectPath || currentProjectPath),
+      source: skill.source as FavoriteSkillSource,
+      snapshot,
+      favoritedAt: Date.now(),
+    }
+
+    // 4. 三元组查重:已存在则移除(toggle),否则添加
+    const existingIndex = get().favorites.findIndex(
+      (f) =>
+        f.library === entry.library &&
+        f.skillId === entry.skillId &&
+        f.originProjectPath === entry.originProjectPath,
+    )
+
+    let nextFavorites: FavoriteSkillEntry[]
+    let toastMessage: string
+    if (existingIndex >= 0) {
+      nextFavorites = [...get().favorites]
+      nextFavorites.splice(existingIndex, 1)
+      toastMessage = `已取消收藏「${entry.snapshot.name}」`
+    } else {
+      nextFavorites = [...get().favorites, entry]
+      toastMessage = `已收藏「${entry.snapshot.name}」`
+    }
+
+    // 5. 乐观更新 + 异步写 web-store(串行化)
+    const previousFavorites = get().favorites
+    set({ favorites: nextFavorites })
+    try {
+      const config: FavoriteSkillConfig = { version: 1, favorites: nextFavorites }
+      await saveFavorites(config)
+      toast.success(toastMessage)
+    } catch (err) {
+      console.error("[favorite] 保存失败:", err)
+      set({ favorites: previousFavorites })
+      toast.error("收藏保存失败,请重试")
+    }
+  },
+
+  removeFavorite: async (favoriteId) => {
+    const previousFavorites = get().favorites
+    const nextFavorites = previousFavorites.filter((f) => f.favoriteId !== favoriteId)
+    set({ favorites: nextFavorites })
+    try {
+      const config: FavoriteSkillConfig = { version: 1, favorites: nextFavorites }
+      await saveFavorites(config)
+    } catch (err) {
+      console.error("[favorite] 删除失败:", err)
+      set({ favorites: previousFavorites })
+      toast.error("删除失败,请重试")
+    }
+  },
+
+  // v5 N1:isFavorited 只需 2 参数(id 命名空间已隔离)
+  isFavorited: (library, skillId) => {
+    return get().favorites.some((f) => f.library === library && f.skillId === skillId)
+  },
+
+  copyToCurrentProject: async (favoriteId) => {
+    const { favorites, currentProjectPath } = get()
+    const entry = favorites.find((f) => f.favoriteId === favoriteId)
+    if (!entry) return { ok: false }
+
+    // v5 N9/N13:currentProjectPath 空检查(防御性编程)
+    if (!currentProjectPath) {
+      toast.error("请先打开一个项目再复制")
+      return { ok: false, reason: "no-project" }
+    }
+
+    // v4 R1/R2/R6:content 非空检查,防止复制后被 normalize 静默丢弃
+    if (!entry.snapshot.content.trim()) {
+      toast.error("原技能内容为空,无法复制")
+      return { ok: false, reason: "empty-content" }
+    }
+
+    const now = Date.now()
+
+    // v5 N14:整个写入逻辑用 try/catch 包裹,写失败返回 write-failed
+    try {
+      if (entry.library === "writing") {
+        const config = await loadUserSkillConfig(currentProjectPath)
+        if (config.skills.some((s) => s.name === entry.snapshot.name)) {
+          return { ok: false, reason: "duplicate-name" }
+        }
+        const newSkill: UserSkill = {
+          id: `skill:${now}`,
+          name: entry.snapshot.name,
+          description: entry.snapshot.description,
+          content: entry.snapshot.content,
+          kind: entry.snapshot.kind,
+          stages: entry.snapshot.stages,
+          modes: entry.snapshot.modes,
+          source: "uploaded",
+          priority: DEFAULT_SKILL_PRIORITY,
+          tags: [],
+          categoryId: "",
+        }
+        await saveUserSkillConfig(currentProjectPath, {
+          ...config,
+          skills: [...config.skills, newSkill],
+        })
+      } else {
+        const config = await loadDeAiSkillConfig(currentProjectPath)
+        if (config.projectSkills.some((s) => s.name === entry.snapshot.name)) {
+          return { ok: false, reason: "duplicate-name" }
+        }
+        const newSkill: DeAiSkill = {
+          id: `project:${now}`,
+          name: entry.snapshot.name,
+          description: entry.snapshot.description,
+          content: entry.snapshot.content,
+          source: "project",
+          templateId: entry.snapshot.templateId || "custom",
+          createdAt: now,
+          updatedAt: now,
+        }
+        await saveDeAiSkillConfig(currentProjectPath, {
+          ...config,
+          projectSkills: [...config.projectSkills, newSkill],
+        })
+      }
+    } catch (err) {
+      console.error("[favorite] 复制到当前项目失败:", err)
+      toast.error("复制失败,请重试")
+      return { ok: false, reason: "write-failed" }
+    }
+
+    useWikiStore.getState().bumpDataVersion()
+    toast.info("已复制到当前项目,可在 [写作/去AI味] Tab 查看")
+    return { ok: true }
+  },
+}))

+ 1 - 1
src/stores/wiki-store.ts

@@ -553,7 +553,7 @@ interface WikiState {
   selectedMemoryCenterEntry: string | null
   chatExpanded: boolean
   searchPanelOpen: boolean
-  activeView: "wiki" | "sources" | "search" | "graph" | "lint" | "soul" | "skillLibrary" | "writingSkillLibrary" | "bookAnalysis" | "settings" | "trash" | "reviewCenter" | "storySimulation"
+  activeView: "wiki" | "sources" | "search" | "graph" | "lint" | "soul" | "skillLibrary" | "writingSkillLibrary" | "skillFavorites" | "bookAnalysis" | "settings" | "trash" | "reviewCenter" | "storySimulation"
   activeSettingsCategory: SettingsCategoryId | null
   selectedSoulId: string | null
   selectedSoulTab: "project" | "character"