walrus_core/model/
message.rs1use crate::model::{StreamChunk, ToolCall};
4use compact_str::CompactString;
5pub use crabtalk_core::Role;
6use serde::{Deserialize, Serialize};
7use smallvec::SmallVec;
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone, Deserialize, Serialize)]
12pub struct Message {
13 pub role: Role,
15
16 #[serde(skip_serializing_if = "String::is_empty")]
18 pub content: String,
19
20 #[serde(skip_serializing_if = "String::is_empty")]
22 pub reasoning_content: String,
23
24 #[serde(skip_serializing_if = "CompactString::is_empty")]
26 pub tool_call_id: CompactString,
27
28 #[serde(skip_serializing_if = "SmallVec::is_empty")]
30 pub tool_calls: SmallVec<[ToolCall; 4]>,
31
32 #[serde(skip)]
37 pub sender: CompactString,
38}
39
40impl Message {
41 pub fn system(content: impl Into<String>) -> Self {
43 Self {
44 role: Role::System,
45 content: content.into(),
46 ..Default::default()
47 }
48 }
49
50 pub fn user(content: impl Into<String>) -> Self {
52 Self {
53 role: Role::User,
54 content: content.into(),
55 ..Default::default()
56 }
57 }
58
59 pub fn user_with_sender(content: impl Into<String>, sender: impl Into<CompactString>) -> Self {
61 Self {
62 role: Role::User,
63 content: content.into(),
64 sender: sender.into(),
65 ..Default::default()
66 }
67 }
68
69 pub fn assistant(
71 content: impl Into<String>,
72 reasoning: Option<String>,
73 tool_calls: Option<&[ToolCall]>,
74 ) -> Self {
75 Self {
76 role: Role::Assistant,
77 content: content.into(),
78 reasoning_content: reasoning.unwrap_or_default(),
79 tool_calls: tool_calls
80 .map(|tc| tc.iter().cloned().collect())
81 .unwrap_or_default(),
82 ..Default::default()
83 }
84 }
85
86 pub fn tool(content: impl Into<String>, call: impl Into<CompactString>) -> Self {
88 Self {
89 role: Role::Tool,
90 content: content.into(),
91 tool_call_id: call.into(),
92 ..Default::default()
93 }
94 }
95
96 pub fn builder(role: Role) -> MessageBuilder {
98 MessageBuilder::new(role)
99 }
100
101 pub fn estimate_tokens(&self) -> usize {
105 let chars = self.content.len()
106 + self.reasoning_content.len()
107 + self.tool_call_id.len()
108 + self
109 .tool_calls
110 .iter()
111 .map(|tc| tc.function.name.len() + tc.function.arguments.len())
112 .sum::<usize>();
113 (chars / 4).max(1)
114 }
115}
116
117pub fn estimate_tokens(messages: &[Message]) -> usize {
119 messages.iter().map(|m| m.estimate_tokens()).sum()
120}
121
122pub struct MessageBuilder {
124 message: Message,
126 calls: BTreeMap<u32, ToolCall>,
128}
129
130impl MessageBuilder {
131 pub fn new(role: Role) -> Self {
133 Self {
134 message: Message {
135 role,
136 ..Default::default()
137 },
138 calls: BTreeMap::new(),
139 }
140 }
141
142 pub fn accept(&mut self, chunk: &StreamChunk) -> bool {
144 if let Some(calls) = chunk.tool_calls() {
145 for call in calls {
146 let entry = self.calls.entry(call.index).or_default();
147 entry.merge(call);
148 }
149 }
150
151 let mut has_content = false;
152 if let Some(content) = chunk.content() {
153 self.message.content.push_str(content);
154 has_content = true;
155 }
156
157 if let Some(reason) = chunk.reasoning_content() {
158 self.message.reasoning_content.push_str(reason);
159 }
160
161 has_content
162 }
163
164 pub fn build(mut self) -> Message {
166 if !self.calls.is_empty() {
167 self.message.tool_calls = self.calls.into_values().collect();
168 }
169 self.message
170 }
171}
172
173impl Default for Message {
174 fn default() -> Self {
175 Self {
176 role: Role::User,
177 content: String::new(),
178 reasoning_content: String::new(),
179 tool_call_id: CompactString::default(),
180 tool_calls: SmallVec::new(),
181 sender: CompactString::default(),
182 }
183 }
184}