tidaldb/applications/iknowyou/components/chat/input-bar.tsx
jordan 51ac377376 refactor: extract shared SSE parser and add eslint to iknowyou
Deduplicate SSE chunk parsing from input-bar.tsx and vllm.ts into a
shared lib/sse.ts consumeSSEChunk helper. Add eslint + next lint config.
Silence monorepo lockfile warning via outputFileTracingRoot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:51:48 -07:00

137 lines
4.0 KiB
TypeScript

"use client";
import { useRef, useCallback } from "react";
import { useChatStore } from "@/lib/store";
import { consumeSSEChunk } from "@/lib/sse";
export function InputBar() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isStreaming = useChatStore((s) => s.isStreaming);
const addUserMessage = useChatStore((s) => s.addUserMessage);
const startStreaming = useChatStore((s) => s.startStreaming);
const appendToken = useChatStore((s) => s.appendToken);
const finishStreaming = useChatStore((s) => s.finishStreaming);
const setError = useChatStore((s) => s.setError);
const messages = useChatStore((s) => s.messages);
const activeConversationId = useChatStore((s) => s.activeConversationId);
const personId = useChatStore((s) => s.personId);
const createConversation = useChatStore((s) => s.createConversation);
const send = useCallback(async () => {
const textarea = textareaRef.current;
if (!textarea) return;
const content = textarea.value.trim();
if (!content || isStreaming) return;
textarea.value = "";
// Auto-create conversation if none active
let conversationId = activeConversationId;
if (!conversationId) {
conversationId = createConversation();
}
addUserMessage(content);
const history = [
...messages.map((m) => ({ role: m.role, content: m.content })),
{ role: "user" as const, content },
];
const assistantId = startStreaming();
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history, conversationId, personId }),
});
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
}
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const { jsonLines, buffer: next } = consumeSSEChunk(
buffer,
decoder.decode(value, { stream: true })
);
buffer = next;
for (const jsonStr of jsonLines) {
try {
const data = JSON.parse(jsonStr);
if (data.error) {
setError(data.error);
return;
}
if (data.token) {
appendToken(assistantId, data.token);
}
} catch {
// skip malformed
}
}
}
finishStreaming(assistantId);
} catch (err) {
const msg =
err instanceof Error ? err.message : "Something went wrong";
setError(msg);
}
textarea.focus();
}, [
isStreaming,
messages,
activeConversationId,
personId,
createConversation,
addUserMessage,
startStreaming,
appendToken,
finishStreaming,
setError,
]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
},
[send]
);
return (
<div className="border-t border-border px-4 py-3 md:px-8">
<div className="max-w-2xl mx-auto flex items-end gap-3">
<textarea
ref={textareaRef}
onKeyDown={handleKeyDown}
placeholder="say something..."
disabled={isStreaming}
rows={1}
className="flex-1 bg-bg-elevated text-text placeholder:text-text-faint rounded-xl px-4 py-3 text-[15px] leading-relaxed resize-none min-h-[48px] max-h-[160px] focus:outline-none focus:ring-1 focus:ring-accent/50 disabled:opacity-50 transition-opacity"
/>
<button
onClick={send}
disabled={isStreaming}
className="h-[48px] px-4 rounded-xl bg-accent-subtle text-accent hover:bg-accent-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-sm font-medium"
>
{isStreaming ? "..." : "send"}
</button>
</div>
</div>
);
}