Files
openhuman/app/src/utils/messageSegmentation.ts
T
3c247a2439 Feat/humanlike replies (#168)
* fix(chat): prevent stacked socket listeners on reconnect

subscribeChatEvents was declared async despite having no awaits, so the
cleanup function was returned in a microtask after React's synchronous
cleanup had already run. Each socket reconnect added another layer of
listeners that were never removed, causing chat:done to fire N times and
produce duplicate message bubbles.

- Remove async keyword from subscribeChatEvents; return cleanup fn directly
- Update useEffect call site to store cleanup synchronously and return it
  to React (eliminates the mounted/then race entirely)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(local-ai): add multi-turn chat via Ollama /api/chat

Expose openhuman.local_ai_chat RPC method so the UI can run full
conversation-history chat directly through the bundled Ollama model
without touching the cloud inference API.

- ollama_api.rs: add OllamaChatMessage / OllamaChatRequest / OllamaChatResponse
  types for the /api/chat endpoint
- service/public_infer.rs: add LocalAiService::chat_with_history() — sends
  multi-turn message array to Ollama, updates latency/TPS status on response
- ops.rs: add LocalAiChatMessage struct and local_ai_chat async op
- schemas.rs: register local_ai_chat controller (schema, handler, params)
  in all_controller_schemas + all_registered_controllers

Zero cloud tokens are consumed on this path; the call never reaches the
backend socket or the /openai/v1/chat/completions endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(conversations): local-model chat gate with multi-bubble delivery

When Ollama is ready (isLocalModelActive), handleSendMessage bypasses
the cloud socket entirely and routes through openhumanLocalAiChat.
Response is segmented and delivered as multiple typed bubbles with
natural pauses — human-like reply behaviour at zero cloud token cost.

UI / delivery
- deliverLocalResponse(): segments full reply via segmentMessage(),
  dispatches each bubble with getSegmentDelay() pause between them;
  typing indicator (isDelivering) shows between segments
- Socket-connected guard skipped on local path so offline local use works
- Cloud socket path (chatSend → chat:done) fully unchanged

Frontend RPC
- tauriCommands: openhumanLocalAiChat(messages, maxTokens?) wraps
  openhuman.local_ai_chat via core RPC; LocalAiChatMessage type exported

Tests (402 passing)
- messageSegmentation: 4 new edge-case tests (whitespace, 80-char
  boundary, paragraph split, delay scaling)
- localChatGating (new file): 9 tests — segmentation correctness, delay
  bounds [500, 1400] ms, sender→role mapping for message history build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(prompts): replace vague emoji guidance with explicit contextual rules

The previous "Minimal — match the user's style" instruction was too
loose, causing the model to stack decorative emojis on every message
(e.g. "Hey! 😄 Just cooking up some AI magic! 🚀🔥").

SOUL.md — add Emoji Rules section:
- Hard cap: one emoji maximum per message; none is always acceptable
- Contextual, not decorative: emoji must reinforce the specific content
  (🔥 for exciting news, 🤔 for uncertainty,  for confirmations)
- Never open a sentence with an emoji
- Skip entirely in error/warning/technical/long responses
- Mirror the user's own emoji usage pattern
- Concrete good/bad examples so the model can calibrate

BOOTSTRAP.md — tighten the Communication Preferences entry to reference
the same rules rather than the old vague one-liner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: scope PR_DESCRIPTION.md to feat/humanlike-replies only

Remove unrelated package manager distribution content (was from a
different branch). Description now covers only the 4 commits on this
branch: socket listener fix, Rust local_ai_chat RPC, frontend local
chat gate + multi-bubble delivery, and emoji prompt rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(local_ai): improve code formatting and readability in chat operations

- Adjusted formatting in local_ai_chat function for better readability by adding line breaks.
- Simplified the mapping of messages to OllamaChatMessage in ops.rs.
- Streamlined the await syntax in schemas.rs for clarity.
- Enhanced formatting in public_infer.rs for consistency in API request construction.

* refactor(conversations): enhance code readability and structure in Conversations component

- Improved formatting and consistency in the Conversations component, including better alignment of dispatch calls and message handling logic.
- Removed redundant imports and streamlined the mapping of stored messages for clarity.
- Adjusted conditional rendering for improved readability in the UI logic.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:43:27 +05:30

122 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* messageSegmentation — splits AI responses into natural chat bubbles.
*
* Gate: only called when local model is active. Cloud/API paths bypass this entirely.
*/
const MIN_SEGMENT_CHARS = 40;
const MAX_SEGMENTS = 5;
/**
* Split `text` into an array of segments suitable for multi-bubble delivery.
*
* Priority order:
* 1. Paragraph breaks (\n\n) — highest-fidelity natural split.
* 2. Sentence-ending punctuation (. ! ?) followed by space + uppercase.
* 3. Fall back to the whole text as a single segment.
*
* Always returns at least one element. Returns the original text as `[text]`
* when it's too short or cannot be meaningfully split.
*/
export function segmentMessage(text: string): string[] {
const trimmed = text.trim();
// Don't bother segmenting short messages
if (trimmed.length < 80) return [trimmed];
// --- Strategy 1: paragraph splits ---
const paragraphs = trimmed
.split(/\n\n+/)
.map(p => p.trim())
.filter(p => p.length > 0);
if (paragraphs.length >= 2) {
const merged = mergeTooShort(paragraphs, '\n\n');
if (merged.length >= 2) return merged.slice(0, MAX_SEGMENTS);
}
// --- Strategy 2: sentence splits ---
const sentences = splitSentences(trimmed);
if (sentences.length >= 2) {
const grouped = groupSentences(sentences);
if (grouped.length >= 2) return grouped.slice(0, MAX_SEGMENTS);
}
// --- Fallback: single bubble ---
return [trimmed];
}
/**
* Estimate a natural inter-bubble delay in milliseconds.
* Scales loosely with the length of the segment just delivered.
* Bounded: min 500ms, max 1 400ms.
*/
export function getSegmentDelay(segment: string): number {
const base = 500;
const perChar = 1.5;
return Math.min(base + segment.length * perChar, 1400);
}
// ─── helpers ─────────────────────────────────────────────────────────────────
/** Merge adjacent items that are shorter than MIN_SEGMENT_CHARS. */
function mergeTooShort(parts: string[], joiner: string): string[] {
const result: string[] = [];
for (const part of parts) {
if (result.length > 0 && part.length < MIN_SEGMENT_CHARS) {
result[result.length - 1] += joiner + part;
} else {
result.push(part);
}
}
return result;
}
/**
* Split on sentence-ending punctuation (. ! ?) followed by a space and
* an uppercase letter. Uses a manual loop to avoid lookbehind assertions
* that may not be available in all WebKit versions.
*/
function splitSentences(text: string): string[] {
const parts: string[] = [];
let current = '';
for (let i = 0; i < text.length; i++) {
current += text[i];
const ch = text[i];
const next1 = text[i + 1];
const next2 = text[i + 2];
if (
(ch === '.' || ch === '!' || ch === '?') &&
next1 === ' ' &&
next2 !== undefined &&
next2 >= 'A' &&
next2 <= 'Z'
) {
parts.push(current.trim());
current = '';
i++; // skip the space
}
}
if (current.trim().length > 0) parts.push(current.trim());
return parts.filter(p => p.length > 0);
}
/**
* Group individual sentences into at most MAX_SEGMENTS bubbles.
* Tries to aim for 23 bubbles for readability.
*/
function groupSentences(sentences: string[]): string[] {
const targetCount = Math.min(3, Math.ceil(sentences.length / 2));
const groupSize = Math.ceil(sentences.length / targetCount);
const groups: string[] = [];
for (let i = 0; i < sentences.length; i += groupSize) {
groups.push(sentences.slice(i, i + groupSize).join(' '));
}
return groups.filter(g => g.length >= MIN_SEGMENT_CHARS);
}