Merge upstream/dev and accept upstream deletions

This commit is contained in:
CREDO23 2025-12-26 14:50:52 +02:00
commit f05a313d73
260 changed files with 50971 additions and 36069 deletions

View file

@ -18,7 +18,7 @@ class AuthApiService {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -44,7 +44,7 @@ class AuthApiService {
console.error("Invalid request:", parsedRequest.error);
// Format a user frendly error message
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}

View file

@ -1,4 +1,4 @@
import type z from "zod";
import type { ZodType } from "zod";
import { getBearerToken, handleUnauthorized } from "../auth-utils";
import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error";
@ -37,7 +37,7 @@ class BaseApiService {
async request<T, R extends ResponseType = ResponseType.JSON>(
url: string,
responseSchema?: z.ZodSchema<T>,
responseSchema?: ZodType<T>,
options?: RequestOptions & { responseType?: R }
): Promise<
R extends ResponseType.JSON
@ -206,7 +206,7 @@ class BaseApiService {
async get<T>(
url: string,
responseSchema?: z.ZodSchema<T>,
responseSchema?: ZodType<T>,
options?: Omit<RequestOptions, "method" | "responseType">
) {
return this.request(url, responseSchema, {
@ -221,7 +221,7 @@ class BaseApiService {
async post<T>(
url: string,
responseSchema?: z.ZodSchema<T>,
responseSchema?: ZodType<T>,
options?: Omit<RequestOptions, "method" | "responseType">
) {
return this.request(url, responseSchema, {
@ -236,7 +236,7 @@ class BaseApiService {
async put<T>(
url: string,
responseSchema?: z.ZodSchema<T>,
responseSchema?: ZodType<T>,
options?: Omit<RequestOptions, "method" | "responseType">
) {
return this.request(url, responseSchema, {
@ -251,7 +251,7 @@ class BaseApiService {
async delete<T>(
url: string,
responseSchema?: z.ZodSchema<T>,
responseSchema?: ZodType<T>,
options?: Omit<RequestOptions, "method" | "responseType">
) {
return this.request(url, responseSchema, {
@ -274,7 +274,7 @@ class BaseApiService {
async postFormData<T>(
url: string,
responseSchema?: z.ZodSchema<T>,
responseSchema?: ZodType<T>,
options?: Omit<RequestOptions, "method" | "responseType" | "body"> & { body: FormData }
) {
// Remove Content-Type from options headers if present

View file

@ -1,130 +0,0 @@
import { z } from "zod";
import {
type CreateChatRequest,
chatDetails,
chatSummary,
createChatRequest,
type DeleteChatRequest,
deleteChatRequest,
deleteChatResponse,
type GetChatDetailsRequest,
type GetChatsRequest,
getChatDetailsRequest,
getChatsRequest,
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.errors.map((err) => err.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.errors.map((err) => err.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));
};
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.errors.map((err) => err.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.errors.map((err) => err.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.errors.map((err) => err.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

@ -40,7 +40,7 @@ class DocumentsApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -73,7 +73,7 @@ class DocumentsApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -89,7 +89,7 @@ class DocumentsApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -107,7 +107,7 @@ class DocumentsApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -132,7 +132,7 @@ class DocumentsApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -167,7 +167,7 @@ class DocumentsApiService {
console.error("Invalid request:", parsedRequest.error);
// Format a user friendly error message
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -199,7 +199,7 @@ class DocumentsApiService {
console.error("Invalid request:", parsedRequest.error);
// Format a user friendly error message
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -220,7 +220,7 @@ class DocumentsApiService {
console.error("Invalid request:", parsedRequest.error);
// Format a user friendly error message
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -242,7 +242,7 @@ class DocumentsApiService {
console.error("Invalid request:", parsedRequest.error);
// Format a user friendly error message
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}

View file

@ -0,0 +1,151 @@
import {
type AcceptInviteRequest,
type AcceptInviteResponse,
acceptInviteRequest,
acceptInviteResponse,
type CreateInviteRequest,
type CreateInviteResponse,
createInviteRequest,
createInviteResponse,
type DeleteInviteRequest,
type DeleteInviteResponse,
deleteInviteRequest,
deleteInviteResponse,
type GetInviteInfoRequest,
type GetInviteInfoResponse,
type GetInvitesRequest,
type GetInvitesResponse,
getInviteInfoRequest,
getInviteInfoResponse,
getInvitesRequest,
getInvitesResponse,
type UpdateInviteRequest,
type UpdateInviteResponse,
updateInviteRequest,
updateInviteResponse,
} from "@/contracts/types/invites.types";
import { ValidationError } from "@/lib/error";
import { baseApiService } from "./base-api.service";
class InvitesApiService {
/**
* Create a new invite
*/
createInvite = async (request: CreateInviteRequest) => {
const parsedRequest = createInviteRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites`,
createInviteResponse,
{
body: parsedRequest.data.data,
}
);
};
/**
* Get all invites for a search space
*/
getInvites = async (request: GetInvitesRequest) => {
const parsedRequest = getInvitesRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites`,
getInvitesResponse
);
};
/**
* Update an invite
*/
updateInvite = async (request: UpdateInviteRequest) => {
const parsedRequest = updateInviteRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.put(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites/${parsedRequest.data.invite_id}`,
updateInviteResponse,
{
body: parsedRequest.data.data,
}
);
};
/**
* Delete an invite
*/
deleteInvite = async (request: DeleteInviteRequest) => {
const parsedRequest = deleteInviteRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.delete(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/invites/${parsedRequest.data.invite_id}`,
deleteInviteResponse
);
};
/**
* Get invite info by invite code
*/
getInviteInfo = async (request: GetInviteInfoRequest) => {
const parsedRequest = getInviteInfoRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/invites/${parsedRequest.data.invite_code}/info`,
getInviteInfoResponse
);
};
/**
* Accept an invite
*/
acceptInvite = async (request: AcceptInviteRequest) => {
const parsedRequest = acceptInviteRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(`/api/v1/invites/accept`, acceptInviteResponse, {
body: parsedRequest.data,
});
};
}
export const invitesApiService = new InvitesApiService();

View file

@ -1,179 +0,0 @@
import {
type CreateLLMConfigRequest,
createLLMConfigRequest,
createLLMConfigResponse,
type DeleteLLMConfigRequest,
deleteLLMConfigRequest,
deleteLLMConfigResponse,
type GetLLMConfigRequest,
type GetLLMConfigsRequest,
type GetLLMPreferencesRequest,
getGlobalLLMConfigsResponse,
getLLMConfigRequest,
getLLMConfigResponse,
getLLMConfigsRequest,
getLLMConfigsResponse,
getLLMPreferencesRequest,
getLLMPreferencesResponse,
type UpdateLLMConfigRequest,
type UpdateLLMPreferencesRequest,
updateLLMConfigRequest,
updateLLMConfigResponse,
updateLLMPreferencesRequest,
updateLLMPreferencesResponse,
} from "@/contracts/types/llm-config.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class LLMConfigApiService {
/**
* Get all global LLM configurations available to all users
*/
getGlobalLLMConfigs = async () => {
return baseApiService.get(`/api/v1/global-llm-configs`, getGlobalLLMConfigsResponse);
};
/**
* Create a new LLM configuration for a search space
*/
createLLMConfig = async (request: CreateLLMConfigRequest) => {
const parsedRequest = createLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(`/api/v1/llm-configs`, createLLMConfigResponse, {
body: parsedRequest.data,
});
};
/**
* Get a list of LLM configurations for a search space
*/
getLLMConfigs = async (request: GetLLMConfigsRequest) => {
const parsedRequest = getLLMConfigsRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
// Transform query params to be string values
const transformedQueryParams = parsedRequest.data.queryParams
? Object.fromEntries(
Object.entries(parsedRequest.data.queryParams).map(([k, v]) => {
return [k, String(v)];
})
)
: undefined;
const queryParams = transformedQueryParams
? new URLSearchParams(transformedQueryParams).toString()
: "";
return baseApiService.get(`/api/v1/llm-configs?${queryParams}`, getLLMConfigsResponse);
};
/**
* Get a single LLM configuration by ID
*/
getLLMConfig = async (request: GetLLMConfigRequest) => {
const parsedRequest = getLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(`/api/v1/llm-configs/${request.id}`, getLLMConfigResponse);
};
/**
* Update an existing LLM configuration
*/
updateLLMConfig = async (request: UpdateLLMConfigRequest) => {
const parsedRequest = updateLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { id, data } = parsedRequest.data;
return baseApiService.put(`/api/v1/llm-configs/${id}`, updateLLMConfigResponse, {
body: data,
});
};
/**
* Delete an LLM configuration
*/
deleteLLMConfig = async (request: DeleteLLMConfigRequest) => {
const parsedRequest = deleteLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.delete(`/api/v1/llm-configs/${request.id}`, deleteLLMConfigResponse);
};
/**
* Get LLM preferences for a search space
*/
getLLMPreferences = async (request: GetLLMPreferencesRequest) => {
const parsedRequest = getLLMPreferencesRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/search-spaces/${request.search_space_id}/llm-preferences`,
getLLMPreferencesResponse
);
};
/**
* Update LLM preferences for a search space
*/
updateLLMPreferences = async (request: UpdateLLMPreferencesRequest) => {
const parsedRequest = updateLLMPreferencesRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { search_space_id, data } = parsedRequest.data;
return baseApiService.put(
`/api/v1/search-spaces/${search_space_id}/llm-preferences`,
updateLLMPreferencesResponse,
{
body: data,
}
);
};
}
export const llmConfigApiService = new LLMConfigApiService();

View file

@ -0,0 +1,126 @@
import {
type DeleteMembershipRequest,
type DeleteMembershipResponse,
deleteMembershipRequest,
deleteMembershipResponse,
type GetMembersRequest,
type GetMembersResponse,
type GetMyAccessRequest,
type GetMyAccessResponse,
getMembersRequest,
getMembersResponse,
getMyAccessRequest,
getMyAccessResponse,
type LeaveSearchSpaceRequest,
type LeaveSearchSpaceResponse,
leaveSearchSpaceRequest,
leaveSearchSpaceResponse,
type UpdateMembershipRequest,
type UpdateMembershipResponse,
updateMembershipRequest,
updateMembershipResponse,
} from "@/contracts/types/members.types";
import { ValidationError } from "@/lib/error";
import { baseApiService } from "./base-api.service";
class MembersApiService {
/**
* Get members of a search space
*/
getMembers = async (request: GetMembersRequest) => {
const parsedRequest = getMembersRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members`,
getMembersResponse
);
};
/**
* Update a member's role
*/
updateMember = async (request: UpdateMembershipRequest) => {
const parsedRequest = updateMembershipRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.put(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`,
updateMembershipResponse,
{
body: parsedRequest.data.data,
}
);
};
/**
* Delete a member from search space
*/
deleteMember = async (request: DeleteMembershipRequest) => {
const parsedRequest = deleteMembershipRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.delete(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members/${parsedRequest.data.membership_id}`,
deleteMembershipResponse
);
};
/**
* Leave a search space (remove self)
*/
leaveSearchSpace = async (request: LeaveSearchSpaceRequest) => {
const parsedRequest = leaveSearchSpaceRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.delete(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/members/me`,
leaveSearchSpaceResponse
);
};
/**
* Get current user's access information for a search space
*/
getMyAccess = async (request: GetMyAccessRequest) => {
const parsedRequest = getMyAccessRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/searchspaces/${parsedRequest.data.search_space_id}/my-access`,
getMyAccessResponse
);
};
}
export const membersApiService = new MembersApiService();

View file

@ -0,0 +1,170 @@
import {
type CreateNewLLMConfigRequest,
createNewLLMConfigRequest,
createNewLLMConfigResponse,
type DeleteNewLLMConfigRequest,
deleteNewLLMConfigRequest,
deleteNewLLMConfigResponse,
type GetNewLLMConfigRequest,
type GetNewLLMConfigsRequest,
getDefaultSystemInstructionsResponse,
getGlobalNewLLMConfigsResponse,
getLLMPreferencesResponse,
getNewLLMConfigRequest,
getNewLLMConfigResponse,
getNewLLMConfigsRequest,
getNewLLMConfigsResponse,
type UpdateLLMPreferencesRequest,
type UpdateNewLLMConfigRequest,
updateLLMPreferencesRequest,
updateLLMPreferencesResponse,
updateNewLLMConfigRequest,
updateNewLLMConfigResponse,
} from "@/contracts/types/new-llm-config.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class NewLLMConfigApiService {
/**
* Get all global NewLLMConfigs available to all users
*/
getGlobalConfigs = async () => {
return baseApiService.get(`/api/v1/global-new-llm-configs`, getGlobalNewLLMConfigsResponse);
};
/**
* Get default system instructions template
*/
getDefaultSystemInstructions = async () => {
return baseApiService.get(
`/api/v1/new-llm-configs/default-system-instructions`,
getDefaultSystemInstructionsResponse
);
};
/**
* Create a new NewLLMConfig for a search space
*/
createConfig = async (request: CreateNewLLMConfigRequest) => {
const parsedRequest = createNewLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post(`/api/v1/new-llm-configs`, createNewLLMConfigResponse, {
body: parsedRequest.data,
});
};
/**
* Get a list of NewLLMConfigs for a search space
*/
getConfigs = async (request: GetNewLLMConfigsRequest) => {
const parsedRequest = getNewLLMConfigsRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const queryParams = new URLSearchParams({
search_space_id: String(parsedRequest.data.search_space_id),
...(parsedRequest.data.skip !== undefined && { skip: String(parsedRequest.data.skip) }),
...(parsedRequest.data.limit !== undefined && { limit: String(parsedRequest.data.limit) }),
}).toString();
return baseApiService.get(`/api/v1/new-llm-configs?${queryParams}`, getNewLLMConfigsResponse);
};
/**
* Get a single NewLLMConfig by ID
*/
getConfig = async (request: GetNewLLMConfigRequest) => {
const parsedRequest = getNewLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/new-llm-configs/${parsedRequest.data.id}`,
getNewLLMConfigResponse
);
};
/**
* Update an existing NewLLMConfig
*/
updateConfig = async (request: UpdateNewLLMConfigRequest) => {
const parsedRequest = updateNewLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { id, data } = parsedRequest.data;
return baseApiService.put(`/api/v1/new-llm-configs/${id}`, updateNewLLMConfigResponse, {
body: data,
});
};
/**
* Delete a NewLLMConfig
*/
deleteConfig = async (request: DeleteNewLLMConfigRequest) => {
const parsedRequest = deleteNewLLMConfigRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.delete(
`/api/v1/new-llm-configs/${parsedRequest.data.id}`,
deleteNewLLMConfigResponse
);
};
/**
* Get LLM preferences for a search space
*/
getLLMPreferences = async (searchSpaceId: number) => {
return baseApiService.get(
`/api/v1/search-spaces/${searchSpaceId}/llm-preferences`,
getLLMPreferencesResponse
);
};
/**
* Update LLM preferences for a search space
*/
updateLLMPreferences = async (request: UpdateLLMPreferencesRequest) => {
const parsedRequest = updateLLMPreferencesRequest.safeParse(request);
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
const { search_space_id, data } = parsedRequest.data;
return baseApiService.put(
`/api/v1/search-spaces/${search_space_id}/llm-preferences`,
updateLLMPreferencesResponse,
{ body: data }
);
};
}
export const newLLMConfigApiService = new NewLLMConfigApiService();

View file

@ -16,7 +16,7 @@ const createNoteResponse = z.object({
content: z.string(),
content_hash: z.string(),
unique_identifier_hash: z.string().nullable(),
document_metadata: z.record(z.any()).nullable(),
document_metadata: z.record(z.string(), z.any()).nullable(),
search_space_id: z.number(),
created_at: z.string(),
updated_at: z.string().nullable(),
@ -36,7 +36,7 @@ const noteItem = z.object({
content: z.string(),
content_hash: z.string(),
unique_identifier_hash: z.string().nullable(),
document_metadata: z.record(z.any()).nullable(),
document_metadata: z.record(z.string(), z.any()).nullable(),
search_space_id: z.number(),
created_at: z.string(),
updated_at: z.string().nullable(),
@ -78,7 +78,7 @@ class NotesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -105,7 +105,7 @@ class NotesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -131,7 +131,7 @@ class NotesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}

View file

@ -1,121 +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.errors.map((err) => err.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.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.get(
`/api/v1/podcasts/by-chat/${request.chat_id}`,
getPodcastByChaIdResponse
);
};
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.errors.map((err) => err.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.errors.map((err) => err.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.errors.map((err) => err.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.delete(`/api/v1/podcasts/${request.id}`, deletePodcastResponse);
};
}
export const podcastsApiService = new PodcastsApiService();

View file

@ -25,7 +25,7 @@ class RolesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -44,7 +44,7 @@ class RolesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -60,7 +60,7 @@ class RolesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -76,7 +76,7 @@ class RolesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -95,7 +95,7 @@ class RolesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}

View file

@ -7,7 +7,6 @@ import {
deleteSearchSpaceResponse,
type GetSearchSpaceRequest,
type GetSearchSpacesRequest,
getCommunityPromptsResponse,
getSearchSpaceRequest,
getSearchSpaceResponse,
getSearchSpacesRequest,
@ -29,7 +28,7 @@ class SearchSpacesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -58,7 +57,7 @@ class SearchSpacesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -67,16 +66,6 @@ class SearchSpacesApiService {
});
};
/**
* Get community-curated prompts for search space system instructions
*/
getCommunityPrompts = async () => {
return baseApiService.get(
`/api/v1/searchspaces/prompts/community`,
getCommunityPromptsResponse
);
};
/**
* Get a single search space by ID
*/
@ -86,7 +75,7 @@ class SearchSpacesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -102,7 +91,7 @@ class SearchSpacesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
@ -120,7 +109,7 @@ class SearchSpacesApiService {
if (!parsedRequest.success) {
console.error("Invalid request:", parsedRequest.error);
const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}

View file

@ -0,0 +1,324 @@
/**
* 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
* For images, we also store the data URL so it can be displayed after persistence
*/
export interface ChatAttachment extends CompleteAttachment {
extractedContent: string;
imageDataUrl?: string; // Base64 data URL for images (persists across page reloads)
}
/**
* 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>();
// Store image data URLs for attachments (so they persist after File objects are lost)
const imageDataUrls = new Map<string, string>();
/**
* Convert a File to a data URL (base64) for images
*/
async function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* 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");
}
// 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 {
// For images, convert to data URL so we can display them after persistence
if (attachmentType === "image") {
const dataUrl = await fileToDataUrl(file);
imageDataUrls.set(id, dataUrl);
}
// Process the file through the backend ETL service
const result = await processAttachment(file);
// 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);
const imageDataUrl = imageDataUrls.get(pendingAttachment.id);
if (result) {
// Clean up stored result
processedAttachments.delete(pendingAttachment.id);
if (imageDataUrl) {
imageDataUrls.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,
imageDataUrl, // Store data URL for images so they can be displayed after persistence
};
}
// 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: "",
imageDataUrl, // Still include data URL if available
};
},
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

@ -0,0 +1,73 @@
/**
* Module-level state for tracking active podcast generation.
* Used by the new-chat adapter to prevent duplicate podcast requests.
*/
type PodcastStateListener = (isGenerating: boolean) => void;
let _activePodcastTaskId: string | null = null;
const _listeners: Set<PodcastStateListener> = new Set();
/**
* Check if a podcast is currently being generated
*/
export function isPodcastGenerating(): boolean {
return _activePodcastTaskId !== null;
}
/**
* Get the active podcast task ID
*/
export function getActivePodcastTaskId(): string | null {
return _activePodcastTaskId;
}
/**
* Set the active podcast task ID (when podcast generation starts)
*/
export function setActivePodcastTaskId(taskId: string): void {
_activePodcastTaskId = taskId;
notifyListeners();
}
/**
* Clear the active podcast task ID (when podcast generation completes or errors)
*/
export function clearActivePodcastTaskId(): void {
_activePodcastTaskId = null;
notifyListeners();
}
/**
* Subscribe to podcast state changes
*/
export function subscribeToPodcastState(listener: PodcastStateListener): () => void {
_listeners.add(listener);
return () => {
_listeners.delete(listener);
};
}
function notifyListeners(): void {
const isGenerating = _activePodcastTaskId !== null;
for (const listener of _listeners) {
listener(isGenerating);
}
}
/**
* Check if a message looks like a podcast request
*/
export function looksLikePodcastRequest(message: string): boolean {
const podcastPatterns = [
/\bpodcast\b/i,
/\bcreate.*podcast\b/i,
/\bgenerate.*podcast\b/i,
/\bmake.*podcast\b/i,
/\bturn.*into.*podcast\b/i,
/\bpodcast.*about\b/i,
/\bgive.*podcast\b/i,
];
return podcastPatterns.some((pattern) => pattern.test(message));
}

View file

@ -0,0 +1,229 @@
/**
* Thread persistence utilities for the new chat feature.
* Provides API functions and thread list management.
*/
import { baseApiService } from "@/lib/apis/base-api.service";
// =============================================================================
// Types matching backend schemas
// =============================================================================
export interface ThreadRecord {
id: number;
title: string;
archived: boolean;
search_space_id: number;
created_at: string;
updated_at: string;
}
export interface MessageRecord {
id: number;
thread_id: number;
role: "user" | "assistant" | "system";
content: unknown;
created_at: string;
}
export interface ThreadListResponse {
threads: ThreadListItem[];
archived_threads: ThreadListItem[];
}
export interface ThreadListItem {
id: number;
title: string;
archived: boolean;
createdAt: string;
updatedAt: string;
}
export interface ThreadHistoryLoadResponse {
messages: MessageRecord[];
}
// =============================================================================
// API Service Functions
// =============================================================================
/**
* Fetch list of threads for a search space
*/
export async function fetchThreads(
searchSpaceId: number,
limit?: number
): Promise<ThreadListResponse> {
const params = new URLSearchParams({ search_space_id: String(searchSpaceId) });
if (limit) params.append("limit", String(limit));
return baseApiService.get<ThreadListResponse>(`/api/v1/threads?${params}`);
}
/**
* Search threads by title
*/
export async function searchThreads(
searchSpaceId: number,
title: string
): Promise<ThreadListItem[]> {
const params = new URLSearchParams({
search_space_id: String(searchSpaceId),
title,
});
return baseApiService.get<ThreadListItem[]>(`/api/v1/threads/search?${params}`);
}
/**
* Create a new thread
*/
export async function createThread(
searchSpaceId: number,
title = "New Chat"
): Promise<ThreadRecord> {
return baseApiService.post<ThreadRecord>("/api/v1/threads", undefined, {
body: {
title,
archived: false,
search_space_id: searchSpaceId,
},
});
}
/**
* Get thread messages
*/
export async function getThreadMessages(threadId: number): Promise<ThreadHistoryLoadResponse> {
return baseApiService.get<ThreadHistoryLoadResponse>(`/api/v1/threads/${threadId}`);
}
/**
* Append a message to a thread
*/
export async function appendMessage(
threadId: number,
message: { role: "user" | "assistant" | "system"; content: unknown }
): Promise<MessageRecord> {
return baseApiService.post<MessageRecord>(`/api/v1/threads/${threadId}/messages`, undefined, {
body: message,
});
}
/**
* Update thread (rename, archive)
*/
export async function updateThread(
threadId: number,
updates: { title?: string; archived?: boolean }
): Promise<ThreadRecord> {
return baseApiService.put<ThreadRecord>(`/api/v1/threads/${threadId}`, undefined, {
body: updates,
});
}
/**
* Delete a thread
*/
export async function deleteThread(threadId: number): Promise<void> {
await baseApiService.delete(`/api/v1/threads/${threadId}`);
}
// =============================================================================
// Thread List Manager (for thread list sidebar)
// =============================================================================
export interface ThreadListAdapterConfig {
searchSpaceId: number;
currentThreadId: number | null;
onThreadSwitch: (threadId: number) => void;
onNewThread: (threadId: number) => void;
}
export interface ThreadListState {
threads: ThreadListItem[];
archivedThreads: ThreadListItem[];
isLoading: boolean;
error: string | null;
}
/**
* Creates a thread list management object.
* This provides methods to manage the thread list for the sidebar.
*/
export function createThreadListManager(config: ThreadListAdapterConfig) {
return {
async loadThreads(): Promise<ThreadListState> {
try {
const response = await fetchThreads(config.searchSpaceId);
return {
threads: response.threads,
archivedThreads: response.archived_threads,
isLoading: false,
error: null,
};
} catch (error) {
console.error("[ThreadListManager] Failed to load threads:", error);
return {
threads: [],
archivedThreads: [],
isLoading: false,
error: error instanceof Error ? error.message : "Failed to load threads",
};
}
},
async createNewThread(title = "New Chat"): Promise<number | null> {
try {
const thread = await createThread(config.searchSpaceId, title);
config.onNewThread(thread.id);
return thread.id;
} catch (error) {
console.error("[ThreadListManager] Failed to create thread:", error);
return null;
}
},
switchToThread(threadId: number) {
config.onThreadSwitch(threadId);
},
async renameThread(threadId: number, newTitle: string): Promise<boolean> {
try {
await updateThread(threadId, { title: newTitle });
return true;
} catch (error) {
console.error("[ThreadListManager] Failed to rename thread:", error);
return false;
}
},
async archiveThread(threadId: number): Promise<boolean> {
try {
await updateThread(threadId, { archived: true });
return true;
} catch (error) {
console.error("[ThreadListManager] Failed to archive thread:", error);
return false;
}
},
async unarchiveThread(threadId: number): Promise<boolean> {
try {
await updateThread(threadId, { archived: false });
return true;
} catch (error) {
console.error("[ThreadListManager] Failed to unarchive thread:", error);
return false;
}
},
async deleteThread(threadId: number): Promise<boolean> {
try {
await deleteThread(threadId);
return true;
} catch (error) {
console.error("[ThreadListManager] Failed to delete thread:", error);
return false;
}
},
};
}

View file

@ -1,7 +1,6 @@
// Helper function to get connector type display name
export const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = {
SERPER_API: "Serper API",
TAVILY_API: "Tavily API",
SEARXNG_API: "SearxNG",
SLACK_CONNECTOR: "Slack",

View file

@ -1,33 +0,0 @@
// Helper to normalize list responses from the API
// Supports shapes: Array<T>, { items: T[]; total: number }, and tuple [T[], total]
export type ListResponse<T> = {
items: T[];
total: number;
};
export function normalizeListResponse<T>(payload: any): ListResponse<T> {
try {
// Case 1: already in desired shape
if (payload && Array.isArray(payload.items)) {
const total = typeof payload.total === "number" ? payload.total : payload.items.length;
return { items: payload.items as T[], total };
}
// Case 2: tuple [items, total]
if (Array.isArray(payload) && payload.length === 2 && Array.isArray(payload[0])) {
const items = (payload[0] ?? []) as T[];
const rawTotal = payload[1];
const total = typeof rawTotal === "number" ? rawTotal : items.length;
return { items, total };
}
// Case 3: plain array
if (Array.isArray(payload)) {
return { items: payload as T[], total: (payload as T[]).length };
}
} catch (e) {
// fallthrough to default
}
return { items: [], total: 0 };
}

View file

@ -0,0 +1,291 @@
import posthog from "posthog-js";
/**
* PostHog Analytics Event Definitions
*
* This file defines all custom analytics events tracked in SurfSense.
* Events follow a consistent naming convention: category_action
*
* Categories:
* - auth: Authentication events
* - search_space: Search space management
* - document: Document management
* - chat: Chat and messaging
* - connector: External connector events
* - contact: Contact form events
* - settings: Settings changes
*/
// ============================================
// AUTH EVENTS
// ============================================
export function trackLoginAttempt(method: "local" | "google") {
posthog.capture("auth_login_attempt", {
method,
});
}
export function trackLoginSuccess(method: "local" | "google") {
posthog.capture("auth_login_success", {
method,
});
}
export function trackLoginFailure(method: "local" | "google", error?: string) {
posthog.capture("auth_login_failure", {
method,
error,
});
}
export function trackRegistrationAttempt() {
posthog.capture("auth_registration_attempt");
}
export function trackRegistrationSuccess() {
posthog.capture("auth_registration_success");
}
export function trackRegistrationFailure(error?: string) {
posthog.capture("auth_registration_failure", {
error,
});
}
export function trackLogout() {
posthog.capture("auth_logout");
}
// ============================================
// SEARCH SPACE EVENTS
// ============================================
export function trackSearchSpaceCreated(searchSpaceId: number, name: string) {
posthog.capture("search_space_created", {
search_space_id: searchSpaceId,
name,
});
}
export function trackSearchSpaceDeleted(searchSpaceId: number) {
posthog.capture("search_space_deleted", {
search_space_id: searchSpaceId,
});
}
export function trackSearchSpaceViewed(searchSpaceId: number) {
posthog.capture("search_space_viewed", {
search_space_id: searchSpaceId,
});
}
// ============================================
// CHAT EVENTS
// ============================================
export function trackChatCreated(searchSpaceId: number, chatId: number) {
posthog.capture("chat_created", {
search_space_id: searchSpaceId,
chat_id: chatId,
});
}
export function trackChatMessageSent(
searchSpaceId: number,
chatId: number,
options?: {
hasAttachments?: boolean;
hasMentionedDocuments?: boolean;
messageLength?: number;
}
) {
posthog.capture("chat_message_sent", {
search_space_id: searchSpaceId,
chat_id: chatId,
has_attachments: options?.hasAttachments ?? false,
has_mentioned_documents: options?.hasMentionedDocuments ?? false,
message_length: options?.messageLength,
});
}
export function trackChatResponseReceived(searchSpaceId: number, chatId: number) {
posthog.capture("chat_response_received", {
search_space_id: searchSpaceId,
chat_id: chatId,
});
}
export function trackChatError(searchSpaceId: number, chatId: number, error?: string) {
posthog.capture("chat_error", {
search_space_id: searchSpaceId,
chat_id: chatId,
error,
});
}
// ============================================
// DOCUMENT EVENTS
// ============================================
export function trackDocumentUploadStarted(
searchSpaceId: number,
fileCount: number,
totalSizeBytes: number
) {
posthog.capture("document_upload_started", {
search_space_id: searchSpaceId,
file_count: fileCount,
total_size_bytes: totalSizeBytes,
});
}
export function trackDocumentUploadSuccess(searchSpaceId: number, fileCount: number) {
posthog.capture("document_upload_success", {
search_space_id: searchSpaceId,
file_count: fileCount,
});
}
export function trackDocumentUploadFailure(searchSpaceId: number, error?: string) {
posthog.capture("document_upload_failure", {
search_space_id: searchSpaceId,
error,
});
}
export function trackDocumentDeleted(searchSpaceId: number, documentId: number) {
posthog.capture("document_deleted", {
search_space_id: searchSpaceId,
document_id: documentId,
});
}
export function trackDocumentBulkDeleted(searchSpaceId: number, count: number) {
posthog.capture("document_bulk_deleted", {
search_space_id: searchSpaceId,
count,
});
}
export function trackYouTubeImport(searchSpaceId: number, url: string) {
posthog.capture("youtube_import_started", {
search_space_id: searchSpaceId,
url,
});
}
// ============================================
// CONNECTOR EVENTS
// ============================================
export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) {
posthog.capture("connector_setup_started", {
search_space_id: searchSpaceId,
connector_type: connectorType,
});
}
export function trackConnectorSetupSuccess(
searchSpaceId: number,
connectorType: string,
connectorId: number
) {
posthog.capture("connector_setup_success", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
});
}
export function trackConnectorSetupFailure(
searchSpaceId: number,
connectorType: string,
error?: string
) {
posthog.capture("connector_setup_failure", {
search_space_id: searchSpaceId,
connector_type: connectorType,
error,
});
}
export function trackConnectorDeleted(
searchSpaceId: number,
connectorType: string,
connectorId: number
) {
posthog.capture("connector_deleted", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
});
}
export function trackConnectorSynced(
searchSpaceId: number,
connectorType: string,
connectorId: number
) {
posthog.capture("connector_synced", {
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
});
}
// ============================================
// SETTINGS EVENTS
// ============================================
export function trackSettingsViewed(searchSpaceId: number, section: string) {
posthog.capture("settings_viewed", {
search_space_id: searchSpaceId,
section,
});
}
export function trackSettingsUpdated(searchSpaceId: number, section: string, setting: string) {
posthog.capture("settings_updated", {
search_space_id: searchSpaceId,
section,
setting,
});
}
// ============================================
// FEATURE USAGE EVENTS
// ============================================
export function trackPodcastGenerated(searchSpaceId: number, chatId: number) {
posthog.capture("podcast_generated", {
search_space_id: searchSpaceId,
chat_id: chatId,
});
}
export function trackSourcesTabViewed(searchSpaceId: number, tab: string) {
posthog.capture("sources_tab_viewed", {
search_space_id: searchSpaceId,
tab,
});
}
// ============================================
// USER IDENTIFICATION
// ============================================
/**
* Identify a user for PostHog analytics
* Call this after successful authentication
*/
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
posthog.identify(userId, properties);
}
/**
* Reset user identity (call on logout)
*/
export function resetUser() {
posthog.reset();
}

View file

@ -0,0 +1,17 @@
import { PostHog } from "posthog-node";
export default function PostHogClient() {
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) {
throw new Error("NEXT_PUBLIC_POSTHOG_KEY is not set");
}
const posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
// Because server-side functions in Next.js can be short-lived,
// we set flushAt to 1 and flushInterval to 0 to ensure events are sent immediately
flushAt: 1,
flushInterval: 0,
});
return posthogClient;
}

View file

@ -1,20 +1,14 @@
import type { GetChatsRequest } from "@/contracts/types/chat.types";
import type { GetConnectorsRequest } from "@/contracts/types/connector.types";
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.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 ? Object.values(queries) : [])] 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"]) =>
@ -25,13 +19,12 @@ export const cacheKeys = {
typeCounts: (searchSpaceId?: string) => ["documents", "type-counts", searchSpaceId] as const,
byChunk: (chunkId: string) => ["documents", "by-chunk", chunkId] as const,
},
llmConfigs: {
global: () => ["llm-configs", "global"] as const,
all: (searchSpaceId: string) => ["llm-configs", searchSpaceId] as const,
withQueryParams: (queries: GetLLMConfigsRequest["queryParams"]) =>
["llm-configs", ...(queries ? Object.values(queries) : [])] as const,
byId: (llmConfigId: string) => ["llm-config", llmConfigId] as const,
preferences: (searchSpaceId: string) => ["llm-preferences", searchSpaceId] as const,
newLLMConfigs: {
all: (searchSpaceId: number) => ["new-llm-configs", searchSpaceId] as const,
byId: (configId: number) => ["new-llm-configs", "detail", configId] as const,
preferences: (searchSpaceId: number) => ["llm-preferences", searchSpaceId] as const,
defaultInstructions: () => ["new-llm-configs", "default-instructions"] as const,
global: () => ["new-llm-configs", "global"] as const,
},
auth: {
user: ["auth", "user"] as const,
@ -41,7 +34,6 @@ export const cacheKeys = {
withQueryParams: (queries: GetSearchSpacesRequest["queryParams"]) =>
["search-spaces", ...(queries ? Object.values(queries) : [])] as const,
detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const,
communityPrompts: ["search-spaces", "community-prompts"] as const,
},
user: {
current: () => ["user", "me"] as const,
@ -53,6 +45,14 @@ export const cacheKeys = {
permissions: {
all: () => ["permissions"] as const,
},
members: {
all: (searchSpaceId: string) => ["members", searchSpaceId] as const,
myAccess: (searchSpaceId: string) => ["members", "my-access", searchSpaceId] as const,
},
invites: {
all: (searchSpaceId: string) => ["invites", searchSpaceId] as const,
info: (inviteCode: string) => ["invites", "info", inviteCode] as const,
},
connectors: {
all: (searchSpaceId: string) => ["connectors", searchSpaceId] as const,
withQueryParams: (queries: GetConnectorsRequest["queryParams"]) =>

View file

@ -1,5 +1,5 @@
import { loader } from "fumadocs-core/source";
import { docs } from "@/.source";
import { docs } from "@/.source/server";
export const source = loader({
baseUrl: "/docs",

View file

@ -11,3 +11,11 @@ export function getChatTitleFromMessages(messages: Message[]) {
if (userMessages.length === 0) return "Untitled Chat";
return userMessages[0].content;
}
export const formatDate = (date: Date): string => {
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};