Prechádzať zdrojové kódy

feat: 重新设计大纲生成弹窗并新增 AI 大纲会话模型选择器

- 重写生成大纲弹窗:新增频道/分类/风格标签/自定义标签/目标字数/故事核心设定/模型选择
- 新增小说分类标签体系(src/lib/novel/outline-genres.ts)
- 扩展大纲生成任务状态,支持任务级模型覆盖
- 在 AI 大纲会话面板底部增加模型选择下拉框,按会话持久化 modelId
- 修复 AI 大纲会话面板选择模型后按钮不显示已选模型的问题
Mochocyang 2 mesiacov pred
rodič
commit
4e65d8b5a3

+ 36 - 9
src/components/sources/outline-chat-panel.tsx

@@ -15,9 +15,10 @@ import { OUTLINE_SECTION_GENERATION_CONFIGS } from "@/lib/novel/outline-generati
 import { prepareOutlineSaveDraft } from "@/lib/outline-save"
 import { resolveUserVisibleReasoning } from "@/lib/user-visible-reasoning"
 import { runDeepOutlineGeneration } from "@/lib/novel/deep-outline-generation"
-import { resolveNovelModel } from "@/lib/novel/model-resolver"
+import { resolveModelConfig, resolveNovelModel } from "@/lib/novel/model-resolver"
 import { createDeepThinkingStreamRenderer } from "@/lib/deep-thinking-stream"
 import { ChatInput } from "@/components/chat/chat-input"
+import { ChatModelSelector } from "@/components/chat/chat-model-selector"
 import {
   buildWebResearchContext,
   collectWebResearch,
@@ -192,6 +193,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const project = useWikiStore((s) => s.project)
   const llmConfig = useWikiStore((s) => s.llmConfig)
   const novelConfig = useWikiStore((s) => s.novelConfig)
+  const providerConfigs = useWikiStore((s) => s.providerConfigs)
 
   const conversations = useOutlineChatStore((s) => s.conversations)
   const activeConversationId = useOutlineChatStore((s) => s.activeConversationId)
@@ -204,11 +206,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const replaceLastAssistant = useOutlineChatStore((s) => s.replaceLastAssistant)
   const removeLastMessage = useOutlineChatStore((s) => s.removeLastMessage)
   const deleteConversation = useOutlineChatStore((s) => s.deleteConversation)
+  const setConversationModel = useOutlineChatStore((s) => s.setConversationModel)
   const setStreamingContent = useOutlineChatStore((s) => s.setStreamingContent)
   const setIsStreaming = useOutlineChatStore((s) => s.setIsStreaming)
   const loadFromDisk = useOutlineChatStore((s) => s.loadFromDisk)
 
+  const activeConv = conversations.find((c) => c.id === activeConversationId)
+  const activeMessages = activeConv?.messages ?? []
+
   const [inputValue, setInputValue] = useState("")
+  const [localModelId, setLocalModelId] = useState(activeConv?.modelId ?? "")
 
   // 加载持久化的历史记录
   useEffect(() => {
@@ -217,8 +224,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     }
   }, [loaded, loadFromDisk])
 
-  const activeConv = conversations.find((c) => c.id === activeConversationId)
-  const activeMessages = activeConv?.messages ?? []
+  // 当前会话切换或持久化 modelId 变化时,同步本地选择状态
+  useEffect(() => {
+    setLocalModelId(activeConv?.modelId ?? "")
+  }, [activeConv?.modelId])
 
   const [saveStatus, setSaveStatus] = useState("")
   const [copied, setCopied] = useState<string | null>(null)
@@ -256,7 +265,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const handleSend = useCallback(async (inputText: string) => {
     const prompt = inputText.trim()
     if (!prompt || !project || isStreaming) return
-    const effectiveLlmConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
+    let effectiveLlmConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
+    if (activeConv?.modelId) {
+      effectiveLlmConfig = resolveModelConfig(activeConv.modelId, effectiveLlmConfig, providerConfigs)
+    }
     if (!hasUsableLlm(effectiveLlmConfig)) {
       const convId = activeConversationId ?? createConversation()
       addMessage(convId, { id: crypto.randomUUID(), role: "assistant", content: "请先在设置中配置可用的AI模型(API Key 和模型名称),或在AI会话中选择一个模型。" })
@@ -396,7 +408,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       setIsStreaming(false)
       abortRef.current = null
     }
-  }, [project, isStreaming, llmConfig, novelConfig, activeConversationId, createConversation, addMessage, replaceLastAssistant, removeLastMessage, setIsStreaming, setStreamingContent])
+  }, [project, isStreaming, llmConfig, novelConfig, providerConfigs, activeConv, activeConversationId, createConversation, addMessage, replaceLastAssistant, removeLastMessage, setIsStreaming, setStreamingContent])
 
   const handleGenerateSection = useCallback((title: string, requestHint: string) => {
     void handleSend(`请继续生成「${title}」。${requestHint} 请基于已有大纲、章节内容和项目记忆直接输出该分项内容,结构清晰,可保存为大纲。`)
@@ -416,7 +428,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
   const handleRegenerate = useCallback(async (msgIndex: number) => {
     if (!project || isStreaming || !activeConversationId) return
-    const effectiveLlmConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
+    let effectiveLlmConfig = resolveNovelModel(llmConfig, novelConfig, "writing")
+    if (activeConv?.modelId) {
+      effectiveLlmConfig = resolveModelConfig(activeConv.modelId, effectiveLlmConfig, providerConfigs)
+    }
     if (!hasUsableLlm(effectiveLlmConfig)) {
       addMessage(activeConversationId, { id: crypto.randomUUID(), role: "assistant", content: "请先在设置中配置可用的AI模型(API Key 和模型名称),或在AI会话中选择一个模型。" })
       return
@@ -489,7 +504,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       setIsStreaming(false)
       abortRef.current = null
     }
-  }, [project, isStreaming, llmConfig, novelConfig, activeConversationId, addMessage, replaceLastAssistant, setIsStreaming, setStreamingContent])
+  }, [project, isStreaming, llmConfig, novelConfig, providerConfigs, activeConv, activeConversationId, addMessage, replaceLastAssistant, setIsStreaming, setStreamingContent])
 
   const handleCopy = useCallback((content: string, id: string) => {
     navigator.clipboard.writeText(content).then(() => {
@@ -628,8 +643,20 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         onChange={setInputValue}
         footerControls={
           <TooltipProvider delay={200}>
-            <div className="flex items-center gap-2 flex-nowrap overflow-x-auto">
-              <ChatDockControls />
+            <div className="flex items-center justify-between gap-2">
+              <div className="flex items-center gap-2 flex-nowrap overflow-x-auto">
+                <ChatDockControls />
+              </div>
+              <ChatModelSelector
+                value={localModelId}
+                onChange={(value) => {
+                  setLocalModelId(value)
+                  if (activeConversationId) {
+                    setConversationModel(activeConversationId, value)
+                  }
+                }}
+                disabled={isStreaming}
+              />
             </div>
           </TooltipProvider>
         }

+ 258 - 96
src/components/sources/outline-generator-dialog.tsx

@@ -1,6 +1,6 @@
 import { useEffect, useMemo, useState } from "react"
 import { useTranslation } from "react-i18next"
-import { Loader2, Sparkles } from "lucide-react"
+import { Loader2, Sparkles, X } from "lucide-react"
 import { listDirectory, readFile } from "@/commands/fs"
 import {
   Dialog,
@@ -12,6 +12,15 @@ import {
 } from "@/components/ui/dialog"
 import { Button } from "@/components/ui/button"
 import { Label } from "@/components/ui/label"
+import { Input } from "@/components/ui/input"
+import { ChatModelSelector } from "@/components/chat/chat-model-selector"
+import {
+  CHANNELS,
+  getChannelLabel,
+  getMainGenreLabel,
+  getMainGenresByChannel,
+  getSubGenres,
+} from "@/lib/novel/outline-genres"
 import {
   addOutlineTaskToSourceList,
   buildOutlineGenerationPrompt,
@@ -27,18 +36,6 @@ import {
 import { useOutlineGenerationStore, type OutlineGenerationState, type OutlineGenerationTask } from "@/stores/outline-generation-store"
 import { useWikiStore } from "@/stores/wiki-store"
 
-const GENRE_KEYS = [
-  "mystery",
-  "xianxia",
-  "romance",
-  "military",
-  "scifi",
-  "fantasy",
-  "historical",
-  "urban",
-  "general",
-] as const
-
 const SCALE_KEYS = ["short", "medium", "long", "epic"] as const
 
 export type OutlineGeneratorMode = "outline" | "refine"
@@ -111,9 +108,14 @@ export function OutlineGeneratorDialog({
   const updateTask = useOutlineGenerationStore((s: OutlineGenerationState) => s.updateTask)
   const tasks = useOutlineGenerationStore((s: OutlineGenerationState) => s.tasks)
 
-  const [genre, setGenre] = useState<string>("general")
+  const [channel, setChannel] = useState<"male" | "female">("male")
+  const [mainGenre, setMainGenre] = useState<string>("xuanhuan")
+  const [subGenres, setSubGenres] = useState<string[]>([])
+  const [customTags, setCustomTags] = useState<string[]>([])
+  const [customTagInput, setCustomTagInput] = useState("")
   const [scale, setScale] = useState<string>("medium")
   const [premise, setPremise] = useState("")
+  const [modelId, setModelId] = useState<string>("")
   const [generating, setGenerating] = useState(false)
   const [error, setError] = useState<string | null>(null)
   const [ingesting, setIngesting] = useState(false)
@@ -135,6 +137,19 @@ export function OutlineGeneratorDialog({
     [selectedSectionKey],
   )
 
+  const currentGenres = useMemo(() => getMainGenresByChannel(channel), [channel])
+  const currentSubGenres = useMemo(
+    () => getSubGenres(channel, mainGenre),
+    [channel, mainGenre],
+  )
+
+  useEffect(() => {
+    const genres = getMainGenresByChannel(channel)
+    const firstGenre = genres[0]?.key ?? ""
+    setMainGenre(firstGenre)
+    setSubGenres([])
+  }, [channel])
+
   const latestTask = useMemo(() => {
     if (!project) return null
     return tasks
@@ -238,6 +253,24 @@ export function OutlineGeneratorDialog({
     return () => { cancelled = true }
   }, [open, mode, project])
 
+  function handleAddCustomTag() {
+    const tag = customTagInput.trim()
+    if (!tag || customTags.includes(tag)) return
+    setCustomTags((prev) => [...prev, tag])
+    setCustomTagInput("")
+  }
+
+  function handleRemoveCustomTag(tag: string) {
+    setCustomTags((prev) => prev.filter((t) => t !== tag))
+  }
+
+  function handleCustomTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
+    if (e.key === "Enter") {
+      e.preventDefault()
+      handleAddCustomTag()
+    }
+  }
+
   async function handleGenerate() {
     if (!project || generating || taskGenerating) return
 
@@ -245,16 +278,32 @@ export function OutlineGeneratorDialog({
     setError(null)
 
     try {
-      const genreLabel = t(`novel.outlineGenerator.genres.${genre}`)
+      const channelLabel = getChannelLabel(channel)
+      const mainGenreLabel = getMainGenreLabel(channel, mainGenre)
+      const selectedSubGenreLabels = subGenres
+        .map((key) => currentSubGenres.find((s) => s.key === key)?.label)
+        .filter(Boolean)
+        .join("、")
+      const allTags = [...customTags]
+      if (selectedSubGenreLabels) {
+        allTags.unshift(selectedSubGenreLabels)
+      }
+      const tagPart = allTags.length > 0 ? `,风格标签:${allTags.join("、")}` : ""
+      const genreLabel = `${channelLabel} / ${mainGenreLabel}${tagPart}`
       const scaleLabel = t(`novel.outlineGenerator.scales.${scale}`)
       const prompt = await buildOutlineGenerationPrompt(project.path, genreLabel, scaleLabel, premise)
 
       const taskId = createTask({
         projectPath: project.path,
-        genre,
+        genre: genreLabel,
         scale,
         premise,
         prompt,
+        channel,
+        mainGenre,
+        subGenres,
+        customTags,
+        modelId: modelId || undefined,
       })
       updateTask(taskId, {
         status: "generating",
@@ -404,45 +453,149 @@ export function OutlineGeneratorDialog({
 
         <div className="flex flex-col gap-4">
           {mode === "outline" ? (
-            <>
-              <div className="flex flex-col gap-1.5">
-                <Label>{t("novel.outlineGenerator.genre")}</Label>
-                <select
-                  value={genre}
-                  onChange={(e) => setGenre(e.target.value)}
-                  disabled={generating || taskGenerating}
-                  className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
-                >
-                  {GENRE_KEYS.map((key) => (
-                    <option key={key} value={key}>
-                      {t(`novel.outlineGenerator.genres.${key}`)}
-                    </option>
+            <div className="flex flex-col gap-5">
+              <div className="flex flex-col gap-2">
+                <Label className="text-sm font-medium">小说频道</Label>
+                <div className="grid grid-cols-2 gap-3">
+                  {CHANNELS.map((c) => (
+                    <Button
+                      key={c.key}
+                      type="button"
+                      variant={channel === c.key ? "default" : "outline"}
+                      onClick={() => setChannel(c.key)}
+                      disabled={generating || taskGenerating}
+                      className="h-10"
+                    >
+                      {c.label}
+                    </Button>
                   ))}
-                </select>
+                </div>
               </div>
 
-              <div className="flex flex-col gap-1.5">
-                <Label>{t("novel.outlineGenerator.scale")}</Label>
-                <select
-                  value={scale}
-                  onChange={(e) => setScale(e.target.value)}
-                  disabled={generating || taskGenerating}
-                  className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
-                >
-                  {SCALE_KEYS.map((key) => (
-                    <option key={key} value={key}>
-                      {t(`novel.outlineGenerator.scales.${key}`)}
-                    </option>
+              <div className="flex flex-col gap-2">
+                <Label className="text-sm font-medium">小说分类</Label>
+                <div className="flex flex-wrap gap-2">
+                  {currentGenres.map((g) => (
+                    <Button
+                      key={g.key}
+                      type="button"
+                      size="sm"
+                      variant={mainGenre === g.key ? "default" : "outline"}
+                      onClick={() => setMainGenre(g.key)}
+                      disabled={generating || taskGenerating}
+                      className="h-8 rounded-full px-3 text-xs"
+                    >
+                      {g.label}
+                    </Button>
                   ))}
-                </select>
+                </div>
+              </div>
+
+              <div className="flex flex-col gap-2">
+                <Label className="text-sm font-medium">风格标签(可多选)</Label>
+                <div className="flex flex-wrap gap-2">
+                  {currentSubGenres.map((s) => {
+                    const selected = subGenres.includes(s.key)
+                    return (
+                      <Button
+                        key={s.key}
+                        type="button"
+                        size="sm"
+                        variant={selected ? "default" : "outline"}
+                        onClick={() =>
+                          setSubGenres((prev) =>
+                            selected ? prev.filter((k) => k !== s.key) : [...prev, s.key]
+                          )
+                        }
+                        disabled={generating || taskGenerating}
+                        className="h-7 rounded-full px-2.5 text-xs"
+                      >
+                        {s.label}
+                      </Button>
+                    )
+                  })}
+                </div>
+
+                {customTags.length > 0 && (
+                  <div className="flex flex-wrap gap-1.5">
+                    {customTags.map((tag) => (
+                      <span
+                        key={tag}
+                        className="inline-flex items-center gap-1 rounded-full bg-primary px-2.5 py-1 text-xs text-primary-foreground"
+                      >
+                        {tag}
+                        <button
+                          type="button"
+                          onClick={() => handleRemoveCustomTag(tag)}
+                          disabled={generating || taskGenerating}
+                          className="rounded-full hover:bg-primary-foreground/20"
+                        >
+                          <X className="h-3 w-3" />
+                        </button>
+                      </span>
+                    ))}
+                  </div>
+                )}
+
+                <div className="flex gap-2">
+                  <Input
+                    value={customTagInput}
+                    onChange={(e) => setCustomTagInput(e.target.value)}
+                    onKeyDown={handleCustomTagKeyDown}
+                    placeholder="输入自定义标签,按回车添加"
+                    disabled={generating || taskGenerating}
+                    className="h-9 text-sm"
+                  />
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="icon"
+                    onClick={handleAddCustomTag}
+                    disabled={generating || taskGenerating}
+                    className="h-9 w-9 shrink-0"
+                  >
+                    +
+                  </Button>
+                </div>
+              </div>
+
+              <div className="flex flex-col gap-2">
+                <Label className="text-sm font-medium">目标字数</Label>
+                <div className="grid grid-cols-4 gap-2">
+                  {SCALE_KEYS.map((key) => {
+                    const labels: Record<string, { title: string; subtitle: string }> = {
+                      short: { title: "短篇", subtitle: "10万字以内" },
+                      medium: { title: "中篇", subtitle: "10-50万字" },
+                      long: { title: "长篇", subtitle: "50-200万字" },
+                      epic: { title: "超长篇", subtitle: "200万字以上" },
+                    }
+                    const item = labels[key]
+                    const selected = scale === key
+                    return (
+                      <Button
+                        key={key}
+                        type="button"
+                        variant={selected ? "default" : "outline"}
+                        onClick={() => setScale(key)}
+                        disabled={generating || taskGenerating}
+                        className="flex h-auto flex-col gap-0.5 py-2 text-xs"
+                      >
+                        <span className="font-medium">{item.title}</span>
+                        <span className={`text-[10px] ${selected ? "text-primary-foreground/80" : "text-muted-foreground"}`}>
+                          {item.subtitle}
+                        </span>
+                      </Button>
+                    )
+                  })}
+                </div>
               </div>
 
-              <div className="flex flex-col gap-1.5">
-                <Label>{t("novel.outlineGenerator.premise")}</Label>
+              <div className="flex flex-col gap-2">
+                <Label className="text-sm font-medium">故事核心设定</Label>
                 <textarea
                   value={premise}
                   onChange={(e) => setPremise(e.target.value)}
-                  placeholder={t("novel.outlineGenerator.premisePlaceholder")}
+                  placeholder="用一段话描述你的故事核心设定,比如主角身份、核心冲突、故事走向等..."
                   disabled={generating || taskGenerating}
                   rows={4}
                   className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
@@ -482,7 +635,7 @@ export function OutlineGeneratorDialog({
                   </div>
                 </div>
               )}
-            </>
+            </div>
           ) : (
             <div className="flex gap-4">
               {/* Left column: settings */}
@@ -706,65 +859,74 @@ export function OutlineGeneratorDialog({
           )}
         </div>
 
-        <DialogFooter>
-          <Button
-            variant="outline"
-            onClick={() => onOpenChange(false)}
-            disabled={ingesting}
-          >
-            {(mode === "outline" || mode === "refine") && taskGenerating
-              ? t("novel.outlineGenerator.hideAndContinue")
-              : t("project.cancel")}
-          </Button>
-          {mode === "outline" ? (
-            hasGeneratedOutline ? (
-              <Button onClick={handleIngestOutline} disabled={ingesting}>
-                {ingesting ? (
-                  <>
-                    <Loader2 className="mr-1 h-4 w-4 animate-spin" />
-                    {t("novel.outlineGenerator.ingesting")}
-                  </>
-                ) : (
-                  <>
-                    <Sparkles className="mr-1 h-4 w-4" />
-                    {t("novel.outlineGenerator.ingest")}
-                  </>
-                )}
-              </Button>
+        <DialogFooter className="items-center gap-2 sm:justify-between">
+          <div className="flex items-center gap-2">
+            <ChatModelSelector
+              value={modelId}
+              onChange={setModelId}
+              disabled={generating || taskGenerating}
+            />
+          </div>
+          <div className="flex items-center gap-2">
+            <Button
+              variant="outline"
+              onClick={() => onOpenChange(false)}
+              disabled={ingesting}
+            >
+              {(mode === "outline" || mode === "refine") && taskGenerating
+                ? t("novel.outlineGenerator.hideAndContinue")
+                : t("project.cancel")}
+            </Button>
+            {mode === "outline" ? (
+              hasGeneratedOutline ? (
+                <Button onClick={handleIngestOutline} disabled={ingesting}>
+                  {ingesting ? (
+                    <>
+                      <Loader2 className="mr-1 h-4 w-4 animate-spin" />
+                      {t("novel.outlineGenerator.ingesting")}
+                    </>
+                  ) : (
+                    <>
+                      <Sparkles className="mr-1 h-4 w-4" />
+                      {t("novel.outlineGenerator.ingest")}
+                    </>
+                  )}
+                </Button>
+              ) : (
+                <Button onClick={handleGenerate} disabled={generating || taskGenerating || !premise.trim()}>
+                  {generating ? (
+                    <>
+                      <Loader2 className="mr-1 h-4 w-4 animate-spin" />
+                      {t("novel.outlineGenerator.generating")}
+                    </>
+                  ) : (
+                    <>
+                      <Sparkles className="mr-1 h-4 w-4" />
+                      {t("novel.outlineGenerator.title")}
+                    </>
+                  )}
+                </Button>
+              )
             ) : (
-              <Button onClick={handleGenerate} disabled={generating || taskGenerating || !premise.trim()}>
-                {generating ? (
+              <Button onClick={handleRefineGenerate} disabled={taskGenerating || checkingOutline || !canRefine}>
+                {taskGenerating ? (
                   <>
                     <Loader2 className="mr-1 h-4 w-4 animate-spin" />
-                    {t("novel.outlineGenerator.generating")}
+                    {activeSectionTitle && activeSectionTitle !== t("novel.outlineGenerator.refineTitle")
+                      ? t("novel.outlineGenerator.sectionGenerating", { title: activeSectionTitle })
+                      : t("novel.outlineGenerator.refining")}
                   </>
                 ) : (
                   <>
                     <Sparkles className="mr-1 h-4 w-4" />
-                    {t("novel.outlineGenerator.title")}
+                    {selectedSectionKey
+                      ? t(`novel.outlineGenerator.sectionButtons.${selectedSectionKey}`)
+                      : t("novel.outlineGenerator.refineTitle")}
                   </>
                 )}
               </Button>
-            )
-          ) : (
-            <Button onClick={handleRefineGenerate} disabled={taskGenerating || checkingOutline || !canRefine}>
-              {taskGenerating ? (
-                <>
-                  <Loader2 className="mr-1 h-4 w-4 animate-spin" />
-                  {activeSectionTitle && activeSectionTitle !== t("novel.outlineGenerator.refineTitle")
-                    ? t("novel.outlineGenerator.sectionGenerating", { title: activeSectionTitle })
-                    : t("novel.outlineGenerator.refining")}
-                </>
-              ) : (
-                <>
-                  <Sparkles className="mr-1 h-4 w-4" />
-                  {selectedSectionKey
-                    ? t(`novel.outlineGenerator.sectionButtons.${selectedSectionKey}`)
-                    : t("novel.outlineGenerator.refineTitle")}
-                </>
-              )}
-            </Button>
-          )}
+            )}
+          </div>
         </DialogFooter>
       </DialogContent>
     </Dialog>

+ 7 - 1
src/lib/novel/outline-generation.ts

@@ -12,6 +12,7 @@ import type { LlmConfig } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { ingestOutline } from "./chapter-ingest"
 import { buildContextPack, type ContextPack } from "./context-engine"
+import { resolveModelConfig } from "@/lib/novel/model-resolver"
 
 export type OutlineSectionGenerationKey =
   | "chapterOutlines"
@@ -447,6 +448,11 @@ export async function runOutlineGenerationTask(taskId: string, llmConfig: LlmCon
   const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
   if (!task) return
 
+  const { providerConfigs } = useWikiStore.getState()
+  const effectiveLlmConfig = task.modelId
+    ? resolveModelConfig(task.modelId, llmConfig, providerConfigs)
+    : llmConfig
+
   const abortController = new AbortController()
   const progressTaskId = useImportProgressStore.getState().startTask({
     projectPath: task.projectPath,
@@ -458,7 +464,7 @@ export async function runOutlineGenerationTask(taskId: string, llmConfig: LlmCon
   })
 
   try {
-    const { outlinePath } = await generateOutlineFile(task.projectPath, llmConfig, task.prompt, abortController.signal)
+    const { outlinePath } = await generateOutlineFile(task.projectPath, effectiveLlmConfig, task.prompt, abortController.signal)
     await refreshProjectState(task.projectPath)
     useOutlineGenerationStore.getState().updateTask(taskId, {
       status: "generated",

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 23 - 0
src/lib/novel/outline-genres.ts


+ 11 - 0
src/stores/outline-chat-store.ts

@@ -15,6 +15,7 @@ export interface OutlineChatConversation {
   title: string
   createdAt: number
   messages: OutlineChatMessage[]
+  modelId?: string
 }
 
 interface OutlineChatState {
@@ -30,6 +31,7 @@ interface OutlineChatState {
   replaceLastAssistant: (convId: string, content: string, sources?: string[]) => void
   removeLastMessage: (convId: string) => void
   deleteConversation: (id: string) => void
+  setConversationModel: (id: string, modelId: string) => void
   setStreamingContent: (content: string) => void
   setIsStreaming: (value: boolean) => void
   loadFromDisk: () => Promise<void>
@@ -112,6 +114,15 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
     void get().saveToDisk()
   },
 
+  setConversationModel: (id, modelId) => {
+    set((s) => ({
+      conversations: s.conversations.map((c) =>
+        c.id === id ? { ...c, modelId } : c
+      ),
+    }))
+    void get().saveToDisk()
+  },
+
   setStreamingContent: (content) => set({ streamingContent: content }),
   setIsStreaming: (value) => set({ isStreaming: value }),
 

+ 15 - 0
src/stores/outline-generation-store.ts

@@ -11,6 +11,11 @@ export interface OutlineGenerationTask {
   scale: string
   premise: string
   prompt: string
+  channel?: "male" | "female"
+  mainGenre?: string
+  subGenres?: string[]
+  customTags?: string[]
+  modelId?: string
   userRequest: string
   selectedSectionKey: string | null
   displayTitle: string | null
@@ -31,6 +36,11 @@ interface CreateOutlineTaskInput {
   scale?: string
   premise?: string
   prompt?: string
+  channel?: "male" | "female"
+  mainGenre?: string
+  subGenres?: string[]
+  customTags?: string[]
+  modelId?: string
   userRequest?: string
   selectedSectionKey?: string | null
   displayTitle?: string | null
@@ -71,6 +81,11 @@ export const useOutlineGenerationStore = create<OutlineGenerationState>((set) =>
           scale: input.scale ?? "",
           premise: input.premise ?? "",
           prompt: input.prompt ?? "",
+          channel: input.channel,
+          mainGenre: input.mainGenre,
+          subGenres: input.subGenres,
+          customTags: input.customTags,
+          modelId: input.modelId,
           userRequest: input.userRequest ?? "",
           selectedSectionKey: input.selectedSectionKey ?? null,
           displayTitle: input.displayTitle ?? null,

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov