Fix: decouple chat from local Ollama, route inference through Rust (#369)

* refactor: streamline Conversations component by removing local response delivery logic

- Eliminated the `deliverLocalResponse` function, which handled segmenting and dispatching local model responses, simplifying the message delivery process.
- Updated socket status checks to remove unnecessary local model condition, ensuring clarity in connection handling.
- Adjusted comments to reflect the new routing logic for cloud backend interactions, emphasizing that local models are now used only for supplementary features.

* refactor: update chat service to utilize core RPC for message handling

- Replaced Socket.IO message sending with core RPC calls for sending and canceling chat messages, enhancing the communication mechanism.
- Improved error handling for socket connection checks, ensuring robust event routing.
- Updated documentation to reflect the new RPC-based approach for chat message processing.

* feat: enhance chat response handling with segmentation and emoji reactions

- Introduced a new `ChatSegmentEvent` interface to support segmented message delivery, allowing for more natural chat interactions.
- Updated the `Conversations` component to handle segmented responses and apply emoji reactions based on user messages.
- Refactored the message delivery logic to improve clarity and maintainability, ensuring that responses are dispatched correctly based on segmentation.
- Enhanced the chat service to emit segment events, facilitating a smoother user experience during conversations.

* refactor: simplify Conversations component by removing unused imports and optimizing rendering logic

- Removed unnecessary imports related to local model status and message segmentation, streamlining the codebase.
- Updated the rendering logic to enhance clarity by simplifying conditions for displaying sending and delivering states in the chat interface.
- Improved overall readability and maintainability of the Conversations component.

* style: apply formatter fixes (prettier + cargo fmt)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* updated convos

* fix test

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-04-06 12:25:10 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 9b73d00b2e
commit df503a23fd
8 changed files with 597 additions and 214 deletions
+54 -186
View File
@@ -4,13 +4,14 @@ import Markdown from 'react-markdown';
import { useNavigate } from 'react-router-dom';
import { type ChatSendError, chatSendError } from '../chat/chatSendError';
import { useLocalModelStatus } from '../hooks/useLocalModelStatus';
import { creditsApi, type TeamUsage } from '../services/api/creditsApi';
import {
chatCancel,
type ChatSegmentEvent,
chatSend,
type ChatToolCallEvent,
type ChatToolResultEvent,
segmentText,
subscribeChatEvents,
useRustChat,
} from '../services/chatService';
@@ -28,14 +29,10 @@ import {
setSelectedThread,
} from '../store/threadSlice';
import type { ThreadMessage } from '../types/thread';
import { getSegmentDelay, segmentMessage } from '../utils/messageSegmentation';
import {
isTauri,
type LocalAiChatMessage,
openhumanAutocompleteAccept,
openhumanAutocompleteCurrent,
openhumanLocalAiChat,
openhumanLocalAiShouldReact,
openhumanVoiceStatus,
openhumanVoiceTranscribeBytes,
openhumanVoiceTts,
@@ -141,14 +138,7 @@ const Conversations = () => {
Record<string, ToolTimelineEntry[]>
>({});
const rustChat = useRustChat();
const isLocalModelActive = useLocalModelStatus();
const isLocalModelActiveRef = useRef(isLocalModelActive);
const [isDelivering, setIsDelivering] = useState(false);
const deliveryActiveRef = useRef(false);
const [reactionPickerMsgId, setReactionPickerMsgId] = useState<string | null>(null);
const defaultChannelType = useAppSelector(
state => state.channelConnections?.defaultMessagingChannel ?? 'web'
);
const pendingReactionRef = useRef<
Map<string, { msgId: string; content: string; threadId: string }>
>(new Map());
@@ -158,10 +148,6 @@ const Conversations = () => {
selectedThreadIdRef.current = selectedThreadId;
}, [selectedThreadId]);
useEffect(() => {
isLocalModelActiveRef.current = isLocalModelActive;
}, [isLocalModelActive]);
const [teamUsage, setTeamUsage] = useState<TeamUsage | null>(null);
const [isLoadingBudget, setIsLoadingBudget] = useState(false);
@@ -305,7 +291,6 @@ const Conversations = () => {
useEffect(() => {
return () => {
deliveryActiveRef.current = false;
mediaRecorderRef.current?.stop();
mediaStreamRef.current?.getTracks().forEach(track => track.stop());
replyAudioRef.current?.pause();
@@ -399,16 +384,24 @@ const Conversations = () => {
return { ...prev, [event.thread_id]: nextEntries };
});
},
onDone: event => {
const currentState = store.getState() as {
thread: { messagesByThreadId: Record<string, ThreadMessage[]> };
};
const threadMessages = currentState.thread.messagesByThreadId[event.thread_id] || [];
const lastMsg = threadMessages[threadMessages.length - 1];
if (lastMsg?.sender === 'agent' && lastMsg?.content === event.full_response) {
return;
onSegment: (event: ChatSegmentEvent) => {
// Rust delivers segments with delays already applied — just dispatch.
if (event.reaction_emoji) {
const pending = pendingReactionRef.current.get(event.thread_id);
if (pending) {
dispatch(
addReaction({
threadId: pending.threadId,
messageId: pending.msgId,
emoji: event.reaction_emoji,
})
);
pendingReactionRef.current.delete(event.thread_id);
}
}
dispatch(addInferenceResponse({ content: segmentText(event), threadId: event.thread_id }));
},
onDone: event => {
// Update tool timeline
setToolTimelineByThread(prev => {
const existing = prev[event.thread_id] ?? [];
@@ -425,58 +418,31 @@ const Conversations = () => {
sendingTimeoutRef.current = null;
}
// Fire-and-forget: auto-react to the user's message
const pending = pendingReactionRef.current.get(event.thread_id);
if (pending) {
maybeAutoReact(pending.msgId, pending.content, pending.threadId);
pendingReactionRef.current.delete(event.thread_id);
}
// Multi-bubble delivery gate: only when local model is active
if (!isLocalModelActiveRef.current) {
dispatch(
addInferenceResponse({ content: event.full_response, threadId: event.thread_id })
);
setIsSending(false);
dispatch(setActiveThread(null));
return;
}
const segments = segmentMessage(event.full_response);
if (segments.length <= 1) {
dispatch(
addInferenceResponse({ content: event.full_response, threadId: event.thread_id })
);
setIsSending(false);
dispatch(setActiveThread(null));
return;
}
// Async delivery: show each segment as a separate bubble with a typing pause
setIsDelivering(true);
deliveryActiveRef.current = true;
void (async () => {
for (let i = 0; i < segments.length; i++) {
if (!deliveryActiveRef.current) break;
if (i > 0) {
await new Promise<void>(resolve =>
setTimeout(resolve, getSegmentDelay(segments[i - 1]))
);
}
if (!deliveryActiveRef.current) break;
dispatch(addInferenceResponse({ content: segments[i], threadId: event.thread_id }));
// Apply reaction emoji from Rust (when not segmented — no onSegment fired).
if (event.reaction_emoji) {
const pending = pendingReactionRef.current.get(event.thread_id);
if (pending) {
dispatch(
addReaction({
threadId: pending.threadId,
messageId: pending.msgId,
emoji: event.reaction_emoji,
})
);
}
}
pendingReactionRef.current.delete(event.thread_id);
deliveryActiveRef.current = false;
setIsDelivering(false);
setIsSending(false);
// activeThreadId was already cleared by the first addInferenceResponse dispatch
})();
// Only add the response bubble if Rust didn't already deliver it
// via chat_segment events (segment_total > 0 means segments were sent).
if (!event.segment_total) {
dispatch(
addInferenceResponse({ content: event.full_response, threadId: event.thread_id })
);
}
setIsSending(false);
dispatch(setActiveThread(null));
},
onError: event => {
if (event.thread_id !== selectedThreadIdRef.current) return;
@@ -529,65 +495,18 @@ const Conversations = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rustChat, socketStatus]);
/**
* Segment a complete response string and dispatch each segment as a
* separate message bubble with a typing pause between them.
* Local-model-only path — no cloud API calls.
*/
const deliverLocalResponse = async (fullResponse: string, threadId: string) => {
const segments = segmentMessage(fullResponse);
if (segments.length <= 1) {
dispatch(addInferenceResponse({ content: fullResponse, threadId }));
return;
}
setIsDelivering(true);
deliveryActiveRef.current = true;
for (let i = 0; i < segments.length; i++) {
if (!deliveryActiveRef.current) break;
if (i > 0) {
await new Promise<void>(resolve => setTimeout(resolve, getSegmentDelay(segments[i - 1])));
}
if (!deliveryActiveRef.current) break;
dispatch(addInferenceResponse({ content: segments[i], threadId }));
}
deliveryActiveRef.current = false;
setIsDelivering(false);
};
/**
* Fire-and-forget: ask the local model if we should auto-react to the
* user's message with an emoji. Adds a personal touch based on channel type.
*/
const maybeAutoReact = (userMessageId: string, messageContent: string, threadId: string) => {
if (!isTauri() || !isLocalModelActiveRef.current) return;
void openhumanLocalAiShouldReact(messageContent, defaultChannelType)
.then(response => {
const decision = response.result;
if (decision?.should_react && decision.emoji) {
console.debug('[conversations:auto-react] reacting with', decision.emoji);
dispatch(addReaction({ threadId, messageId: userMessageId, emoji: decision.emoji }));
}
})
.catch(err => {
console.debug('[conversations:auto-react] failed:', err);
});
};
const handleSendMessage = async (text?: string) => {
const normalized = text ?? inputValue;
const trimmed = normalized.trim();
if (!trimmed || !selectedThreadId || isSending) return;
if (!isLocalModelActiveRef.current && socketStatus !== 'connected') {
setSendError(chatSendError('socket_disconnected', 'Realtime socket is not connected.'));
if (socketStatus !== 'connected') {
setSendError(
chatSendError(
'socket_disconnected',
'Realtime socket is not connected — responses cannot be delivered without a client ID.'
)
);
return;
}
@@ -633,61 +552,10 @@ const Conversations = () => {
setToolTimelineByThread(prev => ({ ...prev, [sendingThreadId]: [] }));
dispatch(setActiveThread(sendingThreadId));
// ── Local Ollama path ────────────────────────────────────────────────────
// When a local model is ready, bypass the cloud socket entirely.
// Zero cloud tokens consumed on this path.
if (isLocalModelActiveRef.current) {
try {
// Build message history: convert stored messages + the new user turn
const storedMessages =
(
store.getState() as {
thread: {
messagesByThreadId: Record<string, import('../types/thread').ThreadMessage[]>;
};
}
).thread.messagesByThreadId[sendingThreadId] ?? [];
const history: LocalAiChatMessage[] = storedMessages
.filter(m => m.sender === 'user' || m.sender === 'agent')
.map(m => ({
role: m.sender === 'user' ? ('user' as const) : ('assistant' as const),
content: m.content,
}));
console.debug('[conversations:local] sending to local model', {
historyLength: history.length,
threadId: sendingThreadId,
});
const response = await openhumanLocalAiChat(history);
const reply = response.result?.trim() ?? '';
if (!reply) {
throw new Error('Local model returned an empty response.');
}
await deliverLocalResponse(reply, sendingThreadId);
pendingReactionRef.current.delete(sendingThreadId);
maybeAutoReact(userMessage.id, trimmed, sendingThreadId);
} catch (err) {
pendingReactionRef.current.delete(sendingThreadId);
const msg = err instanceof Error ? err.message : String(err);
setSendError(chatSendError('local_model_failed', msg));
dispatch(
addInferenceResponse({
content: 'Local model error — please try again.',
threadId: sendingThreadId,
})
);
} finally {
setIsSending(false);
dispatch(setActiveThread(null));
}
return;
}
// ── Cloud socket path (unchanged) ────────────────────────────────────────
// ── Cloud socket path ────────────────────────────────────────────────────
// Always route primary chat through the cloud backend via socket.
// Local model (Ollama) is used only for supplementary features
// (auto-react, autocomplete, etc.) — never as a primary chat path.
try {
await chatSend({ threadId: sendingThreadId, message: trimmed, model: AGENTIC_MODEL_ID });
@@ -1082,7 +950,7 @@ const Conversations = () => {
</div>
</div>
))}
{((activeThreadId === selectedThreadId && isSending) || isDelivering) && (
{activeThreadId === selectedThreadId && isSending && (
<div className="flex justify-start">
<div className="bg-stone-200/80 rounded-2xl rounded-bl-md px-4 py-3">
<div className="flex items-center gap-1">
+75 -9
View File
@@ -1,6 +1,12 @@
/**
* Chat Service — Socket.IO-first chat transport for desktop and web.
* Chat Service — RPC-based chat transport.
*
* Chat messages are SENT via core RPC (`openhuman.channel_web_chat`).
* Responses and events stream back over the existing Socket.IO connection
* (tool_call, tool_result, chat_done, chat_error) via the web-channel
* event bridge in the Rust core.
*/
import { callCoreRpc } from './coreRpcClient';
import { socketService } from './socketService';
export interface ChatToolCallEvent {
@@ -26,6 +32,30 @@ export interface ChatDoneEvent {
rounds_used: number;
total_input_tokens: number;
total_output_tokens: number;
/** Emoji reaction decided by the local model (if any). */
reaction_emoji?: string | null;
/** Total segments when the response was split into bubbles by Rust. */
segment_total?: number | null;
}
/** A single segment of a multi-bubble response, emitted before `chat_done`. */
export interface ChatSegmentEvent {
thread_id: string;
/**
* Wire name is `full_response` for compatibility with {@link WebChannelEvent},
* but this field contains only the **segment text**, not the full response.
* Use {@link segmentText} for clarity in consuming code.
*/
full_response: string;
request_id: string;
segment_index: number;
segment_total: number;
reaction_emoji?: string | null;
}
/** Return the segment text from a {@link ChatSegmentEvent} (avoids the misleading wire name). */
export function segmentText(event: ChatSegmentEvent): string {
return event.full_response;
}
export interface ChatErrorEvent {
@@ -38,6 +68,7 @@ export interface ChatErrorEvent {
export interface ChatEventListeners {
onToolCall?: (event: ChatToolCallEvent) => void;
onToolResult?: (event: ChatToolResultEvent) => void;
onSegment?: (event: ChatSegmentEvent) => void;
onDone?: (event: ChatDoneEvent) => void;
onError?: (event: ChatErrorEvent) => void;
}
@@ -62,6 +93,13 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
handlers.push(['chat:tool_result', cb], ['tool_result', cb]);
}
if (listeners.onSegment) {
const cb = (payload: unknown) => listeners.onSegment?.(payload as ChatSegmentEvent);
socket.on('chat:segment', cb);
socket.on('chat_segment', cb);
handlers.push(['chat:segment', cb], ['chat_segment', cb]);
}
if (listeners.onDone) {
const cb = (payload: unknown) => listeners.onDone?.(payload as ChatDoneEvent);
socket.on('chat:done', cb);
@@ -89,20 +127,48 @@ export interface ChatSendParams {
model: string;
}
/**
* Send a chat message via core RPC.
*
* The Rust core spawns the agent loop asynchronously and streams events
* (tool_call, tool_result, chat_done, chat_error) back over the socket
* connection using the `client_id` (socket ID) for routing.
*/
export async function chatSend(params: ChatSendParams): Promise<void> {
if (!socketService.isConnected()) {
throw new Error('Socket not connected');
const socket = socketService.getSocket();
const clientId = socket?.id;
if (!clientId) {
throw new Error('Socket not connected — no client ID for event routing');
}
const payload = { thread_id: params.threadId, message: params.message, model: params.model };
socketService.emit('chat:start', payload);
await callCoreRpc({
method: 'openhuman.channel_web_chat',
params: {
client_id: clientId,
thread_id: params.threadId,
message: params.message,
model_override: params.model,
},
});
}
/**
* Cancel an in-flight chat request via core RPC.
*/
export async function chatCancel(threadId: string): Promise<boolean> {
if (!socketService.isConnected()) return false;
socketService.emit('chat:cancel', { thread_id: threadId });
return true;
const socket = socketService.getSocket();
const clientId = socket?.id;
if (!clientId) return false;
try {
await callCoreRpc({
method: 'openhuman.channel_web_cancel',
params: { client_id: clientId, thread_id: threadId },
});
return true;
} catch {
return false;
}
}
export function useRustChat(): boolean {
+1
View File
@@ -65,6 +65,7 @@ todo
- memory skill
[] should index properly all the things (sanil)
[] should properly register user interactions
[] integrate memory to all the various skills, like autocomplete and others.
--- e2e tests to write up
+9
View File
@@ -29,6 +29,15 @@ pub struct WebChannelEvent {
pub success: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub round: Option<u32>,
/// Emoji reaction the assistant wants to add to the user's message.
#[serde(skip_serializing_if = "Option::is_none")]
pub reaction_emoji: Option<String>,
/// 0-based index when a response is delivered as multiple segments.
#[serde(skip_serializing_if = "Option::is_none")]
pub segment_index: Option<u32>,
/// Total number of segments in a segmented delivery.
#[serde(skip_serializing_if = "Option::is_none")]
pub segment_total: Option<u32>,
}
#[derive(Debug, Deserialize)]
+1
View File
@@ -10,6 +10,7 @@ pub mod linq;
#[cfg(feature = "channel-matrix")]
pub mod matrix;
pub mod mattermost;
mod presentation;
pub mod qq;
pub mod signal;
pub mod slack;
@@ -0,0 +1,414 @@
//! Presentation layer for web-channel chat responses.
//!
//! Handles two concerns that run on the **local model** (zero cloud cost):
//!
//! 1. **Message segmentation** — split an agent response into human-feeling
//! chat bubbles, but *only* when the content is natural-language prose.
//! Code blocks, structured data, and short messages are never split.
//!
//! 2. **Emoji reactions** — decide whether the assistant should react to the
//! user's message with an emoji.
use crate::core::socketio::WebChannelEvent;
use crate::openhuman::config::rpc as config_rpc;
use super::web::publish_web_channel_event;
const MIN_SEGMENT_CHARS: usize = 40;
const MAX_SEGMENTS: usize = 5;
/// Deliver an agent response to the frontend, applying local-model
/// presentation (segmentation + reaction) when the model is available.
///
/// Always emits at least one `chat_done` event. When the response is
/// segmented, emits one `chat_segment` per bubble first, then a final
/// `chat_done` with the full text for deduplication.
pub async fn deliver_response(
client_id: &str,
thread_id: &str,
request_id: &str,
full_response: &str,
user_message: &str,
) {
// Spawn reaction decision in parallel — it runs on the local model and
// shouldn't block segmentation or delivery.
let user_msg_owned = user_message.to_string();
let reaction_handle = tokio::spawn(async move { try_reaction(&user_msg_owned).await });
// Segmentation is pure CPU work, runs immediately.
let segments = segment_for_delivery(full_response);
// Await the reaction result (should already be done or nearly done).
let reaction_emoji = reaction_handle.await.unwrap_or(None);
if segments.len() <= 1 {
// Single bubble — emit chat_done directly.
publish_web_channel_event(WebChannelEvent {
event: "chat_done".to_string(),
client_id: client_id.to_string(),
thread_id: thread_id.to_string(),
request_id: request_id.to_string(),
full_response: Some(full_response.to_string()),
message: None,
error_type: None,
tool_name: None,
skill_id: None,
args: None,
output: None,
success: None,
round: None,
reaction_emoji,
segment_index: None,
segment_total: None,
});
return;
}
let total = segments.len() as u32;
// Emit each segment as a separate bubble with a human-feeling delay.
for (i, segment) in segments.iter().enumerate() {
if i > 0 {
let delay_ms = segment_delay(&segments[i - 1]);
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
publish_web_channel_event(WebChannelEvent {
event: "chat_segment".to_string(),
client_id: client_id.to_string(),
thread_id: thread_id.to_string(),
request_id: request_id.to_string(),
full_response: Some(segment.clone()),
message: None,
error_type: None,
tool_name: None,
skill_id: None,
args: None,
output: None,
success: None,
round: None,
// Attach reaction emoji only on the first segment.
reaction_emoji: if i == 0 { reaction_emoji.clone() } else { None },
segment_index: Some(i as u32),
segment_total: Some(total),
});
}
// Final chat_done with full text (for deduplication / state sync).
publish_web_channel_event(WebChannelEvent {
event: "chat_done".to_string(),
client_id: client_id.to_string(),
thread_id: thread_id.to_string(),
request_id: request_id.to_string(),
full_response: Some(full_response.to_string()),
message: None,
error_type: None,
tool_name: None,
skill_id: None,
args: None,
output: None,
success: None,
round: None,
reaction_emoji: None,
segment_index: None,
segment_total: Some(total),
});
}
// ── Segmentation ─────────────────────────────────────────────────────────────
/// Decide whether and how to split a response into multiple chat bubbles.
///
/// Rules (applied in order):
/// - Short messages (< 80 chars) are never split.
/// - Messages containing code fences (```) are never split.
/// - Messages that are predominantly structured (lists, tables, headers)
/// are never split — they read better as a single block.
/// - Otherwise, split on paragraph breaks (\n\n), merging segments that
/// are too short to stand alone.
/// - Fallback: split on sentence boundaries if paragraphs don't yield
/// multiple segments.
fn segment_for_delivery(text: &str) -> Vec<String> {
let trimmed = text.trim();
// Don't split short messages.
if trimmed.len() < 80 {
return vec![trimmed.to_string()];
}
// Never split messages containing code fences.
if trimmed.contains("```") {
tracing::debug!("[presentation:segment] skipping segmentation: contains code fences");
return vec![trimmed.to_string()];
}
// Never split messages that are predominantly structured content.
if is_structured_content(trimmed) {
tracing::debug!("[presentation:segment] skipping segmentation: structured content");
return vec![trimmed.to_string()];
}
// Strategy 1: paragraph splits.
let paragraphs: Vec<&str> = trimmed
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.collect();
if paragraphs.len() >= 2 {
let merged = merge_short(&paragraphs, "\n\n");
if merged.len() >= 2 {
tracing::debug!(
segments = merged.len(),
"[presentation:segment] split by paragraphs"
);
return merged.into_iter().take(MAX_SEGMENTS).collect();
}
}
// Strategy 2: sentence splits.
let sentences = split_sentences(trimmed);
if sentences.len() >= 2 {
let grouped = group_sentences(&sentences);
if grouped.len() >= 2 {
tracing::debug!(
segments = grouped.len(),
"[presentation:segment] split by sentences"
);
return grouped.into_iter().take(MAX_SEGMENTS).collect();
}
}
// Fallback: single bubble.
vec![trimmed.to_string()]
}
/// Returns true if the text is predominantly structured content that
/// shouldn't be split across bubbles (markdown lists, tables, headers).
fn is_structured_content(text: &str) -> bool {
let lines: Vec<&str> = text.lines().collect();
if lines.is_empty() {
return false;
}
let structured_count = lines
.iter()
.filter(|line| {
let trimmed = line.trim();
trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed.starts_with("| ")
|| trimmed.starts_with("# ")
|| trimmed.starts_with("## ")
|| trimmed.starts_with("### ")
|| is_numbered_list_item(trimmed)
})
.count();
// If more than 40% of non-empty lines are structured, don't split.
let non_empty = lines.iter().filter(|l| !l.trim().is_empty()).count();
non_empty > 0 && (structured_count * 100 / non_empty) > 40
}
/// Check if a line starts with a numbered list prefix like "1. " or "12. ".
/// Rejects dates ("2024. ") and decimals by requiring the digits+dot+space
/// to appear at the very start and be followed by text.
fn is_numbered_list_item(line: &str) -> bool {
let bytes = line.as_bytes();
let mut i = 0;
// Consume one or more leading ASCII digits.
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
// Must have consumed at least one digit, followed by ". ".
i > 0 && i <= 3 && bytes.get(i) == Some(&b'.') && bytes.get(i + 1) == Some(&b' ')
}
/// Merge adjacent segments shorter than MIN_SEGMENT_CHARS.
fn merge_short(parts: &[&str], joiner: &str) -> Vec<String> {
let mut result: Vec<String> = Vec::new();
for part in parts {
if !result.is_empty() && part.len() < MIN_SEGMENT_CHARS {
let last = result.last_mut().unwrap();
last.push_str(joiner);
last.push_str(part);
} else {
result.push(part.to_string());
}
}
result
}
/// Split text on sentence-ending punctuation (. ! ?) followed by a space
/// and an uppercase letter.
fn split_sentences(text: &str) -> Vec<String> {
let mut parts: Vec<String> = Vec::new();
let mut current = String::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
current.push(chars[i]);
let ch = chars[i];
if (ch == '.' || ch == '!' || ch == '?')
&& i + 2 < chars.len()
&& chars[i + 1] == ' '
&& chars[i + 2].is_ascii_uppercase()
{
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
parts.push(trimmed);
}
current.clear();
i += 2; // skip the space
continue;
}
i += 1;
}
let remaining = current.trim().to_string();
if !remaining.is_empty() {
parts.push(remaining);
}
parts
}
/// Group sentences into 2-3 bubbles.
fn group_sentences(sentences: &[String]) -> Vec<String> {
let target_count = std::cmp::min(3, (sentences.len() + 1) / 2);
let group_size = (sentences.len() + target_count - 1) / target_count;
let mut groups: Vec<String> = Vec::new();
for chunk in sentences.chunks(group_size) {
let joined = chunk.join(" ");
if joined.len() >= MIN_SEGMENT_CHARS {
groups.push(joined);
} else if let Some(last) = groups.last_mut() {
last.push(' ');
last.push_str(&joined);
} else {
groups.push(joined);
}
}
groups
}
/// Compute a human-feeling inter-bubble delay in milliseconds.
/// Bounded: 500ms1400ms, scaling with segment length.
fn segment_delay(segment: &str) -> u64 {
let base: u64 = 500;
let per_char: u64 = 2; // ~1.5-2ms per char for a natural reading pace
std::cmp::min(base + (segment.len() as u64) * per_char, 1400)
}
// ── Reactions ────────────────────────────────────────────────────────────────
/// Ask the local model for an emoji reaction to the user's message.
/// Returns `None` if the local model is unavailable or decides no reaction.
async fn try_reaction(user_message: &str) -> Option<String> {
if user_message.trim().is_empty() {
return None;
}
let config = match config_rpc::load_config_with_timeout().await {
Ok(c) => c,
Err(_) => return None,
};
if !config.local_ai.enabled {
return None;
}
match crate::openhuman::local_ai::ops::local_ai_should_react(&config, user_message, "web").await
{
Ok(outcome) => {
let decision = outcome.value;
if decision.should_react {
decision.emoji
} else {
None
}
}
Err(e) => {
tracing::debug!(error = %e, "[presentation:reaction] local model reaction failed");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_messages_are_never_split() {
let result = segment_for_delivery("Hello there!");
assert_eq!(result, vec!["Hello there!"]);
}
#[test]
fn code_fences_prevent_splitting() {
let text = "Here is some code:\n\n```rust\nfn main() {}\n```\n\nAnd more text after.";
let result = segment_for_delivery(text);
assert_eq!(result.len(), 1);
}
#[test]
fn paragraph_splitting_works() {
let text = "This is the first paragraph with enough content to stand alone.\n\n\
This is the second paragraph that also has sufficient length.";
let result = segment_for_delivery(text);
assert_eq!(result.len(), 2);
}
#[test]
fn structured_content_not_split() {
let text = "Here are the steps:\n\n- First do this thing\n- Then do that thing\n- Finally wrap up\n\nThat should cover it.";
let result = segment_for_delivery(text);
assert_eq!(result.len(), 1);
}
#[test]
fn sentence_splitting_works() {
let text = "This is the first sentence and it has some length. This is the second sentence that continues the thought. And here is a third sentence to round things out.";
let result = segment_for_delivery(text);
assert!(
result.len() >= 2,
"expected >= 2 segments, got {}",
result.len()
);
}
#[test]
fn segment_delay_bounds() {
assert_eq!(segment_delay(""), 500);
assert_eq!(segment_delay(&"x".repeat(1000)), 1400);
assert!(segment_delay("Hello world") > 500);
}
#[test]
fn numbered_list_detection() {
assert!(is_numbered_list_item("1. First item"));
assert!(is_numbered_list_item("12. Twelfth item"));
assert!(!is_numbered_list_item("2024. Was a good year")); // too many digits
assert!(!is_numbered_list_item("hello 1. world")); // digits not at start
assert!(!is_numbered_list_item("1.5 seconds")); // no space after dot
}
#[test]
fn max_segments_respected() {
let paras: Vec<String> = (0..10)
.map(|i| {
format!(
"Paragraph number {} has enough content to stand on its own.",
i
)
})
.collect();
let text = paras.join("\n\n");
let result = segment_for_delivery(&text);
assert!(result.len() <= MAX_SEGMENTS);
}
}
+31 -16
View File
@@ -14,6 +14,8 @@ use crate::openhuman::config::Config;
use crate::openhuman::providers::ConversationMessage;
use crate::rpc::RpcOutcome;
use super::presentation;
static EVENT_BUS: Lazy<broadcast::Sender<WebChannelEvent>> = Lazy::new(|| {
let (tx, _rx) = broadcast::channel(512);
tx
@@ -91,6 +93,9 @@ pub async fn start_chat(
output: None,
success: None,
round: None,
reaction_emoji: None,
segment_index: None,
segment_total: None,
});
}
}
@@ -100,12 +105,13 @@ pub async fn start_chat(
let request_id_task = request_id.clone();
let map_key_task = map_key.clone();
let user_message = message.clone();
let handle = tokio::spawn(async move {
let result = run_chat_task(
&client_id_task,
&thread_id_task,
&request_id_task,
&message,
&user_message,
model_override,
temperature,
)
@@ -113,21 +119,18 @@ pub async fn start_chat(
match result {
Ok(full_response) => {
publish_web_channel_event(WebChannelEvent {
event: "chat_done".to_string(),
client_id: client_id_task.clone(),
thread_id: thread_id_task.clone(),
request_id: request_id_task.clone(),
full_response: Some(full_response),
message: None,
error_type: None,
tool_name: None,
skill_id: None,
args: None,
output: None,
success: None,
round: None,
});
// ── Presentation layer (local model, fire-and-forget) ─────
// Segment the response into human-readable bubbles and
// decide whether to react — both run via local Ollama if
// available, zero cloud cost.
presentation::deliver_response(
&client_id_task,
&thread_id_task,
&request_id_task,
&full_response,
&user_message,
)
.await;
}
Err(err) => {
publish_web_channel_event(WebChannelEvent {
@@ -144,6 +147,9 @@ pub async fn start_chat(
output: None,
success: None,
round: None,
reaction_emoji: None,
segment_index: None,
segment_total: None,
});
}
}
@@ -207,6 +213,9 @@ pub async fn cancel_chat(client_id: &str, thread_id: &str) -> Result<Option<Stri
output: None,
success: None,
round: None,
reaction_emoji: None,
segment_index: None,
segment_total: None,
});
}
@@ -300,6 +309,9 @@ fn publish_tool_events_from_history(
output: None,
success: None,
round: Some(round),
reaction_emoji: None,
segment_index: None,
segment_total: None,
});
}
}
@@ -335,6 +347,9 @@ fn publish_tool_events_from_history(
output: Some(result.content.clone()),
success: Some(success),
round: Some(round.max(1)),
reaction_emoji: None,
segment_index: None,
segment_total: None,
});
}
}
+12 -3
View File
@@ -13,6 +13,15 @@ mod tests {
use crate::openhuman::subconscious::situation_report::build_situation_report;
use crate::openhuman::subconscious::types::Decision;
/// Find the largest byte index ≤ `max_bytes` that is a valid char boundary.
fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> usize {
let mut end = s.len().min(max_bytes);
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
end
}
fn fixture(path: &str) -> String {
let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(
@@ -113,7 +122,7 @@ mod tests {
.await;
println!("=== TICK 1 REPORT ===");
println!("{}", &report1[..report1.len().min(2000)]);
println!("{}", &report1[..truncate_at_char_boundary(&report1, 2000)]);
println!("=====================\n");
// Verify tick 1 report contains ingested data
@@ -184,7 +193,7 @@ mod tests {
.await;
println!("=== TICK 2 REPORT ===");
println!("{}", &report2[..report2.len().min(2000)]);
println!("{}", &report2[..truncate_at_char_boundary(&report2, 2000)]);
println!("=====================\n");
// Verify tick 2 report contains NEW data
@@ -240,7 +249,7 @@ mod tests {
.await;
println!("=== TICK 3 REPORT ===");
println!("{}", &report3[..report3.len().min(2000)]);
println!("{}", &report3[..truncate_at_char_boundary(&report3, 2000)]);
println!("=====================\n");
// Tick 3 should show no changes