Explorar el Código

Merge branch 'aidagangxiugaiduibi' into main

Mochocyang hace 2 meses
padre
commit
cb7a7279d0

+ 65 - 49
src/components/chat/chat-model-selector.tsx

@@ -1,10 +1,11 @@
-import { useState, useMemo, useRef, useEffect } from "react"
+import { useState, useMemo, useRef, useEffect, useCallback } from "react"
 import { useTranslation } from "react-i18next"
 import { useTranslation } from "react-i18next"
 import { ChevronDown, Check } from "lucide-react"
 import { ChevronDown, Check } from "lucide-react"
 import { createPortal } from "react-dom"
 import { createPortal } from "react-dom"
 import { Button } from "@/components/ui/button"
 import { Button } from "@/components/ui/button"
 import { useWikiStore, type SavedModel } from "@/stores/wiki-store"
 import { useWikiStore, type SavedModel } from "@/stores/wiki-store"
 import { LLM_PRESETS } from "@/components/settings/llm-presets"
 import { LLM_PRESETS } from "@/components/settings/llm-presets"
+import { getEffectiveSavedModels } from "@/lib/llm-model-keys"
 
 
 interface ChatModelSelectorProps {
 interface ChatModelSelectorProps {
   value: string
   value: string
@@ -18,48 +19,47 @@ interface ModelGroup {
   models: SavedModel[]
   models: SavedModel[]
 }
 }
 
 
-const DROPDOWN_MAX_HEIGHT = 400
-const DROPDOWN_MIN_HEIGHT = 120
-const DROPDOWN_GAP = 6
+const DROPDOWN_MAX_HEIGHT = 360
+const DROPDOWN_GAP = 4
 
 
 export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelectorProps) {
 export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelectorProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const [open, setOpen] = useState(false)
   const [open, setOpen] = useState(false)
   const triggerRef = useRef<HTMLButtonElement>(null)
   const triggerRef = useRef<HTMLButtonElement>(null)
-  const [dropdownStyle, setDropdownStyle] = useState<{ left: number; top: number; width: number; maxHeight: number } | null>(null)
+  const dropdownRef = useRef<HTMLDivElement>(null)
+  const [dropdownStyle, setDropdownStyle] = useState<{ right: number; bottom: number; width: number; maxHeight: number } | null>(null)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
 
 
-  // 按预设/卡片分组:所有启用的内置预设 + 所有启用的自定义卡片
   const modelGroups = useMemo<ModelGroup[]>(() => {
   const modelGroups = useMemo<ModelGroup[]>(() => {
     const groups: ModelGroup[] = []
     const groups: ModelGroup[] = []
 
 
-    // 遍历所有内置预设(非 custom- 开头),过滤已停用的
     const builtinKeys = Object.keys(providerConfigs).filter((k) => !k.startsWith("custom-"))
     const builtinKeys = Object.keys(providerConfigs).filter((k) => !k.startsWith("custom-"))
     for (const key of builtinKeys) {
     for (const key of builtinKeys) {
       const config = providerConfigs[key]
       const config = providerConfigs[key]
-      // 过滤掉未启用(enabled !== true)的预设
-      if (config.enabled !== true) continue
-      if (config.savedModels && config.savedModels.length > 0) {
+      const hasConfig = config.enabled === true
+        || ((config.apiKey || config.savedModels?.length) && (config.model || config.savedModels?.length))
+      if (!hasConfig) continue
+      const models = getEffectiveSavedModels(config)
+      if (models.length > 0) {
         const preset = LLM_PRESETS.find((p) => p.id === key)
         const preset = LLM_PRESETS.find((p) => p.id === key)
         groups.push({
         groups.push({
           id: key,
           id: key,
           label: preset?.label || config.label || key,
           label: preset?.label || config.label || key,
-          models: config.savedModels,
+          models,
         })
         })
       }
       }
     }
     }
 
 
-    // 自定义卡片
     const customKeys = Object.keys(providerConfigs).filter((k) => k.startsWith("custom-"))
     const customKeys = Object.keys(providerConfigs).filter((k) => k.startsWith("custom-"))
     for (const key of customKeys) {
     for (const key of customKeys) {
       const config = providerConfigs[key]
       const config = providerConfigs[key]
-      // 过滤掉已停用(enabled === false)的卡片
       if (config.enabled === false) continue
       if (config.enabled === false) continue
-      if (config.savedModels && config.savedModels.length > 0) {
+      const models = getEffectiveSavedModels(config)
+      if (models.length > 0) {
         groups.push({
         groups.push({
           id: key,
           id: key,
           label: config.label || "自定义模型",
           label: config.label || "自定义模型",
-          models: config.savedModels,
+          models,
         })
         })
       }
       }
     }
     }
@@ -69,7 +69,6 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
 
 
   const selectedModel = useMemo(() => {
   const selectedModel = useMemo(() => {
     if (!value) return null
     if (!value) return null
-    // 优先按 "providerId/modelId" 格式精确匹配
     const slashIdx = value.indexOf("/")
     const slashIdx = value.indexOf("/")
     if (slashIdx > 0) {
     if (slashIdx > 0) {
       const providerId = value.slice(0, slashIdx)
       const providerId = value.slice(0, slashIdx)
@@ -80,7 +79,6 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
         if (found) return found
         if (found) return found
       }
       }
     }
     }
-    // 回退:按纯模型名匹配(兼容旧数据)
     for (const group of modelGroups) {
     for (const group of modelGroups) {
       const found = group.models.find((m) => m.model === value)
       const found = group.models.find((m) => m.model === value)
       if (found) return found
       if (found) return found
@@ -88,46 +86,63 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
     return null
     return null
   }, [value, modelGroups])
   }, [value, modelGroups])
 
 
-  if (modelGroups.length === 0) {
-    return null
-  }
+  const updatePosition = useCallback(() => {
+    const trigger = triggerRef.current
+    if (!trigger) return
+    const rect = trigger.getBoundingClientRect()
+    const viewportWidth = window.innerWidth
+    const viewportHeight = window.innerHeight
+    const width = Math.max(rect.width, 280)
+    const right = Math.max(4, viewportWidth - rect.right)
+    const spaceAbove = rect.top
+    const spaceBelow = viewportHeight - rect.bottom
+    let maxHeight: number
+    let bottom: number
+    if (spaceBelow >= 200) {
+      maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, spaceBelow - DROPDOWN_GAP - 4)
+      bottom = viewportHeight - rect.bottom - DROPDOWN_GAP
+    } else {
+      maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, Math.max(150, spaceAbove - DROPDOWN_GAP - 4))
+      bottom = viewportHeight - rect.top + DROPDOWN_GAP
+    }
+    setDropdownStyle({ right, bottom, width, maxHeight })
+  }, [])
 
 
   useEffect(() => {
   useEffect(() => {
     if (!open) {
     if (!open) {
       setDropdownStyle(null)
       setDropdownStyle(null)
       return
       return
     }
     }
-    const updatePosition = () => {
-      const rect = triggerRef.current?.getBoundingClientRect()
-      if (!rect) return
-      const width = Math.max(rect.width, 300)
-      const availableAbove = rect.top
-      const availableBelow = window.innerHeight - rect.bottom
-      let top: number
-      let maxHeight: number
-      // 始终优先放下方,只有下方空间不足最小高度时才翻转到上方
-      if (availableBelow < DROPDOWN_MIN_HEIGHT && availableAbove >= DROPDOWN_MIN_HEIGHT) {
-        maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, availableAbove - DROPDOWN_GAP)
-        top = rect.top - maxHeight - DROPDOWN_GAP
-      } else {
-        maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, Math.max(DROPDOWN_MIN_HEIGHT, availableBelow - DROPDOWN_GAP))
-        top = rect.bottom + DROPDOWN_GAP
-      }
-      setDropdownStyle({
-        left: Math.min(rect.left, window.innerWidth - width - 4),
-        top,
-        width,
-        maxHeight,
+    let frame2 = 0
+    const frame1 = requestAnimationFrame(() => {
+      frame2 = requestAnimationFrame(() => {
+        updatePosition()
       })
       })
-    }
-    const raf = requestAnimationFrame(updatePosition)
-    window.addEventListener("resize", updatePosition)
+    })
+    const handleReposition = () => updatePosition()
+    window.addEventListener("resize", handleReposition)
+    window.addEventListener("scroll", handleReposition, true)
     return () => {
     return () => {
-      cancelAnimationFrame(raf)
-      window.removeEventListener("resize", updatePosition)
+      cancelAnimationFrame(frame1)
+      cancelAnimationFrame(frame2)
+      window.removeEventListener("resize", handleReposition)
+      window.removeEventListener("scroll", handleReposition, true)
     }
     }
+  }, [open, updatePosition])
+
+  useEffect(() => {
+    if (!open) return
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "Escape") setOpen(false)
+    }
+    window.addEventListener("keydown", handleKeyDown)
+    return () => window.removeEventListener("keydown", handleKeyDown)
   }, [open])
   }, [open])
 
 
+  if (modelGroups.length === 0) {
+    return null
+  }
+
   return (
   return (
     <div className="relative">
     <div className="relative">
       <Button
       <Button
@@ -152,10 +167,11 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
             onClick={() => setOpen(false)}
             onClick={() => setOpen(false)}
           />
           />
           <div
           <div
-            className="fixed rounded-md border bg-popover p-1 shadow-md model-selector-dropdown"
+            ref={dropdownRef}
+            className="fixed rounded-md border bg-popover p-1 shadow-lg model-selector-dropdown"
             style={{
             style={{
-              left: dropdownStyle.left,
-              top: dropdownStyle.top,
+              right: dropdownStyle.right,
+              bottom: dropdownStyle.bottom,
               width: dropdownStyle.width,
               width: dropdownStyle.width,
               maxHeight: dropdownStyle.maxHeight,
               maxHeight: dropdownStyle.maxHeight,
               overflowY: "auto",
               overflowY: "auto",

+ 6 - 3
src/components/settings/sections/maintenance-section.tsx

@@ -20,7 +20,7 @@ import { Button } from "@/components/ui/button"
 import { Label } from "@/components/ui/label"
 import { Label } from "@/components/ui/label"
 import { ChatModelSelector } from "@/components/chat/chat-model-selector"
 import { ChatModelSelector } from "@/components/chat/chat-model-selector"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
-import { getFirstAvailableModelKey } from "@/lib/llm-model-keys"
+import { getFirstAvailableModelKey, getEffectiveSavedModels } from "@/lib/llm-model-keys"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { normalizePath } from "@/lib/path-utils"
 import { normalizePath } from "@/lib/path-utils"
 import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
 import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
@@ -292,9 +292,12 @@ export function MaintenanceSection() {
       if (key.startsWith("custom-")) {
       if (key.startsWith("custom-")) {
         if (config.enabled === false) continue
         if (config.enabled === false) continue
       } else {
       } else {
-        if (config.enabled !== true) continue
+        // 内置预设:已启用,或有有效配置(apiKey + model/savedModels)
+        const hasConfig = config.enabled === true
+          || Boolean((config.apiKey || config.savedModels?.length) && (config.model || config.savedModels?.length))
+        if (!hasConfig) continue
       }
       }
-      if (config.savedModels && config.savedModels.length > 0) {
+      if (getEffectiveSavedModels(config).length > 0) {
         return true
         return true
       }
       }
     }
     }

+ 6 - 2
src/components/sources/outline-chat-panel.tsx

@@ -117,6 +117,7 @@ import {
   resolveNovelModel,
   resolveNovelModel,
   resolveUsableModelKey,
   resolveUsableModelKey,
 } from "@/lib/novel/model-resolver";
 } from "@/lib/novel/model-resolver";
+import { getEffectiveSavedModels } from "@/lib/llm-model-keys";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
 import { useStreamingText } from "@/hooks/use-streaming-text";
 import { useStreamingText } from "@/hooks/use-streaming-text";
 import { highlightCode } from "@/lib/streaming-code-highlight";
 import { highlightCode } from "@/lib/streaming-code-highlight";
@@ -1211,9 +1212,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       if (key.startsWith("custom-")) {
       if (key.startsWith("custom-")) {
         if (config.enabled === false) continue;
         if (config.enabled === false) continue;
       } else {
       } else {
-        if (config.enabled !== true) continue;
+        // 内置预设:已启用,或有有效配置(apiKey + model/savedModels)
+        const hasConfig = config.enabled === true
+          || Boolean((config.apiKey || config.savedModels?.length) && (config.model || config.savedModels?.length));
+        if (!hasConfig) continue;
       }
       }
-      if (config.savedModels && config.savedModels.length > 0) {
+      if (getEffectiveSavedModels(config).length > 0) {
         return true;
         return true;
       }
       }
     }
     }

+ 31 - 8
src/lib/llm-model-keys.ts

@@ -1,9 +1,12 @@
-import type { ProviderConfigs, ProviderOverride } from "@/stores/wiki-store"
+import type { ProviderConfigs, ProviderOverride, SavedModel } from "@/stores/wiki-store"
 
 
 function isProviderAvailable(providerId: string, config: ProviderOverride): boolean {
 function isProviderAvailable(providerId: string, config: ProviderOverride): boolean {
-  return providerId.startsWith("custom-")
-    ? config.enabled !== false
-    : config.enabled === true
+  if (providerId.startsWith("custom-")) {
+    return config.enabled !== false
+  }
+  // 内置预设:已启用,或有有效配置(apiKey + model/savedModels)
+  return config.enabled === true
+    || Boolean((config.apiKey || config.savedModels?.length) && (config.model || config.savedModels?.length))
 }
 }
 
 
 function getAvailableProviderEntries(
 function getAvailableProviderEntries(
@@ -18,14 +21,34 @@ function getAvailableProviderEntries(
   ]
   ]
 }
 }
 
 
+/**
+ * 获取 provider 的有效模型列表。
+ * 优先使用 savedModels;为空时回退到 model 字段(兼容旧数据/未拉取模型的场景)。
+ */
+export function getEffectiveSavedModels(config: ProviderOverride): SavedModel[] {
+  if (config.savedModels && config.savedModels.length > 0) {
+    return config.savedModels
+  }
+  const model = config.model?.trim()
+  if (model) {
+    return [{
+      id: `fallback-${model}`,
+      name: model,
+      model,
+      createdAt: 0,
+    }]
+  }
+  return []
+}
+
 export function hasAvailableModels(providerConfigs: ProviderConfigs): boolean {
 export function hasAvailableModels(providerConfigs: ProviderConfigs): boolean {
   return getAvailableProviderEntries(providerConfigs)
   return getAvailableProviderEntries(providerConfigs)
-    .some(([, config]) => (config.savedModels?.length ?? 0) > 0)
+    .some(([, config]) => getEffectiveSavedModels(config).length > 0)
 }
 }
 
 
 export function getFirstAvailableModelKey(providerConfigs: ProviderConfigs): string {
 export function getFirstAvailableModelKey(providerConfigs: ProviderConfigs): string {
   for (const [providerId, config] of getAvailableProviderEntries(providerConfigs)) {
   for (const [providerId, config] of getAvailableProviderEntries(providerConfigs)) {
-    const first = config.savedModels?.[0]
+    const first = getEffectiveSavedModels(config)[0]
     if (first) return `${providerId}/${first.model}`
     if (first) return `${providerId}/${first.model}`
   }
   }
   return ""
   return ""
@@ -48,7 +71,7 @@ export function getStableAvailableModelKey(
       const config = providerConfigs[providerId]
       const config = providerConfigs[providerId]
       if (
       if (
         isProviderAvailable(providerId, config)
         isProviderAvailable(providerId, config)
-        && config.savedModels?.some((model) => model.model === modelId)
+        && getEffectiveSavedModels(config).some((model) => model.model === modelId)
       ) {
       ) {
         return `${providerId}/${modelId}`
         return `${providerId}/${modelId}`
       }
       }
@@ -57,7 +80,7 @@ export function getStableAvailableModelKey(
 
 
   for (const [providerId, config] of getAvailableProviderEntries(providerConfigs)) {
   for (const [providerId, config] of getAvailableProviderEntries(providerConfigs)) {
     if (providerId === exactProviderId) continue
     if (providerId === exactProviderId) continue
-    if (config.savedModels?.some((model) => model.model === trimmed)) {
+    if (getEffectiveSavedModels(config).some((model) => model.model === trimmed)) {
       return `${providerId}/${trimmed}`
       return `${providerId}/${trimmed}`
     }
     }
   }
   }

+ 6 - 6
src/lib/novel/model-resolver.ts

@@ -2,7 +2,7 @@ import { useWikiStore, type LlmConfig, type NovelConfig, type ProviderOverride }
 import { LLM_PRESETS } from "@/components/settings/llm-presets"
 import { LLM_PRESETS } from "@/components/settings/llm-presets"
 import { resolveConfig } from "@/components/settings/preset-resolver"
 import { resolveConfig } from "@/components/settings/preset-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
-import { getStableAvailableModelKey } from "@/lib/llm-model-keys"
+import { getStableAvailableModelKey, getEffectiveSavedModels } from "@/lib/llm-model-keys"
 
 
 export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint" | "deAi"
 export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint" | "deAi"
 
 
@@ -31,11 +31,11 @@ export function isModelKeyRegistered(
   if (slashIdx > 0) {
   if (slashIdx > 0) {
     const providerId = trimmed.slice(0, slashIdx)
     const providerId = trimmed.slice(0, slashIdx)
     const modelId = trimmed.slice(slashIdx + 1)
     const modelId = trimmed.slice(slashIdx + 1)
-    return !!providerConfigs[providerId]?.savedModels?.some((m) => m.model === modelId)
+    return !!getEffectiveSavedModels(providerConfigs[providerId] ?? {}).some((m) => m.model === modelId)
   }
   }
 
 
   for (const override of Object.values(providerConfigs)) {
   for (const override of Object.values(providerConfigs)) {
-    if (override.savedModels?.some((m) => m.model === trimmed)) {
+    if (getEffectiveSavedModels(override).some((m) => m.model === trimmed)) {
       return true
       return true
     }
     }
   }
   }
@@ -66,7 +66,7 @@ export function resolveModelConfig(
     const providerId = targetModel.slice(0, slashIdx)
     const providerId = targetModel.slice(0, slashIdx)
     const modelId = targetModel.slice(slashIdx + 1)
     const modelId = targetModel.slice(slashIdx + 1)
     const override = providerConfigs[providerId]
     const override = providerConfigs[providerId]
-    if (override?.savedModels?.some((m) => m.model === modelId)) {
+    if (override && getEffectiveSavedModels(override).some((m) => m.model === modelId)) {
       const template = LLM_PRESETS.find((p) => p.id === providerId) ?? LLM_PRESETS.find((p) => p.id === "custom")
       const template = LLM_PRESETS.find((p) => p.id === providerId) ?? LLM_PRESETS.find((p) => p.id === "custom")
       if (template) {
       if (template) {
         return { ...resolveConfig(template, override, baseConfig), model: modelId }
         return { ...resolveConfig(template, override, baseConfig), model: modelId }
@@ -76,7 +76,7 @@ export function resolveModelConfig(
   }
   }
   // 回退:按纯模型名匹配(兼容旧数据)
   // 回退:按纯模型名匹配(兼容旧数据)
   for (const [providerId, override] of Object.entries(providerConfigs)) {
   for (const [providerId, override] of Object.entries(providerConfigs)) {
-    if (override.savedModels?.some((m) => m.model === targetModel)) {
+    if (getEffectiveSavedModels(override).some((m) => m.model === targetModel)) {
       const template = LLM_PRESETS.find((p) => p.id === providerId) ?? LLM_PRESETS.find((p) => p.id === "custom")
       const template = LLM_PRESETS.find((p) => p.id === providerId) ?? LLM_PRESETS.find((p) => p.id === "custom")
       if (template) {
       if (template) {
         return { ...resolveConfig(template, override, baseConfig), model: targetModel }
         return { ...resolveConfig(template, override, baseConfig), model: targetModel }
@@ -187,7 +187,7 @@ export function formatResolvedModelLabel(
   if (!model) return "未知模型"
   if (!model) return "未知模型"
 
 
   for (const override of Object.values(providerConfigs)) {
   for (const override of Object.values(providerConfigs)) {
-    const found = override.savedModels?.find((saved) => saved.model === model)
+    const found = getEffectiveSavedModels(override).find((saved) => saved.model === model)
     if (found?.name?.trim()) return found.name.trim()
     if (found?.name?.trim()) return found.name.trim()
   }
   }