Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions crates/flock-agent/src/engine/run/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,29 @@ impl AgentEngine {
pub async fn run(&mut self, user_input: &str, msg_id: &str) -> Result<AgentResult, AgentError> {
let (initial_json, config) = prepare_run(self, user_input, msg_id).await?;

// ── 批量 token 缓冲 ──────────────────────────────────────────────────
// 每积累 BATCH_SIZE 个 token 才统一 emit 一次,减少 IPC 调用开销。
// 值越小越流畅但 IPC 越频繁,值越大越节省 IPC 但打字机延迟增大。
// 4 表示约每 4 个 token emit 一次,对 deepseek/claude 高吞吐场景效果明显。
const BATCH_SIZE: usize = 4;
let mut text_buf = String::new();
let mut thinking_buf = String::new();
let mut batch_count: usize = 0;

macro_rules! flush_batch {
() => {
if !thinking_buf.is_empty() {
self.output.emit_thinking(&thinking_buf, msg_id);
thinking_buf.clear();
}
if !text_buf.is_empty() {
self.output.emit_text_delta(&text_buf, msg_id);
text_buf.clear();
}
batch_count = 0;
};
}

let mut stream = self.graph.as_ref().unwrap().astream(
&initial_json,
&config,
Expand All @@ -236,6 +259,7 @@ impl AgentEngine {
while let Some(part_res) = stream.next().await {
// Check if execution was canceled
if self.cancel_flag.load(Ordering::Relaxed) {
flush_batch!();
self.output.emit_info("[engine] cancel_flag is set during stream, aborting run");
self.sync_and_save_session(&config).await;
return Err(AgentError::UserAborted);
Expand All @@ -247,16 +271,27 @@ impl AgentEngine {
let type_str = part_res.data.get("type").and_then(|v| v.as_str()).unwrap_or("content");
if let Some(chunk) = part_res.data.get("chunk").and_then(|v| v.as_str()) {
if type_str == "thinking" {
self.output.emit_thinking(chunk, msg_id);
thinking_buf.push_str(chunk);
} else {
self.output.emit_text_delta(chunk, msg_id);
text_buf.push_str(chunk);
}
batch_count += 1;
if batch_count >= BATCH_SIZE {
flush_batch!();
}
}
}
}
} else {
// 非 token 事件到达(Updates mode:tool_request、stream_end 等),先 flush 缓冲
flush_batch!();
}
}

// 流结束后确保剩余缓冲全部发出
flush_batch!();


// Stream completed, check if any internal error was raised (like user cancellation in FlockToolNode)
let err_opt = self.has_error.lock().unwrap().clone();
if let Some(err) = err_opt {
Expand Down
4 changes: 4 additions & 0 deletions crates/flock-core/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ impl DbManager {
.execute(&pool)
.await?;

sqlx::query("PRAGMA synchronous=NORMAL")
.execute(&pool)
.await?;

let mgr = Self { pool, db_path };
mgr.run_migrations().await?;
mgr.seed_builtin_providers().await?;
Expand Down
26 changes: 26 additions & 0 deletions flock-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions flock-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"@tanstack/react-virtual": "^3.14.2",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@types/react-syntax-highlighter": "^15.5.13",
Expand Down
71 changes: 64 additions & 7 deletions flock-ui/src-tauri/src/ipc/emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ impl TauriProtocolEmitter {
pub fn new(app: AppHandle, session_id: String) -> Self {
Self { app, session_id }
}

fn get_session_id(&self) -> String {
flock_core::CURRENT_SESSION_ID
.try_with(|id| id.clone())
.unwrap_or_else(|_| self.session_id.clone())
}
}

impl ProtocolEmitter for TauriProtocolEmitter {
fn emit(&self, event: &ProtocolEvent) -> std::io::Result<()> {
// 优先使用 task-local(engine.run 内正确反映当前并发 session)
// 若不在作用域(如 start_agent 阶段、drop 清理 task)则退回到构造时绑定的 session_id
let session_id = flock_core::CURRENT_SESSION_ID
.try_with(|id| id.clone())
.unwrap_or_else(|_| self.session_id.clone());

// log::info!("[TauriProtocolEmitter::emit] Emitting event: {:?}, session_id: {}", event, session_id);
let session_id = self.get_session_id();

let mut value = serde_json::to_value(event).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to serialize event: {e}"))
Expand Down Expand Up @@ -127,3 +127,60 @@ impl OutputSink for TauriProtocolEmitter {
});
}
}

/// 批量 token emitter,将高频 text_delta / thinking 合并后再发给前端,
/// 显著减少 IPC 调用次数,提高打字机流畅度。
///
/// 使用方式:在 engine run 循环里构造 BatchEmitter,
/// 每次收到 token chunk 调用 push_text / push_thinking,
/// 在下一个非流 chunk(tool_request/stream_end)到来前调用 flush。
pub struct BatchEmitter<'a> {
inner: &'a TauriProtocolEmitter,
msg_id: String,
text_buf: String,
thinking_buf: String,
}

impl<'a> BatchEmitter<'a> {
pub fn new(inner: &'a TauriProtocolEmitter, msg_id: &str) -> Self {
Self {
inner,
msg_id: msg_id.to_string(),
text_buf: String::new(),
thinking_buf: String::new(),
}
}

/// 追加一段 text_delta token
pub fn push_text(&mut self, text: &str) {
self.text_buf.push_str(text);
}

/// 追加一段 thinking token
pub fn push_thinking(&mut self, text: &str) {
self.thinking_buf.push_str(text);
}

/// 将缓冲区内容一次性发给前端,清空缓冲
pub fn flush(&mut self) {
if !self.thinking_buf.is_empty() {
let _ = self.inner.emit(&ProtocolEvent::Thinking {
text: std::mem::take(&mut self.thinking_buf),
msg_id: self.msg_id.clone(),
});
}
if !self.text_buf.is_empty() {
let _ = self.inner.emit(&ProtocolEvent::TextDelta {
text: std::mem::take(&mut self.text_buf),
msg_id: self.msg_id.clone(),
});
}
}
}

impl<'a> Drop for BatchEmitter<'a> {
fn drop(&mut self) {
// 确保析构时缓冲区不丢失
self.flush();
}
}
111 changes: 98 additions & 13 deletions flock-ui/src/components/chat/assistant/AssistantChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { ScrollArea, Stack } from '@mantine/core';
import { useEffect, useRef, useCallback } from 'react';
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChatMessage } from '@/types/protocol';
import { EmptyState } from './components/EmptyState';
import { MessageBubble } from './components/MessageBubble';
Expand All @@ -9,24 +10,108 @@ interface ChatPanelProps {
}

export function AssistantChatPanel({ messages }: ChatPanelProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
const scrollDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isStreamingRef = useRef(false);

// 检测是否正在流式输出(最后一条消息的 streaming 字段)
const lastMsg = messages[messages.length - 1];
const isStreaming = !!lastMsg?.streaming;
isStreamingRef.current = isStreaming;

const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120, // 每条消息预估高度,虚拟化引擎会动态修正
overscan: 5, // 上下多渲染 5 条,避免滚动时白屏
measureElement: (el) => el?.getBoundingClientRect().height ?? 120,
});

// 平滑滚动到底部,防抖避免每个 token 都触发
const scrollToBottom = useCallback((instant = false) => {
if (!parentRef.current) return;
const el = parentRef.current;
if (instant) {
el.scrollTop = el.scrollHeight;
} else {
if (scrollDebounceRef.current) clearTimeout(scrollDebounceRef.current);
scrollDebounceRef.current = setTimeout(() => {
el.scrollTop = el.scrollHeight;
}, 80); // 80ms debounce,约 12fps 的滚动触发频率,流畅且不跟不上
}
}, []);

// 消息数量变化时(新消息到来)立刻滚到底部
useEffect(() => {
scrollToBottom(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.length]);

// 流式 streaming 期间,内容增长时 debounce scroll
useEffect(() => {
if (isStreaming) {
scrollToBottom(false);
} else {
// streaming 刚结束,立刻滚一次确保看到完整回复
scrollToBottom(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isStreaming, lastMsg?.chunks?.length]);

// 组件卸载时清理 debounce timer
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return () => {
if (scrollDebounceRef.current) clearTimeout(scrollDebounceRef.current);
};
}, []);

if (messages.length === 0) {
return <EmptyState />;
}

const virtualItems = virtualizer.getVirtualItems();

return (
<ScrollArea style={{ flex: 1 }} px="md" py="md">
<Stack gap="lg" pb="lg" style={{ width: '100%' }}>
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
<div ref={bottomRef} />
</Stack>
</ScrollArea>
<Box
ref={parentRef}
style={{
flex: 1,
overflowY: 'auto',
paddingLeft: 16,
paddingRight: 16,
paddingTop: 16,
paddingBottom: 16,
}}
>
{/* 虚拟列表容器,高度由 virtualizer 管理 */}
<Box
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
}}
>
{virtualItems.map((virtualRow) => (
<Box
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{ paddingBottom: 16 }}
>
<MessageBubble message={messages[virtualRow.index]} />
</Box>
))}
</Box>
</Box>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { Box, Stack, Paper, Text, Group, Avatar, Loader, Badge, Button } from '@mantine/core';
import { IconUser, IconRobot } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -56,7 +57,9 @@ interface ChunkRendererProps {
isStreaming: boolean;
}

function ChunkRenderer({ chunk, isStreaming }: ChunkRendererProps) {
// memo 包裹:只有当 chunk 内容或 isStreaming 变化时才重新渲染
// 对于历史 chunk(非最后一个)isStreaming=false,内容不变,完全跳过渲染
const ChunkRenderer = memo(function ChunkRenderer({ chunk, isStreaming }: ChunkRendererProps) {
if (chunk.kind === 'text') {
return (
<Box className="markdown-body" style={{ fontSize: 14, lineHeight: 1.7 }}>
Expand Down Expand Up @@ -111,7 +114,7 @@ function ChunkRenderer({ chunk, isStreaming }: ChunkRendererProps) {
return <InfoGroupRenderer infos={[chunk]} isStreaming={isStreaming} />;
}
return null;
}
});

interface MessageBubbleProps {
message: ChatMessage;
Expand Down
Loading
Loading