Explorar el Código

merge: 合并 PR #28 远程 main 到本地(TOPN/TOPK 文案、数字输入框步进器、聊天保存状态、磁盘同步滚位)

Mochocyang hace 2 meses
padre
commit
d2d5326b19

+ 11 - 0
src/components/chat/chat-message.spec.tsx

@@ -234,6 +234,17 @@ describe("chapter save preview sync regression", () => {
     expect(source).not.toContain("applyPendingChapterSave")
     expect(source).not.toContain("applyPendingChapterSave")
     expect(source).not.toContain("保存到章节后面")
     expect(source).not.toContain("保存到章节后面")
   })
   })
+
+  it("clears chapter save status on new send and conversation switch", () => {
+    const panelSource = readFileSync(resolve(__dirname, "chat-panel.tsx"), "utf8")
+    const messageSource = readFileSync(resolve(__dirname, "chat-message.tsx"), "utf8")
+
+    expect(panelSource).toContain('setChapterSaveStatus("")')
+    expect(panelSource).toMatch(/setDeAiSkillWarningMessage\(""\)[\s\S]*?setChapterSaveStatus\(""\)/)
+    expect(panelSource).toMatch(/userScrolledUpRef\.current = false[\s\S]*?setChapterSaveStatus\(""\)[\s\S]*?\}, \[activeConversationId\]\)/)
+    expect(panelSource).toContain("saveStatus={isLastAssistant ? chapterSaveStatus : undefined}")
+    expect(messageSource).toContain("isLastAssistant && saveStatus")
+  })
 })
 })
 
 
 describe("deep chapter unfinished continuation action", () => {
 describe("deep chapter unfinished continuation action", () => {

+ 36 - 2
src/components/chat/chat-message.tsx

@@ -288,7 +288,7 @@ export function ChatMessage({
               />
               />
             </div>
             </div>
           )}
           )}
-        {saveStatus && (
+        {isLastAssistant && saveStatus && (
           <p className="mt-1 text-xs text-muted-foreground">{saveStatus}</p>
           <p className="mt-1 text-xs text-muted-foreground">{saveStatus}</p>
         )}
         )}
       </div>
       </div>
@@ -977,6 +977,37 @@ function formatThinkingForDisplay(content: string): string {
 function StreamingWorkflowBlock({ content }: { content: string }) {
 function StreamingWorkflowBlock({ content }: { content: string }) {
   const displayContent = useMemo(() => formatThinkingForDisplay(content), [content])
   const displayContent = useMemo(() => formatThinkingForDisplay(content), [content])
   const { title } = useMemo(() => getThinkingBlockMeta(content, true), [content])
   const { title } = useMemo(() => getThinkingBlockMeta(content, true), [content])
+  const scrollRef = useRef<HTMLDivElement>(null)
+  const userScrolledUpRef = useRef(false)
+  const lastScrollTopRef = useRef(0)
+
+  useEffect(() => {
+    const container = scrollRef.current
+    if (!container) return
+    if (!userScrolledUpRef.current) {
+      container.scrollTop = container.scrollHeight
+      lastScrollTopRef.current = container.scrollTop
+    }
+  }, [displayContent])
+
+  useEffect(() => {
+    const container = scrollRef.current
+    if (!container) return
+    lastScrollTopRef.current = container.scrollTop
+    const handleScroll = () => {
+      const threshold = 40
+      const currentScrollTop = container.scrollTop
+      const atBottom = container.scrollHeight - currentScrollTop - container.clientHeight < threshold
+      if (currentScrollTop < lastScrollTopRef.current - 1) {
+        userScrolledUpRef.current = true
+      } else if (atBottom) {
+        userScrolledUpRef.current = false
+      }
+      lastScrollTopRef.current = currentScrollTop
+    }
+    container.addEventListener("scroll", handleScroll)
+    return () => container.removeEventListener("scroll", handleScroll)
+  }, [])
 
 
   return (
   return (
     <div className="w-full min-w-0 rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 px-2.5 py-2 min-h-[3rem]">
     <div className="w-full min-w-0 rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 px-2.5 py-2 min-h-[3rem]">
@@ -984,7 +1015,10 @@ function StreamingWorkflowBlock({ content }: { content: string }) {
         <span className="text-sm animate-pulse">📋</span>
         <span className="text-sm animate-pulse">📋</span>
         <span className="text-xs font-medium text-blue-700 dark:text-blue-400">{title}</span>
         <span className="text-xs font-medium text-blue-700 dark:text-blue-400">{title}</span>
       </div>
       </div>
-      <div className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]">
+      <div
+        ref={scrollRef}
+        className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]"
+      >
         {displayContent}
         {displayContent}
         <span className="text-blue-500"><StreamingSpinner /></span>
         <span className="text-blue-500"><StreamingSpinner /></span>
       </div>
       </div>

+ 5 - 1
src/components/chat/chat-panel.tsx

@@ -1162,6 +1162,8 @@ export function ChatPanel() {
 
 
   useEffect(() => {
   useEffect(() => {
     userScrolledUpRef.current = false
     userScrolledUpRef.current = false
+    // 切换会话时清空上一会话的章节保存状态,避免「已保存为第X章」残留
+    setChapterSaveStatus("")
   }, [activeConversationId])
   }, [activeConversationId])
 
 
   // 加载故事框架绑定状态
   // 加载故事框架绑定状态
@@ -1205,6 +1207,8 @@ export function ChatPanel() {
       const planExecuteActive =
       const planExecuteActive =
         aiWorkflowMode !== "fast" && planExecuteEnabled && !planExecutionFollowup
         aiWorkflowMode !== "fast" && planExecuteEnabled && !planExecutionFollowup
       setDeAiSkillWarningMessage("")
       setDeAiSkillWarningMessage("")
+      // 新一轮对话清空上一轮的章节保存提示,避免「已保存为第X章」残留在新消息下方
+      setChapterSaveStatus("")
 
 
       if (!plainText) {
       if (!plainText) {
         setDeAiSkillWarningMessage("请输入提示词")
         setDeAiSkillWarningMessage("请输入提示词")
@@ -1919,7 +1923,7 @@ export function ChatPanel() {
                       onSaveAsChapter={handleSaveAsChapter}
                       onSaveAsChapter={handleSaveAsChapter}
                       onContinueNextChapter={isLastAssistant ? handleContinueNextChapter : undefined}
                       onContinueNextChapter={isLastAssistant ? handleContinueNextChapter : undefined}
                       onContinueUnfinished={isLastAssistant ? () => handleContinueUnfinished(msg) : undefined}
                       onContinueUnfinished={isLastAssistant ? () => handleContinueUnfinished(msg) : undefined}
-                      saveStatus={chapterSaveStatus}
+                      saveStatus={isLastAssistant ? chapterSaveStatus : undefined}
                       isSaving={isSavingChapter}
                       isSaving={isSavingChapter}
                     />
                     />
                   )
                   )

+ 12 - 0
src/components/editor/wiki-editor.tsx

@@ -579,6 +579,8 @@ interface WikiEditorProps {
 
 
 export interface WikiEditorHandle {
 export interface WikiEditorHandle {
   getCurrentMarkdown: () => string | null;
   getCurrentMarkdown: () => string | null;
+  getImmersiveScrollTop: () => number | null;
+  setImmersiveScrollTop: (scrollTop: number) => void;
 }
 }
 
 
 function wrapBareMathBlocks(text: string): string {
 function wrapBareMathBlocks(text: string): string {
@@ -602,6 +604,7 @@ export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(
     ref,
     ref,
   ) {
   ) {
     const writingTextareaRef = useRef<WritingTextareaHandle>(null);
     const writingTextareaRef = useRef<WritingTextareaHandle>(null);
+    const immersiveScrollRef = useRef<HTMLDivElement>(null);
     // Default to read mode (ReactMarkdown render). Edit mode swaps
     // Default to read mode (ReactMarkdown render). Edit mode swaps
     // in Milkdown WYSIWYG. We default to read because:
     // in Milkdown WYSIWYG. We default to read because:
     //   1. Milkdown's commonmark/gfm preset has no wikilink schema,
     //   1. Milkdown's commonmark/gfm preset has no wikilink schema,
@@ -641,6 +644,14 @@ export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(
           if (liveBody == null) return null;
           if (liveBody == null) return null;
           return rawBlock + liveBody;
           return rawBlock + liveBody;
         },
         },
+        getImmersiveScrollTop: () => {
+          const el = immersiveScrollRef.current;
+          return el ? el.scrollTop : null;
+        },
+        setImmersiveScrollTop: (scrollTop: number) => {
+          const el = immersiveScrollRef.current;
+          if (el) el.scrollTop = scrollTop;
+        },
       }),
       }),
       [rawBlock],
       [rawBlock],
     );
     );
@@ -684,6 +695,7 @@ export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(
           </div>
           </div>
         ) : immersiveWriting ? (
         ) : immersiveWriting ? (
           <div
           <div
+            ref={immersiveScrollRef}
             className="immersive-scroll-container flex h-full w-full flex-col overflow-auto"
             className="immersive-scroll-container flex h-full w-full flex-col overflow-auto"
             style={{
             style={{
               scrollbarWidth: "thin",
               scrollbarWidth: "thin",

+ 3 - 3
src/components/layout/knowledge-tree.tsx

@@ -2031,11 +2031,11 @@ export function RawSourcesSection({ onCancelExtraction }: { onCancelExtraction?:
                         {isRunning
                         {isRunning
                           ? formatImportProgressRunningLabel(task, kindLabel)
                           ? formatImportProgressRunningLabel(task, kindLabel)
                           : task.status === "done"
                           : task.status === "done"
-                            ? `${kindLabel}提取完成`
+                            ? (task.message || `${kindLabel}提取完成`)
                             : task.status === "error"
                             : task.status === "error"
-                              ? `${kindLabel}提取失败`
+                              ? (task.message || `${kindLabel}提取失败`)
                               : task.status === "cancelled"
                               : task.status === "cancelled"
-                                ? `${kindLabel}提取已取消`
+                                ? (task.message || `${kindLabel}提取已取消`)
                                 : task.message ?? ""}
                                 : task.message ?? ""}
                       </span>
                       </span>
                     </span>
                     </span>

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

@@ -218,6 +218,7 @@ export function PreviewPanel() {
   const [chapterToolbarMoreOpen, setChapterToolbarMoreOpen] = useState(false)
   const [chapterToolbarMoreOpen, setChapterToolbarMoreOpen] = useState(false)
   const [loadedFilePath, setLoadedFilePath] = useState<string | null>(null)
   const [loadedFilePath, setLoadedFilePath] = useState<string | null>(null)
   const [diskSyncEpoch, setDiskSyncEpoch] = useState(0)
   const [diskSyncEpoch, setDiskSyncEpoch] = useState(0)
+  const pendingScrollRestoreRef = useRef<number | null>(null)
   // Snapshot of what was most recently loaded from disk. Milkdown re-emits
   // Snapshot of what was most recently loaded from disk. Milkdown re-emits
   // `markdownUpdated` on initial parse (before the user types anything),
   // `markdownUpdated` on initial parse (before the user types anything),
   // which used to trigger an auto-save that could write back a placeholder
   // which used to trigger an auto-save that could write back a placeholder
@@ -277,12 +278,30 @@ export function PreviewPanel() {
     rememberLoadedChapter(normalizedPath, diskContent)
     rememberLoadedChapter(normalizedPath, diskContent)
     fileContentRef.current = diskContent
     fileContentRef.current = diskContent
     if (selectedFileRef.current && normalizePath(selectedFileRef.current) === normalizedPath) {
     if (selectedFileRef.current && normalizePath(selectedFileRef.current) === normalizedPath) {
+      const scrollTop = wikiEditorRef.current?.getImmersiveScrollTop()
+      if (scrollTop != null) {
+        pendingScrollRestoreRef.current = scrollTop
+      }
       setFileContent(diskContent)
       setFileContent(diskContent)
       setDiskSyncEpoch((epoch) => epoch + 1)
       setDiskSyncEpoch((epoch) => epoch + 1)
     }
     }
     return true
     return true
   }, [rememberLoadedChapter, setFileContent])
   }, [rememberLoadedChapter, setFileContent])
 
 
+  useLayoutEffect(() => {
+    const pending = pendingScrollRestoreRef.current
+    if (pending == null) return
+    pendingScrollRestoreRef.current = null
+    const restore = () => wikiEditorRef.current?.setImmersiveScrollTop(pending)
+    restore()
+    // WritingTextarea autofocus/caret-to-end can scrollIntoView after mount;
+    // re-apply on the next frames so the restored position sticks.
+    requestAnimationFrame(() => {
+      restore()
+      requestAnimationFrame(restore)
+    })
+  }, [diskSyncEpoch, selectedFile])
+
   const syncDiskBeforeAction = useCallback(async () => {
   const syncDiskBeforeAction = useCallback(async () => {
     const path = selectedFileRef.current
     const path = selectedFileRef.current
     if (!path) return
     if (!path) return

+ 3 - 3
src/i18n/en.json

@@ -900,8 +900,8 @@
         "enableHint": "Used by search view, chat retrieval, novel plot search, and writing-context assembly.",
         "enableHint": "Used by search view, chat retrieval, novel plot search, and writing-context assembly.",
         "useMainLlm": "Reuse main LLM",
         "useMainLlm": "Reuse main LLM",
         "useMainLlmHint": "Use the active main model as the reranker. Turn this off to configure a dedicated rerank model.",
         "useMainLlmHint": "Use the active main model as the reranker. Turn this off to configure a dedicated rerank model.",
-        "maxCandidates": "Max rerank candidates",
-        "maxCandidatesHint": "How many top candidates enter rerank each time. Higher values improve recall but add latency and cost.",
+        "maxCandidates": "Rerank Candidate Count (TOPN)",
+        "maxCandidatesHint": "After initial retrieval, the top N candidates are sent to the reranker first. TOPN is not the number ultimately injected into the LLM; that is determined by retrieval TOPK and the specific workflow. If fewer than N candidates exist or the workflow needs more final results, the actual candidate count is adjusted accordingly. Higher values improve recall but add latency and cost.",
         "provider": "Provider",
         "provider": "Provider",
         "apiMode": "API Mode",
         "apiMode": "API Mode",
         "wireOpenAi": "OpenAI Compatible",
         "wireOpenAi": "OpenAI Compatible",
@@ -1242,7 +1242,7 @@
       "title": "Novel Writing Settings",
       "title": "Novel Writing Settings",
       "recentSummaryWindow": "Recent Chapter Text Window",
       "recentSummaryWindow": "Recent Chapter Text Window",
       "recentSummaryWindowHint": "Controls how many recent chapters are included in the writing context. The system reads text excerpts from these chapters and combines them with chapter summaries to preserve continuity; higher values use more context.",
       "recentSummaryWindowHint": "Controls how many recent chapters are included in the writing context. The system reads text excerpts from these chapters and combines them with chapter summaries to preserve continuity; higher values use more context.",
-      "searchTopK": "Search Result Count",
+      "searchTopK": "Search Result Count (Retrieval TOPK)",
       "searchTopKHint": "Controls how many relevant memory records are retrieved and injected into the writing context. Higher values add more references, but can add noise and consume context.",
       "searchTopKHint": "Controls how many relevant memory records are retrieved and injected into the writing context. Higher values add more references, but can add noise and consume context.",
       "contextTokenBudget": "Context Token Budget",
       "contextTokenBudget": "Context Token Budget",
       "contextTokenBudgetHint": "0 means unlimited",
       "contextTokenBudgetHint": "0 means unlimited",

+ 3 - 3
src/i18n/zh.json

@@ -607,8 +607,8 @@
         "enableHint": "会用于搜索面板、聊天检索、剧情搜索和写作上下文组装。",
         "enableHint": "会用于搜索面板、聊天检索、剧情搜索和写作上下文组装。",
         "useMainLlm": "复用主模型",
         "useMainLlm": "复用主模型",
         "useMainLlmHint": "直接用当前主模型做重排。关闭后可单独配置一套重排模型。",
         "useMainLlmHint": "直接用当前主模型做重排。关闭后可单独配置一套重排模型。",
-        "maxCandidates": "最大重排候选数",
-        "maxCandidatesHint": "每次进入重排的候选上限。越大召回越稳,但延迟和成本也更高。",
+        "maxCandidates": "重排候选数量(TOPN)",
+        "maxCandidatesHint": "初步检索后,优先将前 N 条候选交给 Reranker 排序。TOPN 不是最终注入 LLM 的条数;最终保留数量由检索 TOPK 和具体场景决定。若候选不足 N 或最终所需数更大,会按实际候选数处理。数值越大召回越稳,但延迟和成本也更高。",
         "provider": "提供方",
         "provider": "提供方",
         "apiMode": "API 模式",
         "apiMode": "API 模式",
         "wireOpenAi": "OpenAI 兼容",
         "wireOpenAi": "OpenAI 兼容",
@@ -1111,7 +1111,7 @@
       "title": "小说写作设置",
       "title": "小说写作设置",
       "recentSummaryWindow": "最近章节正文窗口",
       "recentSummaryWindow": "最近章节正文窗口",
       "recentSummaryWindowHint": "控制写作时纳入上下文的最近章节数量。系统会读取这些章节的正文片段,并结合章节摘要帮助模型承接近期剧情;数值越大越连贯,但会占用更多上下文。",
       "recentSummaryWindowHint": "控制写作时纳入上下文的最近章节数量。系统会读取这些章节的正文片段,并结合章节摘要帮助模型承接近期剧情;数值越大越连贯,但会占用更多上下文。",
-      "searchTopK": "检索结果数量",
+      "searchTopK": "检索结果数量(检索 TOPK)",
       "searchTopKHint": "控制从小说记忆库中检索并注入上下文的相关资料条数。数值越大,参考资料越多,但噪音和上下文占用也会增加。",
       "searchTopKHint": "控制从小说记忆库中检索并注入上下文的相关资料条数。数值越大,参考资料越多,但噪音和上下文占用也会增加。",
       "contextTokenBudget": "上下文 Token 预算",
       "contextTokenBudget": "上下文 Token 预算",
       "contextTokenBudgetHint": "0 表示无限制",
       "contextTokenBudgetHint": "0 表示无限制",

+ 9 - 0
src/index.css

@@ -138,6 +138,15 @@
     --brand-800: oklch(0.22 0.052 178);
     --brand-800: oklch(0.22 0.052 178);
 }
 }
 
 
+/* Keep native number steppers readable when the app switches color schemes. */
+input[type="number"] {
+    color-scheme: light;
+}
+
+.dark input[type="number"] {
+    color-scheme: dark;
+}
+
 :root.visual-cangzhu {
 :root.visual-cangzhu {
     --background: oklch(0.95 0.021 91);
     --background: oklch(0.95 0.021 91);
     --foreground: oklch(0.25 0.035 160);
     --foreground: oklch(0.25 0.035 160);