1#![allow(
5 clippy::cast_possible_truncation,
6 clippy::cast_possible_wrap,
7 clippy::cast_precision_loss,
8 clippy::cast_sign_loss
9)]
10use anyhow::Result;
11use chrono::Utc;
12use std::fmt::Write as _;
13use tokio::sync::mpsc;
14
15use crate::db::Message;
16use crate::provider::{ChatMessage, ChatParams, StreamEvent, ToolCall, Usage};
17
18use super::{App, SPINNER_COLORS, SpinnerColor, THINKING, parse_topic, verbosity_clause};
19
20impl App {
21 pub fn send_message(&mut self, text: String) -> Result<()> {
25 if let Some(session) = self.session.as_ref()
26 && let Some(task) = self.chat_task_for_session(&session.id)
27 {
28 self.push_status(format!(
29 "wait — response still streaming in: {}",
30 task.session_title
31 ));
32 self.push_composer_set(&text);
33 return Ok(());
34 }
35 if self.chat_task_count() >= super::MAX_CHAT_TASKS {
36 self.push_status(format!(
37 "chat task limit reached ({})",
38 super::MAX_CHAT_TASKS
39 ));
40 self.push_composer_set(&text);
41 return Ok(());
42 }
43 if !self.backends.any() {
44 self.push_status("set your API key first with /login".to_string());
45 self.push_composer_set(&text);
46 return Ok(());
47 }
48 let Some(model) = self.current_model.clone() else {
49 self.push_status("pick a model first with /model".to_string());
50 self.push_composer_set(&text);
51 return Ok(());
52 };
53
54 if self.session.is_none() {
57 self.refresh_memory_snapshot();
58 let title = title_from(&text);
59 let s = if self.incognito {
60 crate::db::Session {
61 id: uuid::Uuid::new_v4().to_string(),
62 title,
63 model,
64 slug: None,
65 created_at: Utc::now().to_rfc3339(),
66 compact_summary: None,
67 compact_through: 0,
68 web_mode: self.web_mode,
69 swarm_mode: false,
70 kind: "chat".to_string(),
71 research_parent_id: None,
72 }
73 } else {
74 let mut s =
75 self.db
76 .create_session(&title, &model, &self.active_space.id, "chat")?;
77 if self.web_mode {
79 s.web_mode = true;
80 let _ = self.db.set_session_web_mode(&s.id, true);
81 }
82 s
83 };
84 self.session = Some(s);
85 }
86 let Some(session_id) = self.session.as_ref().map(|s| s.id.clone()) else {
87 return Ok(());
88 };
89
90 if !self.incognito {
91 self.db.add_user_message(&session_id, &text)?;
92 }
93 self.messages.push(Message {
94 role: "user".to_string(),
95 content: text,
96 model: None,
97 reasoning: None,
98 tokens: None,
99 secs: None,
100 cost: None,
101 phrase: None,
102 persona: None,
103 created_at: None,
104 });
105
106 if self.session.as_ref().is_some_and(|s| s.swarm_mode) {
107 self.start_swarm_turn();
108 return Ok(());
109 }
110 self.start_stream()
111 }
112
113 pub fn build_history(&mut self) -> Vec<ChatMessage> {
118 let mut history: Vec<ChatMessage> = Vec::with_capacity(self.messages.len() + 3);
119 history.push(ChatMessage::text("system", self.system_prompt()));
120 if self.current_model_supports_images()
122 && let Some(img_msg) = self.space_images_message()
123 {
124 history.push(img_msg);
125 }
126 if let Some(summary) = self
129 .session
130 .as_ref()
131 .and_then(|s| s.compact_summary.clone())
132 {
133 history.push(ChatMessage::text(
134 "system",
135 format!("Summary of earlier conversation (auto-compacted for length):\n{summary}"),
136 ));
137 }
138 if let Some(name) = self.forced_skill.take()
139 && let Some(skill) = self.skills.iter().find(|s| s.name == name)
140 {
141 let body = std::fs::read_to_string(skill.dir.join("SKILL.md"))
142 .map(|md| crate::skills::skill_body(&md).to_string())
143 .unwrap_or_default();
144 history.push(ChatMessage::text(
145 "system",
146 format!("The user invoked the skill '{name}'. Follow these instructions:\n{body}"),
147 ));
148 }
149 let vision = self.current_model_supports_images();
150 let mut seen_results: std::collections::HashMap<(String, String), String> =
158 std::collections::HashMap::new();
159 let effective = self.effective_messages();
160 let tool_images_dir = self
161 .toolbox
162 .supports_images()
163 .then(|| self.toolbox.space_files_dir())
164 .flatten();
165 let mut index = 0;
166 while index < effective.len() {
167 let m = &effective[index];
168 if Self::excluded_from_model_history(m) {
179 index += 1;
180 continue;
181 }
182 if m.role == "tool_call" {
183 append_tool_call_history(
184 &mut history,
185 effective,
186 &mut index,
187 &mut seen_results,
188 tool_images_dir.as_deref(),
189 );
190 continue;
191 }
192 let mut cm = ChatMessage::text(m.role.clone(), m.content.clone());
193 if m.role == "user" && vision {
194 let images_dir = self.space.files_dir(&self.active_space.name);
195 let mut images = Vec::new();
196 let mut rest = m.content.as_str();
197 while let Some(start) = rest.find("![") {
198 if let Some(end) = rest[start..].find(')') {
199 let inner = &rest[start + 2..start + end];
200 if let Some((_alt, file)) = inner.split_once("](") {
201 let path = images_dir.join(file);
202 if let Ok(bytes) = std::fs::read(&path) {
203 images.push(crate::app::transcribe::png_bytes_data_url(&bytes));
204 }
205 }
206 rest = &rest[start + end + 1..];
207 } else {
208 break;
209 }
210 }
211 cm.images = images;
212 }
213 history.push(cm);
214 index += 1;
215 }
216 history
217 }
218
219 pub fn start_stream(&mut self) -> Result<()> {
221 let Some(model) = self.current_model.clone() else {
222 return Ok(());
223 };
224 let Some((provider, raw_model)) = self.resolve_model_backend(&model) else {
225 self.push_status(format!(
226 "model backend unavailable: {model} — pick another with /model"
227 ));
228 return Ok(());
229 };
230 let history = self.build_history();
231 let stored_effort = self.reasoning.get(&model).cloned();
235 let (reasoning_effort, reasoning_warning) = match stored_effort {
236 Some(effort) if self.effort_accepted(&model, &effort) => (Some(effort), None),
237 Some(effort) => {
238 self.db.set_reasoning(&model, None)?;
239 self.reasoning.remove(&model);
240 (
241 None,
242 Some(format!("cleared unsupported reasoning {effort}: {model}")),
243 )
244 }
245 None => (None, None),
246 };
247 let params = ChatParams {
248 reasoning_effort,
249 prompt_cache_key: self
250 .session
251 .as_ref()
252 .map(|session| self.prompt_cache_key_for(&session.id)),
253 temperature: self.settings.temperature,
254 top_p: self.settings.top_p,
255 max_tokens: self.settings.max_tokens,
256 };
257 let tools = self.toolbox.defs();
258 let (rx, abort) = provider.stream_chat(
259 raw_model.clone(),
260 history,
261 params,
262 tools,
263 self.toolbox.clone(),
264 crate::provider::openrouter::MAX_TOOL_ITERS,
265 );
266 let Some(session) = self.session.clone() else {
267 return Ok(());
268 };
269 let backend = provider.backend_tag();
270 let (thinking_idx, spinner_color) = pick_flavor();
271 let task_id = self.next_chat_task_id;
272 self.next_chat_task_id = self.next_chat_task_id.wrapping_add(1);
273 let tx = self.chat_event_tx.clone();
274 tokio::spawn(async move {
275 let mut rx = rx;
276 while let Some(event) = rx.recv().await {
277 if tx.send(super::ChatEvent { task_id, event }).is_err() {
278 break;
279 }
280 }
281 let _ = tx.send(super::ChatEvent {
282 task_id,
283 event: StreamEvent::Done,
284 });
285 });
286 self.chat_tasks.insert(
287 task_id,
288 super::ChatTask {
289 id: task_id,
290 session_id: session.id,
291 session_title: session.title,
292 space_id: self.active_space.id.clone(),
293 model,
294 model_id: raw_model,
295 backend,
296 incognito: self.incognito,
297 buffer: String::new(),
298 thinking: String::new(),
299 tool_status: None,
300 usage: None,
301 usage_row_id: None,
302 started: std::time::Instant::now(),
303 thinking_idx,
304 spinner_color,
305 abort,
306 },
307 );
308 self.spinner_frame = 0;
309 self.thinking_idx = thinking_idx;
310 self.spinner_color = spinner_color;
311 self.push_status(reasoning_warning.unwrap_or_default());
312 self.push_viewport_reset();
315 Ok(())
316 }
317
318 #[cfg(test)]
321 pub fn on_stream_event(&mut self, ev: StreamEvent) -> Result<()> {
322 let task_id = self
323 .active_chat_task()
324 .map(|task| task.id)
325 .or_else(|| self.chat_tasks.keys().next().copied());
326 if let Some(task_id) = task_id {
327 return self.on_chat_event(task_id, ev);
328 }
329 if let StreamEvent::ToolCall {
330 id,
331 reasoning,
332 assistant_content,
333 name,
334 arguments,
335 result,
336 } = ev
337 {
338 let content = serde_json::json!({
339 "id": id,
340 "reasoning_content": reasoning,
341 "assistant_content": assistant_content,
342 "name": name,
343 "arguments": arguments,
344 "result": result,
345 })
346 .to_string();
347 if let Some(session) = self.session.as_ref()
348 && !self.incognito
349 {
350 let _ = self.db.add_tool_call_message(&session.id, &content);
351 }
352 self.messages.push(Message {
353 role: "tool_call".to_string(),
354 content,
355 model: None,
356 reasoning: None,
357 tokens: None,
358 secs: None,
359 cost: None,
360 phrase: None,
361 persona: None,
362 created_at: None,
363 });
364 }
365 Ok(())
366 }
367
368 #[allow(clippy::too_many_lines)] pub fn on_chat_event(&mut self, task_id: super::ChatTaskId, ev: StreamEvent) -> Result<()> {
370 match ev {
371 StreamEvent::Token(t) => {
372 if let Some(task) = self.chat_tasks.get_mut(&task_id) {
373 task.buffer.push_str(&t);
374 }
375 }
376 StreamEvent::Reasoning(t) => {
377 if let Some(task) = self.chat_tasks.get_mut(&task_id) {
378 task.thinking.push_str(&t);
379 }
380 }
381 StreamEvent::Usage(u) => {
382 let has_tokens = u.prompt_tokens + u.completion_tokens > 0;
392 if has_tokens {
393 self.last_cache_rate = u.cache_hit_rate();
394 }
395 let Some(task) = self.chat_tasks.get_mut(&task_id) else {
396 return Ok(());
397 };
398 let merged = if has_tokens {
399 u
402 } else {
403 task.usage.map_or(u, |prev| Usage {
404 cost: u.cost.or(prev.cost),
405 ..prev
406 })
407 };
408 task.usage = Some(merged);
409 let session_id = task.session_id.clone();
410 let space_id = task.space_id.clone();
411 let model_id = task.model_id.clone();
412 let backend = task.backend;
413 let incognito = task.incognito;
414 let row_id = task.usage_row_id;
415 if !incognito
416 && (merged.prompt_tokens > 0
417 || merged.completion_tokens > 0
418 || merged.cost.is_some())
419 {
420 let cost_is_provider = merged.cost.is_some();
425 let cost = merged.cost.or_else(|| {
426 self.db.request_cost(
427 &model_id,
428 merged.prompt_tokens,
429 merged.completion_tokens,
430 merged.cache_read_tokens,
431 merged.cache_creation_tokens,
432 )
433 });
434 if has_tokens || row_id.is_none() {
435 let id = self.db.log_usage(
436 backend.name(),
437 &model_id,
438 merged.prompt_tokens,
439 merged.completion_tokens,
440 merged.cache_read_tokens,
441 merged.cache_creation_tokens,
442 cost,
443 cost_is_provider,
444 Some(&session_id),
445 Some(&space_id),
446 );
447 if let Ok(id) = id
448 && let Some(task) = self.chat_tasks.get_mut(&task_id)
449 {
450 task.usage_row_id = Some(id);
451 }
452 } else if let Some(row_id) = row_id {
453 let _ = self.db.update_usage(
454 row_id,
455 merged.prompt_tokens,
456 merged.completion_tokens,
457 merged.cache_read_tokens,
458 merged.cache_creation_tokens,
459 cost,
460 cost_is_provider,
461 );
462 }
463 }
464 }
465 StreamEvent::Status(s) => {
466 if let Some(task) = self.chat_tasks.get_mut(&task_id) {
467 task.tool_status = Some(s);
468 }
469 }
470 StreamEvent::ToolCall {
471 id,
472 reasoning,
473 assistant_content,
474 name,
475 arguments,
476 result,
477 } => {
478 if ((name == "skills")
479 || (name == "skill_admin")
480 || (name == "install_skill" && result.starts_with("installed"))
481 || (name == "create_skill"
482 && (result.starts_with("created") || result.starts_with("updated"))))
483 && (result.starts_with("installed")
484 || result.starts_with("created")
485 || result.starts_with("updated"))
486 {
487 self.reload_skills();
488 }
489 let Some(task) = self.chat_tasks.get(&task_id) else {
490 return Ok(());
491 };
492 let content = serde_json::json!({
493 "id": id,
494 "reasoning_content": reasoning,
495 "assistant_content": assistant_content,
496 "name": name,
497 "arguments": arguments,
498 "result": result,
499 })
500 .to_string();
501 let target = task.session_id.clone();
502 let incognito = task.incognito;
503 let in_active_space = task.space_id == self.active_space.id;
504 let viewing = self
505 .session
506 .as_ref()
507 .is_some_and(|session| session.id == target);
508 if !incognito {
509 let _ = self.db.add_tool_call_message(&target, &content);
510 }
511 if viewing {
512 self.messages.push(Message {
513 role: "tool_call".to_string(),
514 content,
515 model: None,
516 reasoning: None,
517 tokens: None,
518 secs: None,
519 cost: None,
520 phrase: None,
521 persona: None,
522 created_at: None,
523 });
524 }
525 let media_generated = matches!(
529 name.as_str(),
530 "generate_image" | "generate_video" | "video_transform"
531 ) || matches!(
532 name.as_str(),
533 "edit_video" | "extract_frame" | "stitch_videos"
534 ) || (name == "media"
535 && serde_json::from_str::<serde_json::Value>(&arguments)
536 .ok()
537 .and_then(|v| v.get("action").and_then(|a| a.as_str()).map(str::to_string))
538 .is_some_and(|a| {
539 matches!(
540 a.as_str(),
541 "generate_image"
542 | "generate_video"
543 | "edit"
544 | "extract_frame"
545 | "stitch"
546 )
547 }));
548 if in_active_space && media_generated {
549 self.rescan_files();
550 }
551 }
552 StreamEvent::Done => self.finish_chat_task(task_id, None)?,
553 StreamEvent::Error(e) => {
554 self.finish_chat_task(task_id, Some(e))?;
555 }
556 }
557 Ok(())
558 }
559
560 pub fn cancel_chat_task(&mut self, task_id: super::ChatTaskId) -> Result<()> {
563 if let Some(task) = self.chat_tasks.remove(&task_id) {
564 task.abort.abort();
565 self.finish_chat_task_state(task, None, true)?;
566 }
567 Ok(())
568 }
569
570 pub fn stop_stream(&mut self) -> Result<()> {
573 let Some(task_id) = self.active_chat_task().map(|task| task.id) else {
574 return Ok(());
575 };
576 self.cancel_chat_task(task_id)
577 }
578
579 pub fn discard_chat_task(&mut self, session_id: &str) {
580 if let Some(task) = self
581 .chat_tasks
582 .iter()
583 .find(|(_, task)| task.session_id == session_id)
584 .map(|(id, _)| *id)
585 .and_then(|id| self.chat_tasks.remove(&id))
586 {
587 task.abort.abort();
588 }
589 }
590
591 fn finish_chat_task(
592 &mut self,
593 task_id: super::ChatTaskId,
594 error: Option<String>,
595 ) -> Result<()> {
596 let Some(task) = self.chat_tasks.remove(&task_id) else {
597 return Ok(());
598 };
599 self.finish_chat_task_state(task, error, false)
600 }
601
602 #[allow(clippy::too_many_lines)]
604 fn finish_chat_task_state(
605 &mut self,
606 task: super::ChatTask,
607 error: Option<String>,
608 stopped: bool,
609 ) -> Result<()> {
610 let mut reasoning = task.thinking;
611 let (buf, inline) = split_inline_reasoning(&task.buffer);
612 if let Some(inline) = inline {
613 if !reasoning.is_empty() {
614 reasoning.push('\n');
615 }
616 reasoning.push_str(&inline);
617 }
618 if buf.is_empty() && error.is_none() {
619 self.push_status(if stopped {
620 "response stopped".to_string()
621 } else {
622 "response finished without text".to_string()
623 });
624 return Ok(());
625 }
626 let viewing = self
632 .session
633 .as_ref()
634 .is_some_and(|session| session.id == task.session_id);
635 let model = Some(task.model.clone());
636 let usage = task.usage;
638 let tokens = Some(match usage {
639 Some(u) => u.completion_tokens as i64,
640 None => buf.chars().count().div_ceil(4) as i64,
641 });
642 if viewing && let Some(u) = usage {
643 let total = if u.total_tokens > 0 {
645 u.total_tokens
646 } else {
647 u.prompt_tokens + u.completion_tokens
648 };
649 self.context_total = Some(total);
650 }
651 let secs = Some(task.started.elapsed().as_secs_f64());
652 let cost = usage.and_then(|u| {
655 u.cost.or_else(|| {
656 self.db.request_cost(
657 &task.model_id,
658 u.prompt_tokens,
659 u.completion_tokens,
660 u.cache_read_tokens,
661 u.cache_creation_tokens,
662 )
663 })
664 });
665 let reasoning = (!reasoning.is_empty()).then_some(reasoning);
666 let phrase = Some(THINKING[task.thinking_idx].1.to_string());
667 let created_at = Some(chrono::Utc::now().to_rfc3339());
668
669 if !task.incognito && !buf.is_empty() {
670 self.db.add_assistant_message(
671 &task.session_id,
672 &buf,
673 model.as_deref(),
674 reasoning.as_deref(),
675 tokens,
676 secs,
677 cost,
678 phrase.as_deref(),
679 )?;
680 }
681 if viewing && !buf.is_empty() {
682 self.messages.push(Message {
683 role: "assistant".to_string(),
684 content: buf,
685 model,
686 reasoning,
687 tokens,
688 secs,
689 cost,
690 phrase,
691 persona: None,
692 created_at,
693 });
694 if !task.incognito {
697 self.maybe_generate_title();
698 self.maybe_extract_memory();
699 self.maybe_compact();
700 }
701 }
702 if let Some(error) = error {
703 if !task.incognito {
704 self.db.add_error_message(&task.session_id, &error)?;
705 }
706 let msg = format!("stream error: {error}");
707 if viewing {
708 self.messages.push(Message {
709 role: "error".to_string(),
710 content: error.clone(),
711 model: None,
712 reasoning: None,
713 tokens: None,
714 secs: None,
715 cost: None,
716 phrase: None,
717 persona: None,
718 created_at: None,
719 });
720 } else if !task.incognito {
721 self.unread.insert(task.session_id.clone());
722 }
723 self.push_status(if viewing {
724 msg.clone()
725 } else {
726 format!("stream error in {}: {error}", task.session_title)
727 });
728 if !viewing && !task.incognito {
729 super::send_system_notification(
730 &format!("Chat failed: {}", task.session_title),
731 &msg,
732 );
733 }
734 if !viewing && !task.incognito {
735 self.notifications.push_back(super::ChatNotification {
736 session_id: task.session_id,
737 title: task.session_title,
738 text: msg,
739 success: false,
740 });
741 }
742 } else if stopped {
743 self.push_status("response stopped".to_string());
744 } else {
745 if !viewing && !task.incognito {
746 self.unread.insert(task.session_id.clone());
747 self.push_status(format!("✓ response ready in: {}", task.session_title));
748 super::send_system_notification(
749 &format!("Chat ready: {}", task.session_title),
750 "response complete",
751 );
752 } else if viewing {
753 self.push_status("response complete".to_string());
754 }
755 if !viewing && !task.incognito {
756 self.notifications.push_back(super::ChatNotification {
757 session_id: task.session_id,
758 title: task.session_title,
759 text: "response complete".to_string(),
760 success: true,
761 });
762 }
763 }
764 Ok(())
765 }
766
767 pub fn maybe_generate_title(&mut self) {
770 let Some(session) = self.session.as_ref() else {
771 return;
772 };
773 let Some((provider, raw_model)) = self
774 .current_model
775 .as_deref()
776 .and_then(|model| self.resolve_model_backend(model))
777 .or_else(|| self.resolve_utility_model_backend(&self.memory_model))
778 else {
779 return;
780 };
781 if session.slug.is_some() {
782 return; }
784 let convo: String = self
786 .messages
787 .iter()
788 .filter(|m| m.role != "tool_call")
789 .map(|m| {
790 format!(
791 "{}: {}",
792 m.role,
793 m.content.chars().take(500).collect::<String>()
794 )
795 })
796 .collect::<Vec<_>>()
797 .join("\n");
798 let session_id = session.id.clone();
799 let (tx, rx) = mpsc::unbounded_channel();
800 self.title_rx = Some(rx);
801 tokio::spawn(async move {
802 let prompt = format!(
803 "Summarise this conversation as a session name. Reply with ONLY a JSON object, \
804 no markdown, of the form {{\"topic\": \"<3-5 word title>\", \"id\": \"<short-kebab-slug>\"}}.\n\n{convo}"
805 );
806 let msgs = vec![ChatMessage::text("user", prompt)];
807 if let Ok(text) = provider.complete(&raw_model, msgs).await
808 && let Some((topic, slug)) = parse_topic(&text)
809 {
810 let _ = tx.send((session_id, topic, slug));
811 }
812 });
813 }
814
815 pub fn on_title_result(&mut self, result: Option<(String, String, String)>) {
817 self.title_rx = None;
818 let Some((id, topic, slug)) = result else {
819 return;
820 };
821 let _ = self.db.set_session_title(&id, &topic, Some(&slug));
822 if let Some(s) = self.session.as_mut().filter(|s| s.id == id) {
823 s.title.clone_from(&topic);
824 s.slug = Some(slug.clone());
825 }
826 if let Some(s) = self.sessions_cache.iter_mut().find(|s| s.id == id) {
827 s.title = topic;
828 s.slug = Some(slug);
829 }
830 }
831
832 pub fn system_prompt(&self) -> String {
839 let mut parts: Vec<String> = vec![self.resolved_base_system_prompt()];
840 if !self.incognito {
841 let instructions =
842 std::fs::read_to_string(self.space.instructions_path(&self.active_space.name))
843 .ok()
844 .map(|s| s.trim().to_string())
845 .filter(|s| !s.is_empty());
846 if let Some(i) = instructions {
847 parts.push(i);
848 }
849 if let Some(files) = self.files_section() {
850 parts.push(files);
851 }
852 if let Some(apps) = self.apps_section() {
853 parts.push(apps);
854 }
855 if let Some(scripts) = self.scripts_section() {
856 parts.push(scripts);
857 }
858 let memory = self.memory_snapshot();
859 if !memory.trim().is_empty() {
860 parts.push(format!("## Memory\n{memory}"));
861 }
862 }
863 if let Some(skills) = self.skills_section() {
864 parts.push(skills);
865 }
866 if self.web_mode {
867 let today = self
868 .prompt_datetime
869 .split_whitespace()
870 .next()
871 .unwrap_or_default();
872 parts.push(web_mode_clause(today));
873 }
874 if self.is_research_session() {
875 parts.push(
876 "This session came from /research — prefer research_lookup with scope=session_sources over search with mode=web for \
877 follow-ups; only use web search on a miss."
878 .to_string(),
879 );
880 }
881 parts.join("\n\n")
882 }
883
884 pub fn open_session_link(&mut self, owner: Option<usize>) {
894 let Some(msg) = owner.and_then(|i| self.messages.get(i)) else {
895 self.push_status(
896 "select text on a session link message, then press Ctrl+O".to_string(),
897 );
898 return;
899 };
900 if msg.role != "session_link" {
901 self.push_status(
902 "select text on a session link message, then press Ctrl+O".to_string(),
903 );
904 return;
905 }
906 let Some(target) = msg
907 .content
908 .split_once('\n')
909 .map(|(s, _)| s.trim().to_string())
910 else {
911 self.push_status("malformed session link".to_string());
912 return;
913 };
914 if let Err(e) = self.switch_to_session_by_id(&target) {
915 self.push_status(format!("session switch failed: {e}"));
916 }
917 }
918
919 pub fn flag_source_under_selection(
924 &mut self,
925 flag: Option<&str>,
926 selected: Option<String>,
927 owner: Option<usize>,
928 ) {
929 let Some(selected) = selected else {
930 self.push_status("select a [n] citation first".to_string());
931 return;
932 };
933 let Some(n) = crate::citations::citation_number_in(&selected) else {
934 self.push_status("no [n] citation in the current selection".to_string());
935 return;
936 };
937 let Some(msg) = owner.and_then(|i| self.messages.get(i)) else {
938 self.push_status("no [n] citation in the current selection".to_string());
939 return;
940 };
941 let citations = crate::citations::parse_citations(&msg.content);
942 let Some((_, url)) = citations.iter().find(|(num, _)| *num == n) else {
943 self.push_status(format!("no source [{n}] in this message"));
944 return;
945 };
946 let Some(session) = &self.session else {
947 self.push_status("no active session".to_string());
948 return;
949 };
950 let url_norm = crate::tools::normalize_url(url);
951 let verb = match flag {
952 Some("discarded") => "discarded",
953 Some(_) => "pinned",
954 None => "cleared",
955 };
956 match self.db.set_source_flag(&session.id, &url_norm, flag) {
957 Ok(()) => {
958 self.push_status(format!("{verb} [{n}]: {url}"));
959 self.refresh_toolbox();
960 }
961 Err(e) => self.push_status(format!("flag failed: {e}")),
962 }
963 }
964
965 pub fn toggle_web_mode(&mut self) {
969 self.web_mode = !self.web_mode;
970 self.bump_cache_epoch();
971 if let Some(session) = self.session.as_mut() {
972 session.web_mode = self.web_mode;
973 let _ = self.db.set_session_web_mode(&session.id, self.web_mode);
974 }
975 self.push_status(if self.web_mode {
976 "🌐 web mode on".to_string()
977 } else {
978 "web mode off".to_string()
979 });
980 }
981
982 pub fn toggle_incognito(&mut self) -> Result<()> {
983 if self.is_streaming() {
984 self.stop_stream()?;
985 }
986 self.session = None;
987 self.messages.clear();
988 self.refresh_memory_snapshot();
989 self.context_total = None;
990 self.push_composer_clear();
991 self.push_viewport_reset();
992 self.cleanup_incognito_images();
993 self.incognito = !self.incognito;
994 self.push_status(if self.incognito {
995 "incognito mode — nothing persists, no apps".to_string()
996 } else {
997 "incognito mode off".to_string()
998 });
999 Ok(())
1000 }
1001
1002 pub fn resolved_base_system_prompt(&self) -> String {
1005 self.base_system_prompt
1006 .replace("{{verbosity}}", verbosity_clause(&self.verbosity))
1007 .replace("{{datetime}}", &self.prompt_datetime)
1008 }
1009
1010 pub fn reload_base_system_prompt(&mut self) {
1012 if let Ok(text) = crate::config::load_system_prompt() {
1013 if text != self.base_system_prompt {
1014 self.base_system_prompt = text;
1015 self.bump_cache_epoch();
1016 }
1017 self.push_status("system prompt reloaded".to_string());
1018 }
1019 }
1020
1021 fn skills_section(&self) -> Option<String> {
1024 if self.skills.is_empty() {
1025 return None;
1026 }
1027 let mut s = "## Skills\nYou have skills available. Their descriptions are metadata only: when a skill \
1028 matches the user's task, call the `skills` tool with action=load and its name before \
1029 acting; the full instructions will be returned. Load bundled resources by their relative \
1030 path only, and do not load every skill spec up front.\n"
1031 .to_string();
1032 for skill in &self.skills {
1033 let _ = writeln!(s, "- {}: {}", skill.name, skill.description);
1034 }
1035 Some(s.trim_end().to_string())
1036 }
1037
1038 fn scripts_section(&self) -> Option<String> {
1041 if self.scripts_cache.is_empty() {
1042 return None;
1043 }
1044 let mut s = "## Scripts\nThe user has reusable scripts in this space. \
1045 Call `scripts(action=read, path=...)` to see one, \
1046 `scripts(action=edit, path=..., edits=...)` to modify, or \
1047 `scripts(action=run, space=true, path=...)` to execute. \
1048 The `path` parameter is relative to the scripts dir — do NOT prefix `scripts/`.\n"
1049 .to_string();
1050 for script in &self.scripts_cache {
1051 let _ = writeln!(s, "- {} ({})", script.name, human_size(script.size));
1052 }
1053 Some(s.trim_end().to_string())
1054 }
1055
1056 fn files_section(&self) -> Option<String> {
1059 if self.files_cache.is_empty() {
1060 return None;
1061 }
1062 let mut s = "## Files\nThe user has imported these files into this space. Do not guess \
1063 their contents: call `files(action=search, query=...)` to find relevant passages, or \
1064 `files(action=read, name=...)` to read one (200 lines per call, use offset to page). \
1065 Need several at once? Wrap them in one `batch` call.\n"
1066 .to_string();
1067 for f in &self.files_cache {
1068 let kind = std::path::Path::new(&f.name)
1069 .extension()
1070 .and_then(|e| e.to_str())
1071 .unwrap_or("file")
1072 .to_lowercase();
1073 let _ = writeln!(
1074 s,
1075 "- {} ({kind}, {}, {})",
1076 f.name,
1077 human_size(f.size.unsigned_abs()),
1078 f.status
1079 );
1080 }
1081 Some(s.trim_end().to_string())
1082 }
1083
1084 fn apps_section(&self) -> Option<String> {
1087 if self.incognito {
1088 return None;
1089 }
1090 self.app_server.as_ref()?;
1091 let mut s = "## Apps\nYou can build apps served locally. \
1092 ALWAYS use the KV store for persistence (not LocalStorage), the upload endpoint for file \
1093 uploads, and the app tool to bring user data into the app.\n\n\
1094 **CRITICAL: URLs are UUID-based.** The UUID comes from `app(action=write)`'s result \
1095 (\"live at http://...\"). Copy it from there — never invent or guess a UUID. \
1096 If you don't have the result, call `app(action=read)` or `app(action=search)` on the app to \
1097 rediscover it.\n\n"
1098 .to_string();
1099
1100 s.push_str("### Tools\n");
1101 s.push_str("- `batch` — wrap several app operations into one round-trip (results come back labeled).\n");
1102 s.push_str("- `app(action=write, app, path, content)` — create/replace a file. App can be a name or UUID; a new app gets a UUID.\n");
1103 s.push_str("- `app(action=read, app, path)` / `app(action=patch, app, path, edits)` — read and edit by hashline.\n");
1104 s.push_str("- `app(action=diff, app, path, content)` — preview a complete-file change without writing it.\n");
1105 s.push_str("- `app(action=search, app, pattern)` — search non-ignored app files; respects `.gitignore`.\n");
1106 s.push_str(
1107 "- `scripts(action=install, app=..., packages=[...])` — npm-install into an app.\n",
1108 );
1109 s.push_str("- `app(action=list)` — list pasted conversation images.\n");
1110 s.push_str("- `app(action=copy_images, image_ids, app)` — copy images into `_images/` for `<img src=\"...\">`.\n");
1111 s.push_str("- `app(action=copy_file, file_name, app)` — copy a space file's text into the app's KV store.\n\n");
1112
1113 s.push_str("### KV Store (persistent key-value per app)\n");
1114 s.push_str("Each app has a SQLite-backed KV store. Call these from frontend JS:\n");
1115 s.push_str("- `PUT <app_url>/_api/kv/<key>` — upsert a value (body = raw text)\n");
1116 s.push_str("- `GET <app_url>/_api/kv/<key>` — read a value\n");
1117 s.push_str("- `DELETE <app_url>/_api/kv/<key>` — delete a value\n");
1118 s.push_str("- `GET <app_url>/_api/kv` — list all keys (returns JSON array)\n\n");
1119
1120 s.push_str("### File Upload\n");
1121 s.push_str("- `POST <app_url>/_api/upload` with `multipart/form-data` — upload a file. Returns `{\"name\", \"url\"}`. Files persist and are served via GET.\n\n");
1122
1123 s.push_str("### Using User Images\n");
1124 s.push_str("1. `app(action=list)` to see conversation images.\n");
1125 s.push_str("2. `app(action=copy_images, image_ids, app)` to copy them into `_images/`.\n");
1126 s.push_str("3. Use returned URLs in `<img src=\"...\">` tags.\n\n");
1127
1128 s.push_str("### Using Space Files\n");
1129 s.push_str("- `app(action=copy_file, file_name, app)` copies file text into KV under `_file:<name>`. Read it via `GET <app_url>/_api/kv/_file:<name>`.\n\n");
1130
1131 let apps = self.list_apps();
1132 if apps.is_empty() {
1133 s.push_str("No apps exist in this space yet.");
1134 } else {
1135 s.push_str("Existing apps:\n");
1136 for a in &apps {
1137 if let Some(uuid) = self
1138 .app_server
1139 .as_ref()
1140 .and_then(|s| s.registry().resolve(&self.active_space.name, a))
1141 {
1142 let _ = writeln!(s, "- {a} (uuid {uuid})");
1143 } else {
1144 let _ = writeln!(s, "- {a}");
1145 }
1146 }
1147 }
1148 Some(s.trim_end().to_string())
1149 }
1150
1151 fn space_images_message(&self) -> Option<ChatMessage> {
1154 let files_dir = self.space.files_dir(&self.active_space.name);
1155 let mut urls: Vec<String> = Vec::new();
1156 let mut names: Vec<&str> = Vec::new();
1157 for f in &self.files_cache {
1158 let ext = std::path::Path::new(&f.name)
1159 .extension()
1160 .and_then(|e| e.to_str())
1161 .unwrap_or("");
1162 if !crate::extract::is_image_ext(ext) {
1163 continue;
1164 }
1165 let path = files_dir.join(&f.name);
1166 if let Ok(bytes) = std::fs::read(&path) {
1167 use base64::Engine;
1168 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1169 let mime = match ext {
1170 "jpg" | "jpeg" => "image/jpeg",
1171 "gif" => "image/gif",
1172 "webp" => "image/webp",
1173 "bmp" => "image/bmp",
1174 _ => "image/png",
1175 };
1176 urls.push(format!("data:{mime};base64,{b64}"));
1177 }
1178 names.push(f.name.as_str());
1179 }
1180 if urls.is_empty() {
1181 return None;
1182 }
1183 let text = format!(
1184 "The user has these image files in this space which you can see below: {}",
1185 names.join(", ")
1186 );
1187 Some(ChatMessage {
1188 role: "system".to_string(),
1189 content: text,
1190 reasoning_content: None,
1191 tool_calls: None,
1192 tool_call_id: None,
1193 images: urls,
1194 })
1195 }
1196
1197 pub fn list_apps(&self) -> Vec<String> {
1199 let dir = self.space.apps_dir(&self.active_space.name);
1200 let Ok(rd) = std::fs::read_dir(dir) else {
1201 return Vec::new();
1202 };
1203 let mut apps: Vec<String> = rd
1204 .filter_map(std::result::Result::ok)
1205 .filter(|e| e.path().is_dir())
1206 .filter_map(|e| e.file_name().into_string().ok())
1207 .collect();
1208 apps.sort();
1209 apps
1210 }
1211}
1212
1213pub fn code_blocks(md: &str) -> Vec<(Option<String>, String)> {
1217 let mut out = Vec::new();
1218 let mut inside = false;
1219 let mut lang: Option<String> = None;
1220 let mut buf = String::new();
1221 for line in md.lines() {
1222 let trimmed = line.trim_start();
1223 if trimmed.starts_with("```") {
1224 if inside {
1225 out.push((lang.take(), std::mem::take(&mut buf)));
1226 } else {
1227 let l = trimmed.trim_start_matches('`').trim();
1228 lang = (!l.is_empty()).then(|| l.to_string());
1229 }
1230 inside = !inside;
1231 continue;
1232 }
1233 if inside {
1234 buf.push_str(line);
1235 buf.push('\n');
1236 }
1237 }
1238 if inside && !buf.is_empty() {
1239 out.push((lang.take(), buf)); }
1241 out
1242}
1243
1244pub fn pick_greeting() -> &'static str {
1245 let n = std::time::SystemTime::now()
1246 .duration_since(std::time::UNIX_EPOCH)
1247 .map_or(0, |d| d.subsec_nanos() as usize);
1248 super::GREETINGS[n % super::GREETINGS.len()]
1249}
1250
1251pub fn pick_flavor() -> (usize, SpinnerColor) {
1252 let n = std::time::SystemTime::now()
1253 .duration_since(std::time::UNIX_EPOCH)
1254 .map_or(0, |d| d.subsec_nanos() as usize);
1255 (n % THINKING.len(), SPINNER_COLORS[n % SPINNER_COLORS.len()])
1256}
1257
1258pub fn web_mode_clause(today: &str) -> String {
1263 format!(
1264 "Web answer mode is ON for this session. Today's date is {today}. Before answering, you \
1265 MUST call search with mode=web and a focused query (and fetch_url on the most promising results) — \
1266 never answer from memory alone. You may search more than once with refined queries if the \
1267 first results are insufficient. Each search result is numbered [1], [2], ... with title, \
1268 URL, and snippet. Cite every claim inline immediately as [n]; do not bunch citations at \
1269 the end of a paragraph. End your reply with a line starting exactly with 'Sources:' \
1270 followed by every citation you used, one per line, as [n] title — url. Do not fabricate \
1271 sources; if a claim is not backed by a search/fetched source, do not cite it."
1272 )
1273}
1274
1275pub fn title_from(text: &str) -> String {
1276 let t: String = text.chars().take(40).collect();
1277 if t.trim().is_empty() {
1278 "new chat".to_string()
1279 } else {
1280 t
1281 }
1282}
1283
1284fn append_tool_call_history(
1287 history: &mut Vec<ChatMessage>,
1288 effective: &[Message],
1289 index: &mut usize,
1290 seen_results: &mut std::collections::HashMap<(String, String), String>,
1291 tool_images_dir: Option<&std::path::Path>,
1292) {
1293 let start = *index;
1294 let mut rows = Vec::new();
1295 while *index < effective.len() && effective[*index].role == "tool_call" {
1296 if let Some(row) = parse_tool_call_row(&effective[*index].content) {
1297 rows.push(row);
1298 }
1299 *index += 1;
1300 }
1301 if rows.is_empty() {
1302 debug_assert!(*index > start);
1304 return;
1305 }
1306 let calls = rows.iter().map(|row| row.call.clone()).collect::<Vec<_>>();
1307 let reasoning_content = rows.iter().find_map(|row| row.reasoning_content.clone());
1308 let assistant_content = rows.iter().find_map(|row| row.assistant_content.clone());
1309 history.push(ChatMessage {
1310 role: "assistant".to_string(),
1311 content: assistant_content.unwrap_or_default(),
1312 reasoning_content,
1313 tool_calls: Some(calls),
1314 tool_call_id: None,
1315 images: Vec::new(),
1316 });
1317 for row in rows {
1318 let call = row.call;
1319 let images = tool_images_dir
1320 .and_then(|dir| crate::provider::openrouter::extract_tool_images(&row.result, dir));
1321 let key = (call.name.clone(), call.arguments.clone());
1322 let content = match seen_results.get(&key) {
1323 Some(prev) if *prev == row.result => {
1324 crate::tools::tool_result_unchanged_note(&call.name, &call.arguments)
1325 }
1326 _ => {
1327 seen_results.insert(key, row.result.clone());
1328 row.result
1329 }
1330 };
1331 history.push(ChatMessage {
1332 role: "tool".to_string(),
1333 content,
1334 reasoning_content: None,
1335 tool_calls: None,
1336 tool_call_id: Some(call.id),
1337 images: Vec::new(),
1338 });
1339 if let Some(images) = images {
1340 history.push(ChatMessage {
1341 role: "user".to_string(),
1342 content: images.description,
1343 reasoning_content: None,
1344 tool_calls: None,
1345 tool_call_id: None,
1346 images: images.urls,
1347 });
1348 }
1349 }
1350}
1351
1352struct StoredToolCall {
1355 call: ToolCall,
1356 result: String,
1357 reasoning_content: Option<String>,
1358 assistant_content: Option<String>,
1359}
1360
1361fn parse_tool_call_row(content: &str) -> Option<StoredToolCall> {
1364 let v: serde_json::Value = serde_json::from_str(content).ok()?;
1365 let name = v.get("name")?.as_str()?.to_string();
1366 let arguments = v.get("arguments")?.as_str()?.to_string();
1367 let result = v.get("result")?.as_str()?.to_string();
1368 Some(StoredToolCall {
1369 call: ToolCall {
1370 id: v
1371 .get("id")
1372 .and_then(serde_json::Value::as_str)
1373 .filter(|id| !id.is_empty())
1374 .unwrap_or("call_0")
1375 .to_string(),
1376 name,
1377 arguments,
1378 },
1379 result,
1380 reasoning_content: v
1381 .get("reasoning_content")
1382 .and_then(serde_json::Value::as_str)
1383 .map(str::to_string),
1384 assistant_content: v
1385 .get("assistant_content")
1386 .and_then(serde_json::Value::as_str)
1387 .map(str::to_string),
1388 })
1389}
1390
1391pub fn human_size(bytes: u64) -> String {
1393 match bytes {
1394 b if b < 1024 => format!("{b} B"),
1395 b if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0),
1396 b => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)),
1397 }
1398}
1399
1400pub fn split_inline_reasoning(text: &str) -> (String, Option<String>) {
1405 const OPEN: &str = "<think>";
1406 const CLOSE: &str = "</think>";
1407 let mut content = String::with_capacity(text.len());
1408 let mut reasoning = String::new();
1409 let mut rest = text;
1410 loop {
1411 let Some(start) = rest.find(OPEN) else {
1412 content.push_str(rest);
1413 break;
1414 };
1415 content.push_str(&rest[..start]);
1416 let after_open = &rest[start + OPEN.len()..];
1417 let (block, remainder) = match after_open.find(CLOSE) {
1418 Some(end) => (&after_open[..end], &after_open[end + CLOSE.len()..]),
1419 None => (after_open, ""),
1420 };
1421 if !reasoning.is_empty() {
1422 reasoning.push('\n');
1423 }
1424 reasoning.push_str(block.trim());
1425 rest = remainder;
1426 }
1427 let content = content.trim().to_string();
1428 (content, (!reasoning.is_empty()).then_some(reasoning))
1429}