diff --git a/services/frontend/components/chat/ChatInput.tsx b/services/frontend/components/chat/ChatInput.tsx index 90639708..b0be5a56 100644 --- a/services/frontend/components/chat/ChatInput.tsx +++ b/services/frontend/components/chat/ChatInput.tsx @@ -43,10 +43,10 @@ export default function ChatInput({ value, onChange, onSubmit, onStop, isStreami return (
- {/* Input pod — single rounded container with the button inside */} + {/* Input pod — textarea on top, tool row + send below, single rounded container */}
onChange(e.target.value)} onKeyDown={handleKeyDown} disabled={disabled} - className="flex-1 resize-none bg-transparent px-5 py-4 pr-2 text-[0.95rem] leading-relaxed text-gray-900 dark:text-ink-text placeholder-gray-400 dark:placeholder-ink-text-soft focus:outline-none min-h-[52px] max-h-[200px]" + className="resize-none bg-transparent px-5 pt-4 pb-1 text-[0.95rem] leading-relaxed text-gray-900 dark:text-ink-text placeholder-gray-400 dark:placeholder-ink-text-soft focus:outline-none min-h-[48px] max-h-[200px]" /> - {/* Think toggle */} - {onToggleThinking && ( -
- + {/* Tool row: subtle inline pills on the left, send on the right */} +
+
+ {onToggleThinking && ( + + )} + {onToggleWebSearch && ( + + )}
- )} - {/* Force web-search toggle */} - {onToggleWebSearch && ( -
- -
- )} - - {/* Send / stop button — vertically centered with the textarea baseline */} -
{isStreaming && onStop ? ( ) : ( )}
diff --git a/services/frontend/components/chat/MessageBubble.tsx b/services/frontend/components/chat/MessageBubble.tsx index 7d7f9a59..034b7475 100644 --- a/services/frontend/components/chat/MessageBubble.tsx +++ b/services/frontend/components/chat/MessageBubble.tsx @@ -17,10 +17,30 @@ type Segment = | { kind: 'tool_call'; content: string; closed: boolean } | { kind: 'tool_result'; content: string; closed: boolean }; +function sanitizeModelOutput(s: string): string { + // Strip training-artifact leaks the small model sometimes emits: + // - HTML bold/italic tags from /// that were never meant to render + // - Stray leading "<" (a dangling tool marker that was never closed — cosmetic only) + // - Leading "Answer:" / "Response:" labels that are training-data prefixes + // - Markdown image references whose payload is clearly placeholder text (e.g. [something image]) + // - Duplicate paragraphs the model sometimes emits verbatim after <|output_end|> + s = s.replace(/<\/?(?:b|i|strong|em|u|small|big|code)\s*>/gi, ''); + // stray standalone "<" on its own line + s = s.replace(/^\s*<\s*$/gm, ''); + // leading "Answer:" / "Response:" labels at start of a line + s = s.replace(/^\s*(?:Answer|Response|Final answer|Reply)\s*:\s*/gim, ''); + // placeholder-image markdown e.g. ![Diary entry for samosa history] + s = s.replace(/!\[[^\]]*?\](?!\()/g, ''); + // normalize double newlines + s = s.replace(/\n{3,}/g, '\n\n'); + return s.trim(); +} + function parseSegments(raw: string): Segment[] { - // First pass: strip orphan tool markers (end-tag without open-tag, or any - // stray marker outside a pair) that the model sometimes emits as loop - // artifacts — otherwise they leak into the message body as raw text. + // Sanitize first — remove HTML tag leaks / stray Answer: / orphan "<" + raw = sanitizeModelOutput(raw); + // Then strip orphan tool markers (end without open) that the model sometimes + // emits as loop artifacts. raw = stripOrphanMarkers(raw); const segs: Segment[] = [];