Quellcode durchsuchen

feat: @引用弹窗技能库增加写作Skill分类及详细信息展示

- 技能库分类下新增去AI味技能和写作Skill两个子分类标签
- 写作Skill项展示类型、适用阶段、适用模式三类标签
- 类型标签实线边框、阶段标签虚线边框、模式标签填充背景
- AI大纲面板@弹窗增加技能库,与会话面板保持一致
- AI大纲面板加载去AI味技能和写作技能配置
Mochocyang vor 2 Monaten
Ursprung
Commit
6834de241e

+ 18 - 5
src/components/chat/chat-panel.tsx

@@ -875,11 +875,24 @@ export function ChatPanel() {
       memoryProvider,
       outlineProvider,
       deductionProvider,
-      createSkillProvider(() =>
-        agentSkillConfig
-          ? resolveAvailableDeAiSkills(agentSkillConfig).map((skill) => ({ id: skill.id, name: skill.name }))
-          : [],
-      ),
+      createSkillProvider(() => {
+        const deAiSkills = agentSkillConfig
+          ? resolveAvailableDeAiSkills(agentSkillConfig).map((skill) => ({
+              id: skill.id,
+              name: skill.name,
+              subtype: "deai" as const,
+            }))
+          : []
+        const writingSkills = agentUserWritingSkills.map((skill) => ({
+          id: skill.id,
+          name: skill.name,
+          subtype: "writing" as const,
+          kind: skill.kinds,
+          stages: skill.stages,
+          modes: skill.modes,
+        }))
+        return [...deAiSkills, ...writingSkills]
+      }),
       createChatHistoryProvider(() =>
         conversations.map((conversation) => ({ id: conversation.id, title: conversation.title })),
       ),

+ 73 - 7
src/components/reference/ReferencePickerDialog.tsx

@@ -1,8 +1,18 @@
-import { useEffect, useMemo, useState } from "react"
+import { useEffect, useMemo, useState } from "react"
 import { Search, X } from "lucide-react"
-import type { ReferenceCategory, ReferenceToken } from "@/lib/reference/types"
+import type { ReferenceCategory, ReferenceToken, SkillSubtype } from "@/lib/reference/types"
 import { MAX_REFERENCE_COUNT, REFERENCE_TABS } from "@/lib/reference/types"
 import type { ReferenceProvider } from "@/lib/reference/providers"
+import {
+  SKILL_KIND_LABELS,
+  SKILL_STAGE_LABELS,
+  SKILL_MODE_LABELS,
+} from "@/lib/novel/skill-library"
+
+const SKILL_SUBTABS: Array<{ key: SkillSubtype; label: string }> = [
+  { key: "deai", label: "去AI味技能" },
+  { key: "writing", label: "写作Skill" },
+]
 
 interface ReferencePickerDialogProps {
   open: boolean
@@ -22,6 +32,7 @@ export function ReferencePickerDialog({
   defaultTab = "chapter",
 }: ReferencePickerDialogProps) {
   const [activeTab, setActiveTab] = useState<ReferenceCategory>(defaultTab)
+  const [skillSubtab, setSkillSubtab] = useState<SkillSubtype>("deai")
   const [items, setItems] = useState<ReferenceToken[]>([])
   const [selected, setSelected] = useState<ReferenceToken[]>([])
   const [search, setSearch] = useState("")
@@ -30,6 +41,7 @@ export function ReferencePickerDialog({
   useEffect(() => {
     if (!open) return
     setActiveTab(defaultTab)
+    setSkillSubtab("deai")
     setSelected([])
     setSearch("")
   }, [open, defaultTab])
@@ -69,9 +81,13 @@ export function ReferencePickerDialog({
 
   const filteredItems = useMemo(() => {
     const keyword = search.trim().toLowerCase()
-    if (!keyword) return items
-    return items.filter((item) => item.title.toLowerCase().includes(keyword))
-  }, [items, search])
+    let result = items
+    if (activeTab === "skill") {
+      result = result.filter((item) => item.skillSubtype === skillSubtab)
+    }
+    if (!keyword) return result
+    return result.filter((item) => item.title.toLowerCase().includes(keyword))
+  }, [items, search, activeTab, skillSubtab])
 
   function toggleItem(item: ReferenceToken) {
     setSelected((prev) => {
@@ -134,6 +150,27 @@ export function ReferencePickerDialog({
           </div>
 
           <div className="flex min-w-0 flex-1 flex-col">
+            {activeTab === "skill" && (
+              <div className="flex gap-1 border-b px-3 py-2">
+                {SKILL_SUBTABS.map((subtab) => (
+                  <button
+                    key={subtab.key}
+                    type="button"
+                    className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
+                      skillSubtab === subtab.key
+                        ? "bg-primary text-primary-foreground"
+                        : "text-muted-foreground hover:bg-accent hover:text-foreground"
+                    }`}
+                    onClick={() => {
+                      setSkillSubtab(subtab.key)
+                      setSearch("")
+                    }}
+                  >
+                    {subtab.label}
+                  </button>
+                ))}
+              </div>
+            )}
             <div className="border-b px-3 py-2">
               <label className="relative block">
                 <Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@@ -158,10 +195,11 @@ export function ReferencePickerDialog({
                 <div className="grid gap-1">
                   {filteredItems.map((item) => {
                     const isSelected = selected.some((selectedItem) => selectedItem.id === item.id)
+                    const hasSkillMeta = item.category === "skill" && (item.skillKinds?.length || item.skillStages?.length || item.skillModes?.length)
                     return (
                       <label
                         key={item.id}
-                        className={`flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2 text-sm transition-colors ${
+                        className={`flex cursor-pointer items-start gap-3 rounded-md border px-3 py-2 text-sm transition-colors ${
                           isSelected
                             ? "border-primary/40 bg-primary/10"
                             : "border-transparent hover:border-border hover:bg-accent/60"
@@ -171,13 +209,41 @@ export function ReferencePickerDialog({
                           type="checkbox"
                           checked={isSelected}
                           onChange={() => toggleItem(item)}
-                          className="h-4 w-4 accent-primary"
+                          className="mt-0.5 h-4 w-4 accent-primary"
                         />
                         <span className="min-w-0 flex-1">
                           <span className="block truncate font-medium">{item.displayTitle || item.title}</span>
                           {item.path ? (
                             <span className="mt-0.5 block truncate text-xs text-muted-foreground">{item.path}</span>
                           ) : null}
+                          {hasSkillMeta ? (
+                            <span className="mt-1.5 flex flex-wrap gap-1">
+                              {item.skillKinds?.map((kind) => (
+                                <span
+                                  key={kind}
+                                  className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
+                                >
+                                  {SKILL_KIND_LABELS[kind]}
+                                </span>
+                              ))}
+                              {item.skillStages?.map((stage) => (
+                                <span
+                                  key={stage}
+                                  className="inline-flex items-center rounded border border-dashed px-1.5 py-0.5 text-[10px] text-muted-foreground"
+                                >
+                                  {SKILL_STAGE_LABELS[stage]}
+                                </span>
+                              ))}
+                              {item.skillModes?.map((mode) => (
+                                <span
+                                  key={mode}
+                                  className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
+                                >
+                                  {SKILL_MODE_LABELS[mode]}
+                                </span>
+                              ))}
+                            </span>
+                          ) : null}
                         </span>
                       </label>
                     )

+ 50 - 1
src/components/sources/outline-chat-panel.tsx

@@ -58,6 +58,7 @@ import {
   chapterProvider,
   createChatHistoryProvider,
   createOutlineHistoryProvider,
+  createSkillProvider,
   deductionProvider,
   memoryProvider,
   outlineProvider,
@@ -74,8 +75,14 @@ import {
 } from "@/lib/agent/tool-events";
 import {
   loadDeAiSkillConfig,
+  resolveAvailableDeAiSkills,
   type DeAiSkillConfig,
 } from "@/lib/novel/de-ai-skill-library";
+import {
+  loadUserSkillConfig,
+  resolveEnabledWritingSkills,
+  type UserSkill,
+} from "@/lib/novel/user-skill-store";
 import { readSoulDoc } from "@/lib/novel/soul-doc";
 import {
   buildWebResearchContext,
@@ -674,6 +681,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const historyRef = useRef<HTMLDivElement | null>(null);
   const historyButtonRef = useRef<HTMLButtonElement | null>(null);
   const [historyDropdownStyle, setHistoryDropdownStyle] = useState<CSSProperties | null>(null);
+  const [deAiSkillConfig, setDeAiSkillConfig] = useState<DeAiSkillConfig | null>(null);
+  const [writingSkills, setWritingSkills] = useState<UserSkill[]>([]);
 
   const referenceProviders = useMemo(
     () => [
@@ -681,6 +690,24 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       memoryProvider,
       outlineProvider,
       deductionProvider,
+      createSkillProvider(() => {
+        const deAiSkills = deAiSkillConfig
+          ? resolveAvailableDeAiSkills(deAiSkillConfig).map((skill) => ({
+              id: skill.id,
+              name: skill.name,
+              subtype: "deai" as const,
+            }))
+          : []
+        const writingSkillList = writingSkills.map((skill) => ({
+          id: skill.id,
+          name: skill.name,
+          subtype: "writing" as const,
+          kind: skill.kinds,
+          stages: skill.stages,
+          modes: skill.modes,
+        }))
+        return [...deAiSkills, ...writingSkillList]
+      }),
       createChatHistoryProvider(() =>
         chatConversations.map((conversation) => ({
           id: conversation.id,
@@ -694,7 +721,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         })),
       ),
     ],
-    [chatConversations, conversations],
+    [chatConversations, conversations, deAiSkillConfig, writingSkills],
   );
 
   // 加载持久化的历史记录
@@ -704,6 +731,28 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     }
   }, [loaded, loadFromDisk]);
 
+  // 加载技能配置
+  useEffect(() => {
+    if (!project?.path) {
+      setDeAiSkillConfig(null);
+      setWritingSkills([]);
+      return;
+    }
+    let cancelled = false;
+    void (async () => {
+      const [deAiConfig, userSkillConfig] = await Promise.all([
+        loadDeAiSkillConfig(project.path).catch((): DeAiSkillConfig | null => null),
+        loadUserSkillConfig(project.path).catch(() => null),
+      ]);
+      if (cancelled) return;
+      setDeAiSkillConfig(deAiConfig);
+      setWritingSkills(userSkillConfig ? resolveEnabledWritingSkills(userSkillConfig) : []);
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, [project?.path]);
+
   // 当前会话切换或持久化 modelId 变化时,同步本地选择状态
   useEffect(() => {
     setLocalModelId(activeConv?.modelId ?? "");

+ 3 - 1
src/lib/reference/providers.ts

@@ -1,5 +1,5 @@
 import { listDirectory, readFile } from "@/commands/fs"
-import type { ReferenceCategory, ReferenceToken } from "./types"
+import type { ReferenceCategory, ReferenceToken, SkillSubtype } from "./types"
 import type { SkillKind, SkillMode, SkillStage } from "@/lib/novel/skill-library"
 
 type ReferenceFileNode = {
@@ -17,6 +17,7 @@ export interface ReferenceProvider {
 interface SkillSummary {
   id: string
   name: string
+  subtype: SkillSubtype
   kind?: SkillKind[]
   stages?: SkillStage[]
   modes?: SkillMode[]
@@ -276,6 +277,7 @@ export function createSkillProvider(getSkills: () => SkillSummary[]): ReferenceP
         category: "skill" as const,
         title: skill.name,
         skillId: skill.id,
+        skillSubtype: skill.subtype,
         skillKinds: skill.kind,
         skillStages: skill.stages,
         skillModes: skill.modes,

+ 4 - 0
src/lib/reference/types.ts

@@ -1,5 +1,7 @@
 import type { SkillKind, SkillMode, SkillStage } from "@/lib/novel/skill-library"
 
+export type SkillSubtype = "deai" | "writing"
+
 export type ReferenceCategory =
   | "chapter"
   | "memory"
@@ -17,6 +19,8 @@ export interface ReferenceToken {
   path?: string
   /** 技能时存 skillId */
   skillId?: string
+  /** 技能子类型:去AI味 / 写作 */
+  skillSubtype?: SkillSubtype
   /** 技能类型、阶段和适用模式 */
   skillKinds?: SkillKind[]
   skillStages?: SkillStage[]