Просмотр исходного кода

fix: 修复技能库已修改标签、AI大纲滚动、推荐卡顿、计划模式thinking参数4个问题

1. 移除技能库与 AI 对话窗口中的已修改标签,描述改为仅选择去AI味 Skill

2. 修复离开 AI 大纲再返回时不自动滚动到最后一条消息的问题

3. 修复接下来想要做什么推荐点击后长时间无响应的问题(生成分支强制刷新上下文)

4. 修复 AI 会话计划模式 HTTP 400 Unsupported parameter thinking 错误(自检与修订时关闭 reasoning)
Mochocyang 2 месяцев назад
Родитель
Сommit
f3a8940d70

+ 3 - 26
src/components/common/event-stream.tsx

@@ -1,10 +1,10 @@
-import { memo, useState, useEffect, useMemo, useRef } from "react"
+import { memo, useEffect, useMemo, useRef } from "react"
 import type { TimelineEvent } from "./timeline-types"
 import { groupTimelineEvents } from "./timeline-grouping"
 import { ThinkingEvent } from "./timeline-thinking-event"
 import { ToolCallEvent } from "./timeline-tool-event"
 import { getToolCallGroupRenderKey, ToolCallGroup } from "./timeline-tool-group"
-import { ArrowDown, Brain, Clock3, Hash } from "lucide-react"
+import { Brain, Clock3, Hash } from "lucide-react"
 
 interface EventStreamProps {
   events: TimelineEvent[]
@@ -15,7 +15,6 @@ interface EventStreamProps {
 
 function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }: EventStreamProps) {
   const containerRef = useRef<HTMLDivElement>(null)
-  const [showScrollButton, setShowScrollButton] = useState(false)
   const userScrolledRef = useRef(false)
   const groupedEvents = useMemo(() => groupTimelineEvents(events), [events])
 
@@ -34,14 +33,6 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
     return `${minutes}分${seconds}秒`
   }
 
-  const scrollToBottom = () => {
-    if (containerRef.current) {
-      containerRef.current.scrollTop = containerRef.current.scrollHeight
-      userScrolledRef.current = false
-      setShowScrollButton(false)
-    }
-  }
-
   useEffect(() => {
     if (!isStreaming) return
     const container = containerRef.current
@@ -52,10 +43,8 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
       const atBottom = scrollHeight - scrollTop - clientHeight < 30
       if (!atBottom) {
         userScrolledRef.current = true
-        setShowScrollButton(true)
       } else {
         userScrolledRef.current = false
-        setShowScrollButton(false)
       }
     }
 
@@ -63,10 +52,9 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
     return () => container.removeEventListener("scroll", handleScroll)
   }, [isStreaming])
 
-  // 流式结束后重置滚动按钮状态,避免 ArrowDown 残留遮挡底部耗时统计
+  // 流式结束后重置滚动锁定状态
   useEffect(() => {
     if (!isStreaming) {
-      setShowScrollButton(false)
       userScrolledRef.current = false
     }
   }, [isStreaming])
@@ -153,17 +141,6 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
           </div>
         )}
       </div>
-
-      {showScrollButton && (
-        <button
-          type="button"
-          onClick={scrollToBottom}
-          className="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-full bg-background border border-border shadow-md hover:bg-accent transition-colors text-foreground/70 hover:text-foreground"
-          title="回到最新"
-        >
-          <ArrowDown className="h-3.5 w-3.5" />
-        </button>
-      )}
     </div>
   )
 }

+ 0 - 1
src/components/layout/preview-panel.tsx

@@ -1666,7 +1666,6 @@ export function PreviewPanel() {
             skills={chapterDeAiOptions.skills}
             currentSkillId={chapterDeAiOptions.currentSkillId}
             defaultSkillId={chapterDeAiOptions.defaultSkillId}
-            modifiedSkillIds={chapterDeAiOptions.modifiedSkillIds}
             onClose={() => {
               setDeAiSkillPickerOpen(false)
               setPendingSelectionForDeAi(null)

+ 3 - 6
src/components/skill-library/de-ai-skill-picker.spec.tsx

@@ -42,7 +42,6 @@ function SkillOptionsProbe() {
       <span data-testid="effective-name">{options.effectiveName}</span>
       <span data-testid="current-id">{options.currentSkillId}</span>
       <span data-testid="default-id">{options.defaultSkillId}</span>
-      <span data-testid="modified-count">{options.modifiedSkillIds.length}</span>
     </div>
   )
 }
@@ -85,7 +84,7 @@ describe("DeAiSkillPicker", () => {
     })
   })
 
-  it("shows the effective de-AI skill and marks modified skills", async () => {
+  it("shows the effective de-AI skill", async () => {
     const { container, root } = await renderPicker()
 
     const button = container.querySelector<HTMLButtonElement>("button")
@@ -96,7 +95,7 @@ describe("DeAiSkillPicker", () => {
       button?.click()
     })
 
-    expect(container.textContent).toContain("已修改")
+    expect(container.textContent).not.toContain("已修改")
 
     cleanup(root, container)
   })
@@ -172,7 +171,6 @@ describe("DeAiSkillPicker", () => {
           }]}
           currentSkillId="built-in:comprehensive"
           defaultSkillId="built-in:comprehensive"
-          modifiedSkillIds={["built-in:comprehensive"]}
           onPick={vi.fn()}
         />,
       )
@@ -181,7 +179,7 @@ describe("DeAiSkillPicker", () => {
     expect(container.textContent).toContain("综合去AI味-项目版")
     expect(container.textContent).toContain("当前")
     expect(container.textContent).toContain("默认")
-    expect(container.textContent).toContain("已修改")
+    expect(container.textContent).not.toContain("已修改")
 
     await act(async () => {
       root.render(
@@ -192,7 +190,6 @@ describe("DeAiSkillPicker", () => {
           skills={[]}
           currentSkillId={null}
           defaultSkillId={null}
-          modifiedSkillIds={[]}
           onPick={vi.fn()}
         />,
       )

+ 1 - 10
src/components/skill-library/de-ai-skill-picker.tsx

@@ -22,7 +22,6 @@ interface DeAiSkillOptionsPanelProps {
   skills: DeAiSkill[]
   currentSkillId?: string | null
   defaultSkillId?: string | null
-  modifiedSkillIds: string[]
   onPick: (skillId: string) => void
   onClose?: () => void
 }
@@ -52,7 +51,6 @@ export function DeAiSkillOptionsPanel({
   skills,
   currentSkillId,
   defaultSkillId,
-  modifiedSkillIds,
   onPick,
   onClose,
 }: DeAiSkillOptionsPanelProps) {
@@ -133,11 +131,6 @@ export function DeAiSkillOptionsPanel({
                 默认
               </span>
             ) : null}
-            {modifiedSkillIds.includes(skill.id) ? (
-              <span className="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-800">
-                已修改
-              </span>
-            ) : null}
           </div>
           {skill.description ? (
             <div className="mt-0.5 truncate text-xs text-muted-foreground">{skill.description}</div>
@@ -167,10 +160,9 @@ export function DeAiSkillPicker({
     effectiveName,
     currentSkillId,
     defaultSkillId,
-    modifiedSkillIds,
     loadError,
   } = useDeAiSkillOptions({ projectPath: project?.path, selectedSkillId: value })
-  const triggerTitle = buttonLabel === "技能库" ? "技能库:选择写作 Skill 或去AI味 Skill" : `当前去AI味 Skill:${effectiveName}`
+  const triggerTitle = buttonLabel === "技能库" ? "技能库:选择去AI味 Skill" : `当前去AI味 Skill:${effectiveName}`
   const iconTriggerDescription = buttonLabel === "技能库"
     ? `${triggerTitle}。点击打开技能选择`
     : `${triggerTitle}。点击选择去AI味 Skill`
@@ -227,7 +219,6 @@ export function DeAiSkillPicker({
             skills={skills}
             currentSkillId={currentSkillId}
             defaultSkillId={defaultSkillId}
-            modifiedSkillIds={modifiedSkillIds}
             onClose={() => setOpen(false)}
             onPick={(skillId) => {
               onChange(skillId)

+ 3 - 3
src/components/skill-library/skill-library-view.spec.tsx

@@ -303,7 +303,7 @@ describe("SkillLibraryView", () => {
     cleanup(root, container)
   })
 
-  it("marks modified skills in the list and detail header", async () => {
+  it("does not show modified badges after saving skill changes", async () => {
     const { container, root } = await renderLibrary()
     const nameInput = container.querySelector<HTMLInputElement>('[data-testid="skill-name-input"]')
     const contentInput = container.querySelector<HTMLTextAreaElement>('[data-testid="skill-content-input"]')
@@ -316,8 +316,8 @@ describe("SkillLibraryView", () => {
       container.querySelector<HTMLButtonElement>('[data-testid="skill-save-button"]')?.click()
     })
 
-    expect(container.querySelectorAll('[data-testid="skill-modified-badge"]').length).toBeGreaterThanOrEqual(2)
-    expect(container.textContent).toContain("已修改")
+    expect(container.querySelectorAll('[data-testid="skill-modified-badge"]').length).toBe(0)
+    expect(container.textContent).not.toContain("已修改")
 
     cleanup(root, container)
   })

+ 0 - 19
src/components/skill-library/skill-library-view.tsx

@@ -3,7 +3,6 @@ import {
   createBlankProjectDeAiSkill,
   deleteProjectDeAiSkill,
   getAllDeAiSkills,
-  isDeAiSkillModified,
   isDeAiSkillConfigCorruptError,
   loadDeAiSkillConfig,
   recreateDeAiSkillConfig,
@@ -171,7 +170,6 @@ export function SkillLibrarySidebarPanel() {
         {allSkills.map((skill) => {
           const active = skill.id === selectedSkillId
           const enabled = !disabledSkillIds.has(skill.id)
-          const modified = isDeAiSkillModified(config!, skill.id)
           return (
             <div
               key={skill.id}
@@ -197,14 +195,6 @@ export function SkillLibrarySidebarPanel() {
                 {config?.defaultSkillId === skill.id ? (
                   <span className="rounded bg-primary px-1.5 py-0.5 text-[10px] text-primary-foreground">默认</span>
                 ) : null}
-                {modified ? (
-                  <span
-                    data-testid="skill-modified-badge"
-                    className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-800"
-                  >
-                    已修改
-                  </span>
-                ) : null}
               </div>
               <div className="mt-1 truncate text-xs text-muted-foreground">{skill.description}</div>
               <label className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
@@ -254,7 +244,6 @@ export function SkillLibraryView() {
   const selectedGenericSkill = selectedSkill ? deAiSkillToUserSkill(selectedSkill) : null
   const selectedHasBuiltInOverride = selectedIsBuiltIn
     && Boolean(config?.builtInSkillOverrides.some((skill) => skill.id === selectedSkill?.id))
-  const selectedModified = Boolean(config && selectedSkill && isDeAiSkillModified(config, selectedSkill.id))
   const selectedEnabled = Boolean(
     config && selectedSkill && !config.disabledSkillIds.includes(selectedSkill.id),
   )
@@ -464,14 +453,6 @@ export function SkillLibraryView() {
                 <div className="text-sm text-muted-foreground">{sourceLabel(selectedSkill)}技能</div>
                 <div className="flex min-w-0 items-center gap-2">
                   <h2 className="truncate text-xl font-semibold">{selectedSkill.name}</h2>
-                  {selectedModified ? (
-                    <span
-                      data-testid="skill-modified-badge"
-                      className="shrink-0 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-800"
-                    >
-                      已修改
-                    </span>
-                  ) : null}
                 </div>
                 {selectedGenericSkill ? (
                   <div className="mt-2 flex flex-wrap gap-1.5 text-xs text-muted-foreground">

+ 0 - 5
src/components/skill-library/use-de-ai-skill-options.ts

@@ -2,7 +2,6 @@ import { useEffect, useState } from "react"
 import { useWikiStore } from "@/stores/wiki-store"
 import {
   isDeAiSkillConfigCorruptError,
-  isDeAiSkillModified,
   loadDeAiSkillConfig,
   resolveAvailableDeAiSkills,
   resolveEffectiveDeAiSkill,
@@ -22,7 +21,6 @@ interface DeAiSkillOptionsState {
   effectiveName: string
   currentSkillId: string | null
   defaultSkillId: string | null
-  modifiedSkillIds: string[]
   loadError: string
 }
 
@@ -38,7 +36,6 @@ export function useDeAiSkillOptions({
     effectiveName: "技能",
     currentSkillId: null,
     defaultSkillId: null,
-    modifiedSkillIds: [],
     loadError: "",
   })
 
@@ -59,7 +56,6 @@ export function useDeAiSkillOptions({
           effectiveName: effectiveSkill?.name ?? "未启用",
           currentSkillId: effectiveSkill?.id ?? null,
           defaultSkillId: config.defaultSkillId,
-          modifiedSkillIds: skills.filter((skill) => isDeAiSkillModified(config, skill.id)).map((skill) => skill.id),
           loadError: "",
         })
       })
@@ -71,7 +67,6 @@ export function useDeAiSkillOptions({
           effectiveName: isDeAiSkillConfigCorruptError(error) ? "配置损坏" : "未启用",
           currentSkillId: null,
           defaultSkillId: null,
-          modifiedSkillIds: [],
           loadError: getDeAiSkillLoadErrorMessage(error),
         })
       })

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

@@ -1471,6 +1471,22 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     lastScrollTopRef.current = container.scrollTop;
   }, [activeMessages, batchedStreamingContent]);
 
+  // 重新进入面板时滚动到最后一条消息
+  // AI 大纲消息渲染较重(StreamingMarkdown、时间线、工具调用等),
+  // 重新挂载时 DOM 渲染较慢,auto-scroll effect 首次执行时 scrollHeight 不是最终值。
+  // 在多个时间点尝试滚动,覆盖不同渲染速度,最后一次必定到底。
+  useEffect(() => {
+    const timers = [100, 300, 600, 1000, 1500].map((delay) =>
+      window.setTimeout(() => {
+        const container = scrollRef.current;
+        if (!container || userScrolledUpRef.current) return;
+        container.scrollTop = container.scrollHeight;
+        lastScrollTopRef.current = container.scrollTop;
+      }, delay),
+    );
+    return () => timers.forEach((t) => clearTimeout(t));
+  }, []);
+
   const handleScrollToBottom = useCallback(() => {
     const container = scrollRef.current;
     if (!container) return;
@@ -2381,6 +2397,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             clearDraft: false,
             intentPhase: "generation",
             systemGenerated: true,
+            forceRefresh: true,
           });
         }
         return { started: true, sent: true };
@@ -2507,7 +2524,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           const intentContext = intentContextsRef.current[capturedConvId] ?? { title: "", hint: "" };
           const generationPrompt = buildGenerationPrompt(intentContext.title, intentContext.hint, scope, intentContext.outputMode);
           const references = outlineReferenceTokens;
-          const result = await handleSend(generationPrompt, references, { conversationId: capturedConvId, intentPhase: "generation", clearDraft: false, systemGenerated: true });
+          const result = await handleSend(generationPrompt, references, { conversationId: capturedConvId, intentPhase: "generation", clearDraft: false, systemGenerated: true, forceRefresh: true });
           if (result.sent) {
             if (shouldClearOutlineReferences({
               invocationConversationId: capturedConvId,

+ 4 - 0
src/lib/novel/chapter-plan-self-check.ts

@@ -169,6 +169,8 @@ export async function runChapterPlanSelfCheck(
       onDone: () => {},
       onError: (error) => { streamError = error },
     },
+    undefined,
+    { reasoning: { mode: "off" } },
   )
   if (streamError) throw streamError
   return parseChapterPlanSelfCheckResult(result.trim()).formattedText || "自检完成,未返回具体结果。"
@@ -212,6 +214,8 @@ export async function runChapterPlanRevision(
       onDone: () => {},
       onError: (error) => { streamError = error },
     },
+    undefined,
+    { reasoning: { mode: "off" } },
   )
   if (streamError) throw streamError
   return result.trim() || "修订失败:模型未返回修订计划。"