Skip to content

Commit 8b73025

Browse files
committed
feat(web): improve chat auto-scroll to latest message; dynamic thresholds; initial scroll to bottom
1 parent 3920516 commit 8b73025

File tree

6 files changed

+184
-54
lines changed

6 files changed

+184
-54
lines changed

src/api/app.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,23 @@ async def event_generator():
149149
"data": json.dumps({"task_id": task_id}, ensure_ascii=False),
150150
}
151151

152-
async for event in run_agent_workflow(
153-
messages,
154-
request.debug,
155-
request.deep_thinking_mode,
156-
request.search_before_planning,
157-
request.team_members,
158-
abort_event=abort_event,
159-
user_id=user_id,
160-
request_headers=dict(req.headers),
161-
):
152+
use_simple = not request.deep_thinking_mode and not request.search_before_planning
153+
from src.service.workflow_service import run_simple_chat
154+
generator = (
155+
run_simple_chat(messages, user_id=user_id)
156+
if use_simple
157+
else run_agent_workflow(
158+
messages,
159+
request.debug,
160+
request.deep_thinking_mode,
161+
request.search_before_planning,
162+
request.team_members,
163+
abort_event=abort_event,
164+
user_id=user_id,
165+
request_headers=dict(req.headers),
166+
)
167+
)
168+
async for event in generator:
162169
# Check if client is still connected or abort requested
163170
if await req.is_disconnected():
164171
logger.info("Client disconnected, stopping workflow")

src/service/workflow_service.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from src.graph import build_graph
77
from src.tools.browser import browser_tool
88
from src.tools.smart_browser import smart_browser_tool
9+
from src.llms.llm import get_llm_by_type
910
from langchain_community.adapters.openai import convert_message_to_dict
1011
import uuid
1112

@@ -89,6 +90,8 @@ async def run_agent_workflow(
8990
workflow_id = str(uuid.uuid4())
9091

9192
team_members = team_members if team_members else TEAM_MEMBERS
93+
if not search_before_planning:
94+
team_members = [m for m in team_members if m != "researcher"]
9295

9396
streaming_llm_agents = [*team_members, "planner", "coordinator"]
9497

@@ -348,3 +351,37 @@ def safe_convert_message(msg):
348351
],
349352
},
350353
}
354+
355+
356+
async def run_simple_chat(
357+
user_input_messages: list,
358+
user_id: Optional[int] = None,
359+
):
360+
if not user_input_messages:
361+
raise ValueError("Input could not be empty")
362+
import uuid
363+
workflow_id = str(uuid.uuid4())
364+
agent_id = f"{workflow_id}_assistant_0"
365+
llm = get_llm_by_type("basic", str(user_id) if user_id else None)
366+
yield {
367+
"event": "start_of_agent",
368+
"data": {"agent_name": "assistant", "agent_id": agent_id},
369+
}
370+
last = user_input_messages[-1]
371+
content = last.get("content") if isinstance(last, dict) else str(last)
372+
full = ""
373+
try:
374+
for chunk in llm.stream(content):
375+
piece = getattr(chunk, "content", "") or getattr(chunk, "additional_kwargs", {}).get("reasoning_content", "")
376+
if not piece:
377+
continue
378+
full += piece
379+
yield {
380+
"event": "message",
381+
"data": {"message_id": agent_id, "delta": {"content": piece}},
382+
}
383+
finally:
384+
yield {
385+
"event": "end_of_agent",
386+
"data": {"agent_name": "assistant", "agent_id": agent_id},
387+
}

web/src/app/_components/InputBox.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,37 +96,37 @@ export function InputBox({
9696

9797
<button
9898
className={cn(
99-
"flex h-6 sm:h-8 lg:h-9 xl:h-10 items-center gap-1 sm:gap-2 rounded-xl sm:rounded-2xl border px-2 sm:px-4 lg:px-5 xl:px-6 text-xs sm:text-sm lg:text-base transition-all duration-300 hover:shadow-lg",
99+
"h-7 w-7 sm:h-8 sm:w-8 flex items-center justify-center rounded-md border transition-all duration-200",
100100
deepThinkingMode
101-
? "border-blue-400 bg-blue-100 text-blue-700 shadow-lg shadow-blue-500/20"
101+
? "border-blue-400 bg-blue-50 text-blue-700 shadow-blue-500/20"
102102
: "border-gray-300 bg-white text-gray-600 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-700",
103103
)}
104104
onClick={() => {
105105
toggleDeepThinking();
106106
}}
107107
disabled={configLoading}
108+
title="深度思考"
109+
aria-label="深度思考"
108110
>
109111
<Atom className={cn("h-4 w-4", deepThinkingMode ? "text-blue-600" : "text-gray-600")} />
110-
<span>Deep Think</span>
111-
{/* {syncing && <span className="text-xs text-blue-500 ml-1">(同步中...)</span>} */}
112112
</button>
113113
<button
114114
className={cn(
115-
"flex h-6 sm:h-8 lg:h-9 xl:h-10 items-center gap-1 rounded-lg border px-2 sm:px-3 lg:px-4 xl:px-5 text-xs sm:text-sm lg:text-base transition-all duration-200",
115+
"h-7 w-7 sm:h-8 sm:w-8 flex items-center justify-center rounded-md border transition-all duration-200",
116116
searchBeforePlanning
117-
? "border-blue-400 bg-blue-100 text-blue-700 shadow-lg shadow-blue-500/20"
118-
: "border-gray-300 bg-white text-gray-700 hover:bg-blue-100 hover:border-blue-400",
117+
? "border-blue-400 bg-blue-50 text-blue-700 shadow-blue-500/20"
118+
: "border-gray-300 bg-white text-gray-700 hover:bg-blue-50 hover:border-blue-400",
119119
)}
120120
onClick={() => {
121121
toggleSearchPlanning();
122122
}}
123123
disabled={configLoading}
124+
title="优先搜索"
125+
aria-label="优先搜索"
124126
>
125127
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
126128
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
127129
</svg>
128-
<span>Search First</span>
129-
{/* {syncing && <span className="text-xs text-blue-500 ml-1">(同步中...)</span>} */}
130130
</button>
131131
</div>
132132
<div className="flex flex-shrink-0 items-center gap-1 sm:gap-2 lg:gap-3">

web/src/app/_components/MessageHistoryView.tsx

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,36 @@ export function MessageHistoryView({ messages, responding, abortController, clas
6262
const SCROLL_MEMORY_KEY = 'chat_scrollTop';
6363
const lastScrollHeightRef = useRef(0);
6464
const lastScrollSetTsRef = useRef(0);
65+
const lastMessageIdRef = useRef<string | null>(null);
66+
const getDynamicThreshold = useCallback((el: HTMLDivElement | null, base: number) => {
67+
const h = el?.clientHeight ?? 0;
68+
const d = Math.floor(h * 0.15);
69+
const t = Math.max(50, d);
70+
return Math.max(50, Math.min(base, t));
71+
}, []);
6572
const hasAssistantContent = useMemo(() => {
6673
return messages.some(
6774
(m) => m.role === "assistant" && m.type === "text" && typeof m.content === "string" && m.content.trim().length > 0,
6875
);
6976
}, [messages]);
77+
78+
// 当开始生成助手回复时,主动滚动到底部,避免需要手动拖动
79+
useEffect(() => {
80+
if (!responding) return;
81+
setIsLockedByUser(false);
82+
const container = scrollElRef.current ?? containerRef.current;
83+
if (container) {
84+
requestAnimationFrame(() => {
85+
container.scrollTop = container.scrollHeight;
86+
lastScrollHeightRef.current = container.scrollHeight;
87+
lastScrollSetTsRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
88+
});
89+
} else if (endRef.current) {
90+
requestAnimationFrame(() => {
91+
endRef.current?.scrollIntoView({ behavior: 'smooth' });
92+
});
93+
}
94+
}, [responding]);
7095
const showInlineSpinner = responding && !hasAssistantContent;
7196

7297
// 防抖处理滚动事件
@@ -115,8 +140,9 @@ export function MessageHistoryView({ messages, responding, abortController, clas
115140

116141
const { scrollTop, scrollHeight, clientHeight } = container;
117142
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
118-
const isAtBottom = distanceToBottom < 50;
119-
setIsNearBottom(distanceToBottom < 100);
143+
const t = getDynamicThreshold(container, 100);
144+
const isAtBottom = distanceToBottom < Math.floor(t * 0.5);
145+
setIsNearBottom(distanceToBottom < t);
120146

121147
if (!isAtBottom) {
122148
setIsUserScrolling(true);
@@ -156,8 +182,9 @@ export function MessageHistoryView({ messages, responding, abortController, clas
156182
container.scrollTop += e.deltaY;
157183
const { scrollTop, scrollHeight, clientHeight } = container;
158184
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
159-
setIsNearBottom(distanceToBottom < 100);
160-
if (distanceToBottom >= 100) {
185+
const t = getDynamicThreshold(container, 100);
186+
setIsNearBottom(distanceToBottom < t);
187+
if (distanceToBottom >= t) {
161188
setIsLockedByUser(true);
162189
}
163190
};
@@ -236,30 +263,34 @@ export function MessageHistoryView({ messages, responding, abortController, clas
236263
setIsLockedByUser(true);
237264
});
238265
}
266+
} else {
267+
requestAnimationFrame(() => {
268+
container.scrollTop = container.scrollHeight;
269+
setIsLockedByUser(false);
270+
lastScrollHeightRef.current = container.scrollHeight;
271+
lastScrollSetTsRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
272+
});
239273
}
240274
} catch {}
241275
}, []);
242276

243277
// 优化的自动滚动逻辑
244278
useEffect(() => {
245-
if (!isUserScrolling && isWindowVisible && !isResizing) {
279+
if (!isUserScrolling && isWindowVisible && !isResizing && !isLockedByUser) {
246280
const scrollToBottom = () => {
247281
if (rafScrollPendingRef.current) return;
248282
rafScrollPendingRef.current = true;
249283
requestAnimationFrame(() => {
250284
const container = scrollElRef.current ?? containerRef.current;
251285
if (container) {
252286
const { scrollTop, scrollHeight, clientHeight } = container;
253-
const nearBottom = scrollHeight - scrollTop - clientHeight < 100;
254-
if (nearBottom && !isLockedByUser) {
255-
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
256-
const changed = scrollHeight > (lastScrollHeightRef.current + 8);
257-
const enoughTime = now - lastScrollSetTsRef.current > 50;
258-
if (changed && enoughTime) {
259-
container.scrollTop = scrollHeight;
260-
lastScrollHeightRef.current = scrollHeight;
261-
lastScrollSetTsRef.current = now;
262-
}
287+
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
288+
const changed = scrollHeight > (lastScrollHeightRef.current + 4);
289+
const enoughTime = now - lastScrollSetTsRef.current > 32;
290+
if (changed && enoughTime) {
291+
container.scrollTop = scrollHeight;
292+
lastScrollHeightRef.current = scrollHeight;
293+
lastScrollSetTsRef.current = now;
263294
}
264295
}
265296
rafScrollPendingRef.current = false;
@@ -273,6 +304,44 @@ export function MessageHistoryView({ messages, responding, abortController, clas
273304
}
274305
}, [messages, responding, isUserScrolling, isWindowVisible, isResizing, isLockedByUser]);
275306

307+
// 当用户发送新消息时,强制滚动到底部以显示最新内容
308+
useEffect(() => {
309+
const last = messages[messages.length - 1];
310+
if (!last) return;
311+
const lastId = String(last.id ?? "");
312+
const isNewLast = lastMessageIdRef.current !== lastId;
313+
lastMessageIdRef.current = lastId;
314+
if (isNewLast && last.role === "user") {
315+
setIsLockedByUser(false);
316+
const container = scrollElRef.current ?? containerRef.current;
317+
if (container) {
318+
requestAnimationFrame(() => {
319+
container.scrollTop = container.scrollHeight;
320+
lastScrollHeightRef.current = container.scrollHeight;
321+
lastScrollSetTsRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
322+
});
323+
} else if (endRef.current) {
324+
requestAnimationFrame(() => {
325+
endRef.current?.scrollIntoView({ behavior: 'smooth' });
326+
});
327+
}
328+
}
329+
if (isNewLast && last.role === "assistant" && !isLockedByUser) {
330+
const container = scrollElRef.current ?? containerRef.current;
331+
if (container) {
332+
requestAnimationFrame(() => {
333+
container.scrollTop = container.scrollHeight;
334+
lastScrollHeightRef.current = container.scrollHeight;
335+
lastScrollSetTsRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
336+
});
337+
} else if (endRef.current) {
338+
requestAnimationFrame(() => {
339+
endRef.current?.scrollIntoView({ behavior: 'smooth' });
340+
});
341+
}
342+
}
343+
}, [messages]);
344+
276345
return (
277346
<div
278347
ref={containerRef}

web/src/app/_components/WorkflowProgressView.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ function unwrapMarkdownFence(text: string): string {
2222
return text;
2323
}
2424

25+
function stripReportHeadingAndSummary(text: string): string {
26+
if (!text) return "";
27+
let t = text.trim();
28+
t = t.replace(/^#\s+[^\n]+\s*\n?/, "");
29+
t = t.replace(/(^|\n)#{1,6}\s*\s*\n[\s\S]*?(?=(\n#{1,6}\s)|$)/, "\n");
30+
return t.trim();
31+
}
32+
2533
export function WorkflowProgressView({
2634
className,
2735
workflow,
@@ -30,7 +38,12 @@ export function WorkflowProgressView({
3038
workflow: Workflow;
3139
}) {
3240
const steps = useMemo(() => {
33-
return workflow.steps.filter((step) => step.agentName !== "reporter");
41+
const { searchBeforePlanning } = getInputConfigSync();
42+
return workflow.steps.filter((step) => {
43+
if (step.agentName === "reporter") return false;
44+
if (step.agentName === "researcher" && !searchBeforePlanning) return false;
45+
return true;
46+
});
3447
}, [workflow]);
3548
const reportStep = useMemo(() => {
3649
return workflow.steps.find((step) => step.agentName === "reporter");
@@ -46,6 +59,7 @@ export function WorkflowProgressView({
4659

4760
// 移除最外层 markdown 代码围栏,避免整个内容被当作代码块显示
4861
const sanitizedReportContent = useMemo(() => unwrapMarkdownFence(reportContent), [reportContent]);
62+
const cleanedReportContent = useMemo(() => stripReportHeadingAndSummary(sanitizedReportContent), [sanitizedReportContent]);
4963

5064
// 调试日志,确认内容是否为 markdown 以及基本统计
5165
useEffect(() => {
@@ -82,11 +96,11 @@ export function WorkflowProgressView({
8296
<div className="agent-reply-card">
8397
<div className="agent-reply-content">
8498
<Markdown className="agent-prose break-words text-sm">
85-
{sanitizedReportContent}
99+
{cleanedReportContent}
86100
</Markdown>
87101
</div>
88102
<div className="agent-reply-toolbar justify-start">
89-
<ReportActions reportContent={sanitizedReportContent} />
103+
<ReportActions reportContent={cleanedReportContent} />
90104
</div>
91105
</div>
92106
)}

web/src/app/chat/page.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import { ResultSidePanel } from "../_components/ResultSidePanel";
1818
import { SessionHistoryModal } from "../_components/SessionHistoryModal";
1919
import { SlidingLayout } from "../_components/SlidingLayout";
2020
import { sidePanelEventManager } from "../_components/ToolCallView";
21+
import { useInputConfigValue } from "~/core/hooks/useInputConfig";
2122

2223
export default function HomePage() {
24+
const inputConfig = useInputConfigValue();
2325
const abortControllerRef = useRef<AbortController | null>(null);
2426
const messages = useStore((state) => state.messages);
2527
const responding = useStore((state) => state.responding);
@@ -278,23 +280,24 @@ export default function HomePage() {
278280
</p>
279281
</div>
280282

281-
{/* Quick Start Examples */}
282-
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-8">
283-
<div className="p-4 bg-white rounded-xl border border-gray-200 hover:border-gray-300 hover:shadow-md transition-all duration-300 cursor-pointer">
284-
<div className="text-sm font-medium text-gray-800 mb-1">💡 Get Ideas</div>
285-
<div className="text-xs text-gray-600">Brainstorm creative solutions</div>
286-
</div>
287-
<div className="p-4 bg-white rounded-xl border border-gray-200 hover:border-gray-300 hover:shadow-md transition-all duration-300 cursor-pointer">
288-
<div className="text-sm font-medium text-gray-800 mb-1">📝 Write Content</div>
289-
<div className="text-xs text-gray-600">Create articles, emails, and more</div>
290-
</div>
291-
<div className="p-4 bg-white rounded-xl border border-gray-200 hover:border-gray-300 hover:shadow-md transition-all duration-300 cursor-pointer">
292-
<div className="text-sm font-medium text-gray-800 mb-1">🔍 Research</div>
293-
<div className="text-xs text-gray-600">Find information and insights</div>
294-
</div>
295-
<div className="p-4 bg-white rounded-xl border border-gray-200 hover:border-gray-300 hover:shadow-md transition-all duration-300 cursor-pointer">
296-
<div className="text-sm font-medium text-gray-800 mb-1">💻 Code Help</div>
297-
<div className="text-xs text-gray-600">Debug and write code</div>
283+
{/* Common Questions */}
284+
<div className="mt-6">
285+
<div className="text-sm font-medium text-gray-700 mb-2">常用问题</div>
286+
<div className="flex flex-col gap-2">
287+
{[
288+
"帮我总结这段文本",
289+
"生成一个三天的上海旅行计划",
290+
"搜索并整理近期关于AI的行业新闻",
291+
"帮我优化这段React代码性能",
292+
].map((q) => (
293+
<button
294+
key={q}
295+
className="text-left px-3 py-1.5 rounded-md border border-gray-200 bg-white text-gray-700 hover:bg-blue-50 hover:border-blue-300 transition-colors"
296+
onClick={() => handleSendMessage(q, inputConfig)}
297+
>
298+
{q}
299+
</button>
300+
))}
298301
</div>
299302
</div>
300303
</div>

0 commit comments

Comments
 (0)