Selaa lähdekoodia

feat(agent): add agent config hook and model support detection

Mochocyang 2 kuukautta sitten
vanhempi
sitoutus
89c2e27074
4 muutettua tiedostoa jossa 361 lisäystä ja 0 poistoa
  1. 188 0
      src/hooks/use-agent-config.spec.ts
  2. 123 0
      src/hooks/use-agent-config.ts
  3. 48 0
      src/lib/agent/config.ts
  4. 2 0
      src/lib/agent/types.ts

+ 188 - 0
src/hooks/use-agent-config.spec.ts

@@ -0,0 +1,188 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
+import React from "react"
+import { createRoot } from "react-dom/client"
+import { act } from "react"
+import type { LlmConfig, ProviderConfigs } from "@/stores/wiki-store"
+import type { WikiProject } from "@/types/wiki"
+import type { Conversation, DisplayMessage } from "@/stores/chat-store"
+import type { OutlineChatConversation } from "@/stores/outline-chat-store"
+import type { DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
+import type { UseAgentConfigResult } from "@/hooks/use-agent-config"
+
+const baseLlmConfig: LlmConfig = {
+  provider: "openai",
+  apiKey: "",
+  model: "",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 8192,
+}
+
+interface StoreStates {
+  wiki?: Partial<{
+    aiChatModel: string
+    project: WikiProject | null
+    dataVersion: number
+    llmConfig: LlmConfig
+    providerConfigs: ProviderConfigs
+  }>
+  chat?: Partial<{
+    conversations: Conversation[]
+    messages: DisplayMessage[]
+  }>
+  outline?: Partial<{
+    conversations: OutlineChatConversation[]
+  }>
+}
+
+function flushPromises() {
+  return new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+async function renderHook(systemPrompt: string, overrides: StoreStates & { skillConfig?: DeAiSkillConfig | null } = {}) {
+  vi.resetModules()
+
+  const wikiState = {
+    aiChatModel: "",
+    project: null as WikiProject | null,
+    dataVersion: 0,
+    llmConfig: baseLlmConfig,
+    providerConfigs: {} as ProviderConfigs,
+    ...overrides.wiki,
+  }
+
+  const chatState = {
+    conversations: [] as Conversation[],
+    messages: [] as DisplayMessage[],
+    ...overrides.chat,
+  }
+
+  const outlineState = {
+    conversations: [] as OutlineChatConversation[],
+    ...overrides.outline,
+  }
+
+  const skillConfig = overrides.skillConfig ?? null
+
+  vi.doMock("@/stores/wiki-store", () => ({
+    useWikiStore: (selector?: (s: typeof wikiState) => unknown) =>
+      selector ? selector(wikiState) : wikiState,
+  }))
+
+  vi.doMock("@/stores/chat-store", () => ({
+    useChatStore: (selector?: (s: typeof chatState) => unknown) =>
+      selector ? selector(chatState) : chatState,
+  }))
+
+  vi.doMock("@/stores/outline-chat-store", () => ({
+    useOutlineChatStore: (selector?: (s: typeof outlineState) => unknown) =>
+      selector ? selector(outlineState) : outlineState,
+  }))
+
+  vi.doMock("@/lib/novel/de-ai-skill-library", () => ({
+    loadDeAiSkillConfig: vi.fn().mockResolvedValue(skillConfig),
+  }))
+
+  const { useAgentConfig } = await import("@/hooks/use-agent-config")
+
+  let result: UseAgentConfigResult | null = null
+
+  function TestComponent() {
+    result = useAgentConfig(systemPrompt)
+    return null
+  }
+
+  const container = document.createElement("div")
+  const root = createRoot(container)
+
+  await act(async () => {
+    root.render(React.createElement(TestComponent))
+    await flushPromises()
+  })
+
+  return {
+    get result() {
+      return result!
+    },
+    cleanup: () => act(() => root.unmount()),
+  }
+}
+
+describe("useAgentConfig", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+  })
+
+  afterEach(() => {
+    vi.doUnmock("@/stores/wiki-store")
+    vi.doUnmock("@/stores/chat-store")
+    vi.doUnmock("@/stores/outline-chat-store")
+    vi.doUnmock("@/lib/novel/de-ai-skill-library")
+  })
+
+  it("当 aiChatModel 在不支持列表中时,返回 supportsTools: false 且 config: null", async () => {
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/o3-mini",
+        project: { path: "/tmp/project" } as WikiProject,
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    expect(result.supportsTools).toBe(false)
+    expect(result.config).toBeNull()
+    expect(result.skillConfigLoaded).toBe(false)
+
+    await cleanup()
+  })
+
+  it("当 project.path 为空时,返回 config: null", async () => {
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/gpt-4o",
+        project: null,
+      },
+    })
+
+    expect(result.supportsTools).toBe(true)
+    expect(result.config).toBeNull()
+    expect(result.skillConfigLoaded).toBe(false)
+
+    await cleanup()
+  })
+
+  it("当模型支持且项目路径存在时,加载 skill config 后返回非空 config 且 registry 包含内置工具", async () => {
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/gpt-4o",
+        project: { path: "/tmp/project" } as WikiProject,
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    expect(result.supportsTools).toBe(true)
+    expect(result.skillConfigLoaded).toBe(true)
+    expect(result.config).not.toBeNull()
+    expect(result.config?.tools.length).toBeGreaterThan(0)
+    expect(result.registry.list().some((tool) => tool.name === "read_chapter")).toBe(true)
+    expect(result.registry.list().some((tool) => tool.name === "apply_skill")).toBe(true)
+
+    await cleanup()
+  })
+})

+ 123 - 0
src/hooks/use-agent-config.ts

@@ -0,0 +1,123 @@
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { useWikiStore } from "@/stores/wiki-store"
+import { useChatStore } from "@/stores/chat-store"
+import { useOutlineChatStore } from "@/stores/outline-chat-store"
+import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
+import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { ToolRegistry } from "@/lib/agent/registry"
+import { buildAgentConfig, modelSupportsTools } from "@/lib/agent/config"
+import type { AgentConfig } from "@/lib/agent/types"
+
+export interface UseAgentConfigResult {
+  config: AgentConfig | null
+  registry: ToolRegistry
+  supportsTools: boolean
+  skillConfigLoaded: boolean
+}
+
+export function useAgentConfig(systemPrompt: string): UseAgentConfigResult {
+  const aiChatModel = useWikiStore((s) => s.aiChatModel)
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const dataVersion = useWikiStore((s) => s.dataVersion)
+  const baseLlmConfig = useWikiStore((s) => s.llmConfig)
+  const providerConfigs = useWikiStore((s) => s.providerConfigs)
+
+  const chatConversations = useChatStore((s) => s.conversations)
+  const chatMessages = useChatStore((s) => s.messages)
+
+  const outlineConversations = useOutlineChatStore((s) => s.conversations)
+
+  const [skillConfig, setSkillConfig] = useState<DeAiSkillConfig | null>(null)
+  const [skillConfigLoaded, setSkillConfigLoaded] = useState(false)
+
+  useEffect(() => {
+    let cancelled = false
+    setSkillConfigLoaded(false)
+
+    if (!projectPath) {
+      setSkillConfig(null)
+      setSkillConfigLoaded(true)
+      return
+    }
+
+    loadDeAiSkillConfig(projectPath)
+      .then((config) => {
+        if (cancelled) return
+        setSkillConfig(config)
+        setSkillConfigLoaded(true)
+      })
+      .catch(() => {
+        if (cancelled) return
+        setSkillConfig(null)
+        setSkillConfigLoaded(true)
+      })
+
+    return () => {
+      cancelled = true
+    }
+  }, [projectPath, dataVersion])
+
+  const getSkillConfig = useCallback(() => skillConfig, [skillConfig])
+
+  const getChatConversations = useCallback(
+    () =>
+      chatConversations.map((conv) => ({
+        id: conv.id,
+        title: conv.title,
+        messages: chatMessages
+          .filter((m) => m.conversationId === conv.id)
+          .map((m) => ({ role: m.role, content: m.content })),
+      })),
+    [chatConversations, chatMessages],
+  )
+
+  const getOutlineConversations = useCallback(
+    () =>
+      outlineConversations.map((conv) => ({
+        id: conv.id,
+        title: conv.title,
+        messages: conv.messages.map((m) => ({ role: m.role, content: m.content })),
+      })),
+    [outlineConversations],
+  )
+
+  return useMemo(() => {
+    const supportsTools = modelSupportsTools(aiChatModel)
+
+    if (!supportsTools || !projectPath || !skillConfigLoaded) {
+      return {
+        config: null,
+        registry: new ToolRegistry(),
+        supportsTools,
+        skillConfigLoaded: false,
+      }
+    }
+
+    const llmConfig = resolveModelConfig(aiChatModel, baseLlmConfig, providerConfigs)
+    const registry = new ToolRegistry()
+    const config = buildAgentConfig(aiChatModel, systemPrompt, registry, {
+      wikiPath: projectPath,
+      getSkillConfig,
+      getChatConversations,
+      getOutlineConversations,
+      llmConfig,
+    })
+
+    return {
+      config,
+      registry,
+      supportsTools: true,
+      skillConfigLoaded: true,
+    }
+  }, [
+    aiChatModel,
+    projectPath,
+    skillConfigLoaded,
+    baseLlmConfig,
+    providerConfigs,
+    systemPrompt,
+    getSkillConfig,
+    getChatConversations,
+    getOutlineConversations,
+  ])
+}

+ 48 - 0
src/lib/agent/config.ts

@@ -0,0 +1,48 @@
+import type { LlmConfig } from "@/stores/wiki-store"
+import type { ToolRegistry } from "./registry"
+import type { AgentConfig } from "./types"
+import { DEFAULT_MAX_ROUNDS } from "./types"
+import { registerAllBuiltInTools } from "./tools"
+import type { ToolFactoryOptions } from "./tools"
+
+export const TOOL_UNSUPPORTED_MODEL_PREFIXES: string[] = [
+  "o1",
+  "o3-mini",
+  "deepseek-reasoner",
+  "claude-code",
+  "codex-cli",
+]
+
+export interface BuildAgentConfigOptions extends ToolFactoryOptions {
+  llmConfig: LlmConfig
+}
+
+export function modelSupportsTools(modelId: string): boolean {
+  const id = modelId.trim().toLowerCase()
+  if (!id) return false
+
+  const modelPart = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id
+
+  return !TOOL_UNSUPPORTED_MODEL_PREFIXES.some((prefix) => {
+    const lowerPrefix = prefix.toLowerCase()
+    return id.startsWith(lowerPrefix) || modelPart.startsWith(lowerPrefix)
+  })
+}
+
+export function buildAgentConfig(
+  modelId: string,
+  systemPrompt: string,
+  registry: ToolRegistry,
+  options: BuildAgentConfigOptions,
+): AgentConfig {
+  registry.clear()
+  registerAllBuiltInTools(registry, options)
+
+  return {
+    maxRounds: DEFAULT_MAX_ROUNDS,
+    tools: registry.list(),
+    systemPrompt,
+    llmConfig: options.llmConfig,
+    modelId,
+  }
+}

+ 2 - 0
src/lib/agent/types.ts

@@ -35,6 +35,8 @@ export interface AgentConfig {
   tools: Tool[]
   systemPrompt: string
   llmConfig: LlmConfig
+  /** 模型标识,用于上层识别当前使用的模型 */
+  modelId?: string
 }
 
 export interface AgentRunCallbacks {