feat: added attachment support

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-12-21 22:26:33 -08:00
parent bb971460fc
commit c2dcb2045d
62 changed files with 1166 additions and 9012 deletions

View file

@ -1,154 +0,0 @@
import { z } from "zod";
import {
type CreateChatRequest,
chatDetails,
chatSummary,
createChatRequest,
type DeleteChatRequest,
deleteChatRequest,
deleteChatResponse,
type GetChatDetailsRequest,
type GetChatsRequest,
getChatDetailsRequest,
getChatsRequest,
type SearchChatsRequest,
searchChatsRequest,
type UpdateChatRequest,
updateChatRequest,
} from "@/contracts/types/chat.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class ChatApiService {
getChatDetails = async (request: GetChatDetailsRequest) => {
// Validate the request
const parsedRequest = getChatDetailsRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(`/api/v1/chats/${request.id}`, chatDetails);
};
getChats = async (request: GetChatsRequest) => {
// Validate the request
const parsedRequest = getChatsRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
// Transform queries params to be string values
const transformedQueryParams = parsedRequest.data.queryParams
? Object.fromEntries(
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
)
: undefined;
const queryParams = transformedQueryParams
? new URLSearchParams(transformedQueryParams).toString()
: undefined;
return baseApiService.get(`/api/v1/chats?${queryParams}`, z.array(chatSummary));
};
searchChats = async (request: SearchChatsRequest) => {
// Validate the request
const parsedRequest = searchChatsRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
// Transform queries params to be string values
const transformedQueryParams = Object.fromEntries(
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
);
const queryParams = new URLSearchParams(transformedQueryParams).toString();
return baseApiService.get(`/api/v1/chats/search?${queryParams}`, z.array(chatSummary));
};
deleteChat = async (request: DeleteChatRequest) => {
// Validate the request
const parsedRequest = deleteChatRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.delete(`/api/v1/chats/${request.id}`, deleteChatResponse);
};
createChat = async (request: CreateChatRequest) => {
// Validate the request
const parsedRequest = createChatRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(
`/api/v1/chats`,
chatSummary,
{
body: parsedRequest.data,
}
);
};
updateChat = async (request: UpdateChatRequest) => {
// Validate the request
const parsedRequest = updateChatRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { type, title, initial_connectors, messages, search_space_id, id } = parsedRequest.data;
return baseApiService.put(
`/api/v1/chats/${id}`,
chatSummary,
{
body: {
type,
title,
initial_connectors,
messages,
search_space_id,
},
}
);
};
}
export const chatsApiService = new ChatApiService();

View file

@ -1,128 +0,0 @@
import z from "zod";
import {
type DeletePodcastRequest,
deletePodcastRequest,
deletePodcastResponse,
type GeneratePodcastRequest,
type GetPodcastByChatIdRequest,
type GetPodcastsRequest,
generatePodcastRequest,
getPodcastByChaIdResponse,
getPodcastByChatIdRequest,
getPodcastsRequest,
type LoadPodcastRequest,
loadPodcastRequest,
podcast,
} from "@/contracts/types/podcast.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class PodcastsApiService {
getPodcasts = async (request: GetPodcastsRequest) => {
// Validate the request
const parsedRequest = getPodcastsRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
// Transform queries params to be string values
const transformedQueryParams = parsedRequest.data.queryParams
? Object.fromEntries(
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)])
)
: undefined;
const queryParams = transformedQueryParams
? new URLSearchParams(transformedQueryParams).toString()
: undefined;
return baseApiService.get(`/api/v1/podcasts?${queryParams}`, z.array(podcast));
};
getPodcastByChatId = async (request: GetPodcastByChatIdRequest) => {
// Validate the request
const parsedRequest = getPodcastByChatIdRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/podcasts/by-chat/${request.chat_id}`,
getPodcastByChaIdResponse
);
};
/**
* Get a podcast by its ID (includes full transcript)
*/
getPodcastById = async (podcastId: number) => {
return baseApiService.get(`/api/v1/podcasts/${podcastId}`, podcast);
};
generatePodcast = async (request: GeneratePodcastRequest) => {
// Validate the request
const parsedRequest = generatePodcastRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(`/api/v1/podcasts/generate`, undefined, {
body: parsedRequest.data,
});
};
loadPodcast = async ({
request,
controller,
}: {
request: LoadPodcastRequest;
controller?: AbortController;
}) => {
// Validate the request
const parsedRequest = loadPodcastRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return await baseApiService.getBlob(`/api/v1/podcasts/${request.id}/stream`, {
signal: controller?.signal,
});
};
deletePodcast = async (request: DeletePodcastRequest) => {
// Validate the request
const parsedRequest = deletePodcastRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.delete(`/api/v1/podcasts/${request.id}`, deletePodcastResponse);
};
}
export const podcastsApiService = new PodcastsApiService();

View file

@ -0,0 +1,304 @@
/**
* Attachment adapter for assistant-ui
*
* This adapter handles file uploads by:
* 1. Uploading the file to the backend /attachments/process endpoint
* 2. The backend extracts markdown content using the configured ETL service
* 3. The extracted content is stored in the attachment and sent with messages
*/
import type { AttachmentAdapter, CompleteAttachment, PendingAttachment } from "@assistant-ui/react";
import { getBearerToken } from "@/lib/auth-utils";
/**
* Supported file types for the attachment adapter
*
* - Text/Markdown: .md, .markdown, .txt
* - Audio (if STT configured): .mp3, .mp4, .mpeg, .mpga, .m4a, .wav, .webm
* - Documents (depends on ETL service): .pdf, .docx, .doc, .pptx, .xlsx, .html
* - Images: .jpg, .jpeg, .png, .gif, .webp
*/
const ACCEPTED_FILE_TYPES = [
// Text/Markdown (always supported)
".md",
".markdown",
".txt",
// Audio files
".mp3",
".mp4",
".mpeg",
".mpga",
".m4a",
".wav",
".webm",
// Document files (depends on ETL service)
".pdf",
".docx",
".doc",
".pptx",
".xlsx",
".html",
// Image files
".jpg",
".jpeg",
".png",
".gif",
".webp",
].join(",");
/**
* Response from the attachment processing endpoint
*/
interface ProcessAttachmentResponse {
id: string;
name: string;
type: "document" | "image" | "file";
content: string;
contentLength: number;
}
/**
* Extended CompleteAttachment with our custom extractedContent field
* We store the extracted text in a custom field so we can access it in onNew
*/
export interface ChatAttachment extends CompleteAttachment {
extractedContent: string;
}
/**
* Process a file through the backend ETL service
*/
async function processAttachment(file: File): Promise<ProcessAttachmentResponse> {
const token = getBearerToken();
if (!token) {
throw new Error("Not authenticated");
}
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${backendUrl}/api/v1/attachments/process`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
console.error("[processAttachment] Error response:", errorText);
let errorDetail = "Unknown error";
try {
const errorJson = JSON.parse(errorText);
// FastAPI validation errors return detail as array
if (Array.isArray(errorJson.detail)) {
errorDetail = errorJson.detail
.map((err: { msg?: string; loc?: string[] }) => {
const field = err.loc?.join(".") || "unknown";
return `${field}: ${err.msg || "validation error"}`;
})
.join("; ");
} else if (typeof errorJson.detail === "string") {
errorDetail = errorJson.detail;
} else {
errorDetail = JSON.stringify(errorJson);
}
} catch {
errorDetail = errorText || `HTTP ${response.status}`;
}
throw new Error(errorDetail);
}
return response.json();
}
// Store processed results for the send() method
const processedAttachments = new Map<string, ProcessAttachmentResponse>();
/**
* Create the attachment adapter for assistant-ui
*
* This adapter:
* 1. Accepts file upload
* 2. Processes the file through the backend ETL service
* 3. Returns the attachment with extracted markdown content
*
* The content is stored in the attachment and will be sent with the message.
*/
export function createAttachmentAdapter(): AttachmentAdapter {
return {
accept: ACCEPTED_FILE_TYPES,
/**
* Async generator that yields pending states while processing
* and returns a pending attachment when done.
*
* IMPORTANT: The generator should return status: { type: "running", progress: 100 }
* NOT status: { type: "complete" }. The "complete" status is set by send().
* Returning "complete" from the generator will prevent send() from being called!
*
* This pattern allows the UI to show a loading indicator
* while the file is being processed by the backend.
* The send() method is called to finalize the attachment.
*/
async *add(input: File | { file: File }): AsyncGenerator<PendingAttachment, void> {
// Handle both direct File and { file: File } patterns
const file = input instanceof File ? input : input.file;
if (!file) {
console.error("[AttachmentAdapter] No file found in input:", input);
throw new Error("No file provided");
}
console.log("[AttachmentAdapter] Processing file:", file.name);
// Generate a unique ID for this attachment
const id = crypto.randomUUID();
// Determine attachment type from file
const attachmentType = file.type.startsWith("image/") ? "image" : "document";
// Yield initial pending state with "running" status (0% progress)
// This triggers the loading indicator in the UI
yield {
id,
type: attachmentType,
name: file.name,
file,
status: { type: "running", reason: "uploading", progress: 0 },
} as PendingAttachment;
try {
// Process the file through the backend ETL service
const result = await processAttachment(file);
console.log(
"[AttachmentAdapter] File processed:",
result.name,
"content length:",
result.contentLength
);
// Verify we have the required fields
if (!result.content) {
console.error("[AttachmentAdapter] WARNING: No content received from backend!");
}
// Store the processed result for send()
processedAttachments.set(id, result);
// Create the final pending attachment
// IMPORTANT: Use "running" status with progress: 100 to indicate processing is done
// but attachment is still pending. The "complete" status will be set by send().
// Yield the final state to ensure it gets processed by the UI
yield {
id,
type: result.type,
name: result.name,
file,
status: { type: "running", reason: "uploading", progress: 100 },
} as PendingAttachment;
} catch (error) {
console.error("[AttachmentAdapter] Failed to process attachment:", error);
throw error;
}
},
/**
* Called when user sends the message.
* Converts the pending attachment to a complete attachment.
*/
async send(pendingAttachment: PendingAttachment): Promise<ChatAttachment> {
const result = processedAttachments.get(pendingAttachment.id);
if (result) {
// Clean up stored result
processedAttachments.delete(pendingAttachment.id);
return {
id: result.id,
type: result.type,
name: result.name,
contentType: "text/markdown",
status: { type: "complete" },
content: [
{
type: "text",
text: result.content,
},
],
extractedContent: result.content,
};
}
// Fallback if no processed result found
console.warn(
"[AttachmentAdapter] send() - No processed result found for attachment:",
pendingAttachment.id
);
return {
id: pendingAttachment.id,
type: pendingAttachment.type,
name: pendingAttachment.name,
contentType: "text/plain",
status: { type: "complete" },
content: [],
extractedContent: "",
};
},
async remove() {
// No server-side cleanup needed since we don't persist attachments
},
};
}
/**
* Extract attachment content for chat request
*
* This function extracts the content from attachments to be sent with the chat request.
* Only attachments that have been fully processed (have content) will be included.
*/
export function extractAttachmentContent(
attachments: Array<unknown>
): Array<{ id: string; name: string; type: string; content: string }> {
return attachments
.filter((att): att is ChatAttachment => {
if (!att || typeof att !== "object") return false;
const a = att as Record<string, unknown>;
// Check for our custom extractedContent field first
if (typeof a.extractedContent === "string" && a.extractedContent.length > 0) {
return true;
}
// Fallback: check if content array has text content
if (Array.isArray(a.content)) {
const textContent = (a.content as Array<{ type: string; text?: string }>).find(
(c) => c.type === "text" && typeof c.text === "string" && c.text.length > 0
);
return Boolean(textContent);
}
return false;
})
.map((att) => {
// Get content from extractedContent or from content array
let content = "";
if (typeof att.extractedContent === "string") {
content = att.extractedContent;
} else if (Array.isArray(att.content)) {
const textContent = (att.content as Array<{ type: string; text?: string }>).find(
(c) => c.type === "text"
);
content = textContent?.text || "";
}
return {
id: att.id,
name: att.name,
type: att.type,
content,
};
});
}

View file

@ -1,27 +1,14 @@
import type { GetChatsRequest } from "@/contracts/types/chat.types";
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
import type { GetMembersRequest } from "@/contracts/types/members.types";
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
import type { GetRolesRequest } from "@/contracts/types/roles.types";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
export const cacheKeys = {
chats: {
activeChat: (chatId: string) => ["active-chat", chatId] as const,
globalQueryParams: (queries: GetChatsRequest["queryParams"]) =>
[
"chats",
queries?.search_space_id,
queries?.limit,
queries?.skip,
queries?.page,
queries?.page_size,
] as const,
},
podcasts: {
globalQueryParams: (queries: GetPodcastsRequest["queryParams"]) =>
["podcasts", ...(queries ? Object.values(queries) : [])] as const,
// New chat threads (assistant-ui)
threads: {
list: (searchSpaceId: number) => ["threads", searchSpaceId] as const,
detail: (threadId: number) => ["threads", "detail", threadId] as const,
search: (searchSpaceId: number, query: string) =>
["threads", "search", searchSpaceId, query] as const,
},
documents: {
globalQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>