Commit e7ac6b83 authored by Mahmoud Aglan's avatar Mahmoud Aglan

test

parent bde969b1
This source diff could not be displayed because it is too large. You can view the blob instead.
-- Attachments table for chat file uploads
CREATE TABLE IF NOT EXISTS attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
media_type TEXT NOT NULL CHECK (media_type IN ('image', 'video', 'document', 'text', 'unknown')),
storage_path TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_attachments_chat ON attachments(chat_id);
CREATE INDEX idx_attachments_message ON attachments(message_id);
CREATE INDEX idx_attachments_user ON attachments(user_id);
\ No newline at end of file
"""
Son of Anton — Attachment Upload Routes
Handles file uploads for chat messages.
"""
import os
import uuid
import shutil
from pathlib import Path
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form
from typing import List
from ..auth import get_current_user
from ..database import get_db
from ..services.file_processor import classify_media, validate_file
router = APIRouter(prefix="/chats/{chat_id}/attachments", tags=["attachments"])
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploads")
Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
@router.post("")
async def upload_attachments(
chat_id: str,
files: List[UploadFile] = File(...),
user=Depends(get_current_user),
db=Depends(get_db),
):
"""Upload one or more files to a chat. Returns attachment metadata."""
# Verify chat belongs to user
chat = await db.fetchrow(
"SELECT id, user_id FROM chats WHERE id = $1", uuid.UUID(chat_id)
)
if not chat:
raise HTTPException(404, "Chat not found")
if str(chat["user_id"]) != str(user["id"]):
raise HTTPException(403, "Not your chat")
if len(files) > 10:
raise HTTPException(400, "Maximum 10 files per upload")
results = []
for f in files:
# Read file content to get size
content = await f.read()
size = len(content)
# Validate
ok, error = validate_file(f.filename or "file", f.content_type or "", size)
if not ok:
raise HTTPException(400, f"File '{f.filename}': {error}")
media_type = classify_media(f.content_type or "")
# Generate unique storage filename
ext = Path(f.filename or "file").suffix or ".bin"
storage_name = f"{uuid.uuid4().hex}{ext}"
chat_dir = Path(UPLOAD_DIR) / chat_id
chat_dir.mkdir(parents=True, exist_ok=True)
storage_path = f"{chat_id}/{storage_name}"
full_path = chat_dir / storage_name
# Write file
full_path.write_bytes(content)
# Insert DB record
att = await db.fetchrow(
"""
INSERT INTO attachments (chat_id, user_id, filename, original_filename, mime_type, file_size, media_type, storage_path)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, filename, original_filename, mime_type, file_size, media_type, created_at
""",
uuid.UUID(chat_id),
user["id"],
storage_name,
f.filename or "file",
f.content_type or "application/octet-stream",
size,
media_type,
storage_path,
)
results.append({
"id": str(att["id"]),
"filename": att["original_filename"],
"mime_type": att["mime_type"],
"file_size": att["file_size"],
"media_type": att["media_type"],
"created_at": att["created_at"].isoformat(),
})
return results
@router.get("")
async def list_attachments(
chat_id: str,
user=Depends(get_current_user),
db=Depends(get_db),
):
"""List all attachments for a chat."""
chat = await db.fetchrow(
"SELECT id, user_id FROM chats WHERE id = $1", uuid.UUID(chat_id)
)
if not chat:
raise HTTPException(404, "Chat not found")
if str(chat["user_id"]) != str(user["id"]):
raise HTTPException(403, "Not your chat")
rows = await db.fetch(
"""
SELECT id, original_filename as filename, mime_type, file_size, media_type, message_id, created_at
FROM attachments WHERE chat_id = $1 ORDER BY created_at
""",
uuid.UUID(chat_id),
)
return [dict(r) for r in rows]
@router.get("/{attachment_id}/preview")
async def preview_attachment(
chat_id: str,
attachment_id: str,
user=Depends(get_current_user),
db=Depends(get_db),
):
"""Return raw file content for preview (images mainly)."""
from fastapi.responses import FileResponse
att = await db.fetchrow(
"""
SELECT a.*, c.user_id as chat_owner
FROM attachments a JOIN chats c ON a.chat_id = c.id
WHERE a.id = $1 AND a.chat_id = $2
""",
uuid.UUID(attachment_id),
uuid.UUID(chat_id),
)
if not att:
raise HTTPException(404, "Attachment not found")
if str(att["chat_owner"]) != str(user["id"]):
raise HTTPException(403, "Not your attachment")
full_path = Path(UPLOAD_DIR) / att["storage_path"]
if not full_path.exists():
raise HTTPException(404, "File not found on disk")
return FileResponse(
path=str(full_path),
media_type=att["mime_type"],
filename=att["original_filename"],
)
\ No newline at end of file
"""
SON OF ANTON — INTEGRATION PATCH FOR YOUR EXISTING MESSAGE ROUTE
This is NOT a standalone file. These are the functions and code blocks
you need to ADD to your existing message-sending route (the one that
handles POST /chats/{chat_id}/messages and streams SSE back).
--- STEP 1: Add these imports at the top of your messages route file ---
"""
# Add to your imports:
import os
from ..services.file_processor import build_content_blocks_for_attachments
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "uploads")
"""
--- STEP 2: In your request body model/parsing, add attachment_ids ---
Your existing body probably looks like:
{ content, model, max_tokens, reasoning_budget, knowledge_base_id }
Add:
attachment_ids: list[str] = []
So it becomes:
{ content, model, max_tokens, reasoning_budget, knowledge_base_id, attachment_ids }
"""
"""
--- STEP 3: Where you build the Claude messages array, replace the simple
text content with a content block array when attachments exist ---
BEFORE (you probably have something like):
user_message_content = body.content
# or
messages_for_claude.append({"role": "user", "content": body.content})
AFTER (replace with):
"""
async def build_user_content(db, body_content: str, attachment_ids: list, chat_id: str):
"""
Build Claude content blocks for a user message.
If there are attachments, returns a list of content blocks.
If no attachments, returns the plain text string (backward compatible).
"""
if not attachment_ids:
return [{"text": body_content}]
import uuid as uuid_mod
# Fetch attachment records
att_uuids = [uuid_mod.UUID(aid) for aid in attachment_ids]
attachments = await db.fetch(
"""
SELECT id, filename, original_filename, mime_type, file_size, media_type, storage_path
FROM attachments
WHERE id = ANY($1) AND chat_id = $2
""",
att_uuids,
uuid_mod.UUID(chat_id),
)
attachments = [dict(a) for a in attachments]
# Build content blocks: text first, then file blocks
content_blocks = []
# Add the text message
if body_content.strip():
content_blocks.append({"text": body_content})
# Add file content blocks
file_blocks = build_content_blocks_for_attachments(attachments, UPLOAD_DIR)
content_blocks.extend(file_blocks)
# If no text was provided, add a default prompt
if not body_content.strip():
content_blocks.insert(0, {"text": "Please describe and analyze the attached file(s)."})
# Link attachments to the message (do after message is saved)
return content_blocks
"""
--- STEP 4: In your Claude API call, use the content blocks ---
Instead of:
{"role": "user", "content": "user text here"}
Use:
{"role": "user", "content": content_blocks}
Where content_blocks comes from build_user_content() above.
--- STEP 5: After saving the user message to DB, link attachments ---
"""
async def link_attachments_to_message(db, attachment_ids: list, message_id):
"""Call this after inserting the user message into your messages table."""
import uuid as uuid_mod
if attachment_ids:
await db.execute(
"UPDATE attachments SET message_id = $1 WHERE id = ANY($2)",
message_id,
[uuid_mod.UUID(aid) for aid in attachment_ids],
)
"""
--- STEP 6: Register the attachments router in your main app file ---
In your main.py or app.py, add:
from .routes.attachments import router as attachments_router
app.include_router(attachments_router, prefix="/api")
--- STEP 7: Also serve uploaded files statically (optional, for image previews) ---
from fastapi.staticfiles import StaticFiles
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
--- DONE. That's it for the backend. ---
"""
\ No newline at end of file
"""
Son of Anton — File Processor
Handles classification, validation, and Claude content-block generation
for uploaded files (images, videos, documents).
"""
import base64
import mimetypes
from pathlib import Path
# Claude Bedrock supported formats
IMAGE_FORMATS = {"image/jpeg", "image/png", "image/gif", "image/webp"}
VIDEO_FORMATS = {"video/mp4", "video/webm", "video/mov", "video/mpeg", "video/mkv",
"video/x-matroska", "video/quicktime", "video/x-flv",
"video/x-ms-wmv", "video/3gpp"}
DOCUMENT_FORMATS = {
"application/pdf": "pdf",
"text/csv": "csv",
"application/msword": "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.ms-excel": "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"text/html": "html",
"text/plain": "txt",
"text/markdown": "md",
}
# Max sizes (bytes)
MAX_IMAGE_SIZE = 20 * 1024 * 1024 # 20MB
MAX_VIDEO_SIZE = 100 * 1024 * 1024 # 100MB (Claude limit ~25MB for video in message)
MAX_DOCUMENT_SIZE = 50 * 1024 * 1024 # 50MB
ALLOWED_MIMES = IMAGE_FORMATS | VIDEO_FORMATS | set(DOCUMENT_FORMATS.keys())
def classify_media(mime_type: str) -> str:
"""Classify a MIME type into a media category."""
if mime_type in IMAGE_FORMATS:
return "image"
if mime_type in VIDEO_FORMATS:
return "video"
if mime_type in DOCUMENT_FORMATS:
return "document"
if mime_type and mime_type.startswith("text/"):
return "text"
return "unknown"
def get_max_size(media_type: str) -> int:
"""Return max allowed file size in bytes for a media type."""
return {
"image": MAX_IMAGE_SIZE,
"video": MAX_VIDEO_SIZE,
"document": MAX_DOCUMENT_SIZE,
"text": MAX_DOCUMENT_SIZE,
}.get(media_type, MAX_DOCUMENT_SIZE)
def validate_file(filename: str, content_type: str, size: int) -> tuple[bool, str]:
"""Validate an uploaded file. Returns (ok, error_message)."""
if not content_type:
guessed, _ = mimetypes.guess_type(filename)
content_type = guessed or "application/octet-stream"
media_type = classify_media(content_type)
if media_type == "unknown":
return False, f"Unsupported file type: {content_type}. Supported: images, videos, PDF, Office docs, text files."
max_size = get_max_size(media_type)
if size > max_size:
return False, f"File too large ({size / 1024 / 1024:.1f}MB). Max for {media_type}: {max_size / 1024 / 1024:.0f}MB."
return True, ""
def mime_to_claude_format(mime_type: str, media_type: str) -> str:
"""Convert MIME type to Claude's format string."""
if media_type == "image":
return mime_type.split("/")[1] # jpeg, png, gif, webp
if media_type == "video":
mapping = {
"video/mp4": "mp4",
"video/webm": "webm",
"video/quicktime": "mov",
"video/mov": "mov",
"video/mpeg": "mpeg",
"video/mkv": "mkv",
"video/x-matroska": "mkv",
"video/x-flv": "flv",
"video/x-ms-wmv": "wmv",
"video/3gpp": "three_gp",
}
return mapping.get(mime_type, "mp4")
if media_type == "document":
return DOCUMENT_FORMATS.get(mime_type, "txt")
return "txt"
def build_content_block(file_path: str, mime_type: str, media_type: str, original_filename: str) -> dict:
"""
Build a Claude Converse API content block for a file.
Returns a dict that goes directly into the message content array.
"""
path = Path(file_path)
if not path.exists():
return {"text": f"[Attachment missing: {original_filename}]"}
file_bytes = path.read_bytes()
fmt = mime_to_claude_format(mime_type, media_type)
if media_type == "image":
return {
"image": {
"format": fmt,
"source": {
"bytes": file_bytes
}
}
}
elif media_type == "video":
return {
"video": {
"format": fmt,
"source": {
"bytes": file_bytes
}
}
}
elif media_type == "document":
return {
"document": {
"format": fmt,
"name": Path(original_filename).stem[:200],
"source": {
"bytes": file_bytes
}
}
}
elif media_type == "text":
try:
text_content = file_bytes.decode("utf-8", errors="replace")
except Exception:
text_content = "[Could not decode text file]"
return {
"text": f"--- Content of {original_filename} ---\n{text_content}\n--- End of {original_filename} ---"
}
else:
return {"text": f"[Unsupported attachment: {original_filename}]"}
def build_content_blocks_for_attachments(attachments: list, upload_dir: str) -> list[dict]:
"""
Given a list of attachment DB records, build all Claude content blocks.
"""
blocks = []
for att in attachments:
file_path = str(Path(upload_dir) / att["storage_path"])
block = build_content_block(
file_path=file_path,
mime_type=att["mime_type"],
media_type=att["media_type"],
original_filename=att["original_filename"],
)
blocks.append(block)
return blocks
\ No newline at end of file
/**
* Son of Anton — API helper functions
*/
const BASE = "/api";
function headers(token) {
......@@ -16,12 +12,13 @@ async function request(method, path, token, body) {
const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || err.message || "Request failed");
throw new Error(err.detail || `Request failed: ${res.status}`);
}
if (res.status === 204) return null;
return res.json();
}
/* ── Auth ─────────────────────────────────── */
/* ── Auth ─────────────────────────────────── */
export const login = (username, password) =>
request("POST", "/auth/login", null, { username, password });
......@@ -97,6 +94,37 @@ export async function* streamMessage(token, chatId, body, signal) {
}
}
/* ── File Uploads (Attachments) ───────────── */
export async function uploadAttachments(token, chatId, files) {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
// Do NOT set Content-Type — browser sets it with boundary for multipart
},
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || "Upload failed");
}
return res.json();
}
export const listAttachments = (token, chatId) =>
request("GET", `/chats/${chatId}/attachments`, token);
export function getAttachmentPreviewUrl(chatId, attachmentId) {
return `${BASE}/chats/${chatId}/attachments/${attachmentId}/preview`;
}
/* ── Knowledge Bases ───────────────────────── */
export const listKnowledgeBases = (token) =>
request("GET", "/knowledge", token);
......@@ -111,64 +139,50 @@ export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export async function uploadDocuments(token, kbId, files) {
const form = new FormData();
const formData = new FormData();
for (const file of files) {
form.append("files", file);
formData.append("files", file);
}
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
const res = await fetch(`${BASE}/knowledge/${kbId}/documents`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: form,
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || "Upload failed");
}
return res.json();
}
// Backward-compat wrapper for single file
export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
export const deleteDocument = (token, kbId, docId) =>
request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token);
/* ── Admin ─────────────────────────────────── */
export const adminStats = (token) =>
request("GET", "/admin/stats", token);
export const adminListUsers = (token) =>
request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) =>
request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) =>
request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) =>
request("GET", "/admin/chats", token);
/* ── File helpers ──────────────────────────── */
export async function downloadZip(token, markdown) {
const res = await fetch(`${BASE}/files/download-zip`, {
/* ── Download Zip ──────────────────────────── */
export async function downloadZip(token, content) {
const res = await fetch(`${BASE}/download-zip`, {
method: "POST",
headers: headers(token),
body: JSON.stringify({ markdown }),
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Download failed");
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/zip")) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "son-of-anton-code.zip";
a.click();
URL.revokeObjectURL(url);
} else {
const data = await res.json();
if (data.error) throw new Error(data.error);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "code-files.zip";
a.click();
URL.revokeObjectURL(url);
}
\ No newline at end of file
import React, { useMemo } from "react";
import {
X,
FileText,
Image as ImageIcon,
Film,
File,
FileSpreadsheet,
} from "lucide-react";
/**
* Renders a preview chip for an attached file.
* Used both in the pending-files area (before send) and in message bubbles (after send).
*
* Props:
* file?: File object (for pending uploads — generates preview from File API)
* attachment?: { filename, mime_type, file_size, media_type, preview_url } (for sent messages)
* onRemove?: () => void (if removable)
* isPending?: boolean (styling difference)
*/
export default function AttachmentPreview({
file,
attachment,
onRemove,
isPending,
}) {
const info = useMemo(() => {
if (file) {
return {
name: file.name,
size: file.size,
type: file.type,
mediaType: classifyMime(file.type),
previewUrl: file.type.startsWith("image/")
? URL.createObjectURL(file)
: null,
};
}
if (attachment) {
return {
name: attachment.filename || attachment.original_filename || "file",
size: attachment.file_size,
type: attachment.mime_type,
mediaType: attachment.media_type,
previewUrl: attachment.preview_url || null,
};
}
return { name: "unknown", size: 0, type: "", mediaType: "unknown", previewUrl: null };
}, [file, attachment]);
const Icon = getIcon(info.mediaType);
const sizeStr = formatSize(info.size);
return (
<div
className={`
group relative flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-xs
${
isPending
? "bg-anton-card border-anton-accent/30 text-anton-text"
: "bg-anton-surface border-anton-border text-anton-muted"
}
transition hover:border-anton-accent/50
`}
>
{/* Image thumbnail */}
{info.previewUrl && info.mediaType === "image" ? (
<img
src={info.previewUrl}
alt={info.name}
className="w-8 h-8 rounded object-cover flex-shrink-0"
onLoad={() => {
// Revoke blob URL after load to free memory (only for pending files)
if (file && info.previewUrl) {
// Don't revoke immediately — component might re-render
}
}}
/>
) : (
<Icon
size={16}
className={`flex-shrink-0 ${
isPending ? "text-anton-accent" : "text-anton-muted"
}`}
/>
)}
<div className="flex flex-col min-w-0">
<span className="truncate max-w-[140px] font-medium">{info.name}</span>
<span className="text-[10px] text-anton-muted">{sizeStr}</span>
</div>
{/* Remove button */}
{onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-1 p-0.5 rounded-full text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition opacity-0 group-hover:opacity-100"
title="Remove"
>
<X size={12} />
</button>
)}
{/* Video badge */}
{info.mediaType === "video" && (
<span className="absolute -top-1 -right-1 bg-anton-accent text-white text-[9px] font-bold px-1 rounded">
VID
</span>
)}
</div>
);
}
function classifyMime(mime) {
if (!mime) return "unknown";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (
mime === "application/pdf" ||
mime.includes("word") ||
mime.includes("document")
)
return "document";
if (
mime.includes("excel") ||
mime.includes("spreadsheet") ||
mime === "text/csv"
)
return "spreadsheet";
if (mime.startsWith("text/")) return "text";
return "unknown";
}
function getIcon(mediaType) {
switch (mediaType) {
case "image":
return ImageIcon;
case "video":
return Film;
case "document":
return FileText;
case "spreadsheet":
return FileSpreadsheet;
case "text":
return FileText;
default:
return File;
}
}
function formatSize(bytes) {
if (!bytes || bytes === 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback } from "react";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { useApp } from "../store";
import {
getMessages, downloadZip, listKnowledgeBases, updateChat,
} from "../api";
import { getMessages, downloadZip, uploadAttachments } from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import FileUploadButton from "./FileUploadButton";
import AttachmentPreview from "./AttachmentPreview";
import {
Send, Square, Settings2, X, Brain, BookOpen, ChevronDown,
Send,
Square,
Download,
ChevronDown,
Loader2,
Paperclip,
} from "lucide-react";
const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" },
];
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
// ── Persisted settings from the chat object ──
const currentChat = state.chats.find((c) => c.id === chatId);
const messages = state.chatMessages[chatId] || [];
const isStreamingGlobal = !!state.activeStreams[chatId];
const [input, setInput] = useState("");
const [pendingFiles, setPendingFiles] = useState([]); // File objects waiting to be sent
const [uploadingFiles, setUploadingFiles] = useState(false);
const [model, setModel] = useState(
() =>
state.chats.find((c) => c.id === chatId)?.model ||
"us.anthropic.claude-sonnet-4-20250514"
);
const [maxTokens, setMaxTokens] = useState(
() => state.chats.find((c) => c.id === chatId)?.max_tokens || 16384
);
const [reasoningBudget, setReasoningBudget] = useState(
() => state.chats.find((c) => c.id === chatId)?.reasoning_budget || 10000
);
const [selectedKbId, setSelectedKbId] = useState(
() => state.chats.find((c) => c.id === chatId)?.knowledge_base_id || ""
);
const [showSettings, setShowSettings] = useState(false);
const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [kbs, setKbs] = useState([]);
// High-frequency stream data — lives outside the global store to avoid
// re-rendering every component on every token
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollContainerRef = useRef(null);
const inputRef = useRef(null);
const shouldAutoScrollRef = useRef(true);
const rafRef = useRef(null);
const textareaRef = useRef(null);
const messages = state.chatMessages[chatId] || [];
// Per-chat streaming state — subscribe to stream manager
const [streamData, setStreamData] = useState(() =>
streamManager.getStreamData(chatId)
);
// ── Subscribe to background stream data for THIS chat ──
useEffect(() => {
// Initial read
setStreamData(streamManager.getStreamData(chatId));
// Subscribe to updates for THIS chat
const unsub = streamManager.subscribe(chatId, () => {
setStreamData(streamManager.getStreamData(chatId));
});
return unsub;
}, [chatId]);
// ── Scroll helpers ──
function handleContainerScroll() {
const el = scrollContainerRef.current;
if (!el) return;
const { scrollHeight, scrollTop, clientHeight } = el;
shouldAutoScrollRef.current = scrollHeight - scrollTop - clientHeight < 200;
}
const scrollToBottom = useCallback(() => {
if (!shouldAutoScrollRef.current) return;
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
const el = scrollContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
rafRef.current = null;
});
}, []);
// ── Load messages & KB list on mount ──
// Load messages on mount
useEffect(() => {
(async () => {
try {
const [msgs, kbData] = await Promise.all([
getMessages(state.token, chatId),
listKnowledgeBases(state.token),
]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData);
} catch { /* */ }
})();
}, [chatId, state.token, dispatch]);
// Scroll when content changes
useEffect(scrollToBottom, [messages, streamData.text, streamData.thinking, scrollToBottom]);
if (!state.chatMessages[chatId]) {
getMessages(state.token, chatId)
.then((msgs) => dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }))
.catch(() => {});
}
}, [chatId, state.token, dispatch, state.chatMessages]);
// Auto-scroll
useEffect(() => {
inputRef.current?.focus();
}, [chatId]);
if (shouldAutoScrollRef.current && scrollContainerRef.current) {
const el = scrollContainerRef.current;
el.scrollTop = el.scrollHeight;
}
}, [messages, streamData]);
// ── Settings persistence ──
async function saveSettings() {
const data = {
model,
max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId || "",
};
try {
await updateChat(state.token, chatId, data);
dispatch({
type: "UPDATE_CHAT",
chat: {
id: chatId,
model,
max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
},
});
} catch { /* */ }
function handleContainerScroll() {
const el = scrollContainerRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
shouldAutoScrollRef.current = nearBottom;
}
function toggleSettings() {
const closing = showSettings;
setShowSettings(!showSettings);
if (closing) saveSettings();
function scrollToBottom() {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop =
scrollContainerRef.current.scrollHeight;
shouldAutoScrollRef.current = true;
}
}
// ── Send message ──
function handleSend() {
// THIS chat streaming — only blocks sends for THIS chat, not others
const isChatStreaming = !!state.activeStreams[chatId];
async function handleSend() {
const content = input.trim();
if (!content || isStreamingGlobal) return;
if ((!content && pendingFiles.length === 0) || isChatStreaming) return;
// Optimistic user message
// Optimistic user message (with attachment previews)
const userMsg = {
id: `tmp-${Date.now()}`,
role: "user",
content,
content: content || "(attached files)",
created_at: new Date().toISOString(),
attachments: pendingFiles.map((f) => ({
id: `pending-${f.name}`,
filename: f.name,
mime_type: f.type,
file_size: f.size,
media_type: classifyFile(f),
preview_url: f.type.startsWith("image/") ? URL.createObjectURL(f) : null,
})),
};
dispatch({ type: "ADD_MESSAGE", chatId, message: userMsg });
setInput("");
shouldAutoScrollRef.current = true;
// Sync settings to store immediately
// Upload files if any
let attachmentIds = [];
if (pendingFiles.length > 0) {
setUploadingFiles(true);
try {
const uploaded = await uploadAttachments(
state.token,
chatId,
pendingFiles
);
attachmentIds = uploaded.map((a) => a.id);
} catch (err) {
// Add error message
dispatch({
type: "ADD_MESSAGE",
chatId,
message: {
id: `err-${Date.now()}`,
role: "assistant",
content: `**Upload failed:** ${err.message}`,
created_at: new Date().toISOString(),
},
});
setUploadingFiles(false);
return;
}
setUploadingFiles(false);
setPendingFiles([]);
} else {
setPendingFiles([]);
}
// Sync settings to store
dispatch({
type: "UPDATE_CHAT",
chat: {
......@@ -144,16 +159,17 @@ export default function ChatView({ chatId }) {
},
});
// Kick off background stream — survives chat switching
// Start stream — includes attachment_ids
streamManager.startStream({
token: state.token,
chatId,
body: {
content,
content: content || "Please describe and analyze the attached file(s).",
model,
max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
attachment_ids: attachmentIds,
},
});
}
......@@ -169,6 +185,45 @@ export default function ChatView({ chatId }) {
}
}
function handleFilesSelected(files) {
setPendingFiles((prev) => [...prev, ...Array.from(files)]);
}
function handleRemovePendingFile(index) {
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
}
function handlePaste(e) {
const items = e.clipboardData?.items;
if (!items) return;
const files = [];
for (const item of items) {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) files.push(file);
}
}
if (files.length > 0) {
e.preventDefault();
handleFilesSelected(files);
}
}
function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFilesSelected(files);
}
}
function handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
}
async function handleDownloadAll() {
const all = messages
.filter((m) => m.role === "assistant")
......@@ -177,14 +232,119 @@ export default function ChatView({ chatId }) {
if (all) {
try {
await downloadZip(state.token, all);
} catch { /* */ }
} catch {
/* */
}
}
}
const streaming = streamData.streaming;
return (
<div className="flex-1 flex flex-col min-h-0">
<div
className="flex-1 flex flex-col min-h-0"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* Header Bar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-anton-border bg-anton-surface/50 backdrop-blur-sm">
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold text-white truncate max-w-[200px]">
{state.chats.find((c) => c.id === chatId)?.title || "New Chat"}
</h2>
{isChatStreaming && (
<span className="flex items-center gap-1 text-xs text-anton-accent">
<Loader2 size={12} className="animate-spin" />
Streaming
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowSettings(!showSettings)}
className="text-xs text-anton-muted hover:text-white px-2 py-1 rounded hover:bg-anton-card transition"
>
⚙ Settings
</button>
<button
onClick={handleDownloadAll}
className="flex items-center gap-1 text-xs text-anton-muted hover:text-anton-accent px-2 py-1 rounded hover:bg-anton-accent/10 transition"
>
<Download size={12} />
Export
</button>
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<div className="px-4 py-3 border-b border-anton-border bg-anton-surface/80 backdrop-blur-sm animate-fade-in">
<div className="flex flex-wrap gap-4 items-end">
<label className="flex flex-col gap-1">
<span className="text-[11px] text-anton-muted uppercase tracking-wider">
Model
</span>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent"
>
<option value="us.anthropic.claude-sonnet-4-20250514">
Claude Sonnet 4
</option>
<option value="us.anthropic.claude-opus-4-20250514">
Claude Opus 4
</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-anton-muted uppercase tracking-wider">
Max Tokens
</span>
<input
type="number"
value={maxTokens}
onChange={(e) => setMaxTokens(Number(e.target.value))}
min={256}
max={128000}
className="bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white w-24 focus:outline-none focus:border-anton-accent"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-anton-muted uppercase tracking-wider">
Reasoning Budget
</span>
<input
type="number"
value={reasoningBudget}
onChange={(e) => setReasoningBudget(Number(e.target.value))}
min={1024}
max={64000}
className="bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white w-24 focus:outline-none focus:border-anton-accent"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-anton-muted uppercase tracking-wider">
Knowledge Base
</span>
<select
value={selectedKbId}
onChange={(e) => setSelectedKbId(e.target.value)}
className="bg-anton-card border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-anton-accent"
>
<option value="">None</option>
{(state.knowledgeBases || []).map((kb) => (
<option key={kb.id} value={kb.id}>
{kb.name}
</option>
))}
</select>
</label>
</div>
</div>
)}
{/* Messages */}
<div
ref={scrollContainerRef}
......@@ -192,7 +352,7 @@ export default function ChatView({ chatId }) {
className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
<MessageBubble key={m.id} message={m} chatId={chatId} />
))}
{/* Streaming overlay — in-progress response */}
......@@ -204,144 +364,83 @@ export default function ChatView({ chatId }) {
content: streamData.text,
thinking_content: streamData.thinking || null,
}}
chatId={chatId}
isStreaming
isThinking={streamData.isThinking}
/>
)}
{/* Waiting indicator */}
{streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-4 py-3 animate-fade-in">
<div className="flex gap-1">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
{/* Waiting indicator — streaming started but no content yet */}
{streaming && !streamData.thinking && !streamData.text && (
<div className="flex items-center gap-2 text-anton-muted text-sm animate-pulse-slow pl-2">
<Loader2 size={14} className="animate-spin text-anton-accent" />
<span>Son of Anton is thinking...</span>
</div>
)}
</div>
{/* Input area */}
<div className="border-t border-anton-border bg-anton-surface p-4">
{/* Settings panel */}
{showSettings && (
<div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5">
<Settings2 size={14} className="text-anton-accent" /> Generation Settings
</h3>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white">
<X size={14} />
</button>
</div>
{/* Model */}
<div>
<label className="text-xs text-anton-muted mb-1 block">Model</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
{MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
</div>
{/* Max Tokens */}
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-anton-muted">Max Output Tokens</span>
<span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span>
</div>
<input
type="range"
min={256}
max={65536}
step={256}
value={maxTokens}
onChange={(e) => setMaxTokens(Number(e.target.value))}
/>
<div className="flex justify-between text-[10px] text-anton-muted mt-0.5">
<span>256</span>
<span>64K</span>
</div>
</div>
{/* Reasoning Budget */}
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-anton-muted flex items-center gap-1">
<Brain size={12} className="text-purple-400" /> Reasoning Budget
</span>
<span className="text-purple-400 font-mono">
{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}
</span>
</div>
<input
type="range"
min={0}
max={32000}
step={500}
value={reasoningBudget}
onChange={(e) => setReasoningBudget(Number(e.target.value))}
{/* Scroll to bottom button */}
{!shouldAutoScrollRef.current && (
<div className="flex justify-center -mt-10 relative z-10">
<button
onClick={scrollToBottom}
className="bg-anton-card border border-anton-border rounded-full p-2 shadow-lg hover:border-anton-accent transition"
>
<ChevronDown size={16} className="text-anton-muted" />
</button>
</div>
)}
{/* Pending file previews */}
{pendingFiles.length > 0 && (
<div className="px-4 py-2 border-t border-anton-border bg-anton-surface/50">
<div className="flex flex-wrap gap-2">
{pendingFiles.map((file, idx) => (
<AttachmentPreview
key={`${file.name}-${idx}`}
file={file}
onRemove={() => handleRemovePendingFile(idx)}
isPending
/>
<div className="flex justify-between text-[10px] text-anton-muted mt-0.5">
<span>Off</span>
<span>32K tokens</span>
</div>
</div>
{/* Knowledge Base */}
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1">
<BookOpen size={12} /> Knowledge Base (RAG)
</label>
<select
value={selectedKbId || ""}
onChange={(e) => setSelectedKbId(e.target.value || null)}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<option value="">None</option>
{kbs.map((kb) => (
<option key={kb.id} value={kb.id}>
{kb.name} ({kb.document_count} docs)
</option>
))}
</select>
</div>
))}
</div>
</div>
)}
{/* Input Area */}
<div className="border-t border-anton-border bg-anton-surface/80 backdrop-blur-sm p-4">
{uploadingFiles && (
<div className="flex items-center gap-2 text-anton-accent text-xs mb-2 animate-pulse">
<Loader2 size={12} className="animate-spin" />
Uploading files...
</div>
)}
<div className="flex items-end gap-2">
<button
onClick={toggleSettings}
className={`p-2.5 rounded-xl transition shrink-0 ${
showSettings
? "bg-anton-accent/20 text-anton-accent"
: "text-anton-muted hover:text-white hover:bg-anton-card"
}`}
>
<Settings2 size={18} />
</button>
<FileUploadButton onFilesSelected={handleFilesSelected} />
<div className="flex-1 relative">
<textarea
ref={inputRef}
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask Son of Anton anything… (Shift+Enter for new line)"
onPaste={handlePaste}
placeholder={
pendingFiles.length > 0
? "Add a message about the files, or just send..."
: "Message Son of Anton..."
}
rows={1}
style={{ maxHeight: "200px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 pr-12 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 pr-12 text-sm text-white placeholder-anton-muted resize-none focus:outline-none focus:border-anton-accent transition max-h-40 overflow-y-auto"
style={{
minHeight: "44px",
height: "auto",
}}
onInput={(e) => {
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px";
e.target.style.height =
Math.min(e.target.scrollHeight, 160) + "px";
}}
/>
</div>
......@@ -349,50 +448,62 @@ export default function ChatView({ chatId }) {
{streaming ? (
<button
onClick={handleStop}
className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"
className="flex items-center justify-center w-10 h-10 rounded-xl bg-anton-danger/20 border border-anton-danger/40 text-anton-danger hover:bg-anton-danger/30 transition"
title="Stop generation"
>
<Square size={18} />
<Square size={16} />
</button>
) : (
<button
onClick={handleSend}
disabled={!input.trim() || isStreamingGlobal}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={
(!input.trim() && pendingFiles.length === 0) ||
isChatStreaming ||
uploadingFiles
}
className="flex items-center justify-center w-10 h-10 rounded-xl bg-anton-accent text-white hover:bg-anton-accentDim transition disabled:opacity-30 disabled:cursor-not-allowed"
title="Send message"
>
<Send size={18} />
<Send size={16} />
</button>
)}
</div>
{/* Quick info bar */}
<div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted">
<span>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span>
<span>{maxTokens.toLocaleString()} max tokens</span>
{reasoningBudget > 0 && (
<>
<span></span>
<span className="text-purple-400">
🧠 {reasoningBudget.toLocaleString()} reasoning
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-anton-muted">
{pendingFiles.length > 0 && (
<span className="text-anton-accent">
{pendingFiles.length} file{pendingFiles.length > 1 ? "s" : ""}{" "}
attached •{" "}
</span>
</>
)}
{selectedKbId && (
<>
<span></span>
<span className="text-green-400">📚 RAG active</span>
</>
)}
{messages.some((m) => m.role === "assistant") && (
<button
onClick={handleDownloadAll}
className="ml-auto hover:text-anton-accent transition"
>
⬇ Download all code
</button>
)}
)}
Shift+Enter for new line • Paste or drag files to attach
</span>
<span className="text-[10px] text-anton-muted">
{Object.keys(state.activeStreams).length > 0 && (
<span className="text-anton-accent">
{Object.keys(state.activeStreams).length} active stream
{Object.keys(state.activeStreams).length > 1 ? "s" : ""}
</span>
)}
</span>
</div>
</div>
</div>
);
}
\ No newline at end of file
}
/** Classify a File object into a media type string */
function classifyFile(file) {
if (file.type.startsWith("image/")) return "image";
if (file.type.startsWith("video/")) return "video";
if (
file.type === "application/pdf" ||
file.type.includes("word") ||
file.type.includes("excel") ||
file.type.includes("spreadsheet")
)
return "document";
if (file.type.startsWith("text/")) return "text";
return "unknown";
}
\ No newline at end of file
import React, { useRef } from "react";
import { Paperclip } from "lucide-react";
const ACCEPT = [
// Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
// Videos
"video/mp4",
"video/webm",
"video/quicktime",
"video/mpeg",
"video/x-matroska",
// Documents
"application/pdf",
"text/csv",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/html",
"text/plain",
"text/markdown",
// Also accept by extension for browsers that are bad at MIME
".jpg",
".jpeg",
".png",
".gif",
".webp",
".mp4",
".webm",
".mov",
".mkv",
".pdf",
".csv",
".doc",
".docx",
".xls",
".xlsx",
".html",
".txt",
".md",
].join(",");
export default function FileUploadButton({ onFilesSelected }) {
const inputRef = useRef(null);
function handleClick() {
inputRef.current?.click();
}
function handleChange(e) {
const files = e.target.files;
if (files && files.length > 0) {
onFilesSelected(files);
}
// Reset so the same file can be re-selected
e.target.value = "";
}
return (
<>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPT}
onChange={handleChange}
className="hidden"
/>
<button
onClick={handleClick}
className="flex items-center justify-center w-10 h-10 rounded-xl border border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent/40 hover:bg-anton-accent/10 transition"
title="Attach files (images, videos, documents)"
>
<Paperclip size={16} />
</button>
</>
);
}
\ No newline at end of file
......@@ -2,137 +2,227 @@ import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock";
import { User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check } from "lucide-react";
import AttachmentPreview from "./AttachmentPreview";
import {
User,
Flame,
Copy,
Check,
ChevronDown,
ChevronRight,
} from "lucide-react";
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking }) {
const { role, content, thinking_content, input_tokens, output_tokens } = message;
const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false);
export default function MessageBubble({
message,
chatId,
isStreaming = false,
isThinking = false,
}) {
const isUser = message.role === "user";
const [copied, setCopied] = useState(false);
const [showThinking, setShowThinking] = useState(false);
function handleCopy() {
navigator.clipboard.writeText(content || "");
navigator.clipboard.writeText(message.content || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const attachments = message.attachments || [];
return (
<div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{/* Avatar */}
<div
className={`flex gap-3 animate-fade-in ${
isUser ? "justify-end" : "justify-start"
}`}
>
{/* Avatar — assistant only */}
{!isUser && (
<div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
<Flame size={16} className="text-white" />
</div>
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent/20 to-transparent border border-anton-accent/20 flex items-center justify-center mt-1">
<Flame size={14} className="text-anton-accent" />
</div>
)}
<div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
{/* Thinking block */}
{thinking_content && (
<div
className={`
max-w-[80%] rounded-2xl px-4 py-3 relative group
${
isUser
? "bg-anton-user border border-anton-border/50 text-white"
: "bg-anton-assistant border border-anton-border/30 text-anton-text"
}
${isStreaming ? "border-anton-accent/30" : ""}
`}
>
{/* User attachments */}
{isUser && attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{attachments.map((att, i) => (
<AttachmentPreview
key={att.id || i}
attachment={att}
/>
))}
</div>
)}
{/* Thinking block (collapsible) */}
{message.thinking_content && (
<div className="mb-2">
<button onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"
<button
onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-anton-accent transition"
>
<Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? (
<span className="thinking-pulse">Reasoning…</span>
{showThinking ? (
<ChevronDown size={12} />
) : (
<span>View reasoning</span>
<ChevronRight size={12} />
)}
{isThinking ? "Thinking..." : "Thought process"}
</button>
{(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">
{thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
{showThinking && (
<div className="mt-1 pl-3 border-l-2 border-anton-accent/20 text-xs text-anton-muted leading-relaxed whitespace-pre-wrap">
{message.thinking_content}
</div>
)}
</div>
)}
{/* Message content */}
<div className={`rounded-2xl px-4 py-3 ${
isUser
? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
{isUser ? (
<div className="text-sm whitespace-pre-wrap">{content}</div>
) : (
<div className="prose-anton text-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || "");
const rawLang = match?.[1] || "";
{/* Thinking indicator during streaming */}
{isStreaming && isThinking && !message.thinking_content && (
<div className="flex items-center gap-2 text-xs text-anton-accent mb-2">
<div className="flex gap-1">
<span
className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</div>
Thinking...
</div>
)}
if (inline) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
{/* Main content */}
{message.content && (
<div className="prose prose-invert prose-sm max-w-none break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || "");
const lang = match ? match[1] : "";
// Parse "lang:filename" format
let lang = rawLang;
let filename = null;
if (rawLang.includes(":")) {
const idx = rawLang.indexOf(":");
lang = rawLang.slice(0, idx);
filename = rawLang.slice(idx + 1);
if (!inline && (match || String(children).includes("\n"))) {
// Extract filename from lang if format is "lang:path/to/file.ext"
let language = lang;
let filename = "";
if (lang && lang.includes(":")) {
const parts = lang.split(":");
language = parts[0];
filename = parts.slice(1).join(":");
}
const code = String(children).replace(/\n$/, "");
return (
<CodeBlock language={lang} filename={filename} code={code} />
<CodeBlock
code={String(children).replace(/\n$/, "")}
language={language}
filename={filename}
/>
);
},
// Make sure pre doesn't double-wrap
pre({ children }) {
return <>{children}</>;
},
}}
>
{content || ""}
</ReactMarkdown>
{isStreaming && !isThinking && (
<span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />
)}
</div>
)}
</div>
}
{/* Footer */}
{!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1.5 px-1">
<button onClick={handleCopy}
className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition"
return (
<code
className="bg-anton-card px-1.5 py-0.5 rounded text-anton-accent text-[13px] font-mono"
{...props}
>
{children}
</code>
);
},
// Style links
a({ children, ...props }) {
return (
<a
className="text-anton-accent hover:underline"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
// Style tables
table({ children }) {
return (
<div className="overflow-x-auto my-2">
<table className="border-collapse border border-anton-border text-xs">
{children}
</table>
</div>
);
},
th({ children }) {
return (
<th className="border border-anton-border bg-anton-card px-3 py-1.5 text-left text-anton-text font-semibold">
{children}
</th>
);
},
td({ children }) {
return (
<td className="border border-anton-border px-3 py-1.5">
{children}
</td>
);
},
}}
>
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
{message.content}
</ReactMarkdown>
</div>
)}
{/* Streaming cursor */}
{isStreaming && !isThinking && (
<span className="inline-block w-2 h-4 bg-anton-accent/70 animate-pulse ml-0.5 align-middle" />
)}
{/* Copy button — assistant messages only */}
{!isUser && message.content && !isStreaming && (
<div className="absolute -bottom-3 right-2 opacity-0 group-hover:opacity-100 transition">
<button
onClick={handleCopy}
className="flex items-center gap-1 text-[10px] text-anton-muted hover:text-white bg-anton-card border border-anton-border rounded-md px-2 py-0.5 shadow-lg"
>
{copied ? (
<>
<Check size={10} className="text-anton-success" /> Copied
</>
) : (
<>
<Copy size={10} /> Copy
</>
)}
</button>
{(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[11px] text-anton-muted">
{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}↑ tokens
</span>
)}
</div>
)}
</div>
{/* User avatar */}
{/* Avatar — user only */}
{isUser && (
<div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={16} className="text-anton-muted" />
</div>
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center mt-1">
<User size={14} className="text-anton-muted" />
</div>
)}
</div>
);
});
export default MessageBubble;
\ No newline at end of file
}
\ No newline at end of file
......@@ -48,6 +48,12 @@ function EmptyState() {
Avatar of All Elements of Code. Create a new chat to begin — but bring
real questions, not that first-result-of-Google garbage.
</p>
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-anton-muted">
<span>📎 Supports images, videos, PDFs, and documents</span>
</div>
<div className="mt-1 flex items-center justify-center gap-2 text-xs text-anton-muted">
<span>⚡ Multiple chats can stream in parallel</span>
</div>
</div>
</div>
);
......
/**
* Global state via React Context + useReducer.
*
* Holds chat messages and streaming flags so they persist
* across chat switches (background streams keep running).
*/
import React, { createContext, useContext, useReducer, useEffect } from "react";
import { setDispatch } from "./streamManager";
const AppContext = createContext();
const initialState = {
token: localStorage.getItem("soa_token") || null,
user: JSON.parse(localStorage.getItem("soa_user") || "null"),
token: localStorage.getItem("token") || null,
user: null,
chats: [],
activeChatId: null,
sidebarOpen: true,
chatMessages: {}, // { [chatId]: Message[] }
activeStreams: {}, // { [chatId]: true } — which chats are currently streaming
chatMessages: {}, // chatId -> [messages]
activeStreams: {}, // chatId -> true (which chats are currently streaming)
};
function reducer(state, action) {
switch (action.type) {
case "LOGIN":
localStorage.setItem("soa_token", action.token);
localStorage.setItem("soa_user", JSON.stringify(action.user));
return { ...state, token: action.token, user: action.user };
case "SET_TOKEN":
if (action.token) localStorage.setItem("token", action.token);
else localStorage.removeItem("token");
return { ...state, token: action.token };
case "SET_USER":
return { ...state, user: action.user };
case "LOGOUT":
localStorage.removeItem("soa_token");
localStorage.removeItem("soa_user");
return {
...initialState,
token: null,
user: null,
chatMessages: {},
activeStreams: {},
};
localStorage.removeItem("token");
return { ...initialState, token: null };
case "SET_CHATS":
return { ...state, chats: action.chats };
......@@ -55,7 +44,7 @@ function reducer(state, action) {
return { ...state, chats: updated };
}
case "REMOVE_CHAT": {
case "DELETE_CHAT": {
const filtered = state.chats.filter((c) => c.id !== action.chatId);
const newMessages = { ...state.chatMessages };
delete newMessages[action.chatId];
......@@ -102,6 +91,7 @@ function reducer(state, action) {
};
// ── Background streaming flags ───────────────
// NOW PER-CHAT — no longer blocks other chats
case "SET_STREAMING": {
if (action.streaming) {
return {
......
......@@ -39,6 +39,11 @@ export function isStreaming(chatId) {
return _streams.has(chatId);
}
/** Is ANY chat currently streaming? (for UI indicators, NOT for blocking) */
export function isAnyStreaming() {
return _streams.size > 0;
}
/** Subscribe to stream data changes for a specific chat. Returns unsubscribe fn. */
export function subscribe(chatId, callback) {
if (!_listeners.has(chatId)) _listeners.set(chatId, new Set());
......@@ -70,103 +75,70 @@ export function abortStream(chatId) {
/**
* Start a background stream for a chat.
* Does nothing if that chat is already streaming.
*
* @param {object} opts
* @param {string} opts.token - JWT
* @param {string} opts.chatId - chat UUID
* @param {object} opts.body - SendMessageBody
* If a stream already exists for this chat, it is aborted first.
*/
export function startStream({ token, chatId, body }) {
if (_streams.has(chatId)) return;
// Abort existing stream for THIS chat only (other chats keep streaming)
if (_streams.has(chatId)) {
abortStream(chatId);
}
const ac = new AbortController();
_streams.set(chatId, {
const abortController = new AbortController();
const streamState = {
text: "",
thinking: "",
isThinking: false,
abortController: ac,
});
abortController,
};
_streams.set(chatId, streamState);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
_notify(chatId);
// Fire-and-forget async IIFE — runs entirely in the background
// Fire and forget — runs independently of React
(async () => {
const s = _streams.get(chatId);
if (!s) return;
let usage = {};
let msgId = "";
try {
for await (const evt of streamMessage(token, chatId, body, ac.signal)) {
if (ac.signal.aborted) break;
if (!_streams.has(chatId)) break;
switch (evt.type) {
case "thinking_start":
s.isThinking = true;
_notify(chatId);
break;
case "thinking_delta":
s.thinking += evt.content;
_notify(chatId);
break;
case "thinking_end":
s.isThinking = false;
_notify(chatId);
break;
case "text_delta":
s.text += evt.content;
_notify(chatId);
break;
case "usage":
usage = {
input_tokens: evt.input_tokens,
output_tokens: evt.output_tokens,
};
break;
case "title_update":
if (_dispatch)
_dispatch({
type: "UPDATE_CHAT",
chat: { id: chatId, title: evt.title },
});
break;
case "done":
msgId = evt.message_id;
break;
case "error":
s.text += `\n\n**Error:** ${evt.message}`;
_notify(chatId);
break;
const gen = streamMessage(token, chatId, body, abortController.signal);
for await (const event of gen) {
const s = _streams.get(chatId);
if (!s) break; // stream was aborted
if (event.type === "thinking") {
s.thinking += event.content || "";
s.isThinking = true;
} else if (event.type === "text") {
s.text += event.content || "";
s.isThinking = false;
} else if (event.type === "done") {
// Final message — add to store
if (_dispatch && event.message) {
_dispatch({
type: "ADD_MESSAGE",
chatId,
message: event.message,
});
}
// Auto-title
if (_dispatch && event.title) {
_dispatch({
type: "UPDATE_CHAT",
chat: { id: chatId, title: event.title },
});
}
} else if (event.type === "error") {
s.text += `\n\n**Error:** ${event.content || "Unknown error"}`;
}
}
// Stream finished normally — persist the final assistant message
if (!ac.signal.aborted && _dispatch) {
const assistantMsg = {
id: msgId || `gen-${Date.now()}`,
role: "assistant",
content: s.text,
thinking_content: s.thinking || null,
input_tokens: usage.input_tokens || 0,
output_tokens: usage.output_tokens || 0,
created_at: new Date().toISOString(),
};
_dispatch({ type: "ADD_MESSAGE", chatId, message: assistantMsg });
_notify(chatId);
}
} catch (err) {
// Only surface errors that aren't deliberate aborts
if (!ac.signal.aborted && _dispatch) {
const errMsg = {
id: `err-${Date.now()}`,
role: "assistant",
content: `**Error:** ${err.message}`,
created_at: new Date().toISOString(),
};
_dispatch({ type: "ADD_MESSAGE", chatId, message: errMsg });
if (err.name !== "AbortError") {
const s = _streams.get(chatId);
if (s) {
s.text += `\n\n**Stream error:** ${err.message}`;
_notify(chatId);
}
}
} finally {
_streams.delete(chatId);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment