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
534 changes: 500 additions & 34 deletions clients/voce-tui/Cargo.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion clients/voce-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio-tungstenite = { version = "0.21.0", features = ["native-tls"] }
futures-util = "0.3.30"
ratatui = "0.26.2"
ratatui = "0.29.0"
crossterm = "0.27.0"
cpal = "0.15.3"
ringbuf = "0.4.1"
Expand All @@ -25,3 +25,5 @@ webrtc-audio-processing = { version = "0.3.0", features = ["bundled"] }
toml = "0.8"
unicode-width = "0.1.11"
once_cell = "1.19"
ratatui-core = "0.1.0"
tui-markdown = "0.3.7"
147 changes: 124 additions & 23 deletions clients/voce-tui/src/pages/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub struct Subtitle {
pub role: String,
pub text: String,
pub is_final: bool,
pub cached_lines: Vec<Line<'static>>,
}

pub struct Chat {
Expand Down Expand Up @@ -242,11 +243,42 @@ impl Chat {
}

fn push_subtitle(&mut self, role: String, text: String, is_final: bool) {
// 0. Normalize text to avoid common markdown parsing glitches (like \r\n or trailing whitespace)
let normalized_text = text.replace('\r', "").trim_end().to_string();
if normalized_text.is_empty() && !is_final {
return;
}

// 1. Markdown 解析
let rendered = tui_markdown::from_str(&normalized_text);
let mut cached_lines: Vec<Line<'static>> = rendered.lines.into_iter().map(|line| {
let spans: Vec<Span<'static>> = line.spans.into_iter().map(|s| {
let mut span = Span::from(s.content.into_owned());
span.style = convert_style(s.style);
span
}).collect();
Line::from(spans)
}).collect();

// 2. 注入 Role 前缀 (保留配色)
if !cached_lines.is_empty() {
let color = if role == "user" { Color::Green } else { Color::Magenta };
let prefix = Span::styled(format!("{}: ", role), Style::default().fg(color).add_modifier(Modifier::BOLD));
cached_lines[0].spans.insert(0, prefix);
}

// 3. 更新或推入
if let Some(last) = self.subtitles.back_mut() {
if last.role == role && !last.is_final { last.text = text; last.is_final = is_final; return; }
if last.role == role && !last.is_final {
last.text = normalized_text;
last.is_final = is_final;
last.cached_lines = cached_lines;
return;
}
}

if self.subtitles.len() >= 50 { self.subtitles.pop_front(); }
self.subtitles.push_back(Subtitle { role, text, is_final });
self.subtitles.push_back(Subtitle { role, text: normalized_text, is_final, cached_lines });
}
}

Expand All @@ -270,7 +302,7 @@ impl Page for Chat {
Constraint::Min(10), // Subtitles
Constraint::Length(3), // Footer
])
.split(f.size());
.split(f.area());

let display_agent_speaking = self.agent_speaking || self.agent_playback_active;

Expand Down Expand Up @@ -319,30 +351,14 @@ impl Page for Chat {
])
.split(chunks[2]);

// --- Subtitles Rendering (Deterministic Physical Wrapping) ---
// --- Subtitles Rendering (Markdown Display & Dynamic Wrapping) ---
let area_width = mid_chunks[0].width.saturating_sub(2).max(1) as usize;
let area_height = mid_chunks[0].height.saturating_sub(2).max(1);

let mut physical_lines = Vec::new();

for s in &self.subtitles {
let (name, color) = if s.role == "user" { ("User: ", Color::Green) } else { ("Agent: ", Color::Magenta) };
let name_style = Style::default().fg(color).add_modifier(Modifier::BOLD);

let mut current_line_spans = vec![Span::styled(name, name_style)];
let mut current_width = name.width();

for c in s.text.chars() {
let cw = UnicodeWidthStr::width(c.to_string().as_str());
if current_width + cw > area_width {
physical_lines.push(Line::from(current_line_spans));
current_line_spans = Vec::new();
current_width = 0;
}
current_line_spans.push(Span::raw(c.to_string()));
current_width += cw;
}
if !current_line_spans.is_empty() {
physical_lines.push(Line::from(current_line_spans));
for line in &s.cached_lines {
physical_lines.extend(self.wrap_line(line.clone(), area_width));
}
}

Expand Down Expand Up @@ -426,4 +442,89 @@ impl Chat {
f.render_widget(Paragraph::new(Line::from(spans)), Rect::new(area.x, area.y + row as u16, area.width, 1));
}
}

/// Helper to wrap a Line (potentially with multiple Spans) to a specific width.
fn wrap_line<'a>(&self, line: Line<'a>, width: usize) -> Vec<Line<'a>> {
let mut result = Vec::new();
let mut current_spans = Vec::<Span<'a>>::new();
let mut current_width = 0;

for span in line.spans {
let style = span.style;
for c in span.content.chars() {
let char_str = c.to_string();
let cw = UnicodeWidthStr::width(char_str.as_str());

// Wrap if adding this char exceeds width
if current_width + cw > width && !current_spans.is_empty() {
result.push(Line::from(std::mem::take(&mut current_spans)));
current_width = 0;
}

// Append to existing span if style matches, otherwise push new one
if let Some(last) = current_spans.last_mut().filter(|s| s.style == style) {
last.content.to_mut().push(c);
} else {
current_spans.push(Span::styled(char_str, style));
}

current_width += cw;
}
}

if !current_spans.is_empty() {
result.push(Line::from(current_spans));
}

if result.is_empty() {
result.push(Line::from(""));
}
result
}
}

/// Helper to convert ratatui-core Style to ratatui Style.
fn convert_style(s: ratatui_core::style::Style) -> Style {
let mut style = Style::default()
.add_modifier(convert_modifier(s.add_modifier))
.remove_modifier(convert_modifier(s.sub_modifier));

if let Some(fg) = s.fg {
style = style.fg(convert_color(fg));
}
if let Some(bg) = s.bg {
style = style.bg(convert_color(bg));
}
style
}

/// Helper to convert ratatui-core Color to ratatui Color.
fn convert_color(c: ratatui_core::style::Color) -> Color {
use ratatui_core::style::Color::*;
match c {
Reset => Color::Reset,
Black => Color::Black,
Red => Color::Red,
Green => Color::Green,
Yellow => Color::Yellow,
Blue => Color::Blue,
Magenta => Color::Magenta,
Cyan => Color::Cyan,
Gray => Color::Gray,
DarkGray => Color::DarkGray,
LightRed => Color::LightRed,
LightGreen => Color::LightGreen,
LightYellow => Color::LightYellow,
LightBlue => Color::LightBlue,
LightMagenta => Color::LightMagenta,
LightCyan => Color::LightCyan,
White => Color::White,
Rgb(r, g, b) => Color::Rgb(r, g, b),
Indexed(i) => Color::Indexed(i),
}
}

/// Helper to convert ratatui-core Modifier to ratatui Modifier.
fn convert_modifier(m: ratatui_core::style::Modifier) -> Modifier {
Modifier::from_bits_truncate(m.bits())
}
2 changes: 1 addition & 1 deletion clients/voce-tui/src/pages/monitor_dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ impl Page for MonitorDashboard {
Constraint::Min(10), // Full-width Traffic Chart
Constraint::Length(10), // Bottom Stats (Mem, GC, etc)
])
.split(f.size());
.split(f.area());

// 1. Header
f.render_widget(Paragraph::new(" [ESC/q] Back to List | SYSTEM WIDE MONITORING ").block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))), chunks[0]);
Expand Down
2 changes: 1 addition & 1 deletion clients/voce-tui/src/pages/property_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ impl Page for PropertyEditor {
}

fn draw(&mut self, f: &mut Frame) {
let area = centered_rect(60, 30, f.size());
let area = centered_rect(60, 30, f.area());
let input = Paragraph::new(self.input.as_str())
.block(Block::default().title(format!(" 2. Config for {} ", self.workflow.name)).borders(Borders::ALL).border_style(Style::default().fg(Color::Green)))
.wrap(Wrap { trim: false });
Expand Down
2 changes: 1 addition & 1 deletion clients/voce-tui/src/pages/workflow_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ impl Page for WorkflowList {
}

fn draw(&mut self, f: &mut Frame) {
let area = centered_rect(60, 40, f.size());
let area = centered_rect(60, 40, f.area());
let items: Vec<ListItem> = self.workflows.iter().map(|w| ListItem::new(format!(" {} ", w.name))).collect();
let list = List::new(items)
.block(Block::default().title(" 1. Select Workflow ").borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan)))
Expand Down
1 change: 1 addition & 0 deletions docs/plugins_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Voce 采用插件化服务,通过不同的插件组合实现多模态能力的

- **interrupter**: 实时打断控制器,负责发送打断信号。
- **caption**: 字幕传输插件,负责实时下发 ASR 或 LLM 产生的文本内容。
- **markdown_filter**: 实时过滤文本中的 Markdown 标记代码(如标题、粗体、链接、代码块等),通常用于 TTS 前置转换。
- **sink**: 统一数据出口。

---
Expand Down
1 change: 1 addition & 0 deletions internal/plugins/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
_ "github.com/wnnce/voce/internal/plugins/elevenlabs_tts"
_ "github.com/wnnce/voce/internal/plugins/google_asr"
_ "github.com/wnnce/voce/internal/plugins/interrupter"
_ "github.com/wnnce/voce/internal/plugins/md_filter"
_ "github.com/wnnce/voce/internal/plugins/minimax_tts"
_ "github.com/wnnce/voce/internal/plugins/openai_llm"
_ "github.com/wnnce/voce/internal/plugins/openai_tts"
Expand Down
133 changes: 133 additions & 0 deletions internal/plugins/md_filter/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package md_filter

import (
"context"
"strings"

"github.com/wnnce/voce/internal/engine"
"github.com/wnnce/voce/internal/schema"
)

const (
stateNormal int = iota
stateInCodeBlock
stateInLinkUrl
)

type Plugin struct {
engine.BuiltinPlugin
state int
backtickBuf strings.Builder
seenCloseBracket bool
}

func NewPlugin(_ engine.EmptyPluginConfig) engine.Plugin {
return &Plugin{}
}

func (p *Plugin) OnSignal(ctx context.Context, flow engine.Flow, signal schema.Signal) {
if signal.Name() == schema.SignalInterrupter {
p.state = stateNormal
p.backtickBuf.Reset()
p.seenCloseBracket = false
}
flow.SendSignal(signal)
}

func (p *Plugin) OnPayload(ctx context.Context, flow engine.Flow, payload schema.Payload) {
if payload.Name() != schema.PayloadLLMChunk {
flow.SendPayload(payload)
return
}

text := schema.GetAs(payload, "sentence", "")
isFinal := schema.GetAs(payload, "is_final", false)

filtered := p.filter(text)

if strings.TrimSpace(filtered) == "" && !isFinal {
return
}

out := schema.NewPayload(schema.PayloadLLMChunk)
_ = out.Set("sentence", filtered)
_ = out.Set("is_final", isFinal)
flow.SendPayload(out.ReadOnly())
}

func (p *Plugin) filter(input string) string {
var out strings.Builder
runes := []rune(input)

for i := 0; i < len(runes); i++ {
r := runes[i]

if r == '`' {
p.backtickBuf.WriteRune(r)
p.seenCloseBracket = false
continue
}

if p.backtickBuf.Len() > 0 {
count := p.backtickBuf.Len()
p.backtickBuf.Reset()
if count >= 3 {
if p.state == stateInCodeBlock {
p.state = stateNormal
} else {
p.state = stateInCodeBlock
}
}
i--
continue
}

if p.state == stateInCodeBlock {
continue
}

if p.state == stateInLinkUrl {
if r == ')' {
p.state = stateNormal
}
continue
}

if r == '(' && p.seenCloseBracket {
p.state = stateInLinkUrl
p.seenCloseBracket = false
continue
}
p.seenCloseBracket = false

if r == '*' || r == '_' || r == '~' || r == '#' || r == '>' ||
r == '[' || r == '\\' {
continue
}

if r == ']' {
p.seenCloseBracket = true
continue
}

out.WriteRune(r)
}

return out.String()
}

func init() {
if err := engine.RegisterPlugin(NewPlugin, engine.PluginMetadata{
Name: "markdown_filter",
Inputs: engine.NewPropertyBuilder().
AddPayload(schema.PayloadLLMChunk, "sentence", engine.TypeString, true).
AddPayload(schema.PayloadLLMChunk, "is_final", engine.TypeBoolean, true).
Build(),
Outputs: engine.NewPropertyBuilder().
AddPayload(schema.PayloadLLMChunk, "sentence", engine.TypeString, true).
AddPayload(schema.PayloadLLMChunk, "is_final", engine.TypeBoolean, true).
Build(),
}); err != nil {
panic(err)
}
}
Loading
Loading