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
This diff is collapsed.
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
This diff is collapsed.
......@@ -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