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() {
56 let title = title_from(&text);
57 let s = if self.incognito {
58 crate::db::Session {
59 id: uuid::Uuid::new_v4().to_string(),
60 title,
61 model,
62 slug: None,
63 created_at: Utc::now().to_rfc3339(),
64 compact_summary: None,
65 compact_through: 0,
66 web_mode: self.web_mode,
67 swarm_mode: false,
68 kind: "chat".to_string(),
69 research_parent_id: None,
70 }
71 } else {
72 let mut s =
73 self.db
74 .create_session(&title, &model, &self.active_space.id, "chat")?;
75 if self.web_mode {
77 s.web_mode = true;
78 let _ = self.db.set_session_web_mode(&s.id, true);
79 }
80 s
81 };
82 self.session = Some(s);
83 }
84 let Some(session_id) = self.session.as_ref().map(|s| s.id.clone()) else {
85 return Ok(());
86 };
87
88 if !self.incognito {
89 self.db.add_user_message(&session_id, &text)?;
90 }
91 self.messages.push(Message {
92 role: "user".to_string(),
93 content: text,
94 model: None,
95 reasoning: None,
96 tokens: None,
97 secs: None,
98 cost: None,
99 phrase: None,
100 persona: None,
101 created_at: None,
102 });
103
104 if self.session.as_ref().is_some_and(|s| s.swarm_mode) {
105 self.start_swarm_turn();
106 return Ok(());
107 }
108 self.start_stream()
109 }
110
111 pub fn build_history(&mut self) -> Vec<ChatMessage> {
116 let mut history: Vec<ChatMessage> = Vec::with_capacity(self.messages.len() + 3);
117 history.push(ChatMessage::text("system", self.system_prompt()));
118 if self.current_model_supports_images()
120 && let Some(img_msg) = self.space_images_message()
121 {
122 history.push(img_msg);
123 }
124 if let Some(summary) = self
127 .session
128 .as_ref()
129 .and_then(|s| s.compact_summary.clone())
130 {
131 history.push(ChatMessage::text(
132 "system",
133 format!("Summary of earlier conversation (auto-compacted for length):\n{summary}"),
134 ));
135 }
136 if let Some(name) = self.forced_skill.take()
137 && let Some(skill) = self.skills.iter().find(|s| s.name == name)
138 {
139 let body = std::fs::read_to_string(skill.dir.join("SKILL.md"))
140 .map(|md| crate::skills::skill_body(&md).to_string())
141 .unwrap_or_default();
142 history.push(ChatMessage::text(
143 "system",
144 format!("The user invoked the skill '{name}'. Follow these instructions:\n{body}"),
145 ));
146 }
147 let vision = self.current_model_supports_images();
148 let mut seen_results: std::collections::HashMap<(String, String), String> =
156 std::collections::HashMap::new();
157 for m in self.effective_messages() {
158 if Self::excluded_from_model_history(m) {
169 continue;
170 }
171 if m.role == "tool_call" {
172 if let Some((call, result)) = parse_tool_call_row(&m.content) {
173 history.push(ChatMessage {
174 role: "assistant".to_string(),
175 content: String::new(),
176 tool_calls: Some(vec![call.clone()]),
177 tool_call_id: None,
178 images: Vec::new(),
179 });
180 let key = (call.name.clone(), call.arguments.clone());
181 let content = match seen_results.get(&key) {
182 Some(prev) if *prev == result => {
183 crate::tools::tool_result_unchanged_note(&call.name, &call.arguments)
184 }
185 _ => {
186 seen_results.insert(key, result.clone());
187 result
188 }
189 };
190 history.push(ChatMessage {
191 role: "tool".to_string(),
192 content,
193 tool_calls: None,
194 tool_call_id: Some(call.id),
195 images: Vec::new(),
196 });
197 }
198 continue;
199 }
200 let mut cm = ChatMessage::text(m.role.clone(), m.content.clone());
201 if m.role == "user" && vision {
202 let images_dir = self.space.files_dir(&self.active_space.name);
203 let mut images = Vec::new();
204 let mut rest = m.content.as_str();
205 while let Some(start) = rest.find("![") {
206 if let Some(end) = rest[start..].find(')') {
207 let inner = &rest[start + 2..start + end];
208 if let Some((_alt, file)) = inner.split_once("](") {
209 let path = images_dir.join(file);
210 if let Ok(bytes) = std::fs::read(&path) {
211 images.push(crate::app::transcribe::png_bytes_data_url(&bytes));
212 }
213 }
214 rest = &rest[start + end + 1..];
215 } else {
216 break;
217 }
218 }
219 cm.images = images;
220 }
221 history.push(cm);
222 }
223 history
224 }
225
226 pub fn start_stream(&mut self) -> Result<()> {
228 let Some(model) = self.current_model.clone() else {
229 return Ok(());
230 };
231 let Some((provider, raw_model)) = self.resolve_model_backend(&model) else {
232 self.push_status(format!(
233 "model backend unavailable: {model} — pick another with /model"
234 ));
235 return Ok(());
236 };
237 let history = self.build_history();
238 let stored_effort = self.reasoning.get(&model).cloned();
242 let (reasoning_effort, reasoning_warning) = match stored_effort {
243 Some(effort) if self.effort_accepted(&model, &effort) => (Some(effort), None),
244 Some(effort) => {
245 self.db.set_reasoning(&model, None)?;
246 self.reasoning.remove(&model);
247 (
248 None,
249 Some(format!("cleared unsupported reasoning {effort}: {model}")),
250 )
251 }
252 None => (None, None),
253 };
254 let params = ChatParams {
255 reasoning_effort,
256 temperature: self.settings.temperature,
257 top_p: self.settings.top_p,
258 max_tokens: self.settings.max_tokens,
259 };
260 let tools = self.toolbox.defs();
261 let (rx, abort) = provider.stream_chat(
262 raw_model.clone(),
263 history,
264 params,
265 tools,
266 self.toolbox.clone(),
267 crate::provider::openrouter::MAX_TOOL_ITERS,
268 );
269 let Some(session) = self.session.clone() else {
270 return Ok(());
271 };
272 let backend = provider.backend_tag();
273 let (thinking_idx, spinner_color) = pick_flavor();
274 let task_id = self.next_chat_task_id;
275 self.next_chat_task_id = self.next_chat_task_id.wrapping_add(1);
276 let tx = self.chat_event_tx.clone();
277 tokio::spawn(async move {
278 let mut rx = rx;
279 while let Some(event) = rx.recv().await {
280 if tx.send(super::ChatEvent { task_id, event }).is_err() {
281 break;
282 }
283 }
284 let _ = tx.send(super::ChatEvent {
285 task_id,
286 event: StreamEvent::Done,
287 });
288 });
289 self.chat_tasks.insert(
290 task_id,
291 super::ChatTask {
292 id: task_id,
293 session_id: session.id,
294 session_title: session.title,
295 space_id: self.active_space.id.clone(),
296 model,
297 model_id: raw_model,
298 backend,
299 incognito: self.incognito,
300 buffer: String::new(),
301 thinking: String::new(),
302 tool_status: None,
303 usage: None,
304 usage_row_id: None,
305 started: std::time::Instant::now(),
306 thinking_idx,
307 spinner_color,
308 abort,
309 },
310 );
311 self.spinner_frame = 0;
312 self.thinking_idx = thinking_idx;
313 self.spinner_color = spinner_color;
314 self.push_status(reasoning_warning.unwrap_or_default());
315 self.push_viewport_reset();
318 Ok(())
319 }
320
321 #[cfg(test)]
324 pub fn on_stream_event(&mut self, ev: StreamEvent) -> Result<()> {
325 let task_id = self
326 .active_chat_task()
327 .map(|task| task.id)
328 .or_else(|| self.chat_tasks.keys().next().copied());
329 if let Some(task_id) = task_id {
330 return self.on_chat_event(task_id, ev);
331 }
332 if let StreamEvent::ToolCall {
333 name,
334 arguments,
335 result,
336 } = ev
337 {
338 let content = serde_json::json!({
339 "name": name,
340 "arguments": arguments,
341 "result": result,
342 })
343 .to_string();
344 if let Some(session) = self.session.as_ref()
345 && !self.incognito
346 {
347 let _ = self.db.add_tool_call_message(&session.id, &content);
348 }
349 self.messages.push(Message {
350 role: "tool_call".to_string(),
351 content,
352 model: None,
353 reasoning: None,
354 tokens: None,
355 secs: None,
356 cost: None,
357 phrase: None,
358 persona: None,
359 created_at: None,
360 });
361 }
362 Ok(())
363 }
364
365 #[allow(clippy::too_many_lines)] pub fn on_chat_event(&mut self, task_id: super::ChatTaskId, ev: StreamEvent) -> Result<()> {
367 match ev {
368 StreamEvent::Token(t) => {
369 if let Some(task) = self.chat_tasks.get_mut(&task_id) {
370 task.buffer.push_str(&t);
371 }
372 }
373 StreamEvent::Reasoning(t) => {
374 if let Some(task) = self.chat_tasks.get_mut(&task_id) {
375 task.thinking.push_str(&t);
376 }
377 }
378 StreamEvent::Usage(u) => {
379 if u.prompt_tokens + u.completion_tokens > 0 {
389 self.last_cache_rate = u.cache_hit_rate();
390 }
391 let Some(task) = self.chat_tasks.get_mut(&task_id) else {
392 return Ok(());
393 };
394 let merged = match task.usage {
395 Some(prev) if u.prompt_tokens + u.completion_tokens > 0 => {
396 Usage {
398 cost: u.cost.or(prev.cost),
399 ..u
400 }
401 }
402 Some(prev) => Usage {
403 cost: u.cost.or(prev.cost),
404 ..prev
405 },
406 None => u,
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 let Some(id) = row_id {
435 let _ = self.db.update_usage(
436 id,
437 merged.prompt_tokens,
438 merged.completion_tokens,
439 merged.cache_read_tokens,
440 merged.cache_creation_tokens,
441 cost,
442 cost_is_provider,
443 );
444 } else {
445 let id = self.db.log_usage(
446 backend.name(),
447 &model_id,
448 merged.prompt_tokens,
449 merged.completion_tokens,
450 merged.cache_read_tokens,
451 merged.cache_creation_tokens,
452 cost,
453 cost_is_provider,
454 Some(&session_id),
455 Some(&space_id),
456 );
457 if let Ok(id) = id
458 && let Some(task) = self.chat_tasks.get_mut(&task_id)
459 {
460 task.usage_row_id = Some(id);
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 name,
472 arguments,
473 result,
474 } => {
475 if ((name == "skills")
476 || (name == "skill_admin")
477 || (name == "install_skill" && result.starts_with("installed"))
478 || (name == "create_skill"
479 && (result.starts_with("created") || result.starts_with("updated"))))
480 && (result.starts_with("installed")
481 || result.starts_with("created")
482 || result.starts_with("updated"))
483 {
484 self.reload_skills();
485 }
486 let Some(task) = self.chat_tasks.get(&task_id) else {
487 return Ok(());
488 };
489 let content =
490 serde_json::json!({ "name": name, "arguments": arguments, "result": result })
491 .to_string();
492 let target = task.session_id.clone();
493 let incognito = task.incognito;
494 let in_active_space = task.space_id == self.active_space.id;
495 let viewing = self
496 .session
497 .as_ref()
498 .is_some_and(|session| session.id == target);
499 if !incognito {
500 let _ = self.db.add_tool_call_message(&target, &content);
501 }
502 if viewing {
503 self.messages.push(Message {
504 role: "tool_call".to_string(),
505 content,
506 model: None,
507 reasoning: None,
508 tokens: None,
509 secs: None,
510 cost: None,
511 phrase: None,
512 persona: None,
513 created_at: None,
514 });
515 }
516 let media_generated = matches!(
520 name.as_str(),
521 "generate_image" | "generate_video" | "video_transform"
522 ) || matches!(
523 name.as_str(),
524 "edit_video" | "extract_frame" | "stitch_videos"
525 ) || (name == "media"
526 && serde_json::from_str::<serde_json::Value>(&arguments)
527 .ok()
528 .and_then(|v| v.get("action").and_then(|a| a.as_str()).map(str::to_string))
529 .is_some_and(|a| {
530 matches!(
531 a.as_str(),
532 "generate_image"
533 | "generate_video"
534 | "edit"
535 | "extract_frame"
536 | "stitch"
537 )
538 }));
539 if in_active_space && media_generated {
540 self.rescan_files();
541 }
542 }
543 StreamEvent::Done => self.finish_chat_task(task_id, None)?,
544 StreamEvent::Error(e) => {
545 self.finish_chat_task(task_id, Some(e))?;
546 }
547 }
548 Ok(())
549 }
550
551 pub fn cancel_chat_task(&mut self, task_id: super::ChatTaskId) -> Result<()> {
554 if let Some(task) = self.chat_tasks.remove(&task_id) {
555 task.abort.abort();
556 self.finish_chat_task_state(task, None, true)?;
557 }
558 Ok(())
559 }
560
561 pub fn stop_stream(&mut self) -> Result<()> {
564 let Some(task_id) = self.active_chat_task().map(|task| task.id) else {
565 return Ok(());
566 };
567 self.cancel_chat_task(task_id)
568 }
569
570 pub fn discard_chat_task(&mut self, session_id: &str) {
571 if let Some(task) = self
572 .chat_tasks
573 .iter()
574 .find(|(_, task)| task.session_id == session_id)
575 .map(|(id, _)| *id)
576 .and_then(|id| self.chat_tasks.remove(&id))
577 {
578 task.abort.abort();
579 }
580 }
581
582 fn finish_chat_task(
583 &mut self,
584 task_id: super::ChatTaskId,
585 error: Option<String>,
586 ) -> Result<()> {
587 let Some(task) = self.chat_tasks.remove(&task_id) else {
588 return Ok(());
589 };
590 self.finish_chat_task_state(task, error, false)
591 }
592
593 #[allow(clippy::too_many_lines)]
595 fn finish_chat_task_state(
596 &mut self,
597 task: super::ChatTask,
598 error: Option<String>,
599 stopped: bool,
600 ) -> Result<()> {
601 let mut reasoning = task.thinking;
602 let (buf, inline) = split_inline_reasoning(&task.buffer);
603 if let Some(inline) = inline {
604 if !reasoning.is_empty() {
605 reasoning.push('\n');
606 }
607 reasoning.push_str(&inline);
608 }
609 if buf.is_empty() && error.is_none() {
610 self.push_status(if stopped {
611 "response stopped".to_string()
612 } else {
613 "response finished without text".to_string()
614 });
615 return Ok(());
616 }
617 let viewing = self
623 .session
624 .as_ref()
625 .is_some_and(|session| session.id == task.session_id);
626 let model = Some(task.model.clone());
627 let usage = task.usage;
629 let tokens = Some(match usage {
630 Some(u) => u.completion_tokens as i64,
631 None => buf.chars().count().div_ceil(4) as i64,
632 });
633 if viewing && let Some(u) = usage {
634 let total = if u.total_tokens > 0 {
636 u.total_tokens
637 } else {
638 u.prompt_tokens + u.completion_tokens
639 };
640 self.context_total = Some(total);
641 }
642 let secs = Some(task.started.elapsed().as_secs_f64());
643 let cost = usage.and_then(|u| {
646 u.cost.or_else(|| {
647 self.db.request_cost(
648 &task.model_id,
649 u.prompt_tokens,
650 u.completion_tokens,
651 u.cache_read_tokens,
652 u.cache_creation_tokens,
653 )
654 })
655 });
656 let reasoning = (!reasoning.is_empty()).then_some(reasoning);
657 let phrase = Some(THINKING[task.thinking_idx].1.to_string());
658 let created_at = Some(chrono::Utc::now().to_rfc3339());
659
660 if !task.incognito && !buf.is_empty() {
661 self.db.add_assistant_message(
662 &task.session_id,
663 &buf,
664 model.as_deref(),
665 reasoning.as_deref(),
666 tokens,
667 secs,
668 cost,
669 phrase.as_deref(),
670 )?;
671 }
672 if viewing && !buf.is_empty() {
673 self.messages.push(Message {
674 role: "assistant".to_string(),
675 content: buf,
676 model,
677 reasoning,
678 tokens,
679 secs,
680 cost,
681 phrase,
682 persona: None,
683 created_at,
684 });
685 if !task.incognito {
688 self.maybe_generate_title();
689 self.maybe_extract_memory();
690 self.maybe_compact();
691 }
692 }
693 if let Some(error) = error {
694 if !task.incognito {
695 self.db.add_error_message(&task.session_id, &error)?;
696 }
697 let msg = format!("stream error: {error}");
698 if viewing {
699 self.messages.push(Message {
700 role: "error".to_string(),
701 content: error.clone(),
702 model: None,
703 reasoning: None,
704 tokens: None,
705 secs: None,
706 cost: None,
707 phrase: None,
708 persona: None,
709 created_at: None,
710 });
711 } else if !task.incognito {
712 self.unread.insert(task.session_id.clone());
713 }
714 self.push_status(if viewing {
715 msg.clone()
716 } else {
717 format!("stream error in {}: {error}", task.session_title)
718 });
719 if !viewing && !task.incognito {
720 super::send_system_notification(
721 &format!("Chat failed: {}", task.session_title),
722 &msg,
723 );
724 }
725 if !viewing && !task.incognito {
726 self.notifications.push_back(super::ChatNotification {
727 session_id: task.session_id,
728 title: task.session_title,
729 text: msg,
730 success: false,
731 });
732 }
733 } else if stopped {
734 self.push_status("response stopped".to_string());
735 } else {
736 if !viewing && !task.incognito {
737 self.unread.insert(task.session_id.clone());
738 self.push_status(format!("✓ response ready in: {}", task.session_title));
739 super::send_system_notification(
740 &format!("Chat ready: {}", task.session_title),
741 "response complete",
742 );
743 } else if viewing {
744 self.push_status("response complete".to_string());
745 }
746 if !viewing && !task.incognito {
747 self.notifications.push_back(super::ChatNotification {
748 session_id: task.session_id,
749 title: task.session_title,
750 text: "response complete".to_string(),
751 success: true,
752 });
753 }
754 }
755 Ok(())
756 }
757
758 pub fn maybe_generate_title(&mut self) {
761 let Some(session) = self.session.as_ref() else {
762 return;
763 };
764 let Some((provider, raw_model)) = self
765 .current_model
766 .as_deref()
767 .and_then(|model| self.resolve_model_backend(model))
768 .or_else(|| self.resolve_utility_model_backend(&self.memory_model))
769 else {
770 return;
771 };
772 if session.slug.is_some() {
773 return; }
775 let convo: String = self
777 .messages
778 .iter()
779 .filter(|m| m.role != "tool_call")
780 .map(|m| {
781 format!(
782 "{}: {}",
783 m.role,
784 m.content.chars().take(500).collect::<String>()
785 )
786 })
787 .collect::<Vec<_>>()
788 .join("\n");
789 let session_id = session.id.clone();
790 let (tx, rx) = mpsc::unbounded_channel();
791 self.title_rx = Some(rx);
792 tokio::spawn(async move {
793 let prompt = format!(
794 "Summarise this conversation as a session name. Reply with ONLY a JSON object, \
795 no markdown, of the form {{\"topic\": \"<3-5 word title>\", \"id\": \"<short-kebab-slug>\"}}.\n\n{convo}"
796 );
797 let msgs = vec![ChatMessage::text("user", prompt)];
798 if let Ok(text) = provider.complete(&raw_model, msgs).await
799 && let Some((topic, slug)) = parse_topic(&text)
800 {
801 let _ = tx.send((session_id, topic, slug));
802 }
803 });
804 }
805
806 pub fn on_title_result(&mut self, result: Option<(String, String, String)>) {
808 self.title_rx = None;
809 let Some((id, topic, slug)) = result else {
810 return;
811 };
812 let _ = self.db.set_session_title(&id, &topic, Some(&slug));
813 if let Some(s) = self.session.as_mut().filter(|s| s.id == id) {
814 s.title.clone_from(&topic);
815 s.slug = Some(slug.clone());
816 }
817 if let Some(s) = self.sessions_cache.iter_mut().find(|s| s.id == id) {
818 s.title = topic;
819 s.slug = Some(slug);
820 }
821 }
822
823 pub fn system_prompt(&self) -> String {
830 let mut parts: Vec<String> = vec![self.resolved_base_system_prompt()];
831 if !self.incognito {
832 let instructions =
833 std::fs::read_to_string(self.space.instructions_path(&self.active_space.name))
834 .ok()
835 .map(|s| s.trim().to_string())
836 .filter(|s| !s.is_empty());
837 if let Some(i) = instructions {
838 parts.push(i);
839 }
840 if let Some(files) = self.files_section() {
841 parts.push(files);
842 }
843 if let Some(apps) = self.apps_section() {
844 parts.push(apps);
845 }
846 if let Some(scripts) = self.scripts_section() {
847 parts.push(scripts);
848 }
849 let memory = self.read_memory();
850 if !memory.trim().is_empty() {
851 parts.push(format!("## Memory\n{memory}"));
852 }
853 }
854 if let Some(skills) = self.skills_section() {
855 parts.push(skills);
856 }
857 if self.web_mode {
858 let today = Utc::now().format("%Y-%m-%d").to_string();
859 parts.push(web_mode_clause(&today));
860 }
861 if self.is_research_session() {
862 parts.push(
863 "This session came from /research — prefer research_lookup with scope=session_sources over search with mode=web for \
864 follow-ups; only use web search on a miss."
865 .to_string(),
866 );
867 }
868 parts.join("\n\n")
869 }
870
871 pub fn open_session_link(&mut self, owner: Option<usize>) {
881 let Some(msg) = owner.and_then(|i| self.messages.get(i)) else {
882 self.push_status(
883 "select text on a session link message, then press Ctrl+O".to_string(),
884 );
885 return;
886 };
887 if msg.role != "session_link" {
888 self.push_status(
889 "select text on a session link message, then press Ctrl+O".to_string(),
890 );
891 return;
892 }
893 let Some(target) = msg
894 .content
895 .split_once('\n')
896 .map(|(s, _)| s.trim().to_string())
897 else {
898 self.push_status("malformed session link".to_string());
899 return;
900 };
901 if let Err(e) = self.switch_to_session_by_id(&target) {
902 self.push_status(format!("session switch failed: {e}"));
903 }
904 }
905
906 pub fn flag_source_under_selection(
911 &mut self,
912 flag: Option<&str>,
913 selected: Option<String>,
914 owner: Option<usize>,
915 ) {
916 let Some(selected) = selected else {
917 self.push_status("select a [n] citation, then press x".to_string());
918 return;
919 };
920 let Some(n) = crate::citations::citation_number_in(&selected) else {
921 self.push_status("no [n] citation in the current selection".to_string());
922 return;
923 };
924 let Some(msg) = owner.and_then(|i| self.messages.get(i)) else {
925 self.push_status("no [n] citation in the current selection".to_string());
926 return;
927 };
928 let citations = crate::citations::parse_citations(&msg.content);
929 let Some((_, url)) = citations.iter().find(|(num, _)| *num == n) else {
930 self.push_status(format!("no source [{n}] in this message"));
931 return;
932 };
933 let Some(session) = &self.session else {
934 self.push_status("no active session".to_string());
935 return;
936 };
937 let url_norm = crate::tools::normalize_url(url);
938 let verb = match flag {
939 Some("discarded") => "discarded",
940 Some(_) => "pinned",
941 None => "cleared",
942 };
943 match self.db.set_source_flag(&session.id, &url_norm, flag) {
944 Ok(()) => {
945 self.push_status(format!("{verb} [{n}]: {url}"));
946 self.refresh_toolbox();
947 }
948 Err(e) => self.push_status(format!("flag failed: {e}")),
949 }
950 }
951
952 pub fn toggle_web_mode(&mut self) {
956 self.web_mode = !self.web_mode;
957 if let Some(session) = self.session.as_mut() {
958 session.web_mode = self.web_mode;
959 let _ = self.db.set_session_web_mode(&session.id, self.web_mode);
960 }
961 self.push_status(if self.web_mode {
962 "🌐 web mode on".to_string()
963 } else {
964 "web mode off".to_string()
965 });
966 }
967
968 pub fn toggle_incognito(&mut self) -> Result<()> {
969 if self.is_streaming() {
970 self.stop_stream()?;
971 }
972 self.session = None;
973 self.messages.clear();
974 self.context_total = None;
975 self.push_composer_clear();
976 self.push_viewport_reset();
977 self.cleanup_incognito_images();
978 self.incognito = !self.incognito;
979 self.push_status(if self.incognito {
980 "incognito mode — nothing persists, no apps".to_string()
981 } else {
982 "incognito mode off".to_string()
983 });
984 Ok(())
985 }
986
987 pub fn resolved_base_system_prompt(&self) -> String {
990 let now = Utc::now().format("%Y-%m-%d %H:%M UTC, %A").to_string();
991 self.base_system_prompt
992 .replace("{{verbosity}}", verbosity_clause(&self.verbosity))
993 .replace("{{datetime}}", &now)
994 }
995
996 pub fn reload_base_system_prompt(&mut self) {
998 if let Ok(text) = crate::config::load_system_prompt() {
999 self.base_system_prompt = text;
1000 self.push_status("system prompt reloaded".to_string());
1001 }
1002 }
1003
1004 fn skills_section(&self) -> Option<String> {
1007 if self.skills.is_empty() {
1008 return None;
1009 }
1010 let mut s = "## Skills\nYou have skills available. To use one, call the `skills` tool \
1011 with action=load and its name; the full instructions will be returned.\n"
1012 .to_string();
1013 for skill in &self.skills {
1014 let _ = writeln!(s, "- {}: {}", skill.name, skill.description);
1015 }
1016 Some(s.trim_end().to_string())
1017 }
1018
1019 fn scripts_section(&self) -> Option<String> {
1022 if self.scripts_cache.is_empty() {
1023 return None;
1024 }
1025 let mut s = "## Scripts\nThe user has reusable scripts in this space. \
1026 Call `scripts(action=read, path=...)` to see one, \
1027 `scripts(action=edit, path=..., edits=...)` to modify, or \
1028 `scripts(action=run, space=true, path=...)` to execute. \
1029 The `path` parameter is relative to the scripts dir — do NOT prefix `scripts/`.\n"
1030 .to_string();
1031 for script in &self.scripts_cache {
1032 let _ = writeln!(s, "- {} ({})", script.name, human_size(script.size));
1033 }
1034 Some(s.trim_end().to_string())
1035 }
1036
1037 fn files_section(&self) -> Option<String> {
1040 if self.files_cache.is_empty() {
1041 return None;
1042 }
1043 let mut s = "## Files\nThe user has imported these files into this space. Do not guess \
1044 their contents: call `files(action=search, query=...)` to find relevant passages, or \
1045 `files(action=read, name=...)` to read one (200 lines per call, use offset to page). \
1046 Need several at once? Wrap them in one `batch` call.\n"
1047 .to_string();
1048 for f in &self.files_cache {
1049 let kind = std::path::Path::new(&f.name)
1050 .extension()
1051 .and_then(|e| e.to_str())
1052 .unwrap_or("file")
1053 .to_lowercase();
1054 let _ = writeln!(
1055 s,
1056 "- {} ({kind}, {}, {})",
1057 f.name,
1058 human_size(f.size.unsigned_abs()),
1059 f.status
1060 );
1061 }
1062 Some(s.trim_end().to_string())
1063 }
1064
1065 fn apps_section(&self) -> Option<String> {
1068 if self.incognito {
1069 return None;
1070 }
1071 self.app_server.as_ref()?;
1072 let mut s = "## Apps\nYou can build apps served locally. \
1073 ALWAYS use the KV store for persistence (not LocalStorage), the upload endpoint for file \
1074 uploads, and the app tool to bring user data into the app.\n\n\
1075 **CRITICAL: URLs are UUID-based.** The UUID comes from `app(action=write)`'s result \
1076 (\"live at http://...\"). Copy it from there — never invent or guess a UUID. \
1077 If you don't have the result, call `app(action=read)` or `app(action=search)` on the app to \
1078 rediscover it.\n\n"
1079 .to_string();
1080
1081 s.push_str("### Tools\n");
1082 s.push_str("- `batch` — wrap several app operations into one round-trip (results come back labeled).\n");
1083 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");
1084 s.push_str("- `app(action=read, app, path)` / `app(action=patch, app, path, edits)` — read and edit by hashline.\n");
1085 s.push_str("- `app(action=diff, app, path, content)` — preview a complete-file change without writing it.\n");
1086 s.push_str("- `app(action=search, app, pattern)` — search non-ignored app files; respects `.gitignore`.\n");
1087 s.push_str(
1088 "- `scripts(action=install, app=..., packages=[...])` — npm-install into an app.\n",
1089 );
1090 s.push_str("- `app(action=list)` — list pasted conversation images.\n");
1091 s.push_str("- `app(action=copy_images, image_ids, app)` — copy images into `_images/` for `<img src=\"...\">`.\n");
1092 s.push_str("- `app(action=copy_file, file_name, app)` — copy a space file's text into the app's KV store.\n\n");
1093
1094 s.push_str("### KV Store (persistent key-value per app)\n");
1095 s.push_str("Each app has a SQLite-backed KV store. Call these from frontend JS:\n");
1096 s.push_str("- `PUT <app_url>/_api/kv/<key>` — upsert a value (body = raw text)\n");
1097 s.push_str("- `GET <app_url>/_api/kv/<key>` — read a value\n");
1098 s.push_str("- `DELETE <app_url>/_api/kv/<key>` — delete a value\n");
1099 s.push_str("- `GET <app_url>/_api/kv` — list all keys (returns JSON array)\n\n");
1100
1101 s.push_str("### File Upload\n");
1102 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");
1103
1104 s.push_str("### Using User Images\n");
1105 s.push_str("1. `app(action=list)` to see conversation images.\n");
1106 s.push_str("2. `app(action=copy_images, image_ids, app)` to copy them into `_images/`.\n");
1107 s.push_str("3. Use returned URLs in `<img src=\"...\">` tags.\n\n");
1108
1109 s.push_str("### Using Space Files\n");
1110 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");
1111
1112 let apps = self.list_apps();
1113 if apps.is_empty() {
1114 s.push_str("No apps exist in this space yet.");
1115 } else {
1116 s.push_str("Existing apps:\n");
1117 for a in &apps {
1118 if let Some(uuid) = self
1119 .app_server
1120 .as_ref()
1121 .and_then(|s| s.registry().resolve(&self.active_space.name, a))
1122 {
1123 let _ = writeln!(s, "- {a} (uuid {uuid})");
1124 } else {
1125 let _ = writeln!(s, "- {a}");
1126 }
1127 }
1128 }
1129 Some(s.trim_end().to_string())
1130 }
1131
1132 fn space_images_message(&self) -> Option<ChatMessage> {
1135 let files_dir = self.space.files_dir(&self.active_space.name);
1136 let mut urls: Vec<String> = Vec::new();
1137 let mut names: Vec<&str> = Vec::new();
1138 for f in &self.files_cache {
1139 let ext = std::path::Path::new(&f.name)
1140 .extension()
1141 .and_then(|e| e.to_str())
1142 .unwrap_or("");
1143 if !crate::extract::is_image_ext(ext) {
1144 continue;
1145 }
1146 let path = files_dir.join(&f.name);
1147 if let Ok(bytes) = std::fs::read(&path) {
1148 use base64::Engine;
1149 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
1150 let mime = match ext {
1151 "jpg" | "jpeg" => "image/jpeg",
1152 "gif" => "image/gif",
1153 "webp" => "image/webp",
1154 "bmp" => "image/bmp",
1155 _ => "image/png",
1156 };
1157 urls.push(format!("data:{mime};base64,{b64}"));
1158 }
1159 names.push(f.name.as_str());
1160 }
1161 if urls.is_empty() {
1162 return None;
1163 }
1164 let text = format!(
1165 "The user has these image files in this space which you can see below: {}",
1166 names.join(", ")
1167 );
1168 Some(ChatMessage {
1169 role: "system".to_string(),
1170 content: text,
1171 tool_calls: None,
1172 tool_call_id: None,
1173 images: urls,
1174 })
1175 }
1176
1177 pub fn list_apps(&self) -> Vec<String> {
1179 let dir = self.space.apps_dir(&self.active_space.name);
1180 let Ok(rd) = std::fs::read_dir(dir) else {
1181 return Vec::new();
1182 };
1183 let mut apps: Vec<String> = rd
1184 .filter_map(std::result::Result::ok)
1185 .filter(|e| e.path().is_dir())
1186 .filter_map(|e| e.file_name().into_string().ok())
1187 .collect();
1188 apps.sort();
1189 apps
1190 }
1191}
1192
1193pub fn code_blocks(md: &str) -> Vec<(Option<String>, String)> {
1197 let mut out = Vec::new();
1198 let mut inside = false;
1199 let mut lang: Option<String> = None;
1200 let mut buf = String::new();
1201 for line in md.lines() {
1202 let trimmed = line.trim_start();
1203 if trimmed.starts_with("```") {
1204 if inside {
1205 out.push((lang.take(), std::mem::take(&mut buf)));
1206 } else {
1207 let l = trimmed.trim_start_matches('`').trim();
1208 lang = (!l.is_empty()).then(|| l.to_string());
1209 }
1210 inside = !inside;
1211 continue;
1212 }
1213 if inside {
1214 buf.push_str(line);
1215 buf.push('\n');
1216 }
1217 }
1218 if inside && !buf.is_empty() {
1219 out.push((lang.take(), buf)); }
1221 out
1222}
1223
1224pub fn pick_greeting() -> &'static str {
1225 let n = std::time::SystemTime::now()
1226 .duration_since(std::time::UNIX_EPOCH)
1227 .map_or(0, |d| d.subsec_nanos() as usize);
1228 super::GREETINGS[n % super::GREETINGS.len()]
1229}
1230
1231pub fn pick_flavor() -> (usize, SpinnerColor) {
1232 let n = std::time::SystemTime::now()
1233 .duration_since(std::time::UNIX_EPOCH)
1234 .map_or(0, |d| d.subsec_nanos() as usize);
1235 (n % THINKING.len(), SPINNER_COLORS[n % SPINNER_COLORS.len()])
1236}
1237
1238pub fn web_mode_clause(today: &str) -> String {
1243 format!(
1244 "Web answer mode is ON for this session. Today's date is {today}. Before answering, you \
1245 MUST call search with mode=web and a focused query (and fetch_url on the most promising results) — \
1246 never answer from memory alone. You may search more than once with refined queries if the \
1247 first results are insufficient. Each search result is numbered [1], [2], ... with title, \
1248 URL, and snippet. Cite every claim inline immediately as [n]; do not bunch citations at \
1249 the end of a paragraph. End your reply with a line starting exactly with 'Sources:' \
1250 followed by every citation you used, one per line, as [n] title — url. Do not fabricate \
1251 sources; if a claim is not backed by a search/fetched source, do not cite it."
1252 )
1253}
1254
1255pub fn title_from(text: &str) -> String {
1256 let t: String = text.chars().take(40).collect();
1257 if t.trim().is_empty() {
1258 "new chat".to_string()
1259 } else {
1260 t
1261 }
1262}
1263
1264fn parse_tool_call_row(content: &str) -> Option<(ToolCall, String)> {
1269 let v: serde_json::Value = serde_json::from_str(content).ok()?;
1270 let name = v.get("name")?.as_str()?.to_string();
1271 let arguments = v.get("arguments")?.as_str()?.to_string();
1272 let result = v.get("result")?.as_str()?.to_string();
1273 Some((
1274 ToolCall {
1275 id: "call_0".to_string(),
1276 name,
1277 arguments,
1278 },
1279 result,
1280 ))
1281}
1282
1283pub fn human_size(bytes: u64) -> String {
1285 match bytes {
1286 b if b < 1024 => format!("{b} B"),
1287 b if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0),
1288 b => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)),
1289 }
1290}
1291
1292pub fn split_inline_reasoning(text: &str) -> (String, Option<String>) {
1297 const OPEN: &str = "<think>";
1298 const CLOSE: &str = "</think>";
1299 let mut content = String::with_capacity(text.len());
1300 let mut reasoning = String::new();
1301 let mut rest = text;
1302 loop {
1303 let Some(start) = rest.find(OPEN) else {
1304 content.push_str(rest);
1305 break;
1306 };
1307 content.push_str(&rest[..start]);
1308 let after_open = &rest[start + OPEN.len()..];
1309 let (block, remainder) = match after_open.find(CLOSE) {
1310 Some(end) => (&after_open[..end], &after_open[end + CLOSE.len()..]),
1311 None => (after_open, ""),
1312 };
1313 if !reasoning.is_empty() {
1314 reasoning.push('\n');
1315 }
1316 reasoning.push_str(block.trim());
1317 rest = remainder;
1318 }
1319 let content = content.trim().to_string();
1320 (content, (!reasoning.is_empty()).then_some(reasoning))
1321}