PicoBot/src/client/tui/components/chat_history.rs

166 lines
6.2 KiB
Rust

use crate::client::tui::app::{App, MessageRole};
use crate::session::{ToolStatus, TurnBlock, TurnPhase, TurnStatus};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let content_width = area.width.saturating_sub(4).max(1) as usize;
let mut lines = Vec::new();
if app.messages.is_empty() {
lines.push(Line::from("开始一段对话,或从左侧选择已有会话。"));
}
for message in &app.messages {
let (label, color) = match message.role {
MessageRole::User => ("", Color::Blue),
MessageRole::Assistant => ("PicoBot", Color::Green),
MessageRole::System => ("系统", Color::Yellow),
};
lines.push(Line::from(Span::styled(
label,
Style::default().fg(color).add_modifier(Modifier::BOLD),
)));
if let Some(reasoning) = &message.reasoning_content {
lines.push(Line::from(Span::styled(
"思考过程",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
)));
push_wrapped(&mut lines, reasoning, content_width, Color::DarkGray);
}
for source_line in message.content.lines() {
let wrapped = textwrap::wrap(source_line, content_width);
if wrapped.is_empty() {
lines.push(Line::from(""));
} else {
lines.extend(
wrapped
.into_iter()
.map(|line| Line::from(line.into_owned())),
);
}
}
if message.completion_status != crate::bus::CompletionStatus::Completed {
lines.push(Line::from(Span::styled(
format!("[{}]", message.completion_status.as_str()),
Style::default().fg(Color::Yellow),
)));
}
for attachment in &message.attachments {
lines.push(Line::from(Span::styled(
format!(" [附件 {}] {}", attachment.index + 1, attachment.name),
Style::default().fg(Color::Cyan),
)));
}
lines.push(Line::from(""));
}
if let Some(turn) = &app.active_turn {
lines.push(Line::from(Span::styled(
"PicoBot",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)));
for block in &turn.blocks {
match block {
TurnBlock::Reasoning { text, .. } => {
lines.push(Line::from(Span::styled(
"思考过程",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
)));
push_wrapped(&mut lines, text, content_width, Color::DarkGray);
}
TurnBlock::Assistant { text, .. } => {
push_wrapped(&mut lines, text, content_width, Color::Reset);
}
TurnBlock::Tool {
name,
status,
preview,
..
} => {
let status = match status {
ToolStatus::Running => "执行中",
ToolStatus::Completed => "已完成",
ToolStatus::Cancelled => "已停止",
ToolStatus::Failed => "失败",
};
lines.push(Line::from(Span::styled(
format!("工具 · {name} · {status}"),
Style::default().fg(Color::Magenta),
)));
if let Some(preview) = preview {
push_wrapped(&mut lines, preview, content_width, Color::DarkGray);
}
}
}
}
let phase = match turn.phase {
TurnPhase::Queued => "排队中",
TurnPhase::Reasoning => "思考中",
TurnPhase::Responding => "生成中",
TurnPhase::Acting => "调用工具中",
TurnPhase::Finalizing => "收尾中",
};
let status = match turn.status {
TurnStatus::Running => phase,
TurnStatus::Completed => "已完成",
TurnStatus::Cancelled => "已停止",
TurnStatus::Failed => "失败",
};
lines.push(Line::from(Span::styled(
format!("{status}"),
Style::default().fg(if turn.status == TurnStatus::Failed {
Color::Red
} else {
Color::Cyan
}),
)));
if let Some(error) = &turn.error {
push_wrapped(&mut lines, error, content_width, Color::Red);
}
lines.push(Line::from(""));
} else if app.pending_responses > 0 {
lines.push(Line::from(Span::styled(
"● 正在思考…",
Style::default().fg(Color::Cyan),
)));
}
let visible_height = area.height.saturating_sub(2);
let line_count = u16::try_from(lines.len()).unwrap_or(u16::MAX);
let max_scroll = line_count.saturating_sub(visible_height);
let scroll = max_scroll.saturating_sub(app.chat_scroll_from_bottom.min(max_scroll));
let title = if app.chat_scroll_from_bottom > 0 {
" 对话 · 已暂停自动滚动 "
} else {
" 对话 "
};
frame.render_widget(
Paragraph::new(lines)
.scroll((scroll, 0))
.block(Block::default().title(title).borders(Borders::ALL)),
area,
);
}
fn push_wrapped(lines: &mut Vec<Line<'static>>, text: &str, width: usize, color: Color) {
for source_line in text.lines() {
let wrapped = textwrap::wrap(source_line, width);
if wrapped.is_empty() {
lines.push(Line::from(""));
} else {
lines.extend(wrapped.into_iter().map(|line| {
Line::from(Span::styled(line.into_owned(), Style::default().fg(color)))
}));
}
}
}