Преглед изворни кода

feat(settings): 点击提取模型错误跳转默认模型设置

未配置提取模型时,toast 与结果提示可直接进入模型设置的默认模型页,避免用户在设置里再找配置入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi пре 2 недеља
родитељ
комит
d5fe27d9d3

+ 12 - 3
src/components/settings/sections/model-settings-section.tsx

@@ -1,12 +1,13 @@
-import { useState } from "react"
+import { useEffect, useState } from "react"
 import { useTranslation } from "react-i18next"
 import type { SettingsDraft, DraftSetter } from "../settings-types"
+import { useWikiStore, type ModelSettingsTabId } from "@/stores/wiki-store"
 import { LlmProviderSection } from "./llm-provider-section"
 import { EmbeddingSection } from "./embedding-section"
 import { RerankSection } from "./rerank-section"
 import { DefaultModelSettingsPanel } from "./default-model-settings-panel"
 
-type ModelTabId = "default" | "llm" | "rerank" | "embedding"
+type ModelTabId = ModelSettingsTabId
 
 interface Props {
   draft: SettingsDraft
@@ -28,7 +29,15 @@ export function ModelSettingsSection({ draft, setDraft }: Props) {
     { id: "embedding", label: t("settings.categories.embedding", { defaultValue: "向量模型" }) },
   ]
 
-  const [active, setActive] = useState<ModelTabId>("llm")
+  const requestedTab = useWikiStore((s) => s.activeModelSettingsTab)
+  const setRequestedTab = useWikiStore((s) => s.setActiveModelSettingsTab)
+  const [active, setActive] = useState<ModelTabId>(() => requestedTab ?? "llm")
+
+  useEffect(() => {
+    if (!requestedTab) return
+    setActive(requestedTab)
+    setRequestedTab(null)
+  }, [requestedTab, setRequestedTab])
 
   return (
     <div className="space-y-4">

+ 8 - 1
src/components/settings/settings-model-section.spec.ts

@@ -10,7 +10,7 @@ describe("ModelSettingsSection", () => {
     expect(source).toContain('{ id: "llm", label:')
     expect(source).toContain('{ id: "rerank", label:')
     expect(source).toContain('{ id: "embedding", label:')
-    expect(source).toContain('useState<ModelTabId>("llm")')
+    expect(source).toContain('useState<ModelTabId>(() => requestedTab ?? "llm")')
   })
 
   it("renders default, LLM, rerank, and embedding panels switchably", () => {
@@ -19,4 +19,11 @@ describe("ModelSettingsSection", () => {
     expect(source).toContain('active === "rerank" && <RerankSection draft={draft} setDraft={setDraft}')
     expect(source).toContain('active === "embedding" && <EmbeddingSection draft={draft} setDraft={setDraft}')
   })
+
+  it("opens a requested model tab from the store and then clears it", () => {
+    expect(source).toContain("activeModelSettingsTab")
+    expect(source).toContain("setActiveModelSettingsTab")
+    expect(source).toContain("requestedTab ?? \"llm\"")
+    expect(source).toContain("setRequestedTab(null)")
+  })
 })

+ 5 - 0
src/components/sources/outline-action-toolbar.spec.ts

@@ -17,4 +17,9 @@ describe("OutlineActionToolbar", () => {
     expect(source).toContain('handleBulkIngest("all")')
     expect(source).toContain('runBulkOutlineIngest(project.path, { mode })')
   })
+
+  it("opens default model settings when the ingest LLM toast is clicked", () => {
+    expect(source).toContain("openDefaultModelSettings")
+    expect(source).toContain("toast.error(err.message, { onClick: openDefaultModelSettings })")
+  })
 })

+ 2 - 1
src/components/sources/outline-action-toolbar.tsx

@@ -18,6 +18,7 @@ import {
 } from "@/lib/novel/outline-generation"
 import { cn } from "@/lib/utils"
 import { toast } from "@/lib/toast"
+import { openDefaultModelSettings } from "@/lib/open-settings"
 import { useImportProgressStore } from "@/stores/import-progress-store"
 import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
 import { useWikiStore } from "@/stores/wiki-store"
@@ -70,7 +71,7 @@ export function OutlineActionToolbar({
       onBulkIngestResult?.(formatBulkOutlineIngestResult(result))
     } catch (err) {
       if (err instanceof OutlineIngestNotReadyError) {
-        toast.error(err.message)
+        toast.error(err.message, { onClick: openDefaultModelSettings })
         onBulkIngestResult?.(err.message)
         return
       }

+ 14 - 1
src/components/sources/sources-view.tsx

@@ -4,11 +4,15 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { OutlineActionToolbar } from "@/components/sources/outline-action-toolbar"
 import { OutlineWorkbench } from "@/components/sources/outline-workbench"
 import { PreviewPanel } from "@/components/layout/preview-panel"
+import { openDefaultModelSettings } from "@/lib/open-settings"
+import { cn } from "@/lib/utils"
 
 export function SourcesView() {
   const { t } = useTranslation()
   const novelMode = useWikiStore((s) => s.novelMode)
   const [bulkIngestResult, setBulkIngestResult] = useState<string | null>(null)
+  const ingestNoLlm = t("novel.outlineGenerator.ingestNoLlm")
+  const canOpenDefaultModel = Boolean(bulkIngestResult?.includes(ingestNoLlm))
 
   return (
     <div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
@@ -24,7 +28,16 @@ export function SourcesView() {
       </div>
 
       {bulkIngestResult ? (
-        <div className="border-b px-4 py-2 text-xs text-muted-foreground whitespace-pre-line">
+        <div
+          className={cn(
+            "border-b px-4 py-2 text-xs whitespace-pre-line",
+            canOpenDefaultModel
+              ? "cursor-pointer text-destructive hover:underline"
+              : "text-muted-foreground",
+          )}
+          role={canOpenDefaultModel ? "button" : undefined}
+          onClick={canOpenDefaultModel ? openDefaultModelSettings : undefined}
+        >
           {bulkIngestResult}
         </div>
       ) : null}

+ 29 - 0
src/lib/open-settings.spec.ts

@@ -0,0 +1,29 @@
+import { beforeEach, describe, expect, it } from "vitest"
+import { openDefaultModelSettings, openModelSettings } from "./open-settings"
+import { useWikiStore } from "@/stores/wiki-store"
+
+describe("openModelSettings", () => {
+  beforeEach(() => {
+    useWikiStore.setState({
+      activeView: "sources",
+      activeSettingsCategory: null,
+      activeModelSettingsTab: null,
+    })
+  })
+
+  it("opens model settings on the requested tab", () => {
+    openModelSettings("default")
+    const state = useWikiStore.getState()
+    expect(state.activeView).toBe("settings")
+    expect(state.activeSettingsCategory).toBe("model")
+    expect(state.activeModelSettingsTab).toBe("default")
+  })
+
+  it("openDefaultModelSettings targets the default model tab", () => {
+    openDefaultModelSettings()
+    const state = useWikiStore.getState()
+    expect(state.activeView).toBe("settings")
+    expect(state.activeSettingsCategory).toBe("model")
+    expect(state.activeModelSettingsTab).toBe("default")
+  })
+})

+ 12 - 0
src/lib/open-settings.ts

@@ -0,0 +1,12 @@
+import { useWikiStore, type ModelSettingsTabId } from "@/stores/wiki-store"
+
+export function openModelSettings(tab: ModelSettingsTabId = "llm"): void {
+  const { setActiveSettingsCategory, setActiveModelSettingsTab, setActiveView } = useWikiStore.getState()
+  setActiveSettingsCategory("model")
+  setActiveModelSettingsTab(tab)
+  setActiveView("settings")
+}
+
+export function openDefaultModelSettings(): void {
+  openModelSettings("default")
+}

+ 18 - 0
src/lib/toast.spec.tsx

@@ -49,6 +49,24 @@ describe("ToastProvider", () => {
     expect(document.body.textContent).not.toContain("普通四")
   })
 
+  it("invokes onClick when the toast card is clicked and then dismisses", async () => {
+    const onClick = vi.fn()
+    await act(async () => { api.error("未配置可用的提取模型,请先在设置中配置 LLM。", { onClick }) })
+    const card = document.querySelector<HTMLElement>('[data-toast-clickable="true"]')
+    expect(card).not.toBeNull()
+    await act(async () => { card?.click() })
+    expect(onClick).toHaveBeenCalledOnce()
+    expect(document.body.textContent).not.toContain("未配置可用的提取模型")
+  })
+
+  it("does not navigate when the close button is clicked", async () => {
+    const onClick = vi.fn()
+    await act(async () => { api.error("可点击错误", { onClick }) })
+    await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="关闭提示"]')?.click())
+    expect(onClick).not.toHaveBeenCalled()
+    expect(document.body.textContent).not.toContain("可点击错误")
+  })
+
   it("flushes global toast calls made by descendant mount effects", async () => {
     function OnMount() {
       useEffect(() => { toast.error("挂载错误", { persistent: true, dedupeKey: "mount-error" }) }, [])

+ 18 - 7
src/lib/toast.tsx

@@ -5,9 +5,9 @@ import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react"
 
 type ToastKind = "success" | "error" | "info"
 interface ToastAction { label: string; onClick: () => void }
-interface ToastOptions { title?: string; action?: ToastAction; persistent?: boolean; dedupeKey?: string }
+interface ToastOptions { title?: string; action?: ToastAction; onClick?: () => void; persistent?: boolean; dedupeKey?: string }
 export type ToastArgument = ToastAction | ToastOptions | undefined
-interface ToastItem { id: number; key: string; kind: ToastKind; title?: string; message: string; createdAt: number; action?: ToastAction; persistent: boolean }
+interface ToastItem { id: number; key: string; kind: ToastKind; title?: string; message: string; createdAt: number; action?: ToastAction; onClick?: () => void; persistent: boolean }
 export interface ToastApi {
   success: (message: string, options?: ToastArgument) => void
   error: (message: string, options?: ToastArgument) => void
@@ -49,6 +49,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
       message,
       createdAt: Date.now(),
       action: options.action,
+      onClick: options.onClick,
       persistent: options.persistent === true,
     }
     itemsRef.current = [...itemsRef.current, item]
@@ -56,7 +57,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
   }, [])
   useEffect(() => {
     for (const item of items.slice(0, MAX_VISIBLE_TOASTS)) {
-      if (item.persistent || item.action || timersRef.current.has(item.id)) continue
+      if (item.persistent || item.action || item.onClick || timersRef.current.has(item.id)) continue
       const timer = setTimeout(() => dismiss(item.id), TOAST_DURATION_MS)
       timersRef.current.set(item.id, timer)
     }
@@ -90,16 +91,26 @@ export function ToastProvider({ children }: { children: ReactNode }) {
 function ToastCard({ item, onDismiss }: { item: ToastItem; onDismiss: () => void }) {
   const config = KIND_STYLES[item.kind]
   const Icon = config.icon
-  const handleAction = () => { try { item.action?.onClick() } finally { onDismiss() } }
+  const runAndDismiss = (handler?: () => void) => {
+    try { handler?.() } finally { onDismiss() }
+  }
+  const handleAction = () => runAndDismiss(item.action?.onClick)
+  const handleClick = item.onClick ? () => runAndDismiss(item.onClick) : undefined
   return (
-    <div role={item.kind === "error" ? "alert" : "status"} data-toast-card="true" className={`pointer-events-auto flex items-start gap-2 rounded-md border bg-background p-3 shadow-lg ${config.container}`}>
+    <div
+      role={item.kind === "error" ? "alert" : "status"}
+      data-toast-card="true"
+      data-toast-clickable={item.onClick ? "true" : undefined}
+      onClick={handleClick}
+      className={`pointer-events-auto flex items-start gap-2 rounded-md border bg-background p-3 shadow-lg ${config.container}${item.onClick ? " cursor-pointer" : ""}`}
+    >
       <Icon aria-hidden="true" className={`mt-0.5 h-4 w-4 shrink-0 ${config.iconClass}`} />
       <div className="min-w-0 flex-1">
         {item.title ? <div className="mb-0.5 text-sm font-medium text-foreground">{item.title}</div> : null}
         <div className="max-h-24 overflow-y-auto whitespace-pre-wrap break-words text-sm leading-5 text-foreground">{item.message}</div>
       </div>
-      {item.action ? <button type="button" onClick={handleAction} className="ml-1 shrink-0 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary hover:bg-primary/20">{item.action.label}</button> : null}
-      <button type="button" onClick={onDismiss} className="ml-1 rounded p-0.5 text-muted-foreground hover:bg-muted" aria-label="关闭提示"><X className="h-3.5 w-3.5" /></button>
+      {item.action ? <button type="button" onClick={(event) => { event.stopPropagation(); handleAction() }} className="ml-1 shrink-0 rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary hover:bg-primary/20">{item.action.label}</button> : null}
+      <button type="button" onClick={(event) => { event.stopPropagation(); onDismiss() }} className="ml-1 rounded p-0.5 text-muted-foreground hover:bg-muted" aria-label="关闭提示"><X className="h-3.5 w-3.5" /></button>
     </div>
   )
 }

+ 6 - 0
src/stores/wiki-store.ts

@@ -63,6 +63,8 @@ type SettingsCategoryId =
   | "contact-support"
   | "changelog"
 
+export type ModelSettingsTabId = "default" | "llm" | "rerank" | "embedding"
+
 const readStoredUiFontSizeScale = (): number => {
   if (typeof localStorage === "undefined") return 1
   const saved = Number(localStorage.getItem(UI_FONT_SIZE_SCALE_KEY) ?? "1")
@@ -568,6 +570,7 @@ interface WikiState {
   searchPanelOpen: boolean
   activeView: "wiki" | "sources" | "search" | "graph" | "lint" | "soul" | "skillLibrary" | "writingSkillLibrary" | "skillFavorites" | "bookAnalysis" | "settings" | "trash" | "reviewCenter" | "storySimulation"
   activeSettingsCategory: SettingsCategoryId | null
+  activeModelSettingsTab: ModelSettingsTabId | null
   selectedSoulId: string | null
   selectedSoulTab: "project" | "character"
   selectedSoulSection: "builtIn" | "custom"
@@ -644,6 +647,7 @@ interface WikiState {
   setSearchPanelOpen: (open: boolean) => void
   setActiveView: (view: WikiState["activeView"]) => void
   setActiveSettingsCategory: (category: SettingsCategoryId | null) => void
+  setActiveModelSettingsTab: (tab: ModelSettingsTabId | null) => void
   setSelectedSoulId: (id: string | null) => void
   setSelectedSoulTab: (tab: "project" | "character") => void
   setSelectedSoulSection: (section: "builtIn" | "custom") => void
@@ -719,6 +723,7 @@ export const useWikiStore = create<WikiState>((set) => ({
   searchPanelOpen: false,
   activeView: "wiki",
   activeSettingsCategory: null,
+  activeModelSettingsTab: null,
   selectedSoulId: null,
   selectedSoulTab: "project",
   selectedSoulSection: "builtIn",
@@ -806,6 +811,7 @@ export const useWikiStore = create<WikiState>((set) => ({
     }
   }),
   setActiveSettingsCategory: (activeSettingsCategory) => set({ activeSettingsCategory }),
+  setActiveModelSettingsTab: (activeModelSettingsTab) => set({ activeModelSettingsTab }),
   setSelectedSoulId: (selectedSoulId) => set({ selectedSoulId }),
   setSelectedSoulTab: (selectedSoulTab) => set({ selectedSoulTab }),
   setSelectedSoulSection: (selectedSoulSection) => set({ selectedSoulSection }),