Sfoglia il codice sorgente

Merge pull request #28 from darknessomi/fix/settings-i18n-and-number-input

fix(ui): 澄清 TOPN/TOPK 文案并修复数字输入框步进器
Mochocyang 2 mesi fa
parent
commit
917aea2592

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

@@ -76,6 +76,17 @@ describe("chapter save preview sync regression", () => {
     expect(source).not.toContain("applyPendingChapterSave")
     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", () => {

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

@@ -140,7 +140,7 @@ export function ChatMessage({ message, isLastAssistant, onRegenerate, novelMode,
             )}
           </div>
         )}
-        {saveStatus && (
+        {isLastAssistant && saveStatus && (
           <p className="mt-1 text-xs text-muted-foreground">{saveStatus}</p>
         )}
       </div>
@@ -873,6 +873,37 @@ function formatThinkingForDisplay(content: string): string {
 function StreamingWorkflowBlock({ content }: { content: string }) {
   const displayContent = useMemo(() => formatThinkingForDisplay(content), [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 (
     <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]">
@@ -880,7 +911,10 @@ function StreamingWorkflowBlock({ content }: { content: string }) {
         <span className="text-sm animate-pulse">📋</span>
         <span className="text-xs font-medium text-blue-700 dark:text-blue-400">{title}</span>
       </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}
         <span className="animate-pulse text-blue-500">▊</span>
       </div>

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

@@ -351,6 +351,8 @@ export function ChatPanel() {
 
   useEffect(() => {
     userScrolledUpRef.current = false
+    // 切换会话时清空上一会话的章节保存状态,避免「已保存为第X章」残留
+    setChapterSaveStatus("")
   }, [activeConversationId])
 
   // 加载故事框架绑定状态
@@ -390,6 +392,8 @@ export function ChatPanel() {
     async (text: string) => {
       // Auto-create a conversation if none is active
       setDeAiSkillWarningMessage("")
+      // 新一轮对话清空上一轮的章节保存提示,避免「已保存为第X章」残留在新消息下方
+      setChapterSaveStatus("")
       let convId = useChatStore.getState().activeConversationId
       if (!convId) {
         convId = createConversation()
@@ -1504,7 +1508,7 @@ export function ChatPanel() {
                       onSaveAsChapter={handleSaveAsChapter}
                       onContinueNextChapter={isLastAssistant ? handleContinueNextChapter : undefined}
                       onContinueUnfinished={isLastAssistant ? () => handleContinueUnfinished(msg) : undefined}
-                      saveStatus={chapterSaveStatus}
+                      saveStatus={isLastAssistant ? chapterSaveStatus : undefined}
                       isSaving={isSavingChapter}
                     />
                   )

+ 15 - 1
src/components/editor/wiki-editor.tsx

@@ -504,6 +504,8 @@ interface WikiEditorProps {
 
 export interface WikiEditorHandle {
   getCurrentMarkdown: () => string | null
+  getImmersiveScrollTop: () => number | null
+  setImmersiveScrollTop: (scrollTop: number) => void
 }
 
 function wrapBareMathBlocks(text: string): string {
@@ -523,6 +525,7 @@ export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(function
   onHighlightHandled,
 }, ref) {
   const writingTextareaRef = useRef<WritingTextareaHandle>(null)
+  const immersiveScrollRef = useRef<HTMLDivElement>(null)
   // Default to read mode (ReactMarkdown render). Edit mode swaps
   // in Milkdown WYSIWYG. We default to read because:
   //   1. Milkdown's commonmark/gfm preset has no wikilink schema,
@@ -560,6 +563,14 @@ export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(function
       if (liveBody == null) return null
       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])
 
   useEffect(() => {
@@ -589,7 +600,10 @@ export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(function
         </div>
       ) : (
         immersiveWriting ? (
-          <div className="immersive-scroll-container flex h-full w-full flex-col overflow-auto" style={{ 
+          <div
+            ref={immersiveScrollRef}
+            className="immersive-scroll-container flex h-full w-full flex-col overflow-auto"
+            style={{
             scrollbarWidth: "thin",
             scrollbarColor: "oklch(0.75 0 0) transparent"
           }}>

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

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

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

@@ -214,6 +214,7 @@ export function PreviewPanel() {
   const [chapterToolbarMoreOpen, setChapterToolbarMoreOpen] = useState(false)
   const [loadedFilePath, setLoadedFilePath] = useState<string | null>(null)
   const [diskSyncEpoch, setDiskSyncEpoch] = useState(0)
+  const pendingScrollRestoreRef = useRef<number | null>(null)
   // Snapshot of what was most recently loaded from disk. Milkdown re-emits
   // `markdownUpdated` on initial parse (before the user types anything),
   // which used to trigger an auto-save that could write back a placeholder
@@ -272,12 +273,30 @@ export function PreviewPanel() {
     rememberLoadedChapter(normalizedPath, diskContent)
     fileContentRef.current = diskContent
     if (selectedFileRef.current && normalizePath(selectedFileRef.current) === normalizedPath) {
+      const scrollTop = wikiEditorRef.current?.getImmersiveScrollTop()
+      if (scrollTop != null) {
+        pendingScrollRestoreRef.current = scrollTop
+      }
       setFileContent(diskContent)
       setDiskSyncEpoch((epoch) => epoch + 1)
     }
     return true
   }, [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 path = selectedFileRef.current
     if (!path) return

+ 3 - 3
src/i18n/en.json

@@ -893,8 +893,8 @@
         "enableHint": "Used by search view, chat retrieval, novel plot search, and writing-context assembly.",
         "useMainLlm": "Reuse main LLM",
         "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",
         "apiMode": "API Mode",
         "wireOpenAi": "OpenAI Compatible",
@@ -1196,7 +1196,7 @@
       "title": "Novel Writing Settings",
       "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.",
-      "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.",
       "contextTokenBudget": "Context Token Budget",
       "contextTokenBudgetHint": "0 means unlimited",

+ 3 - 3
src/i18n/zh.json

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

+ 9 - 0
src/index.css

@@ -83,6 +83,15 @@
     --sidebar-ring: oklch(0.708 0 0);
 }
 
+/* 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 {
     --background: oklch(0.95 0.021 91);
     --foreground: oklch(0.25 0.035 160);