diff --git a/frontend-web/src/open-canvas/api/rewrite.ts b/frontend-web/src/open-canvas/api/rewrite.ts index 41474426..cae36bdd 100644 --- a/frontend-web/src/open-canvas/api/rewrite.ts +++ b/frontend-web/src/open-canvas/api/rewrite.ts @@ -1,7 +1,5 @@ import { apiFetch } from "@/core/api/fetch-client"; -import { env } from "@/env"; - -const BASE = (env.NEXT_PUBLIC_BACKEND_BASE_URL as string | undefined) ?? "http://localhost:8001"; +import { getBackendBaseURL } from "@/core/config"; export type RewriteAction = "polish" | "expand" | "shorten" | "tone"; export type RewriteTone = @@ -12,7 +10,7 @@ export type RewriteTone = | "expressive"; export interface RewriteRequest { - threadId: string; + threadId?: string | null; selection: { from: number; to: number; text: string }; action: RewriteAction; tone?: RewriteTone; @@ -71,7 +69,7 @@ export async function* streamRewrite( req: RewriteRequest, signal?: AbortSignal, ): AsyncGenerator { - const res = await apiFetch(`${BASE}/api/writing/rewrite`, { + const res = await apiFetch(`${getBackendBaseURL()}/api/writing/rewrite`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req), diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index ec63a4c3..4fdd9b7d 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -1,6 +1,6 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Plus, RotateCcw, Settings2, Square } from 'lucide-react'; +import { ChevronsDown, Plus, RotateCcw, Settings2, Square } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -50,6 +50,84 @@ export function AIWritingPanel() { const isRunning = !isIdle && !isDone && !isError; const showSkeleton = isRunning && !state.currentPause; + // ── 自动滚底逻辑 ──────────────────────────────────────────────────────────── + const scrollRef = useRef(null); + // 程序触发滚动时置 true,阻止 scroll 事件误判为用户上划 + const isAutoScrollingRef = useRef(false); + // 同步 ref:让 scroll/wheel 回调可以立即读取最新值,不依赖 state 异步更新 + const autoScrollRef = useRef(true); + const [autoScroll, setAutoScroll] = useState(true); + + const disableAutoScroll = useCallback(() => { + autoScrollRef.current = false; + setAutoScroll(false); + }, []); + + // 新会话 / 重置后恢复自动滚底 + useEffect(() => { + if (isIdle) { + autoScrollRef.current = true; + setAutoScroll(true); + } + }, [isIdle]); + + // 监听用户手动滚动(挂载一次) + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + + // wheel 事件:鼠标滚轮 / 触控板,只由用户触发 + const onWheel = (e: WheelEvent) => { + if (e.deltaY < 0) disableAutoScroll(); // 向上滚 + }; + + // scroll 事件:兼容拖动滚动条的场景 + // 通过对比前后 scrollTop 判断是否向上滚(排除程序触发的向下滚) + let lastTop = el.scrollTop; + const onScroll = () => { + const cur = el.scrollTop; + if (!isAutoScrollingRef.current && cur < lastTop - 2) { + disableAutoScroll(); + } + lastTop = cur; + }; + + el.addEventListener('wheel', onWheel, { passive: true }); + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('wheel', onWheel); + el.removeEventListener('scroll', onScroll); + }; + }, [disableAutoScroll]); + + // 内容变化时若自动滚底开启则滚到底部 + useEffect(() => { + // 检查同步 ref,避免 state 异步导致的时序竞争 + if (!autoScrollRef.current || !isRunning || !scrollRef.current) return; + const el = scrollRef.current; + isAutoScrollingRef.current = true; + el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => { isAutoScrollingRef.current = false; }); + }, [ + autoScroll, // 点击按钮后重新触发一次 + isRunning, + state.progressEvents.length, + state.streamingDraftSections.length, + state.streamingReviewItems.length, + state.outlineStreamingText, + state.currentPause, + ]); + + const scrollToBottom = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + isAutoScrollingRef.current = true; + el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => { isAutoScrollingRef.current = false; }); + autoScrollRef.current = true; + setAutoScroll(true); + }, []); + const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => { setHistorySession(session); // Push historical draft to the right panel @@ -158,8 +236,9 @@ export function AIWritingPanel() { - {/* 主体:可滚动 */} -
+ {/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */} +
+
{/* 未开始时显示表单 */} {isIdle && ( @@ -233,7 +312,21 @@ export function AIWritingPanel() { {state.error}
)} -
+
{/* 滚动内容区结束 */} + + {/* 回到底部悬浮按钮:生成中且用户已上划时出现 */} + {isRunning && !autoScroll && ( +
+ +
+ )} + {/* 相对定位容器结束 */} {/* 状态栏 */} {!isIdle && ( diff --git a/frontend-web/src/open-canvas/hooks/useWritingRewrite.ts b/frontend-web/src/open-canvas/hooks/useWritingRewrite.ts index ab70fcaf..de8a303d 100644 --- a/frontend-web/src/open-canvas/hooks/useWritingRewrite.ts +++ b/frontend-web/src/open-canvas/hooks/useWritingRewrite.ts @@ -122,7 +122,7 @@ export function useWritingRewrite(editor: Editor | null) { const rewrite = useCallback( async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => { - if (!writingSelection || !threadId || !editor) return; + if (!writingSelection || !editor) return; abortRef.current?.abort(); const abort = new AbortController(); @@ -152,12 +152,12 @@ export function useWritingRewrite(editor: Editor | null) { await runStream(req, abort); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [writingSelection, threadId, modelName, editor, runStream], + [writingSelection, modelName, editor, runStream], ); const rewriteFollowUp = useCallback( async (instruction: string, action: RewriteAction, tone?: RewriteTone) => { - if (!writingSelection || !threadId || !editor) return; + if (!writingSelection || !editor) return; const prevResult = result; if (!prevResult) return; @@ -190,7 +190,7 @@ export function useWritingRewrite(editor: Editor | null) { await runStream(req, abort); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [writingSelection, threadId, modelName, editor, result, runStream], + [writingSelection, modelName, editor, result, runStream], ); const reset = useCallback(() => { diff --git a/offline-backend-20260512/backend/app/gateway/routers/writing.py b/offline-backend-20260512/backend/app/gateway/routers/writing.py index 6380b88b..b8fe31ab 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/writing.py +++ b/offline-backend-20260512/backend/app/gateway/routers/writing.py @@ -70,7 +70,7 @@ class SurroundingContext(BaseModel): class RewriteRequest(BaseModel): - thread_id: str = Field(..., alias="threadId") + thread_id: str | None = Field(default=None, alias="threadId") selection: SelectionInfo action: str = Field(..., description="polish | expand | shorten | tone") tone: str | None = Field(default=None)