@@ -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
|
- Token refresh
|
||||||
- No error when streaming with too large model
|
- No error when streaming with too large model
|
||||||
- Translations
|
- Translations
|
||||||
- stop models during geenration
|
- When user stop generation, forward it to server
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ function IconRail({
|
|||||||
onClick={() => toggle(panel.id, effectiveId)}
|
onClick={() => toggle(panel.id, effectiveId)}
|
||||||
title={panel.label}
|
title={panel.label}
|
||||||
className={`w-8 h-8 rounded-lg flex items-center justify-center text-secondary ${
|
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}
|
{panel.icon}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import ModelSelect from './ModelSelect';
|
import ModelSelect from './ModelSelect';
|
||||||
import Sidebar from '../Sidebar';
|
import Sidebar from '../Sidebar';
|
||||||
import { SIDEBAR_PANELS } from './chat.panels';
|
import { SIDEBAR_PANELS } from './chat.panels';
|
||||||
@@ -9,6 +9,8 @@ import { Message, ModelInfo } from '@/types/chat-api';
|
|||||||
import { ChatRequest } from '@/types/chat-api';
|
import { ChatRequest } from '@/types/chat-api';
|
||||||
import { Settings } from './chat.panels';
|
import { Settings } from './chat.panels';
|
||||||
import { fetchChatStream, readStreamChunks } from './stream';
|
import { fetchChatStream, readStreamChunks } from './stream';
|
||||||
|
import { MessageIcon } from '../icons/MessageIcon';
|
||||||
|
import { CancelIcon } from '../icons/CancelIcon';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
session: Session;
|
session: Session;
|
||||||
@@ -123,21 +125,31 @@ export default function ChatUI({
|
|||||||
|
|
||||||
// ── Streaming path ────────────────────────────────────────────────────────
|
// ── Streaming path ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const stopStream = () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
|
||||||
const handleStream = async (
|
const handleStream = async (
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
body: ChatRequest,
|
body: ChatRequest,
|
||||||
priorMessages: Message[],
|
priorMessages: Message[],
|
||||||
) => {
|
) => {
|
||||||
// Append an empty assistant message that we'll fill in as tokens arrive
|
const placeholderIndex = priorMessages.length;
|
||||||
const placeholderIndex = priorMessages.length; // index in the upcoming state
|
|
||||||
setMessages((prev) => [...prev, { role: ASSISTANT_ROLE, content: '' }]);
|
setMessages((prev) => [...prev, { role: ASSISTANT_ROLE, content: '' }]);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
const response = await fetchChatStream(
|
const response = await fetchChatStream(
|
||||||
endpoint,
|
endpoint,
|
||||||
body,
|
body,
|
||||||
session?.accessToken as string,
|
session?.accessToken as string,
|
||||||
|
controller.signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
for await (const { content, done } of readStreamChunks(response)) {
|
for await (const { content, done } of readStreamChunks(response)) {
|
||||||
if (done) break;
|
if (done) break;
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
@@ -154,6 +166,12 @@ export default function ChatUI({
|
|||||||
return next;
|
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 ────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
@@ -166,12 +184,9 @@ export default function ChatUI({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Sidebar (icon rail + sliding panel) ── */}
|
|
||||||
<Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} />
|
<Sidebar panels={SIDEBAR_PANELS({ settings, setSettings })} />
|
||||||
|
|
||||||
{/* ── Main chat area ── */}
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<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">
|
<header className="p-4 flex justify-between items-center border-b border-white/10">
|
||||||
<ModelSelect
|
<ModelSelect
|
||||||
models={models}
|
models={models}
|
||||||
@@ -180,14 +195,10 @@ export default function ChatUI({
|
|||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Messages + centered input when empty */}
|
|
||||||
<main className="flex-1 overflow-y-auto flex flex-col p-6">
|
<main className="flex-1 overflow-y-auto flex flex-col p-6">
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
/* ── Empty state: input centered within the chat column only ── */
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center">
|
<div className="flex-1 flex flex-col items-center justify-center">
|
||||||
<p className="text-primary text-sm mb-3">
|
<p className="text-primary text-sm mb-3">Start a conversation…</p>
|
||||||
Start a conversation…
|
|
||||||
</p>
|
|
||||||
<input
|
<input
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
@@ -200,7 +211,6 @@ export default function ChatUI({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── Active chat: messages list ── */
|
|
||||||
<div className="w-full max-w-2xl mx-auto space-y-3">
|
<div className="w-full max-w-2xl mx-auto space-y-3">
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
@@ -220,17 +230,39 @@ export default function ChatUI({
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Input bar — only shown once conversation has started */}
|
|
||||||
{messages.length > 0 && (
|
{messages.length > 0 && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
<div className="flex items-center w-full max-w-2xl mx-auto relative">
|
||||||
<input
|
<input
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !loading && sendMessage()}
|
onKeyDown={(e) =>
|
||||||
disabled={loading}
|
e.key === 'Enter' && !loading && sendMessage()
|
||||||
className="w-full max-w-2xl mx-auto block p-3 rounded-xl bg-secondary border border-muted outline-none disabled:opacity-50"
|
}
|
||||||
|
className="w-full p-3 pr-12 rounded-xl bg-secondary border border-muted outline-none"
|
||||||
placeholder="Message..."
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,9 +92,11 @@ export async function fetchChatStream(
|
|||||||
url: string,
|
url: string,
|
||||||
body: object,
|
body: object,
|
||||||
token: string,
|
token: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
signal,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
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