@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,36 +125,52 @@ 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,
|
||||
);
|
||||
|
||||
for await (const { content, done } of readStreamChunks(response)) {
|
||||
if (done) break;
|
||||
if (!content) continue;
|
||||
try {
|
||||
for await (const { content, done } of readStreamChunks(response)) {
|
||||
if (done) break;
|
||||
if (!content) continue;
|
||||
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const target = next[placeholderIndex];
|
||||
if (target) {
|
||||
next[placeholderIndex] = {
|
||||
...target,
|
||||
content: target.content + content,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const target = next[placeholderIndex];
|
||||
if (target) {
|
||||
next[placeholderIndex] = {
|
||||
...target,
|
||||
content: target.content + content,
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
<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"
|
||||
placeholder="Message..."
|
||||
/>
|
||||
<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()
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user