- applications/iknowyou: new Next.js chat application with persona-aware conversations, briefing API, cohort logic, vLLM streaming, and sidebar navigation - tidal M8: add replication control plane (control.rs), tenant migration state machine (migration.rs), tenant/upgrade coordinators, cluster/fault test harnesses - tidal M8 tests: expand m8p2/m8p3/m8p4 test suites; add m8p5_multitenancy and m8_uat - tidal db: split replication_ops out of db/mod.rs (was 647 lines, now 574) - .claude: add kai-park, kaya-osei, mira-vasquez agents; add aeries-design-architect, aeries-fullstack-engineer, aeries-product-visionary skills - docs: update ROADMAP.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
138 lines
4.0 KiB
TypeScript
138 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useCallback } from "react";
|
|
import { useChatStore } from "@/lib/store";
|
|
|
|
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;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop()!;
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("data: ") || trimmed === "data: [DONE]")
|
|
continue;
|
|
|
|
try {
|
|
const data = JSON.parse(trimmed.slice(6));
|
|
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>
|
|
);
|
|
}
|