fix: ui chat
CI / CI - Validation Technique (push) Successful in 35s

This commit is contained in:
2026-04-29 23:05:29 +02:00
parent a5b2dc6fef
commit 1fe5acc871
6 changed files with 100 additions and 37 deletions
+1 -1
View File
@@ -144,4 +144,4 @@ git tag -d v1.0.0; git push origin :refs/tags/v1.0.0; git tag -a v1.0.0 -m "Rele
- Token refresh
- No error when streaming with too large model
- Translations
- stop models during geenration
- When user stop generation, forward it to server
+1 -1
View File
@@ -142,7 +142,7 @@ function IconRail({
onClick={() => toggle(panel.id, effectiveId)}
title={panel.label}
className={`w-8 h-8 rounded-lg flex items-center justify-center text-secondary ${
effectiveId === panel.id ? 'border-2 border-[var(--accent2)]' : ''
effectiveId === panel.id ? 'border-2 border-[var(--accent)]' : ''
}`}
>
{panel.icon}
+48 -16
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useRef } from 'react';
import ModelSelect from './ModelSelect';
import Sidebar from '../Sidebar';
import { SIDEBAR_PANELS } from './chat.panels';
@@ -9,6 +9,8 @@ import { Message, ModelInfo } from '@/types/chat-api';
import { ChatRequest } from '@/types/chat-api';
import { Settings } from './chat.panels';
import { fetchChatStream, readStreamChunks } from './stream';
import { MessageIcon } from '../icons/MessageIcon';
import { CancelIcon } from '../icons/CancelIcon';
type Props = {
session: Session;
@@ -123,21 +125,31 @@ export default function ChatUI({
// ── Streaming path ────────────────────────────────────────────────────────
const abortRef = useRef<AbortController | null>(null);
const stopStream = () => {
abortRef.current?.abort();
};
const handleStream = async (
endpoint: string,
body: ChatRequest,
priorMessages: Message[],
) => {
// Append an empty assistant message that we'll fill in as tokens arrive
const placeholderIndex = priorMessages.length; // index in the upcoming state
const placeholderIndex = priorMessages.length;
setMessages((prev) => [...prev, { role: ASSISTANT_ROLE, content: '' }]);
const controller = new AbortController();
abortRef.current = controller;
const response = await fetchChatStream(
endpoint,
body,
session?.accessToken as string,
controller.signal,
);
try {
for await (const { content, done } of readStreamChunks(response)) {
if (done) break;
if (!content) continue;
@@ -154,6 +166,12 @@ export default function ChatUI({
return next;
});
}
} catch (err: any) {
// AbortError is expected when the user clicks Stop — don't rethrow
if (err?.name !== 'AbortError') throw err;
} finally {
abortRef.current = null;
}
};
// ── Render ────────────────────────────────────────────────────────────────
@@ -166,12 +184,9 @@ export default function ChatUI({
</div>
)}
{/* ── Sidebar (icon rail + sliding panel) ── */}
<Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} />
{/* ── Main chat area ── */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top bar */}
<header className="p-4 flex justify-between items-center border-b border-white/10">
<ModelSelect
models={models}
@@ -180,14 +195,10 @@ export default function ChatUI({
/>
</header>
{/* Messages + centered input when empty */}
<main className="flex-1 overflow-y-auto flex flex-col p-6">
{messages.length === 0 ? (
/* ── Empty state: input centered within the chat column only ── */
<div className="flex-1 flex flex-col items-center justify-center">
<p className="text-primary text-sm mb-3">
Start a conversation…
</p>
<p className="text-primary text-sm mb-3">Start a conversation…</p>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -200,7 +211,6 @@ export default function ChatUI({
/>
</div>
) : (
/* ── Active chat: messages list ── */
<div className="w-full max-w-2xl mx-auto space-y-3">
{messages.map((msg, i) => (
<div key={i} className="text-sm">
@@ -220,17 +230,39 @@ export default function ChatUI({
)}
</main>
{/* Input bar — only shown once conversation has started */}
{messages.length > 0 && (
<div className="p-4">
<div className="flex items-center w-full max-w-2xl mx-auto relative">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !loading && sendMessage()}
disabled={loading}
className="w-full max-w-2xl mx-auto block p-3 rounded-xl bg-secondary border border-muted outline-none disabled:opacity-50"
onKeyDown={(e) =>
e.key === 'Enter' && !loading && sendMessage()
}
className="w-full p-3 pr-12 rounded-xl bg-secondary border border-muted outline-none"
placeholder="Message..."
/>
<div className="absolute right-2 flex items-center">
{loading ? (
<button
onClick={stopStream}
className="group p-2 rounded-lg"
title="Stop generation"
>
<CancelIcon className="group-hover:text-[var(--accent)]" />
</button>
) : (
<button
onClick={sendMessage}
disabled={!input.trim() || !selectedModel}
className="group p-2 rounded-lg disabled:opacity-30 disabled:pointer-events-none"
title="Send"
>
<MessageIcon className="group-hover:group-disabled:hover:text-inherit group-hover:text-[var(--accent)]" />
</button>
)}
</div>
</div>
</div>
)}
</div>
+2
View File
@@ -92,9 +92,11 @@ export async function fetchChatStream(
url: string,
body: object,
token: string,
signal?: AbortSignal,
): Promise<Response> {
const res = await fetch(url, {
method: 'POST',
signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
+11
View File
@@ -0,0 +1,11 @@
export const CancelIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
className={`w-4 h-4 ${props.className ?? ''}`}
>
<rect x="4" y="4" width="16" height="16" rx="2" />
</svg>
);
+18
View File
@@ -0,0 +1,18 @@
export function MessageIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
className={`w-4 h-4 ${props.className ?? ''}`}
>
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
);
}