- Fix Rust model scan to only list top-level models (not subdirectories) - Add scan_model_dir_top_level() function in model_files.rs - Create ModelSelectorPill component for chat input area - Remove model-section from Sidebar, move to ChatView - Update ChatView props to receive model-related props - Update tests for new ChatView props
952 lines
38 KiB
TypeScript
952 lines
38 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import type { DB } from "../lib/db";
|
|
import type { ChatTokenTotals, Message, Usage, ModelInfo, ServerStatus } from "../lib/types";
|
|
import type { RuntimeModelFile } from "../features/runtime/types";
|
|
import { buildChatTitle, parseAssistantResponse } from "../lib/chat";
|
|
import { renderMarkdown, ensureHljs } from "../lib/markdown";
|
|
import { getComposerNotice, getSuggestedPrompts } from "./chat-view-state";
|
|
import MessageBubble from "./MessageBubble";
|
|
import ModelSelectorPill from "./ModelSelectorPill";
|
|
import { useMemoryExtractor } from "../hooks/useMemoryExtractor";
|
|
import { getDisplayName } from "../lib/modelAlias";
|
|
import { ArrowUp, Image, Microphone, Paperclip, ChatCircleText, PaperPlaneRight } from "@phosphor-icons/react";
|
|
import "./ChatView.css";
|
|
|
|
const MAX_MEMORY = 20;
|
|
|
|
/** Model used for background memory extraction (small/fast). */
|
|
const MEMORY_EXTRACTOR_MODEL = "smollm2";
|
|
|
|
interface Props {
|
|
db: DB;
|
|
chatId: number | null;
|
|
server: string;
|
|
apiKey: string;
|
|
model: string;
|
|
models: ModelInfo[];
|
|
localModels: RuntimeModelFile[];
|
|
selectedModel: string;
|
|
onModelChange: (id: string) => void;
|
|
onModelSwap?: (path: string) => void;
|
|
isSwapping?: boolean;
|
|
status: ServerStatus;
|
|
onChatCreated: (id: number) => void;
|
|
onChatUpdated: () => void;
|
|
onOpenSidebar?: () => void;
|
|
onDegradationDetected?: () => void;
|
|
}
|
|
|
|
export default function ChatView({
|
|
db, chatId, server, apiKey, model,
|
|
models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status,
|
|
onChatCreated, onChatUpdated, onOpenSidebar, onDegradationDetected
|
|
}: Props) {
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [input, setInput] = useState("");
|
|
const [generating, setGenerating] = useState(false);
|
|
const [streamContent, setStreamContent] = useState("");
|
|
const [streamReasoning, setStreamReasoning] = useState("");
|
|
const [streamThinkingOpen, setStreamThinkingOpen] = useState(false);
|
|
const [showLiveReasoning, setShowLiveReasoning] = useState(false);
|
|
const [tps, setTps] = useState<number | null>(null);
|
|
const [useStream, setUseStream] = useState(true);
|
|
const [useMemory, setUseMemory] = useState(true);
|
|
const [streamTokenCount, setStreamTokenCount] = useState(0);
|
|
const [messageQueue, setMessageQueue] = useState<string[]>([]);
|
|
const [showMemoryModal, setShowMemoryModal] = useState(false);
|
|
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const activeChatIdRef = useRef<number | null>(chatId);
|
|
const pendingRequestChatIdRef = useRef<number | null>(null);
|
|
const loadMessagesRef = useRef<(targetChatId?: number | null) => void>(() => {});
|
|
const messageQueueRef = useRef<string[]>([]);
|
|
const sendRef = useRef<(text: string, fromChatId?: number, replaceAssistantId?: number) => Promise<void>>(async () => {});
|
|
// Tracks accumulated stream content so abort can persist it
|
|
const streamContentAccRef = useRef<string>("");
|
|
// TPS degradation detection: timer fires if TPS stays < 30 for 5s
|
|
const degradationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Single helper to keep ref and state in sync — use at every queue mutation site
|
|
const setQueue = (next: string[]) => {
|
|
messageQueueRef.current = next;
|
|
setMessageQueue(next);
|
|
};
|
|
|
|
const composerNotice = getComposerNotice(model, server);
|
|
const starterPrompts = getSuggestedPrompts();
|
|
const estimatedTokens = input.length > 0 ? Math.ceil(input.length / 4) : 0;
|
|
|
|
const chatTotals = messages.reduce<ChatTokenTotals>((acc, msg) => {
|
|
acc.prompt_tokens += msg.prompt_tokens ?? 0;
|
|
acc.completion_tokens += msg.completion_tokens ?? 0;
|
|
acc.total_tokens += msg.total_tokens ?? 0;
|
|
return acc;
|
|
}, { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
|
|
|
const memoryMessageCount = chatId != null
|
|
? Math.min(
|
|
messages.filter(m => m.content && !m.content.startsWith("⚠️") && m.content !== "internal error").length,
|
|
useMemory ? MAX_MEMORY : 1,
|
|
)
|
|
: 0;
|
|
|
|
// ── Memory extractor (background, fire-and-forget) ────────────────────────
|
|
const { extractMemories, buildMemorySystemPrompt } = useMemoryExtractor({
|
|
db,
|
|
server,
|
|
apiKey,
|
|
memoryModel: MEMORY_EXTRACTOR_MODEL,
|
|
enabled: useMemory,
|
|
});
|
|
|
|
useEffect(() => { ensureHljs(); }, []);
|
|
|
|
// TPS degradation detection: if TPS < 30 for 5 continuous seconds, notify
|
|
useEffect(() => {
|
|
if (tps === null) return;
|
|
if (tps < 30) {
|
|
if (!degradationTimerRef.current) {
|
|
degradationTimerRef.current = setTimeout(() => {
|
|
onDegradationDetected?.();
|
|
degradationTimerRef.current = null;
|
|
}, 5000);
|
|
}
|
|
} else {
|
|
// TPS recovered — cancel pending timer
|
|
if (degradationTimerRef.current) {
|
|
clearTimeout(degradationTimerRef.current);
|
|
degradationTimerRef.current = null;
|
|
}
|
|
}
|
|
return () => {
|
|
if (degradationTimerRef.current) {
|
|
clearTimeout(degradationTimerRef.current);
|
|
degradationTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [tps, onDegradationDetected]);
|
|
|
|
const loadMessages = useCallback((targetChatId: number | null = chatId) => {
|
|
if (targetChatId == null) { setMessages([]); return; }
|
|
const rows = db.query<Message>(
|
|
"SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC",
|
|
[targetChatId],
|
|
);
|
|
setMessages(rows);
|
|
}, [db, chatId]);
|
|
|
|
loadMessagesRef.current = loadMessages;
|
|
|
|
useEffect(() => { loadMessages(); }, [loadMessages]);
|
|
|
|
useEffect(() => {
|
|
activeChatIdRef.current = chatId;
|
|
|
|
const switchedAway = pendingRequestChatIdRef.current !== null && chatId !== pendingRequestChatIdRef.current;
|
|
|
|
if (abortRef.current && switchedAway) {
|
|
abortRef.current.abort();
|
|
abortRef.current = null;
|
|
messageQueueRef.current = [];
|
|
setMessageQueue([]);
|
|
setGenerating(false);
|
|
setStreamContent("");
|
|
setStreamReasoning("");
|
|
setStreamThinkingOpen(false);
|
|
setShowLiveReasoning(false);
|
|
setTps(null);
|
|
setStreamTokenCount(0);
|
|
pendingRequestChatIdRef.current = null;
|
|
return;
|
|
}
|
|
|
|
if (!abortRef.current) {
|
|
setGenerating(false);
|
|
setStreamContent("");
|
|
setStreamReasoning("");
|
|
setStreamThinkingOpen(false);
|
|
setShowLiveReasoning(false);
|
|
setTps(null);
|
|
setStreamTokenCount(0);
|
|
}
|
|
}, [chatId]);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages, streamContent]);
|
|
|
|
const autoResize = () => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
|
};
|
|
|
|
const getOrCreateChat = useCallback((firstMessage?: string): number => {
|
|
if (chatId != null) return chatId;
|
|
const newId = db.insert("INSERT INTO chats (title) VALUES (?)", [buildChatTitle(firstMessage || "")]);
|
|
onChatCreated(newId);
|
|
return newId;
|
|
}, [chatId, db, onChatCreated]);
|
|
|
|
const ensureChatTitle = useCallback((cid: number, firstMsg: string) => {
|
|
const title = buildChatTitle(firstMsg);
|
|
db.run(
|
|
"UPDATE chats SET title = CASE WHEN title = 'New Chat' OR title = '' THEN ? ELSE title END, updated_at = strftime('%s','now') WHERE id = ?",
|
|
[title, cid],
|
|
);
|
|
onChatUpdated();
|
|
}, [db, onChatUpdated]);
|
|
|
|
const buildApiMessages = useCallback((cid: number) => {
|
|
const limit = useMemory ? MAX_MEMORY : 1;
|
|
const history = db.query<{ role: string; content: string }>(
|
|
"SELECT role, content FROM messages WHERE chat_id = ? AND content != '' AND content NOT LIKE '⚠️ Error:%' AND content != 'internal error' ORDER BY created_at DESC LIMIT ?",
|
|
[cid, limit],
|
|
).reverse();
|
|
|
|
// Prepend system prompt with user memories when memory mode is on
|
|
const systemPrompt = useMemory ? buildMemorySystemPrompt() : "";
|
|
if (systemPrompt) {
|
|
return [{ role: "system", content: systemPrompt }, ...history];
|
|
}
|
|
return history;
|
|
}, [db, useMemory, buildMemorySystemPrompt]);
|
|
|
|
const send = useCallback(async (text: string, fromChatId?: number, replaceAssistantId?: number) => {
|
|
if (!text.trim() || generating) return;
|
|
if (getComposerNotice(model, server)) return;
|
|
|
|
setGenerating(true);
|
|
setStreamContent("");
|
|
setStreamReasoning("");
|
|
setStreamThinkingOpen(false);
|
|
setShowLiveReasoning(false);
|
|
setStreamTokenCount(0);
|
|
streamContentAccRef.current = "";
|
|
|
|
const cid = fromChatId ?? getOrCreateChat(text);
|
|
activeChatIdRef.current = cid;
|
|
pendingRequestChatIdRef.current = cid;
|
|
|
|
// When replaceAssistantId is set we are resending — the user message already
|
|
// exists in the DB; we only delete the old assistant reply and replace it.
|
|
if (replaceAssistantId == null) {
|
|
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)", [cid, text]);
|
|
const count = db.get<{ c: number }>("SELECT COUNT(*) as c FROM messages WHERE chat_id = ?", [cid]);
|
|
if (count?.c === 1) ensureChatTitle(cid, text);
|
|
} else {
|
|
// Delete the old assistant message that we're replacing
|
|
db.run("DELETE FROM messages WHERE id = ?", [replaceAssistantId]);
|
|
}
|
|
|
|
loadMessagesRef.current(cid);
|
|
onChatUpdated();
|
|
|
|
const apiMessages = buildApiMessages(cid);
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
|
|
abortRef.current = new AbortController();
|
|
|
|
try {
|
|
const res = await fetch(`${server}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers,
|
|
signal: abortRef.current.signal,
|
|
body: JSON.stringify({
|
|
model,
|
|
messages: apiMessages,
|
|
stream: useStream,
|
|
stream_options: useStream ? { include_usage: true } : undefined,
|
|
max_tokens: 2048,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as { error?: { message?: string } })?.error?.message || `HTTP ${res.status}`);
|
|
}
|
|
|
|
let fullContent = "";
|
|
let usage: Usage | null = null;
|
|
let tokenCount = 0;
|
|
|
|
if (useStream) {
|
|
const reader = res.body!.getReader();
|
|
const decoder = new TextDecoder('utf-8');
|
|
let buf = "";
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
const lines = buf.split("\n");
|
|
buf = lines.pop()!;
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data: ")) continue;
|
|
const data = line.slice(6).trim();
|
|
if (data === "[DONE]") continue;
|
|
try {
|
|
const chunk = JSON.parse(data);
|
|
const delta = chunk.choices?.[0]?.delta?.content;
|
|
if (delta) {
|
|
tokenCount++;
|
|
setStreamTokenCount(tokenCount);
|
|
fullContent += delta;
|
|
streamContentAccRef.current = fullContent;
|
|
const parsed = parseAssistantResponse(fullContent);
|
|
setStreamContent(parsed.content);
|
|
setStreamReasoning(parsed.reasoning);
|
|
setStreamThinkingOpen(parsed.thinkingOpen);
|
|
if (parsed.reasoning) setShowLiveReasoning(true);
|
|
}
|
|
if (chunk.usage) usage = chunk.usage;
|
|
} catch { /* ignore malformed chunks */ }
|
|
}
|
|
}
|
|
} else {
|
|
const data = await res.json();
|
|
fullContent = data.choices?.[0]?.message?.content || "";
|
|
streamContentAccRef.current = fullContent;
|
|
usage = data.usage;
|
|
const parsed = parseAssistantResponse(fullContent);
|
|
setStreamContent(parsed.content);
|
|
setStreamReasoning(parsed.reasoning);
|
|
setStreamThinkingOpen(parsed.thinkingOpen);
|
|
if (parsed.reasoning) setShowLiveReasoning(true);
|
|
}
|
|
|
|
const parsed = parseAssistantResponse(fullContent);
|
|
const finalContent = parsed.content.trim() || fullContent.trim();
|
|
if (finalContent || parsed.reasoning) {
|
|
db.run(
|
|
`INSERT INTO messages (
|
|
chat_id, role, content, reasoning_content, prompt_tokens, completion_tokens, total_tokens, tokens_per_second
|
|
) VALUES (?, 'assistant', ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
cid,
|
|
finalContent,
|
|
parsed.content.trim() ? parsed.reasoning : null,
|
|
usage?.prompt_tokens ?? 0,
|
|
usage?.completion_tokens ?? 0,
|
|
usage?.total_tokens ?? 0,
|
|
usage?.tokens_per_second ?? null,
|
|
],
|
|
);
|
|
}
|
|
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
|
|
if (usage?.tokens_per_second) setTps(usage.tokens_per_second);
|
|
onChatUpdated();
|
|
|
|
// Fire background memory extraction (non-blocking)
|
|
setTimeout(() => { extractMemories(cid).catch(() => undefined); }, 500);
|
|
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && err.name === "AbortError") {
|
|
// ── Save partial response on user-initiated stop ──────────────────
|
|
const partial = streamContentAccRef.current;
|
|
if (partial.trim()) {
|
|
const parsed = parseAssistantResponse(partial);
|
|
const partialContent = parsed.content.trim() || partial.trim();
|
|
db.run(
|
|
`INSERT INTO messages (chat_id, role, content, reasoning_content) VALUES (?, 'assistant', ?, ?)`,
|
|
[cid, `${partialContent} _(geração cancelada)_`, parsed.reasoning || null],
|
|
);
|
|
db.run("UPDATE chats SET updated_at = strftime('%s','now') WHERE id = ?", [cid]);
|
|
onChatUpdated();
|
|
}
|
|
} else if (err instanceof Error) {
|
|
db.run("INSERT INTO messages (chat_id, role, content) VALUES (?, 'assistant', ?)", [
|
|
cid,
|
|
`⚠️ Error: ${err.message}`,
|
|
]);
|
|
onChatUpdated();
|
|
}
|
|
} finally {
|
|
setGenerating(false);
|
|
setStreamContent("");
|
|
setStreamReasoning("");
|
|
setStreamThinkingOpen(false);
|
|
setShowLiveReasoning(false);
|
|
setStreamTokenCount(0);
|
|
streamContentAccRef.current = "";
|
|
|
|
if (activeChatIdRef.current === cid) {
|
|
loadMessagesRef.current(cid);
|
|
}
|
|
pendingRequestChatIdRef.current = null;
|
|
abortRef.current = null;
|
|
|
|
// Process message queue
|
|
const nextInQueue = messageQueueRef.current[0];
|
|
if (nextInQueue && activeChatIdRef.current === cid) {
|
|
setQueue(messageQueueRef.current.slice(1));
|
|
setTimeout(() => sendRef.current(nextInQueue), 100);
|
|
}
|
|
}
|
|
}, [generating, model, server, apiKey, useStream, db, getOrCreateChat, ensureChatTitle, buildApiMessages, onChatUpdated, extractMemories]);
|
|
|
|
// Keep sendRef always pointing to latest send closure
|
|
sendRef.current = send;
|
|
|
|
const handleSend = () => {
|
|
const text = input.trim();
|
|
if (!text || composerNotice) return;
|
|
setInput("");
|
|
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
|
|
|
if (generating) {
|
|
setQueue([...messageQueueRef.current, text]);
|
|
return;
|
|
}
|
|
send(text);
|
|
};
|
|
|
|
const handleStop = () => {
|
|
abortRef.current?.abort();
|
|
setQueue([]);
|
|
};
|
|
|
|
const handleDeleteMessage = (id: number) => {
|
|
if (!window.confirm("Delete this message?")) return;
|
|
db.run("DELETE FROM messages WHERE id = ?", [id]);
|
|
loadMessages();
|
|
};
|
|
|
|
const handleStarterPrompt = (prompt: string) => {
|
|
setInput(prompt);
|
|
requestAnimationFrame(() => {
|
|
textareaRef.current?.focus();
|
|
autoResize();
|
|
});
|
|
};
|
|
|
|
const handleEditAndResend = (msg: Message, newContent: string) => {
|
|
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, msg.id]);
|
|
db.run("DELETE FROM messages WHERE chat_id = ? AND id > ?", [msg.chat_id, msg.id]);
|
|
loadMessages();
|
|
if (generating) {
|
|
setQueue([...messageQueueRef.current, newContent]);
|
|
} else {
|
|
send(newContent, msg.chat_id);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Resend: regenerate the assistant reply for an existing assistant message.
|
|
* Finds the preceding user message and calls send() with replaceAssistantId
|
|
* so the old assistant row is replaced — no duplicate user message is inserted.
|
|
*/
|
|
const handleResend = useCallback((assistantMsg: Message) => {
|
|
if (generating) return;
|
|
// Find the user message immediately before this assistant message
|
|
const userMsg = db.get<Message>(
|
|
`SELECT * FROM messages
|
|
WHERE chat_id = ? AND id < ? AND role = 'user'
|
|
ORDER BY id DESC LIMIT 1`,
|
|
[assistantMsg.chat_id, assistantMsg.id],
|
|
);
|
|
if (!userMsg) return;
|
|
|
|
// Delete any messages after the user message up to and including this assistant msg
|
|
db.run(
|
|
"DELETE FROM messages WHERE chat_id = ? AND id >= ? AND id <= ?",
|
|
[assistantMsg.chat_id, userMsg.id + 1, assistantMsg.id],
|
|
);
|
|
loadMessages();
|
|
send(userMsg.content, assistantMsg.chat_id, assistantMsg.id);
|
|
}, [generating, db, send, loadMessages]);
|
|
|
|
const handleEditAssistant = (id: number, newContent: string) => {
|
|
db.run("UPDATE messages SET content = ? WHERE id = ?", [newContent, id]);
|
|
loadMessages();
|
|
};
|
|
|
|
const memoryMessages = showMemoryModal && chatId != null ? buildApiMessages(chatId) : [];
|
|
|
|
const isInitialScreen = chatId === null && messages.length === 0;
|
|
const modelShort = getDisplayName(model);
|
|
const workspaceStateLabel = chatId === null ? "Workspace" : "Conversation";
|
|
const workspaceStateCopy = composerNotice
|
|
? composerNotice.title
|
|
: server
|
|
? "Runtime ready"
|
|
: "Runtime offline";
|
|
const heroCards = [
|
|
{
|
|
title: "Personalize ForgeLM",
|
|
copy: "Tune workspace defaults, preferred prompts, and local runtime behavior.",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className={`chat-view${isInitialScreen ? " initial-screen" : ""}`}>
|
|
{/* ── Top bar ── */}
|
|
<div className="chat-topbar">
|
|
<div className="chat-topbar-left">
|
|
{onOpenSidebar && (
|
|
<button type="button" className="chat-menu-btn" onClick={onOpenSidebar} aria-label="Open navigation">
|
|
☰
|
|
</button>
|
|
)}
|
|
{model && (
|
|
<div className={`topbar-model-badge${isInitialScreen ? " topbar-model-badge--hero" : ""}`}>
|
|
{!isInitialScreen && <div className={`topbar-status-dot${server ? " connected" : ""}`} />}
|
|
<span className="topbar-model-name">{modelShort}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="chat-topbar-center">
|
|
{isInitialScreen ? null : chatId !== null && chatTotals.total_tokens > 0 ? (
|
|
<div className="chat-token-summary">
|
|
<span title="Prompt tokens sent">↑ {chatTotals.prompt_tokens.toLocaleString()}</span>
|
|
<span title="Completion tokens generated">↓ {chatTotals.completion_tokens.toLocaleString()}</span>
|
|
<span title="Total tokens" className="token-total">Σ {chatTotals.total_tokens.toLocaleString()}</span>
|
|
</div>
|
|
) : (
|
|
<div className="chat-topbar-context" aria-label="Workspace status">
|
|
<span className="chat-topbar-context-label">{workspaceStateLabel}</span>
|
|
<span className="chat-topbar-context-copy">{workspaceStateCopy}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="chat-topbar-right">
|
|
<span className={`topbar-state-pill${server ? " online" : " offline"}${isInitialScreen ? " topbar-state-pill--hero" : ""}`}>
|
|
{server ? "Runtime ready" : "Runtime offline"}
|
|
</span>
|
|
{!isInitialScreen && generating && streamTokenCount > 0 && (
|
|
<div className="live-token-counter">
|
|
<span className="live-token-pulse" />
|
|
<span>{streamTokenCount.toLocaleString()} tokens</span>
|
|
</div>
|
|
)}
|
|
{!isInitialScreen && tps !== null && !generating && (
|
|
<span className="tps-badge">{tps.toFixed(1)} tok/s</span>
|
|
)}
|
|
|
|
{!isInitialScreen && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className={`topbar-icon-btn${showMemoryModal ? " active" : ""}`}
|
|
onClick={() => setShowMemoryModal(!showMemoryModal)}
|
|
title={`Memory context — ${memoryMessageCount} message${memoryMessageCount !== 1 ? "s" : ""}`}
|
|
aria-label="View memory context"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
|
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
|
|
</svg>
|
|
{memoryMessageCount > 0 && (
|
|
<span className="memory-count-badge">{memoryMessageCount}</span>
|
|
)}
|
|
</button>
|
|
|
|
<div className="topbar-toggles">
|
|
<button
|
|
type="button"
|
|
className={`toggle-pill${useStream ? " on" : " off"}`}
|
|
onClick={() => setUseStream(!useStream)}
|
|
title={useStream ? "Streaming enabled" : "Streaming disabled"}
|
|
>
|
|
Stream
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`toggle-pill${useMemory ? " on" : " off"}`}
|
|
onClick={() => setUseMemory(!useMemory)}
|
|
title={useMemory ? `Memory: last ${MAX_MEMORY} messages` : "Memory off — single turn"}
|
|
>
|
|
Memory
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Memory Modal ── */}
|
|
{showMemoryModal && (
|
|
<div className="memory-modal-overlay" onClick={() => setShowMemoryModal(false)}>
|
|
<div className="memory-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="memory-modal-header">
|
|
<div className="memory-modal-title">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
|
<path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/>
|
|
</svg>
|
|
<span>Memory Context</span>
|
|
</div>
|
|
<span className="memory-modal-subtitle">
|
|
{memoryMessages.length} message{memoryMessages.length !== 1 ? "s" : ""} · {useMemory ? `last ${MAX_MEMORY}` : "single turn"} mode
|
|
</span>
|
|
<button type="button" className="memory-modal-close" onClick={() => setShowMemoryModal(false)}>✕</button>
|
|
</div>
|
|
<div className="memory-modal-body">
|
|
{memoryMessages.length === 0 ? (
|
|
<div className="memory-empty">No messages in context yet. Start a conversation to populate memory.</div>
|
|
) : (
|
|
memoryMessages.map((msg, i) => (
|
|
<div key={i} className={`memory-item ${msg.role}`}>
|
|
<span className="memory-item-role">{msg.role}</span>
|
|
<span className="memory-item-content">
|
|
{msg.content.length > 240 ? msg.content.slice(0, 238) + "…" : msg.content}
|
|
</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Chat Layout ── */}
|
|
<div className="chat-layout-root">
|
|
{/* Scrollable message area */}
|
|
<div className="chat-scroll-area">
|
|
<div className={`messages-container${isInitialScreen ? " messages-container--hero" : ""}`}>
|
|
{chatId === null && messages.length === 0 && (
|
|
<div className="empty-state empty-state--hero">
|
|
<section className="empty-state-panel empty-state-panel--hero" aria-labelledby="workspace-welcome">
|
|
<div className="empty-state-header empty-state-header--hero">
|
|
<span className="empty-kicker">ForgeLM workspace</span>
|
|
<h1 id="workspace-welcome">What can I do for you?</h1>
|
|
</div>
|
|
|
|
<div className="hero-composer-block">
|
|
<div className="input-box input-box--hero">
|
|
<ModelSelectorPill
|
|
models={models}
|
|
localModels={localModels}
|
|
selectedModel={selectedModel}
|
|
onModelChange={onModelChange}
|
|
onModelSwap={onModelSwap}
|
|
isSwapping={isSwapping}
|
|
status={status}
|
|
/>
|
|
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
|
|
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
|
|
<Image size={20} weight="duotone" />
|
|
</button>
|
|
<button type="button" disabled aria-disabled="true" title="Attach audio (Coming soon)">
|
|
<Microphone size={20} weight="duotone" />
|
|
</button>
|
|
<button type="button" disabled aria-disabled="true" title="Attach document (Coming soon)">
|
|
<Paperclip size={20} weight="duotone" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="input-box-main">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={input}
|
|
onChange={(e) => { setInput(e.target.value); autoResize(); }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
|
}}
|
|
placeholder={
|
|
composerNotice
|
|
? `${composerNotice.title}. ${composerNotice.description}`
|
|
: generating
|
|
? "Type to queue next message…"
|
|
: "Assign a task or ask anything"
|
|
}
|
|
rows={1}
|
|
disabled={Boolean(composerNotice)}
|
|
/>
|
|
|
|
{generating ? (
|
|
<button className="stop-btn" onClick={handleStop} type="button">
|
|
■ Stop
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="send-btn"
|
|
onClick={handleSend}
|
|
disabled={!input.trim() || Boolean(composerNotice)}
|
|
type="button"
|
|
aria-label="Send message"
|
|
>
|
|
<PaperPlaneRight size={18} weight="fill" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="composer-footer-row">
|
|
<span className="composer-footer-hint">
|
|
{composerNotice ? `${composerNotice.title} · ${composerNotice.description}` : "Connect your tools to ForgeLM"}
|
|
</span>
|
|
<div className="composer-footer-icons" aria-hidden="true">
|
|
<span />
|
|
<span />
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="starter-chip-rail" aria-label="Starter prompts">
|
|
{starterPrompts.map((prompt) => (
|
|
<button key={prompt} type="button" className="starter-chip starter-chip--hero" onClick={() => handleStarterPrompt(prompt)}>
|
|
{prompt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="hero-card-carousel" aria-label="Workspace shortcuts">
|
|
{heroCards.map((card) => (
|
|
<button key={card.title} type="button" className="hero-card" onClick={() => handleStarterPrompt(card.copy)}>
|
|
<div>
|
|
<span className="hero-card-title">{card.title}</span>
|
|
<span className="hero-card-copy">{card.copy}</span>
|
|
</div>
|
|
<div className="hero-card-preview" aria-hidden="true">
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
</button>
|
|
))}
|
|
<div className="hero-card-pager" aria-hidden="true">
|
|
<span className="active" />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((msg) => (
|
|
<MessageBubble
|
|
key={msg.id}
|
|
message={msg}
|
|
onDelete={handleDeleteMessage}
|
|
onEditAndResend={msg.role === "user" ? handleEditAndResend : undefined}
|
|
onEditAssistant={msg.role === "assistant" ? handleEditAssistant : undefined}
|
|
onResend={msg.role === "assistant" ? handleResend : undefined}
|
|
renderMarkdown={renderMarkdown}
|
|
/>
|
|
))}
|
|
|
|
{/* Live streaming bubble */}
|
|
{generating && (streamReasoning || streamContent || streamThinkingOpen) && (
|
|
<div className="msg-wrapper assistant">
|
|
<div className="msg-bubble">
|
|
{(streamReasoning || streamThinkingOpen) && (
|
|
<details
|
|
className="msg-thinking"
|
|
open={showLiveReasoning}
|
|
onToggle={(event) => setShowLiveReasoning(event.currentTarget.open)}
|
|
>
|
|
<summary className="thinking-summary" aria-label="Toggle live thinking details">
|
|
<svg className="thinking-chevron" viewBox="0 0 16 16" focusable="false" aria-hidden="true">
|
|
<path d="M6 4l4 4-4 4" />
|
|
</svg>
|
|
<span className="reasoning-live-dot" aria-hidden="true" />
|
|
Thinking{streamThinkingOpen ? "..." : ""}
|
|
</summary>
|
|
{streamReasoning && (
|
|
<div
|
|
className="thinking-content"
|
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamReasoning) }}
|
|
/>
|
|
)}
|
|
</details>
|
|
)}
|
|
|
|
{streamContent && (
|
|
<div
|
|
className="msg-content"
|
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(streamContent) }}
|
|
/>
|
|
)}
|
|
|
|
{!streamContent && streamThinkingOpen && (
|
|
<div className="thinking-manus">
|
|
<span className="thinking-dot" aria-hidden="true" />
|
|
<span className="thinking-label">Thinking</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{generating && !streamReasoning && !streamContent && !streamThinkingOpen && (
|
|
<div className="msg-wrapper assistant">
|
|
<div className="thinking-manus">
|
|
<span className="thinking-dot" aria-hidden="true" />
|
|
<span className="thinking-label">Thinking</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
<div className="scroll-spacer" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Message Queue indicator ── */}
|
|
{!isInitialScreen && messageQueue.length > 0 && (
|
|
<div className="queue-indicator">
|
|
<span className="queue-indicator-pulse" />
|
|
<span className="queue-indicator-text">
|
|
{messageQueue.length} message{messageQueue.length > 1 ? "s" : ""} queued
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="queue-clear-btn"
|
|
onClick={() => { messageQueueRef.current = []; setMessageQueue([]); }}
|
|
>
|
|
✕ Clear
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Fixed Input ── */}
|
|
{!isInitialScreen && (
|
|
<div className="input-sticky-wrapper">
|
|
<div className="input-gradient-mask" />
|
|
<div className="input-content-constrained">
|
|
<div className="input-wrapper">
|
|
{composerNotice && (
|
|
<div className={`composer-notice ${composerNotice.tone}`}>
|
|
<div>
|
|
<strong>{composerNotice.title}</strong>
|
|
<p>{composerNotice.description}</p>
|
|
</div>
|
|
{onOpenSidebar && (
|
|
<button type="button" className="notice-action-btn" onClick={onOpenSidebar}>
|
|
{composerNotice.actionLabel}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Model capabilities — floating above the pill */}
|
|
<div className="model-capabilities" aria-label="Model capabilities">
|
|
<span className="capability-group">
|
|
<span className="label">In</span>
|
|
<ChatCircleText size={14} weight="duotone" />
|
|
<Image size={14} weight="duotone" />
|
|
</span>
|
|
<span className="separator">·</span>
|
|
<span className="capability-group">
|
|
<span className="label">Out</span>
|
|
<ChatCircleText size={14} weight="duotone" />
|
|
</span>
|
|
</div>
|
|
|
|
{/* The typing pill */}
|
|
<div className="input-box">
|
|
<ModelSelectorPill
|
|
models={models}
|
|
localModels={localModels}
|
|
selectedModel={selectedModel}
|
|
onModelChange={onModelChange}
|
|
onModelSwap={onModelSwap}
|
|
isSwapping={isSwapping}
|
|
status={status}
|
|
/>
|
|
<div className="attachment-actions" aria-label="Attachment options (coming soon)">
|
|
<button type="button" disabled aria-disabled="true" title="Attach image (Coming soon)">
|
|
<Image size={20} weight="duotone" />
|
|
</button>
|
|
<button type="button" disabled aria-disabled="true" title="Attach audio (Coming soon)">
|
|
<Microphone size={20} weight="duotone" />
|
|
</button>
|
|
<button type="button" disabled aria-disabled="true" title="Attach document (Coming soon)">
|
|
<Paperclip size={20} weight="duotone" />
|
|
</button>
|
|
</div>
|
|
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={input}
|
|
onChange={(e) => { setInput(e.target.value); autoResize(); }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
|
}}
|
|
placeholder={
|
|
composerNotice
|
|
? `${composerNotice.title}. ${composerNotice.description}`
|
|
: generating
|
|
? "Type to queue next message…"
|
|
: "Message..."
|
|
}
|
|
rows={1}
|
|
disabled={Boolean(composerNotice)}
|
|
/>
|
|
|
|
{generating ? (
|
|
<button className="stop-btn" onClick={handleStop} type="button">
|
|
■ Stop
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="send-btn"
|
|
onClick={handleSend}
|
|
disabled={!input.trim() || Boolean(composerNotice)}
|
|
type="button"
|
|
aria-label="Send message"
|
|
>
|
|
<PaperPlaneRight size={18} weight="fill" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Queue system */}
|
|
{messageQueue.length > 0 && (
|
|
<div className="queue-container">
|
|
{isQueueOpen && (
|
|
<div className="queue-popover">
|
|
<div className="queue-header">
|
|
<span>Mensagens em espera</span>
|
|
</div>
|
|
<ul className="queue-list">
|
|
{messageQueue.map((msg, index) => (
|
|
<li key={index} className="queue-item">
|
|
<span className="queue-text">{msg}</span>
|
|
<button
|
|
className="queue-remove"
|
|
aria-label="Remover da fila"
|
|
onClick={() => {
|
|
const next = messageQueue.filter((_, i) => i !== index);
|
|
setQueue(next);
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
<button
|
|
className="queue-toggle"
|
|
onClick={() => setIsQueueOpen(!isQueueOpen)}
|
|
>
|
|
{messageQueue.length} queued
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|