Files
nextcraft/apps/web/hooks/use-chat-stream.ts
T
CIAgent 135ea21a61 merge: hotfix/fresh-box-experience → main (v0.3.5 fresh-box experience)
Fixes the v0.3.4 fresh-box failures: silent CLI under bare-word PATH
invocation (SEA argv detection), bootstrap dying on venv creation without an
actionable hint (poisoned-partial-venv recovery + apt hint + doctor
venv-capability probe + preflight), installer falsely 'verifying' a silent
binary, and localhost-only server binding (network mode: 0.0.0.0 + wildcard
CORS + hostname-derived API URL — remote browsing zero-config).

38 CLI tests, 3 web tests, 409 ai-service tests green; build/typecheck/lint
clean.

---ci---
phase: hotfix
milestone: v0.4
status: complete
type: hotfix
requirements:
  covered: [REQ-4-001, REQ-4-002, REQ-4-003, REQ-4-004, REQ-4-005]
  partial: []
---/ci---
2026-09-13 00:05:37 +00:00

219 lines
6.3 KiB
TypeScript

'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { parseSseEvents } from '../lib/sse';
import { engineBaseUrl } from '../lib/engine-base-url';
const AI_SERVICE_URL = engineBaseUrl();
export type AgentName = 'coach' | 'tutor' | 'lab' | 'assessor' | 'proctor' | 'mentor';
export interface StreamMessage {
id: string;
role: 'user' | 'assistant';
content: string;
agent?: AgentName;
streaming?: boolean;
suggestedActions?: string[];
}
interface StreamState {
messages: StreamMessage[];
isStreaming: boolean;
error: string | null;
model: string | null;
}
interface ChatStreamEvent {
type: 'meta' | 'delta' | 'done' | 'error';
[key: string]: unknown;
}
function decodeEvent(raw: string): ChatStreamEvent | '[DONE]' | null {
if (raw === '[DONE]') return '[DONE]';
try {
const parsed = JSON.parse(raw);
if (typeof parsed?.type === 'string') return parsed as ChatStreamEvent;
// OpenAI-shaped chunks (id/choices) are not used by our envelope;
// ignore anything without a type.
return null;
} catch {
return null;
}
}
export function useChatStream(agent: AgentName) {
const [state, setState] = useState<StreamState>({
messages: [],
isStreaming: false,
error: null,
model: null,
});
const abortRef = useRef<AbortController | null>(null);
// Agent-scoped sessions (A-007/D-019): switching agents starts a NEW
// session per agent — no persona bleed across switcher flips.
const sessionsRef = useRef<Partial<Record<AgentName, string>>>({});
if (!sessionsRef.current[agent]) {
const uuid =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: String(Date.now());
sessionsRef.current[agent] = `${agent}-${uuid}`;
}
// Idempotent abort + cleanup on unmount or agent switch (Strict Mode safe)
const abort = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
useEffect(() => {
return () => {
abortRef.current?.abort();
abortRef.current = null;
};
}, []);
const send = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || abortRef.current) return;
const userMessage: StreamMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: trimmed,
};
const assistantId = `assistant-${Date.now()}`;
setState((prev) => ({
...prev,
messages: [...prev.messages, userMessage],
isStreaming: true,
error: null,
}));
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch(`${AI_SERVICE_URL}/v1/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent,
session_id: sessionsRef.current[agent],
messages: [{ role: 'user', content: trimmed }],
}),
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`AI service unavailable (${response.status})`);
}
setState((prev) => ({
...prev,
messages: [
...prev.messages,
{ id: assistantId, role: 'assistant', content: '', agent, streaming: true },
],
}));
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
if (readerDone) break;
buffer += decoder.decode(value, { stream: true });
const { events, rest } = parseSseEvents(buffer);
buffer = rest;
for (const raw of events) {
const event = decodeEvent(raw);
if (event === null) continue;
if (event === '[DONE]') {
done = true;
setState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
break;
}
if (event.type === 'meta') {
setState((prev) => ({ ...prev, model: (event.model as string) ?? null }));
} else if (event.type === 'delta') {
const content = event.content as string;
setState((prev) => ({
...prev,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, content: m.content + content } : m,
),
}));
} else if (event.type === 'error') {
setState((prev) => ({
...prev,
error: (event.message as string) ?? 'stream error',
}));
}
}
}
setState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
} catch (err) {
const aborted = err instanceof DOMException && err.name === 'AbortError';
if (!aborted) {
setState((prev) => ({
...prev,
isStreaming: false,
error: err instanceof Error ? err.message : 'connection failed',
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
} else {
setState((prev) => ({
...prev,
isStreaming: false,
messages: prev.messages.map((m) =>
m.id === assistantId ? { ...m, streaming: false } : m,
),
}));
}
} finally {
abortRef.current = null;
}
},
[agent],
);
const retry = useCallback(() => {
setState((prev) => ({ ...prev, error: null }));
const lastUser = [...state.messages].reverse().find((m) => m.role === 'user');
if (lastUser) void send(lastUser.content);
}, [send, state.messages]);
return {
messages: state.messages,
isStreaming: state.isStreaming,
error: state.error,
model: state.model,
send,
retry,
abort,
};
}