GT AI OS Community Edition v2.0.33

Security hardening release addressing CodeQL and Dependabot alerts:

- Fix stack trace exposure in error responses
- Add SSRF protection with DNS resolution checking
- Implement proper URL hostname validation (replaces substring matching)
- Add centralized path sanitization to prevent path traversal
- Fix ReDoS vulnerability in email validation regex
- Improve HTML sanitization in validation utilities
- Fix capability wildcard matching in auth utilities
- Update glob dependency to address CVE
- Add CodeQL suppression comments for verified false positives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HackWeasel
2025-12-12 17:04:45 -05:00
commit b9dfb86260
746 changed files with 232071 additions and 0 deletions

View File

@@ -0,0 +1,535 @@
/**
* Enhanced Agent Service for GT 2.0
*
* Comprehensive agent management with enterprise features, access control,
* personality types, and advanced capabilities.
*/
import { api, ApiResponse } from './api';
// Core types
export type PersonalityType = 'geometric' | 'organic' | 'minimal' | 'technical';
export type Visibility = 'private' | 'team' | 'organization' | 'public';
export type DatasetConnection = 'none' | 'all' | 'selected';
// AgentCategory is now dynamic (loaded from API) - use string type
// Default categories: general, coding, writing, analysis, creative, research, business, education
// Custom categories can be created by users
export type AgentCategory = string;
export type AccessFilter = 'all' | 'mine' | 'team' | 'org' | 'public';
// Enhanced agent interface
export interface EnhancedAgent {
id: string;
team_id: string;
name: string;
description: string;
disclaimer: string;
category: AgentCategory;
custom_category?: string;
visibility: Visibility;
featured: boolean;
personality_type: PersonalityType;
personality_profile: PersonalityProfile;
custom_avatar_url?: string;
model_id: string;
system_prompt: string;
model_parameters: ModelParameters;
dataset_connection: DatasetConnection;
selected_dataset_ids: string[];
require_moderation: boolean;
blocked_terms: string[];
enabled_capabilities: string[];
mcp_integration_ids: string[];
tool_configurations: Record<string, any>;
owner_id: string;
owner_name?: string; // Full name of the agent creator
collaborator_ids: string[];
can_fork: boolean;
parent_agent_id?: string;
version: number;
usage_count: number;
average_rating?: number;
tags: string[];
example_prompts: string[];
easy_prompts?: string[] | null; // Quick-access prompts (max 10)
safety_flags: string[];
created_at: string;
updated_at: string;
published_at?: string;
last_used_at?: string;
// Access indicators for UI
is_owner: boolean;
can_edit: boolean;
can_delete: boolean;
can_share: boolean;
// Team sharing configuration
team_shares?: Array<{
team_id: string;
team_name: string;
user_permissions: Record<string, 'read' | 'edit'>;
}>;
}
export interface PersonalityProfile {
colors: {
primary: string;
secondary: string;
accent: string;
background: string;
};
animation: {
style: string;
duration: number;
easing: string;
};
visual: {
shapes: string[];
patterns: string[];
effects: string[];
};
interaction: {
greeting_style: string;
conversation_tone: string;
response_style: string;
};
}
export interface ModelParameters {
max_history_items: number;
max_chunks: number;
// max_tokens removed - now determined by model configuration
trim_ratio: number;
temperature: number;
top_p: number;
frequency_penalty: number;
presence_penalty: number;
}
export interface ExamplePrompt {
text: string;
category: string;
expected_behavior?: string;
}
// Request/Response types
export interface CreateEnhancedAgentRequest {
name: string;
description?: string;
category?: AgentCategory;
custom_category?: string;
visibility?: Visibility;
personality_type?: PersonalityType;
model_id?: string;
system_prompt?: string;
model_parameters?: Partial<ModelParameters>;
dataset_connection?: DatasetConnection;
selected_dataset_ids?: string[];
require_moderation?: boolean;
blocked_terms?: string[];
enabled_capabilities?: string[];
tags?: string[];
example_prompts?: ExamplePrompt[];
// Legacy fields removed - use team_shares instead for team collaboration
}
export interface UpdateEnhancedAgentRequest {
name?: string;
description?: string;
category?: AgentCategory;
visibility?: Visibility;
personality_type?: PersonalityType;
system_prompt?: string;
model_parameters?: Partial<ModelParameters>;
dataset_connection?: DatasetConnection;
selected_dataset_ids?: string[];
tags?: string[];
example_prompts?: ExamplePrompt[];
disclaimer?: string;
easy_prompts?: string[];
team_shares?: Array<{
team_id: string;
user_permissions: Record<string, 'read' | 'edit'>;
}>;
}
export interface AgentTemplate {
id: string;
name: string;
description: string;
category: AgentCategory;
personality_type: PersonalityType;
model_id: string;
system_prompt: string;
capabilities: string[];
example_prompts: ExamplePrompt[];
model_parameters: ModelParameters;
tags: string[];
}
export interface ForkAgentRequest {
new_name: string;
}
export interface CategoryInfo {
value: string;
label: string;
description: string;
count?: number;
}
// Enhanced Agent Service
export class EnhancedAgentService {
/**
* List agents with access control filtering
*/
async listAgents(options: {
access_filter?: AccessFilter;
category?: string;
search?: string;
featured_only?: boolean;
limit?: number;
offset?: number;
sort_by?: 'usage_count' | 'average_rating' | 'created_at' | 'recent_usage' | 'my_most_used';
filter?: 'used_last_7_days' | 'used_last_30_days';
} = {}): Promise<ApiResponse<EnhancedAgent[]>> {
const params = new URLSearchParams();
Object.entries(options).forEach(([key, value]) => {
if (value !== undefined) {
// Map sort_by to sort for backend compatibility
const paramKey = key === 'sort_by' ? 'sort' : key;
params.append(paramKey, value.toString());
}
});
const query = params.toString();
const endpoint = `/api/v1/agents${query ? `?${query}` : ''}`;
return api.get<EnhancedAgent[]>(endpoint);
}
/**
* Get specific agent with full details
*/
async getAgent(agentId: string): Promise<ApiResponse<EnhancedAgent>> {
return api.get<EnhancedAgent>(`/api/v1/agents/${agentId}`);
}
/**
* Create new enhanced agent
*/
async createAgent(request: CreateEnhancedAgentRequest): Promise<ApiResponse<EnhancedAgent>> {
return api.post<EnhancedAgent>('/api/v1/agents', request);
}
/**
* Update existing agent (owner only)
*/
async updateAgent(
agentId: string,
request: UpdateEnhancedAgentRequest
): Promise<ApiResponse<EnhancedAgent>> {
return api.put<EnhancedAgent>(`/api/v1/agents/${agentId}`, request);
}
/**
* Delete agent (owner only)
*/
async deleteAgent(agentId: string): Promise<ApiResponse<{ message: string }>> {
return api.delete<{ message: string }>(`/api/v1/agents/${agentId}`);
}
/**
* Fork an existing agent
*/
async forkAgent(
agentId: string,
newName: string
): Promise<ApiResponse<EnhancedAgent>> {
return api.post<EnhancedAgent>(`/api/v1/agents/${agentId}/fork`, { new_name: newName });
}
/**
* Get available agent templates
*/
async getTemplates(category?: string): Promise<ApiResponse<AgentTemplate[]>> {
const params = new URLSearchParams();
if (category) {
params.append('category', category);
}
const query = params.toString();
const endpoint = `/api/v1/agents/templates/${query ? `?${query}` : ''}`;
return api.get<AgentTemplate[]>(endpoint);
}
/**
* Create agent from template
*/
async createFromTemplate(
templateId: string,
customization: Partial<CreateEnhancedAgentRequest>
): Promise<ApiResponse<EnhancedAgent>> {
return api.post<EnhancedAgent>(`/api/v1/agents/templates/${templateId}`, customization);
}
/**
* Get agent categories with counts
*/
async getCategories(includeTeamId?: string): Promise<ApiResponse<CategoryInfo[]>> {
const params = new URLSearchParams();
params.append('include_counts', 'true');
if (includeTeamId) {
params.append('team_id', includeTeamId);
}
const query = params.toString();
return api.get<CategoryInfo[]>(`/api/v1/agents/categories/?${query}`);
}
/**
* Get public agents (legacy endpoint)
*/
async getPublicAgents(options: {
category?: string;
search?: string;
featured_only?: boolean;
limit?: number;
offset?: number;
sort_by?: string;
} = {}): Promise<ApiResponse<EnhancedAgent[]>> {
const params = new URLSearchParams();
Object.entries(options).forEach(([key, value]) => {
if (value !== undefined) {
params.append(key, value.toString());
}
});
const query = params.toString();
return api.get<EnhancedAgent[]>(`/api/v1/agents/public/?${query}`);
}
/**
* Get agents filtered by access level
*/
async getMyAgents(): Promise<ApiResponse<EnhancedAgent[]>> {
return this.listAgents({ access_filter: 'mine' });
}
async getTeamAgents(): Promise<ApiResponse<EnhancedAgent[]>> {
return this.listAgents({ access_filter: 'team' });
}
async getOrgAgents(): Promise<ApiResponse<EnhancedAgent[]>> {
return this.listAgents({ access_filter: 'org' });
}
async getFeaturedAgents(): Promise<ApiResponse<EnhancedAgent[]>> {
return this.listAgents({ featured_only: true });
}
/**
* Search agents by name, description, or tags
*/
async searchAgents(
query: string,
accessFilter?: AccessFilter,
category?: string
): Promise<ApiResponse<EnhancedAgent[]>> {
return this.listAgents({
access_filter: accessFilter,
search: query,
category
});
}
/**
* Get agents by category
*/
async getAgentsByCategory(
category: AgentCategory,
accessFilter?: AccessFilter
): Promise<ApiResponse<EnhancedAgent[]>> {
return this.listAgents({
access_filter: accessFilter,
category
});
}
/**
* Get user's agent statistics summary
*/
async getUserAgentSummary(): Promise<ApiResponse<{
total_agents: number;
owned_agents: number;
team_agents: number;
org_agents: number;
public_agents: number;
total_usage: number;
avg_rating: number;
categories: Record<string, number>;
}>> {
const allAgents = await this.listAgents({ access_filter: 'all' });
if (!allAgents.data) {
return {
error: allAgents.error,
status: allAgents.status
};
}
const agents = allAgents.data;
const categoryCount: Record<string, number> = {};
agents.forEach(agent => {
categoryCount[agent.category] = (categoryCount[agent.category] || 0) + 1;
});
const summary = {
total_agents: agents.length,
owned_agents: agents.filter(a => a.is_owner).length,
team_agents: agents.filter(a => a.visibility === 'team' && !a.is_owner).length,
org_agents: agents.filter(a => a.visibility === 'organization' && !a.is_owner).length,
public_agents: agents.filter(a => a.visibility === 'public' && !a.is_owner).length,
total_usage: agents.reduce((sum, a) => sum + a.usage_count, 0),
avg_rating: agents.filter(a => a.average_rating).reduce((sum, a, _, arr) =>
sum + (a.average_rating || 0) / arr.length, 0),
categories: categoryCount
};
return {
data: summary,
status: 200
};
}
/**
* Get personality profiles for different types
*/
getPersonalityProfiles(): Record<PersonalityType, PersonalityProfile> {
return {
geometric: {
colors: {
primary: "#00FF94",
secondary: "#0066FF",
accent: "#FFD700",
background: "rgba(0, 255, 148, 0.05)"
},
animation: {
style: "sharp",
duration: 300,
easing: "cubic-bezier(0.4, 0, 0.2, 1)"
},
visual: {
shapes: ["square", "triangle", "hexagon"],
patterns: ["grid", "lines", "dots"],
effects: ["slide", "fade", "scale"]
},
interaction: {
greeting_style: "direct",
conversation_tone: "structured",
response_style: "organized"
}
},
organic: {
colors: {
primary: "#FF6B6B",
secondary: "#4ECDC4",
accent: "#FFE66D",
background: "rgba(255, 107, 107, 0.05)"
},
animation: {
style: "fluid",
duration: 600,
easing: "cubic-bezier(0.25, 0.46, 0.45, 0.94)"
},
visual: {
shapes: ["circle", "ellipse", "blob"],
patterns: ["waves", "curves", "spirals"],
effects: ["bounce", "spring", "flow"]
},
interaction: {
greeting_style: "warm",
conversation_tone: "friendly",
response_style: "conversational"
}
},
minimal: {
colors: {
primary: "#333333",
secondary: "#666666",
accent: "#999999",
background: "rgba(51, 51, 51, 0.02)"
},
animation: {
style: "subtle",
duration: 200,
easing: "ease-in-out"
},
visual: {
shapes: ["line", "rectangle", "point"],
patterns: ["minimal", "clean", "simple"],
effects: ["fade", "opacity", "transform"]
},
interaction: {
greeting_style: "concise",
conversation_tone: "professional",
response_style: "efficient"
}
},
technical: {
colors: {
primary: "#0088FF",
secondary: "#00CCFF",
accent: "#FFFFFF",
background: "rgba(0, 136, 255, 0.03)"
},
animation: {
style: "precise",
duration: 250,
easing: "linear"
},
visual: {
shapes: ["circuit", "connector", "node"],
patterns: ["matrix", "grid", "network"],
effects: ["pulse", "glow", "scan"]
},
interaction: {
greeting_style: "systematic",
conversation_tone: "analytical",
response_style: "detailed"
}
}
};
}
}
// Singleton instance
export const enhancedAgentService = new EnhancedAgentService();
// Convenience exports
export const {
listAgents: listEnhancedAgents,
getAgent: getEnhancedAgent,
createAgent: createEnhancedAgent,
updateAgent: updateEnhancedAgent,
deleteAgent: deleteEnhancedAgent,
forkAgent,
getTemplates: getAgentTemplates,
createFromTemplate,
getCategories: getAgentCategories,
getPublicAgents,
getMyAgents,
getTeamAgents,
getOrgAgents,
getFeaturedAgents,
searchAgents,
getAgentsByCategory,
getUserAgentSummary,
getPersonalityProfiles
} = enhancedAgentService;

View File

@@ -0,0 +1,317 @@
/**
* GT 2.0 Agent Service
*
* API client for AI agent management with template support.
*/
import { api } from './api';
export interface Agent {
agent_id: string;
name: string;
description: string;
template_id: string;
category: string;
is_favorite: boolean;
conversation_count: number;
icon?: string;
last_used_at?: Date;
system_prompt?: string;
instructions?: string;
capabilities?: string[];
disclaimer?: string;
easy_prompts?: string[];
created_at: string;
updated_at: string;
}
export interface AgentTemplate {
template_id: string;
name: string;
description: string;
category: string;
system_prompt: string;
default_instructions: string;
capabilities: string[];
icon: string;
tags: string[];
use_cases: string[];
example_conversations: string[];
}
export interface CreateAgentRequest {
name: string;
description?: string;
template_id: string;
instructions?: string;
capabilities?: string[];
is_favorite?: boolean;
}
export interface UpdateAgentRequest {
name?: string;
description?: string;
instructions?: string;
capabilities?: string[];
is_favorite?: boolean;
}
/**
* Get all available agent templates
*/
export async function getAgentTemplates() {
return api.get<AssistantTemplate[]>('/api/v1/agents/templates');
}
/**
* Get specific agent template
*/
export async function getAgentTemplate(templateId: string) {
return api.get<AgentTemplate>(`/api/v1/agents/templates/${templateId}`);
}
/**
* List user's agents
*/
export async function listAgents(params?: {
category?: string;
is_favorite?: boolean;
limit?: number;
offset?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.category) searchParams.set('category', params.category);
if (params?.is_favorite !== undefined) searchParams.set('is_favorite', params.is_favorite.toString());
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.offset) searchParams.set('offset', params.offset.toString());
const query = searchParams.toString();
return api.get<Agent[]>(`/api/v1/agents${query ? `?${query}` : ''}`);
}
/**
* Get specific agent
*/
export async function getAgent(agentId: string) {
return api.get<Agent>(`/api/v1/agents/${agentId}`);
}
/**
* Create new agent
*/
export async function createAgent(request: CreateAgentRequest) {
return api.post<Agent>('/api/v1/agents', request);
}
/**
* Update existing agent
*/
export async function updateAgent(agentId: string, request: UpdateAgentRequest) {
return api.put<Agent>(`/api/v1/agents/${agentId}`, request);
}
/**
* Delete agent
*/
export async function deleteAgent(agentId: string) {
return api.delete(`/api/v1/agents/${agentId}`);
}
/**
* Toggle favorite status
*/
export async function toggleFavorite(agentId: string, is_favorite: boolean) {
return api.put<Agent>(`/api/v1/agents/${agentId}`, { is_favorite });
}
/**
* Get agent usage statistics
*/
export async function getAgentStats(agentId: string) {
return api.get<{
conversation_count: number;
total_messages: number;
last_used_at: string;
average_conversation_length: number;
most_common_topics: string[];
}>(`/api/v1/agents/${agentId}/stats`);
}
/**
* Get agent categories for filtering
*/
export async function getAgentCategories() {
const response = await getAssistantTemplates();
if (response.data) {
const categories = [...new Set(response.data.map(t => t.category))];
return { data: categories, status: response.status };
}
return response;
}
/**
* Bulk import result interface
*/
export interface BulkImportResult {
success_count: number;
error_count: number;
total_rows: number;
created_agents: Array<{
id: string;
name: string;
original_name?: string;
}>;
errors: Array<{
row_number: number;
field: string;
message: string;
}>;
}
/**
* Bulk import agents from CSV
*/
export async function bulkImportAgents(
data: { file?: File; text?: string }
): Promise<BulkImportResult> {
if (data.text) {
// Send CSV text using fetch to properly handle the request
const { getAuthToken, getTenantInfo, getUser } = await import('./auth');
const token = getAuthToken();
const tenantInfo = getTenantInfo();
const user = getUser();
const tenantDomain = tenantInfo?.domain || process.env.NEXT_PUBLIC_TENANT_DOMAIN || 'test-company';
const userId = user?.email || user?.user_id || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Tenant-Domain': tenantDomain,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (userId) {
headers['X-User-ID'] = userId;
}
const response = await fetch('/api/v1/agents/bulk-import', {
method: 'POST',
headers,
body: JSON.stringify({ csv_text: data.text })
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Import failed' }));
throw new Error(error.detail || 'Import failed');
}
return response.json();
} else if (data.file) {
// For file upload, use fetch directly with FormData
const { getAuthToken, getTenantInfo, getUser } = await import('./auth');
const token = getAuthToken();
const tenantInfo = getTenantInfo();
const user = getUser();
const tenantDomain = tenantInfo?.domain || process.env.NEXT_PUBLIC_TENANT_DOMAIN || 'test-company';
const userId = user?.email || user?.user_id || '';
const headers: Record<string, string> = {
'X-Tenant-Domain': tenantDomain,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (userId) {
headers['X-User-ID'] = userId;
}
const formData = new FormData();
formData.append('csv_file', data.file);
const response = await fetch('/api/v1/agents/bulk-import', {
method: 'POST',
headers,
body: formData
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Import failed' }));
throw new Error(error.detail || 'Import failed');
}
return response.json();
} else {
throw new Error('Either file or text must be provided');
}
}
/**
* Export agent configuration as CSV
*/
export async function exportAgent(agentId: string, format: 'download' | 'clipboard' = 'download'): Promise<string> {
// Import auth functions
const { getAuthToken, getTenantInfo, getUser } = await import('./auth');
const token = getAuthToken();
const tenantInfo = getTenantInfo();
const user = getUser();
const tenantDomain = tenantInfo?.domain || process.env.NEXT_PUBLIC_TENANT_DOMAIN || 'test-company';
const userId = user?.email || user?.user_id || '';
const headers: Record<string, string> = {
'X-Tenant-Domain': tenantDomain,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (userId) {
headers['X-User-ID'] = userId;
}
const response = await fetch(
`/api/v1/agents/${agentId}/export?format=${format}`,
{
method: 'GET',
headers
}
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Export failed' }));
throw new Error(error.detail || 'Export failed');
}
if (format === 'download') {
// Trigger browser download
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `agent_${agentId}.csv`;
document.body.appendChild(a);
a.click();
// Cleanup
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
return 'Downloaded';
} else {
// Return CSV text for clipboard
return response.text();
}
}

View File

@@ -0,0 +1,344 @@
/**
* GT 2.0 API Service Layer
*
* Centralized API client for all backend communication with proper
* authentication, error handling, and tenant isolation.
*/
import { getAuthToken, getTenantInfo, getUser, isTokenValid } from './auth';
export interface ApiResponse<T> {
data?: T;
error?: string;
status: number;
}
class ApiClient {
private baseURL: string;
private defaultHeaders: Record<string, string>;
constructor() {
// Use relative path for browser, will be proxied by Next.js to tenant-backend via Docker network
this.baseURL = '';
this.defaultHeaders = {
'Content-Type': 'application/json',
};
}
private getAuthHeaders(): Record<string, string> {
console.log('🔐 Getting auth headers...');
// GT 2.0: Check token validity before using
const tokenValid = isTokenValid();
console.log('🔐 Token valid:', tokenValid);
if (!tokenValid) {
console.log('🔐 No valid token, using default headers');
// For unauthenticated requests, still include tenant domain from environment
const tenantDomain = process.env.NEXT_PUBLIC_TENANT_DOMAIN || 'test-company';
return {
...this.defaultHeaders,
'X-Tenant-Domain': tenantDomain,
};
}
const token = getAuthToken();
const tenantInfo = getTenantInfo();
const user = getUser();
// GT 2.0: Validate tenant domain is available
const tenantDomain = tenantInfo?.domain || process.env.NEXT_PUBLIC_TENANT_DOMAIN || 'test-company';
// GT 2.0: Get user ID from stored user info (email is used as user_id)
const userId = user?.email || user?.user_id || '';
if (!tenantInfo?.domain) {
console.warn('⚠️ No tenant domain from auth context, falling back to environment variable:', tenantDomain);
}
if (!userId) {
console.warn('⚠️ No user ID available from auth context');
}
console.log('🔐 Auth data:', {
hasToken: !!token,
tokenPrefix: token?.substring(0, 20) + '...',
tenantDomain: tenantDomain,
userId: userId,
userEmail: user?.email,
source: tenantInfo?.domain ? 'auth' : 'environment'
});
const headers: Record<string, string> = {
...this.defaultHeaders,
'X-Tenant-Domain': tenantDomain,
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
if (userId) {
headers['X-User-ID'] = userId;
}
return headers;
}
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const requestId = Math.random().toString(36).substr(2, 9);
console.log(`🔍 [${requestId}] API Request Start: ${endpoint}`, {
method: options.method || 'GET',
baseURL: this.baseURL
});
try {
// Get auth headers with debugging
console.log(`🔍 [${requestId}] Getting auth headers...`);
console.log(`🔥 DETAILED LOGGING IS ACTIVE - requestId: ${requestId}`);
let headers;
try {
headers = this.getAuthHeaders();
console.log(`✅ [${requestId}] Auth headers obtained successfully`);
} catch (authError) {
console.error(`❌ [${requestId}] Auth headers failed:`, authError);
throw authError;
}
console.log(`🔐 [${requestId}] Auth Headers:`, {
hasAuth: !!headers.Authorization,
hasTenant: !!headers['X-Tenant-Domain'],
authPrefix: headers.Authorization?.substring(0, 20) + '...',
tenantDomain: headers['X-Tenant-Domain'],
allHeaders: Object.keys(headers)
});
const fullUrl = `${this.baseURL}${endpoint}`;
console.log(`📡 [${requestId}] Making fetch request to: ${fullUrl}`);
console.log(`📡 [${requestId}] Request options:`, {
method: options.method || 'GET',
headers: Object.keys(headers)
});
let response: Response;
try {
console.log(`📡 [${requestId}] About to call fetch()...`);
console.log(`📡 [${requestId}] Fetch URL: ${fullUrl}`);
console.log(`📡 [${requestId}] Fetch options:`, {
method: options.method,
headers: { ...headers, ...options.headers },
body: options.body ? `BODY: ${options.body}` : 'NO_BODY',
fullUrl: fullUrl,
baseURL: this.baseURL,
endpoint: endpoint
});
// Try fetch with detailed logging
console.log(`🌐 [${requestId}] === ATTEMPTING FETCH ===`);
console.log(`🌐 [${requestId}] fetch(${fullUrl}, {`);
console.log(`🌐 [${requestId}] method: ${options.method}`);
console.log(`🌐 [${requestId}] headers:`, { ...headers, ...options.headers });
console.log(`🌐 [${requestId}] body: ${options.body || 'undefined'}`);
console.log(`🌐 [${requestId}] })`);
response = await fetch(fullUrl, {
...options,
headers: {
...headers,
...options.headers,
},
});
console.log(`✅ [${requestId}] Fetch completed successfully!`);
console.log(`📊 [${requestId}] Response status: ${response.status}`);
console.log(`📊 [${requestId}] Response headers:`, Object.fromEntries(response.headers.entries()));
console.log(`📊 [${requestId}] Response URL: ${response.url}`);
} catch (fetchError: any) {
console.error(`❌ [${requestId}] Fetch failed:`, {
error: fetchError,
message: fetchError?.message,
name: fetchError?.name,
stack: fetchError?.stack
});
return {
error: `Network request failed: ${fetchError?.message || 'Unknown fetch error'}`,
status: 0,
};
}
console.log(`📥 Response received:`, {
status: response.status,
ok: response.ok,
statusText: response.statusText,
url: response.url
});
const contentType = response.headers.get('content-type');
let data: T | undefined;
// Always try to get response text first for debugging
let responseText: string;
try {
responseText = await response.text();
console.log(`📄 Response text (${responseText?.length} chars):`, {
contentType: contentType,
textPreview: responseText?.substring(0, 200),
isEmpty: !responseText,
isUndefined: responseText === 'undefined'
});
} catch (textError) {
console.error('❌ Failed to get response text:', textError);
return {
error: `Failed to read response: ${textError}`,
status: response.status,
};
}
if (contentType?.includes('application/json') && responseText) {
if (responseText === 'undefined') {
console.error('❌ Response text is literally "undefined"');
return {
error: 'Server returned "undefined" instead of valid JSON',
status: response.status,
};
}
try {
data = JSON.parse(responseText);
console.log('✅ JSON parsed successfully:', typeof data);
} catch (jsonError) {
console.error('❌ JSON parsing failed:', {
error: jsonError,
responseText: responseText,
responseLength: responseText.length
});
return {
error: `Invalid JSON response: ${jsonError}`,
status: response.status,
};
}
} else if (!responseText) {
console.warn('⚠️ Empty response body');
data = undefined;
} else {
console.log(' Non-JSON response:', { contentType, textLength: responseText.length });
}
if (!response.ok) {
// GT 2.0: Handle authentication failures gracefully
if (response.status === 401) {
// Use centralized logout from auth store
if (typeof window !== 'undefined') {
const { useAuthStore } = await import('@/stores/auth-store');
useAuthStore.getState().logout('unauthorized');
}
return {
error: 'Session expired. Please log in again.',
status: response.status,
};
}
return {
error: (data as any)?.detail || (data as any)?.message || `HTTP ${response.status}: ${response.statusText}`,
status: response.status,
};
}
return {
data,
status: response.status,
};
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Network error',
status: 0,
};
}
}
async get<T>(endpoint: string, options?: { params?: Record<string, any> }): Promise<ApiResponse<T>> {
let url = endpoint;
if (options?.params) {
const queryParams = new URLSearchParams();
Object.entries(options.params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value));
}
});
const queryString = queryParams.toString();
if (queryString) {
url = `${endpoint}?${queryString}`;
}
}
return this.makeRequest<T>(url, { method: 'GET' });
}
async post<T>(endpoint: string, data?: any): Promise<ApiResponse<T>> {
return this.makeRequest<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
async put<T>(endpoint: string, data?: any): Promise<ApiResponse<T>> {
return this.makeRequest<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.makeRequest<T>(endpoint, { method: 'DELETE' });
}
async upload<T>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> {
try {
const token = await getAuthToken();
const tenantInfo = getTenantInfo();
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
if (tenantInfo?.domain) headers['X-Tenant-Domain'] = tenantInfo.domain;
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: formData,
});
const data = await response.json();
if (!response.ok) {
return {
error: data?.detail || `Upload failed: ${response.statusText}`,
status: response.status,
};
}
return {
data,
status: response.status,
};
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Upload error',
status: 0,
};
}
}
}
// Singleton instance
export const apiClient = new ApiClient();
// Convenience methods
export const api = {
get: <T>(endpoint: string, options?: { params?: Record<string, any> }) => apiClient.get<T>(endpoint, options),
post: <T>(endpoint: string, data?: any) => apiClient.post<T>(endpoint, data),
put: <T>(endpoint: string, data?: any) => apiClient.put<T>(endpoint, data),
delete: <T>(endpoint: string) => apiClient.delete<T>(endpoint),
upload: <T>(endpoint: string, formData: FormData) => apiClient.upload<T>(endpoint, formData),
};

View File

@@ -0,0 +1,514 @@
/**
* GT 2.0 Authentication Service
*
* Handles JWT token management, user authentication, and tenant isolation.
*/
export interface User {
email: string;
full_name: string;
user_id: string;
tenant_domain: string;
role: 'admin' | 'developer' | 'analyst' | 'student';
is_active: boolean;
created_at: string;
}
export interface TenantInfo {
domain: string;
name: string;
id: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user: User;
tenant: TenantInfo;
}
// TFA Response types (minimal - session data stored server-side)
export interface TFASetupResponse {
requires_tfa: true;
tfa_configured: false;
// Session data (QR code, manual key, etc.) fetched via /tfa/session-data using HTTP-only cookie
}
export interface TFAVerificationResponse {
requires_tfa: true;
tfa_configured: true;
// Session data fetched via /tfa/session-data using HTTP-only cookie
}
// Union type for login responses
export type LoginResult = LoginResponse | TFASetupResponse | TFAVerificationResponse;
// Type guard functions
export function isTFAResponse(data: any): data is TFASetupResponse | TFAVerificationResponse {
return data && data.requires_tfa === true;
}
export function isTFASetupResponse(data: any): data is TFASetupResponse {
return data && data.requires_tfa === true && data.tfa_configured === false;
}
export function isTFAVerificationResponse(data: any): data is TFAVerificationResponse {
return data && data.requires_tfa === true && data.tfa_configured === true;
}
// GT 2.0 Single Token Key - Elegant Simplicity
const TOKEN_KEY = 'gt2_token';
const USER_KEY = 'gt2_user';
const TENANT_KEY = 'gt2_tenant';
/**
* Get stored authentication token
*/
export function getAuthToken(): string | null {
if (typeof window === 'undefined') return null;
const token = localStorage.getItem(TOKEN_KEY);
console.log('getAuthToken - retrieved token:', token ? 'EXISTS' : 'NULL');
return token;
}
/**
* Store authentication token
*/
export function setAuthToken(token: string): void {
if (typeof window === 'undefined') return;
localStorage.setItem(TOKEN_KEY, token);
}
/**
* Remove authentication token
*/
export function removeAuthToken(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
localStorage.removeItem(TENANT_KEY);
}
/**
* Get stored user information
*/
export function getUser(): User | null {
if (typeof window === 'undefined') return null;
const userStr = localStorage.getItem(USER_KEY);
return userStr ? JSON.parse(userStr) : null;
}
/**
* Store user information
*/
export function setUser(user: User): void {
if (typeof window === 'undefined') return;
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
/**
* Get stored tenant information
*/
export function getTenantInfo(): TenantInfo | null {
if (typeof window === 'undefined') return null;
const tenantStr = localStorage.getItem(TENANT_KEY);
// Check for invalid JSON strings
if (!tenantStr || tenantStr === 'undefined' || tenantStr === 'null') {
// GT 2.0: Set default tenant for development
const defaultTenant = {
domain: 'test-company',
name: 'Test Company'
};
setTenantInfo(defaultTenant);
return defaultTenant;
}
try {
return JSON.parse(tenantStr);
} catch (error) {
console.warn('Failed to parse tenant info from localStorage:', error);
// Clear invalid data
localStorage.removeItem(TENANT_KEY);
// GT 2.0: Set default tenant for development
const defaultTenant = {
domain: 'test-company',
name: 'Test Company'
};
setTenantInfo(defaultTenant);
return defaultTenant;
}
}
/**
* Store tenant information
*/
export function setTenantInfo(tenant: TenantInfo): void {
if (typeof window === 'undefined') return;
localStorage.setItem(TENANT_KEY, JSON.stringify(tenant));
}
/**
* Check if user is authenticated
*/
export function isAuthenticated(): boolean {
const token = getAuthToken();
const user = getUser();
return !!(token && user);
}
/**
* Parse JWT token payload (without verification)
*/
export function parseTokenPayload(token: string): any {
try {
// Validate input
if (!token || typeof token !== 'string') {
console.warn('parseTokenPayload: Invalid token (null or not string)');
return null;
}
const parts = token.split('.');
if (parts.length !== 3) {
console.warn('parseTokenPayload: Invalid JWT format (not 3 parts)');
return null;
}
const payload = parts[1];
if (!payload) {
console.warn('parseTokenPayload: Missing payload section');
return null;
}
// Add padding if needed for proper base64 decoding
const paddedPayload = payload + '='.repeat((4 - payload.length % 4) % 4);
const decoded = atob(paddedPayload);
return JSON.parse(decoded);
} catch (error) {
console.error('Failed to parse JWT payload:', error);
return null;
}
}
/**
* Map Control Panel user_type to Tenant role format
*
* Control Panel uses: super_admin, tenant_admin, tenant_user
* Tenant uses: admin, developer, analyst, student
*
* This bridges the gap between authentication (Control Panel) and authorization (Tenant)
*/
export function mapControlPanelRoleToTenantRole(userType?: string): 'admin' | 'developer' | 'analyst' | 'student' {
if (!userType) return 'student';
const lowerType = userType.toLowerCase();
// Both super_admin and tenant_admin map to admin role in tenant system
if (lowerType === 'super_admin' || lowerType === 'tenant_admin') {
return 'admin';
}
// Default to student for tenant_user or any other type
return 'student';
}
/**
* Check if token is expired
*/
export function isTokenExpired(token: string): boolean {
const payload = parseTokenPayload(token);
if (!payload || !payload.exp) return true;
const now = Math.floor(Date.now() / 1000);
return payload.exp < now;
}
/**
* Check if current token is valid
*/
export function isTokenValid(): boolean {
const token = getAuthToken();
if (!token) return false;
return !isTokenExpired(token);
}
/**
* Parse capabilities from JWT token (GT 2.0 Security Model)
*/
export function parseCapabilities(token: string): string[] {
const payload = parseTokenPayload(token);
if (!payload) return [];
// For super_admin users, grant all capabilities automatically
if (payload.user_type === 'super_admin') {
return [
'agents:read', 'agents:create', 'agents:edit', 'agents:delete', 'agents:execute',
'datasets:read', 'datasets:create', 'datasets:upload', 'datasets:delete',
'conversations:read', 'conversations:create', 'conversations:delete',
'documents:read', 'documents:upload', 'documents:delete',
'admin:users', 'admin:tenants', 'admin:system'
];
}
// Check for capabilities in current_tenant first (enhanced JWTs from tenant backend)
const currentTenant = payload.current_tenant || {};
let capabilities = currentTenant.capabilities || [];
// If no capabilities in current_tenant, check root level (Control Panel JWTs)
if (!Array.isArray(capabilities) || capabilities.length === 0) {
capabilities = payload.capabilities || [];
}
// Convert capability objects to strings if needed
if (Array.isArray(capabilities)) {
const parsed = capabilities.map(cap => {
if (typeof cap === 'string') return cap;
if (typeof cap === 'object' && cap.resource && cap.actions) {
// Handle wildcard capabilities (super_admin)
if (cap.resource === '*' && cap.actions.includes('*')) {
// Return all possible capabilities for wildcard access
return [
'agents:read', 'agents:create', 'agents:edit', 'agents:delete', 'agents:execute',
'datasets:read', 'datasets:create', 'datasets:upload', 'datasets:delete',
'conversations:read', 'conversations:create', 'conversations:delete',
'documents:read', 'documents:upload', 'documents:delete',
'admin:users', 'admin:tenants', 'admin:system'
];
}
// Convert {resource: "agents", actions: ["read", "write"]} to ["agents:read", "agents:write"]
return cap.actions.map((action: string) => `${cap.resource}:${action}`);
}
return cap;
}).flat();
return parsed;
}
return [];
}
/**
* Check if user has specific capability
*/
export function hasCapability(capability: string): boolean {
const token = getAuthToken();
if (!token || !isTokenValid()) return false;
const capabilities = parseCapabilities(token);
return capabilities.includes(capability);
}
/**
* Get current user's capabilities
*/
export function getUserCapabilities(): string[] {
const token = getAuthToken();
console.log('getUserCapabilities - token exists:', !!token);
if (!token) {
console.log('getUserCapabilities - no token found');
return [];
}
if (!isTokenValid()) {
console.log('getUserCapabilities - token is invalid/expired');
return [];
}
const capabilities = parseCapabilities(token);
console.log('getUserCapabilities - parsed capabilities:', capabilities);
return capabilities;
}
/**
* Login with username/password
*/
export async function login(credentials: LoginRequest): Promise<LoginResult | { error: string }> {
try {
// Get tenant domain from environment or use configured default
const tenantDomain = process.env.NEXT_PUBLIC_TENANT_DOMAIN || 'test-company';
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-Domain': tenantDomain,
},
body: JSON.stringify({
email: credentials.username,
password: credentials.password,
}),
});
if (!response.ok) {
let errorMessage = 'Login failed';
try {
const errorData = await response.json();
// Extract error message from various possible formats
if (typeof errorData === 'string') {
errorMessage = errorData;
} else if (errorData.detail) {
// Handle string detail
errorMessage = typeof errorData.detail === 'string'
? errorData.detail
: 'Login failed. Please try again.';
} else if (errorData.message) {
// Handle message field - but use friendly message for generic errors
if (typeof errorData.message === 'string') {
// If it's a generic "Login failed" message, make it more specific
if (errorData.message === 'Login failed' || errorData.message === 'login failed') {
errorMessage = 'Invalid email or password';
} else {
errorMessage = errorData.message;
}
} else {
errorMessage = 'Login failed. Please try again.';
}
} else if (errorData.error) {
errorMessage = typeof errorData.error === 'string'
? errorData.error
: 'Login failed. Please try again.';
} else {
// No recognized fields, use status-based message
errorMessage = 'Login failed. Please try again.';
}
} catch (e) {
// Failed to parse error response, will use status-based message below
}
// Override with user-friendly messages based on status code
if (response.status === 401) {
errorMessage = 'Invalid email or password';
} else if (response.status === 422) {
errorMessage = 'Please check your email and password format';
} else if (response.status === 500) {
// For 500 errors during login, it's likely an auth issue
errorMessage = 'Invalid email or password';
} else if (response.status >= 500) {
errorMessage = 'Server error. Please try again later.';
}
return { error: errorMessage };
}
const data = await response.json();
// Check if this is a TFA response (setup or verification required)
if (isTFAResponse(data)) {
// TFA required - return response for auth store to handle
// Auth store will redirect to /verify-tfa page
console.log('TFA required for login');
return data;
}
// Normal login response - validate required fields
if (!data.access_token) {
return { error: 'Server returned invalid response (missing token)' };
}
console.log('Login successful - storing authentication data');
// Store authentication data
setAuthToken(data.access_token);
setUser(data.user);
setTenantInfo(data.tenant);
return data;
} catch (error) {
console.error('Login network error:', error);
if (error instanceof TypeError && error.message.includes('fetch')) {
return { error: 'Cannot connect to server. Please check if the application is running.' };
}
return { error: error instanceof Error ? error.message : 'Network error occurred' };
}
}
/**
* Logout and clear stored data
*/
export function logout(): void {
removeAuthToken();
// Optionally call logout endpoint
// await fetch('/api/v1/auth/logout', { method: 'POST' });
}
/**
* Token refresh result type
*
* NIST/OWASP Compliant Session Management (Issue #242):
* - success: Token refreshed, session extended
* - absolute_timeout: 8-hour session limit reached, must re-login
* - error: Other refresh failure
*/
export interface RefreshResult {
success: boolean;
error?: 'absolute_timeout' | 'expired' | 'network' | 'unknown';
}
/**
* Refresh authentication token
*
* NIST/OWASP Compliant Session Management (Issue #242):
* - Returns detailed result to distinguish between idle timeout and absolute timeout
* - absolute_timeout: Session hit 8-hour limit, user must re-authenticate
* - expired: Normal token expiration (idle timeout exceeded)
*/
export async function refreshToken(): Promise<RefreshResult> {
const token = getAuthToken();
if (!token) return { success: false, error: 'expired' };
try {
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
// Check for absolute timeout (Issue #242)
// Server returns 401 with X-Session-Expired: absolute header
const sessionExpiredHeader = response.headers.get('X-Session-Expired');
if (response.status === 401 && sessionExpiredHeader === 'absolute') {
console.log('[Auth] Absolute session timeout reached (8 hours)');
return { success: false, error: 'absolute_timeout' };
}
// Regular token expiration or other auth failure
return { success: false, error: 'expired' };
}
const data = await response.json();
setAuthToken(data.access_token);
return { success: true };
} catch (error) {
console.error('[Auth] Token refresh network error:', error);
return { success: false, error: 'network' };
}
}
/**
* Auto-refresh token if needed
*/
export async function ensureValidToken(): Promise<boolean> {
const token = getAuthToken();
if (!token) return false;
if (isTokenExpired(token)) {
const result = await refreshToken();
return result.success;
}
return true;
}

View File

@@ -0,0 +1,88 @@
/**
* GT 2.0 Categories Service
*
* API client for tenant-scoped agent category management.
* Supports Issue #215 requirements for editable/deletable categories.
*/
import { api, ApiResponse } from './api';
/**
* Category data from the backend
*/
export interface Category {
id: string;
name: string;
slug: string;
description: string | null;
icon: string | null;
is_default: boolean;
created_by: string | null;
created_by_name: string | null;
can_edit: boolean;
can_delete: boolean;
sort_order: number;
created_at: string;
updated_at: string;
}
/**
* Response from listing categories
*/
export interface CategoryListResponse {
categories: Category[];
total: number;
}
/**
* Request to create a new category
*/
export interface CategoryCreateRequest {
name: string;
description?: string;
icon?: string;
}
/**
* Request to update a category
*/
export interface CategoryUpdateRequest {
name?: string;
description?: string;
icon?: string;
}
/**
* Get all categories for the tenant
*/
export async function getCategories(): Promise<ApiResponse<CategoryListResponse>> {
return api.get<CategoryListResponse>('/api/v1/categories');
}
/**
* Get a single category by ID
*/
export async function getCategory(id: string): Promise<ApiResponse<Category>> {
return api.get<Category>(`/api/v1/categories/${id}`);
}
/**
* Create a new category
*/
export async function createCategory(data: CategoryCreateRequest): Promise<ApiResponse<Category>> {
return api.post<Category>('/api/v1/categories', data);
}
/**
* Update an existing category
*/
export async function updateCategory(id: string, data: CategoryUpdateRequest): Promise<ApiResponse<Category>> {
return api.put<Category>(`/api/v1/categories/${id}`, data);
}
/**
* Delete a category (soft delete)
*/
export async function deleteCategory(id: string): Promise<ApiResponse<{ message: string }>> {
return api.delete<{ message: string }>(`/api/v1/categories/${id}`);
}

View File

@@ -0,0 +1,325 @@
/**
* GT 2.0 Streaming Chat Service
*
* Handles Server-Sent Events (SSE) streaming from the chat API
* for real-time AI response display.
*/
import { getAuthToken, getTenantInfo, isTokenValid } from './auth';
export interface StreamingChatMessage {
role: 'user' | 'agent' | 'system';
content: string;
name?: string;
}
export interface StreamingChatRequest {
model: string;
messages: StreamingChatMessage[];
temperature?: number;
max_tokens?: number;
top_p?: number;
frequency_penalty?: number;
presence_penalty?: number;
stop?: string | string[] | null;
stream: boolean;
agent_id?: string;
conversation_id?: string;
// Search Control Extensions
knowledge_search_enabled?: boolean;
// RAG Extensions
use_rag?: boolean;
dataset_ids?: string[];
rag_max_chunks?: number;
rag_similarity_threshold?: number;
}
export interface StreamingChunk {
id: string;
object: string;
created: number;
model: string;
choices: Array<{
index: number;
delta: {
content?: string;
role?: string;
};
finish_reason?: string | null;
}>;
conversation_id?: string;
agent_id?: string;
}
export interface TokenUsage {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
// Compound model billing - actual costs from Groq response
cost_breakdown?: {
models?: Array<{model_id: string; input_tokens: number; output_tokens: number; cost_dollars: number}>;
tools?: Array<{tool_name: string; invocations: number; cost_dollars: number}>;
total_cost_dollars?: number;
total_cost_cents?: number;
};
}
export type StreamingEventHandler = {
onStart?: () => void;
onChunk?: (chunk: StreamingChunk) => void;
onContent?: (content: string) => void;
onComplete?: (fullContent: string, finishReason?: string, model?: string, usage?: TokenUsage) => void;
onError?: (error: Error) => void;
};
class ChatService {
private baseURL: string;
private abortControllers: Map<string, AbortController> = new Map();
constructor() {
// Use relative path for browser, will be proxied by Next.js to tenant-backend via Docker network
this.baseURL = '';
}
private getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Check token validity first
if (!isTokenValid()) {
console.warn('ChatService: Token invalid/expired, triggering logout');
// Trigger logout immediately before attempting request
if (typeof window !== 'undefined') {
import('@/stores/auth-store').then(({ useAuthStore }) => {
useAuthStore.getState().logout('expired');
});
}
return headers; // Return without auth header - request will fail with 401
}
const token = getAuthToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
// Add tenant context
const tenantInfo = getTenantInfo();
if (tenantInfo?.domain) {
headers['X-Tenant-Domain'] = tenantInfo.domain;
}
return headers;
}
async streamChatCompletion(
request: StreamingChatRequest,
handlers: StreamingEventHandler
): Promise<void> {
const conversationId = request.conversation_id || 'default';
// Cancel any existing stream for this conversation
const existingController = this.abortControllers.get(conversationId);
if (existingController) {
existingController.abort();
}
const controller = new AbortController();
this.abortControllers.set(conversationId, controller);
try {
const headers = this.getAuthHeaders();
const url = `${this.baseURL}/api/v1/chat/completions`;
console.log('🌊 Starting streaming chat completion:', {
url,
model: request.model,
messageCount: request.messages.length,
agentId: request.agent_id
});
handlers.onStart?.();
// Use non-streaming response for reliability
const requestData = { ...request, stream: false };
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(requestData),
signal: controller.signal,
});
if (!response.ok) {
// Handle 401 - session expired
if (response.status === 401) {
if (typeof window !== 'undefined') {
const { useAuthStore } = await import('@/stores/auth-store');
useAuthStore.getState().logout('expired');
}
throw new Error('SESSION_EXPIRED');
}
// Handle 402 - Budget exceeded (Issue #234)
if (response.status === 402) {
let detail = 'Monthly budget limit reached. Contact your administrator.';
try {
const errorData = await response.json();
detail = errorData.detail || detail;
} catch {
// Use default message if JSON parsing fails
}
const budgetError = new Error('BUDGET_EXCEEDED');
(budgetError as any).detail = detail;
throw budgetError;
}
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
// Get the complete JSON response instead of streaming
const result = await response.json();
console.log('🌊 Received complete response:', result);
// Extract content, finish_reason, model, and usage from the response
const content = result.choices?.[0]?.message?.content || '';
const finishReason = result.choices?.[0]?.finish_reason;
const model = result.model;
const usage: TokenUsage | undefined = result.usage ? {
prompt_tokens: result.usage.prompt_tokens,
completion_tokens: result.usage.completion_tokens,
total_tokens: result.usage.total_tokens,
// Include cost_breakdown for Compound models (pass-through billing)
cost_breakdown: result.cost_breakdown
} : undefined;
console.log('🌊 Extracted content:', content);
console.log('🌊 Finish reason:', finishReason);
console.log('🌊 Model:', model);
console.log('🌊 Token usage:', usage);
// Handle non-streaming response
if (content) {
// Simulate the streaming behavior for UI compatibility
handlers.onContent?.(content);
console.log('🌊 Complete content delivered');
}
// Call completion handler with all metadata
handlers.onComplete?.(content, finishReason, model, usage);
console.log('🌊 Non-streaming chat completed');
} catch (error) {
console.error('🌊 Streaming error:', error);
if (error instanceof Error) {
if (error.name === 'AbortError') {
console.log('🌊 Stream aborted by user');
return;
}
handlers.onError?.(error);
} else {
handlers.onError?.(new Error('Unknown streaming error'));
}
} finally {
// Clean up the controller for this conversation
this.abortControllers.delete(conversationId);
}
}
/**
* Cancel the streaming request for a specific conversation
*/
cancelStream(conversationId?: string): void {
if (conversationId) {
const controller = this.abortControllers.get(conversationId);
if (controller) {
console.log(`🌊 Canceling stream for conversation: ${conversationId}`);
controller.abort();
this.abortControllers.delete(conversationId);
}
} else {
// Cancel all streams if no conversation ID provided
console.log('🌊 Canceling all streams...');
this.abortControllers.forEach((controller) => controller.abort());
this.abortControllers.clear();
}
}
/**
* Check if a stream is currently active for a specific conversation
*/
isStreaming(conversationId?: string): boolean {
if (conversationId) {
return this.abortControllers.has(conversationId);
}
return this.abortControllers.size > 0;
}
}
// Export singleton instance
export const chatService = new ChatService();
// Utility function for easy streaming
export async function streamChat(
messages: StreamingChatMessage[],
options: {
model?: string;
agentId?: string;
conversationId?: string;
temperature?: number;
maxTokens?: number;
use_rag?: boolean;
dataset_ids?: string[];
rag_max_chunks?: number;
rag_similarity_threshold?: number;
onContent?: (content: string) => void;
onComplete?: (fullContent: string) => void;
onError?: (error: Error) => void;
} = {}
): Promise<void> {
// Require model to be explicitly provided - no hardcoded fallback
if (!options.model) {
throw new Error('Model must be specified - no default model available');
}
// Auto-detect knowledge search enabled based on dataset presence
const knowledgeSearchEnabled = options.use_rag && options.dataset_ids && options.dataset_ids.length > 0;
const request: StreamingChatRequest = {
model: options.model,
messages,
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens,
stream: false,
agent_id: options.agentId,
conversation_id: options.conversationId,
// Search Control Extensions
knowledge_search_enabled: knowledgeSearchEnabled,
// RAG Extensions
use_rag: options.use_rag,
dataset_ids: options.dataset_ids,
rag_max_chunks: options.rag_max_chunks,
rag_similarity_threshold: options.rag_similarity_threshold,
};
console.log('🔧 Chat service parameters:', {
knowledge_search_enabled: request.knowledge_search_enabled,
use_rag: request.use_rag,
dataset_count: request.dataset_ids?.length || 0
});
const handlers: StreamingEventHandler = {
onStart: () => console.log('🌊 Chat stream started'),
onContent: options.onContent,
onComplete: options.onComplete,
onError: options.onError,
};
await chatService.streamChatCompletion(request, handlers);
}

View File

@@ -0,0 +1,246 @@
/**
* GT 2.0 Conversation Service
*
* API client for chat conversation management and WebSocket messaging.
*/
import { api } from './api';
export interface Message {
id: string;
conversation_id: string;
role: 'user' | 'agent' | 'system';
content: string;
metadata?: {
context_sources?: string[];
agent_id?: string;
model?: string;
token_count?: number;
processing_time_ms?: number;
};
created_at: string;
}
export interface Conversation {
id: string;
user_id: string;
agent_id: string;
title: string;
model_id: string;
message_count: number;
is_archived: boolean;
created_at: string;
updated_at: string;
last_message_at?: string;
}
export interface CreateConversationRequest {
agent_id: string;
title?: string;
}
export interface SendMessageRequest {
content: string;
context_sources?: string[];
stream?: boolean;
}
export interface StreamingResponse {
delta: string;
done: boolean;
metadata?: {
model?: string;
token_count?: number;
processing_time_ms?: number;
};
}
/**
* List user's conversations
*/
export async function listConversations(params?: {
agent_id?: string;
limit?: number;
offset?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.agent_id) searchParams.set('agent_id', params.agent_id);
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.offset) searchParams.set('offset', params.offset.toString());
const query = searchParams.toString();
return api.get<{
conversations: Conversation[];
total: number;
limit: number;
offset: number;
}>(`/api/v1/conversations${query ? `?${query}` : ''}`);
}
/**
* Create new conversation
*/
export async function createConversation(request: CreateConversationRequest) {
return api.post<Conversation>('/api/v1/conversations', request);
}
/**
* Get specific conversation
*/
export async function getConversation(conversationId: string, includeMessages: boolean = false) {
const params = includeMessages ? '?include_messages=true' : '';
return api.get<{
conversation: Conversation;
messages?: Message[];
}>(`/api/v1/conversations/${conversationId}${params}`);
}
/**
* Delete conversation
*/
export async function deleteConversation(conversationId: string) {
return api.delete(`/api/v1/conversations/${conversationId}`);
}
/**
* Archive/unarchive conversation
*/
export async function toggleConversationArchive(conversationId: string, isArchived: boolean) {
return api.put<Conversation>(`/api/v1/conversations/${conversationId}`, {
is_archived: isArchived
});
}
/**
* Update conversation title
*/
export async function updateConversationTitle(conversationId: string, title: string) {
return api.put<Conversation>(`/api/v1/conversations/${conversationId}`, { title });
}
/**
* Get conversation messages
*/
export async function getConversationMessages(
conversationId: string,
params?: {
limit?: number;
offset?: number;
}
) {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.offset) searchParams.set('offset', params.offset.toString());
const query = searchParams.toString();
return api.get<{
messages: Message[];
total: number;
limit: number;
offset: number;
}>(`/api/v1/conversations/${conversationId}/messages${query ? `?${query}` : ''}`);
}
/**
* Send message to conversation (non-streaming)
*/
export async function sendMessage(conversationId: string, request: SendMessageRequest) {
return api.post<Message>(`/api/v1/conversations/${conversationId}/messages`, {
...request,
stream: false
});
}
/**
* WebSocket connection for real-time chat
*/
export class ChatWebSocket {
private ws: WebSocket | null = null;
private conversationId: string;
private onMessage: (message: StreamingResponse) => void;
private onError: (error: string) => void;
private onOpen: () => void;
private onClose: () => void;
constructor(
conversationId: string,
callbacks: {
onMessage: (message: StreamingResponse) => void;
onError: (error: string) => void;
onOpen: () => void;
onClose: () => void;
}
) {
this.conversationId = conversationId;
this.onMessage = callbacks.onMessage;
this.onError = callbacks.onError;
this.onOpen = callbacks.onOpen;
this.onClose = callbacks.onClose;
}
connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/chat/${this.conversationId}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.onOpen();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.onMessage(data);
} catch (error) {
this.onError('Failed to parse WebSocket message');
}
};
this.ws.onerror = () => {
this.onError('WebSocket connection error');
};
this.ws.onclose = () => {
this.onClose();
};
}
sendMessage(request: SendMessageRequest) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'send_message',
...request,
stream: true
}));
} else {
this.onError('WebSocket not connected');
}
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
}
/**
* Create WebSocket connection for conversation
*/
export function createChatWebSocket(
conversationId: string,
callbacks: {
onMessage: (message: StreamingResponse) => void;
onError: (error: string) => void;
onOpen: () => void;
onClose: () => void;
}
): ChatWebSocket {
return new ChatWebSocket(conversationId, callbacks);
}

View File

@@ -0,0 +1,285 @@
/**
* Dataset Service for GT 2.0
*
* Provides CRUD operations and access control for datasets with
* proper tenant isolation and user permissions.
*/
import { api, ApiResponse } from './api';
// Access control types
export type AccessGroup = 'individual' | 'team' | 'organization';
export type AccessFilter = 'all' | 'mine' | 'team' | 'org';
// Dataset interfaces
export interface Dataset {
id: string;
name: string;
description?: string;
owner_id: string;
access_group: AccessGroup;
team_members: string[];
document_count: number;
chunk_count: number;
vector_count: number;
storage_size_mb: number;
tags: string[];
created_at: string;
updated_at: string;
// Chunking configuration
chunking_strategy?: 'hybrid' | 'semantic' | 'fixed';
chunk_size?: number;
chunk_overlap?: number;
embedding_model?: string;
// Access indicators for UI
is_owner: boolean;
can_edit: boolean;
can_delete: boolean;
can_share: boolean;
}
export interface CreateDatasetRequest {
name: string;
description?: string;
access_group?: AccessGroup;
team_members?: string[];
tags?: string[];
}
export interface UpdateDatasetRequest {
name?: string;
description?: string;
tags?: string[];
access_group?: AccessGroup;
team_members?: string[];
chunking_strategy?: 'hybrid' | 'semantic' | 'fixed';
chunk_size?: number;
chunk_overlap?: number;
embedding_model?: string;
}
export interface ShareDatasetRequest {
access_group: AccessGroup;
team_members?: string[];
}
export interface DatasetStats {
dataset_id: string;
name: string;
document_count: number;
chunk_count: number;
vector_count: number;
storage_size_mb: number;
created_at: string;
updated_at: string;
access_group: AccessGroup;
team_member_count: number;
tags: string[];
}
// Dataset service class
export class DatasetService {
/**
* List datasets based on user access rights
*/
async listDatasets(options: {
access_filter?: AccessFilter;
include_stats?: boolean;
} = {}): Promise<ApiResponse<Dataset[]>> {
const params = new URLSearchParams();
if (options.access_filter) {
params.append('access_filter', options.access_filter);
}
if (options.include_stats !== undefined) {
params.append('include_stats', options.include_stats.toString());
}
const query = params.toString();
const endpoint = `/api/v1/datasets/${query ? `?${query}` : ''}`;
return api.get<Dataset[]>(endpoint);
}
/**
* Get specific dataset details
*/
async getDataset(datasetId: string): Promise<ApiResponse<Dataset>> {
return api.get<Dataset>(`/api/v1/datasets/${datasetId}`);
}
/**
* Create new dataset
*/
async createDataset(request: CreateDatasetRequest): Promise<ApiResponse<Dataset>> {
return api.post<Dataset>('/api/v1/datasets/', request);
}
/**
* Update existing dataset (owner only)
*/
async updateDataset(
datasetId: string,
request: UpdateDatasetRequest
): Promise<ApiResponse<Dataset>> {
return api.put<Dataset>(`/api/v1/datasets/${datasetId}`, request);
}
/**
* Share dataset with team or organization (owner only)
*/
async shareDataset(
datasetId: string,
request: ShareDatasetRequest
): Promise<ApiResponse<Dataset>> {
return api.put<Dataset>(`/api/v1/datasets/${datasetId}/share`, request);
}
/**
* Delete dataset (owner only)
*/
async deleteDataset(datasetId: string): Promise<ApiResponse<{ message: string }>> {
return api.delete<{ message: string }>(`/api/v1/datasets/${datasetId}`);
}
/**
* Add documents to dataset
*/
async addDocumentsToDataset(
datasetId: string,
documentIds: string[]
): Promise<ApiResponse<{
message: string;
dataset_id: string;
added_documents: string[];
failed_documents: string[];
}>> {
return api.post(
`/api/v1/datasets/${datasetId}/documents`,
documentIds
);
}
/**
* Get detailed dataset statistics
*/
async getDatasetStats(datasetId: string): Promise<ApiResponse<DatasetStats>> {
return api.get<DatasetStats>(`/api/v1/datasets/${datasetId}/stats`);
}
/**
* Get datasets filtered by access level
*/
async getMyDatasets(): Promise<ApiResponse<Dataset[]>> {
return this.listDatasets({ access_filter: 'mine' });
}
async getTeamDatasets(): Promise<ApiResponse<Dataset[]>> {
return this.listDatasets({ access_filter: 'team' });
}
async getOrgDatasets(): Promise<ApiResponse<Dataset[]>> {
return this.listDatasets({ access_filter: 'org' });
}
/**
* Search datasets by name or description
*/
async searchDatasets(query: string, accessFilter?: AccessFilter): Promise<ApiResponse<Dataset[]>> {
const allDatasets = await this.listDatasets({ access_filter: accessFilter });
if (!allDatasets.data) {
return allDatasets;
}
const filtered = allDatasets.data.filter(dataset =>
dataset.name.toLowerCase().includes(query.toLowerCase()) ||
dataset.description?.toLowerCase().includes(query.toLowerCase()) ||
dataset.tags.some(tag => tag.toLowerCase().includes(query.toLowerCase()))
);
return {
data: filtered,
status: allDatasets.status
};
}
/**
* Get datasets by tag
*/
async getDatasetsByTag(tag: string, accessFilter?: AccessFilter): Promise<ApiResponse<Dataset[]>> {
const allDatasets = await this.listDatasets({ access_filter: accessFilter });
if (!allDatasets.data) {
return allDatasets;
}
const filtered = allDatasets.data.filter(dataset =>
dataset.tags.some(t => t.toLowerCase() === tag.toLowerCase())
);
return {
data: filtered,
status: allDatasets.status
};
}
/**
* Get user's dataset statistics summary
*/
async getUserDatasetSummary(): Promise<ApiResponse<{
total_datasets: number;
owned_datasets: number;
team_datasets: number;
org_datasets: number;
total_documents: number;
total_storage_mb: number;
}>> {
// Call the new complete summary endpoint
return api.get('/api/v1/datasets/summary/complete');
}
/**
* Get AI-generated summary for a specific dataset
*/
async getDatasetSummary(
datasetId: string,
forceRegenerate: boolean = false
): Promise<ApiResponse<{
summary: string;
key_topics: string[];
document_types: Record<string, number>;
total_documents: number;
total_chunks: number;
common_themes: string[];
search_optimization_tips: string[];
generated_at?: string;
}>> {
const params = forceRegenerate ? '?force_regenerate=true' : '';
return api.get(`/api/v1/datasets/${datasetId}/summary${params}`);
}
}
// Singleton instance
export const datasetService = new DatasetService();
// Convenience exports with proper binding
export const listDatasets = datasetService.listDatasets.bind(datasetService);
export const getDataset = datasetService.getDataset.bind(datasetService);
export const createDataset = datasetService.createDataset.bind(datasetService);
export const updateDataset = datasetService.updateDataset.bind(datasetService);
export const shareDataset = datasetService.shareDataset.bind(datasetService);
export const deleteDataset = datasetService.deleteDataset.bind(datasetService);
export const addDocumentsToDataset = datasetService.addDocumentsToDataset.bind(datasetService);
export const getDatasetStats = datasetService.getDatasetStats.bind(datasetService);
export const getMyDatasets = datasetService.getMyDatasets.bind(datasetService);
export const getTeamDatasets = datasetService.getTeamDatasets.bind(datasetService);
export const getOrgDatasets = datasetService.getOrgDatasets.bind(datasetService);
export const searchDatasets = datasetService.searchDatasets.bind(datasetService);
export const getDatasetsByTag = datasetService.getDatasetsByTag.bind(datasetService);
export const getUserDatasetSummary = datasetService.getUserDatasetSummary.bind(datasetService);
export const getDatasetSummary = datasetService.getDatasetSummary.bind(datasetService);

View File

@@ -0,0 +1,524 @@
/**
* GT 2.0 Document & RAG Service
*
* API client for document upload, processing, and RAG dataset management
* with real-time progress tracking and comprehensive error handling.
*/
import { api } from './api';
import { getTenantInfo } from './auth';
export type DocumentStatus = 'uploading' | 'processing' | 'completed' | 'failed' | 'pending';
export type DocumentType = 'pdf' | 'docx' | 'txt' | 'md' | 'csv' | 'xlsx' | 'pptx' | 'html' | 'json';
export interface Document {
id: string;
name: string;
filename: string;
original_filename: string;
file_path: string;
file_type: string;
file_extension: string;
file_size_bytes: number;
uploaded_by: string;
processing_status: DocumentStatus;
chunk_count?: number;
vector_count?: number;
error_details?: any;
created_at: string;
updated_at?: string;
chunks_processed?: number;
total_chunks_expected?: number;
processing_progress?: number;
processing_stage?: string;
// New fields for enhanced UI
access_group?: 'individual' | 'team' | 'organization';
tags?: string[];
dataset_id?: string;
can_edit?: boolean;
can_delete?: boolean;
// Content metadata
page_count?: number;
word_count?: number;
character_count?: number;
language?: string;
}
export interface RAGDataset {
id: string;
user_id: string;
dataset_name: string;
description?: string;
chunking_strategy: string;
chunk_size: number;
chunk_overlap: number;
embedding_model: string;
document_count: number;
chunk_count: number;
vector_count: number;
total_size_bytes: number;
created_at: string;
updated_at: string;
}
export interface CreateDatasetRequest {
dataset_name: string;
description?: string;
chunking_strategy?: 'hybrid' | 'semantic' | 'fixed';
chunk_size?: number;
chunk_overlap?: number;
embedding_model?: string;
}
export interface SearchResult {
document: string;
metadata: any;
similarity: number;
chunk_id: string;
source_document_id: string;
}
export interface SearchResponse {
query: string;
results: SearchResult[];
}
export interface DocumentContext {
document_id: string;
document_name: string;
query: string;
relevant_chunks: SearchResult[];
context_text: string;
}
export interface RAGStatistics {
user_id: string;
document_count: number;
dataset_count: number;
total_size_bytes: number;
total_size_mb: number;
total_chunks: number;
processed_documents: number;
pending_documents: number;
failed_documents: number;
}
export interface ConversationHistoryResult {
conversation_id: string;
message_id: string;
content: string;
role: string;
created_at: string;
conversation_title: string;
agent_name: string;
relevance_score: number;
}
/**
* List user's documents with optional filtering
*/
export async function listDocuments(params?: {
status?: 'pending' | 'processing' | 'completed' | 'failed';
dataset_id?: string;
offset?: number;
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set('status', params.status);
if (params?.dataset_id) searchParams.set('dataset_id', params.dataset_id);
if (params?.offset) searchParams.set('offset', params.offset.toString());
if (params?.limit) searchParams.set('limit', params.limit.toString());
const query = searchParams.toString();
return api.get<Document[]>(`/api/v1/documents${query ? `?${query}` : ''}`);
}
/**
* Get documents for a specific dataset
*/
export async function getDocumentsByDataset(datasetId: string) {
return api.get<Document[]>(`/api/v1/documents?dataset_id=${datasetId}`);
}
// Upload progress tracking interfaces
export interface UploadProgressEvent {
document_id: string;
filename: string;
bytes_uploaded: number;
total_bytes: number;
percentage: number;
status: DocumentStatus;
error?: string;
}
export interface ProcessingProgressEvent {
document_id: string;
status: DocumentStatus;
stage: 'extracting' | 'chunking' | 'embedding' | 'indexing' | 'completed';
progress_percentage: number;
chunks_processed: number;
total_chunks: number;
error?: string;
}
export interface BulkUploadOptions {
dataset_id?: string;
chunking_strategy?: 'hybrid'; // Always hybrid for AI-driven optimization
embedding_model?: string;
access_group?: 'individual' | 'team' | 'organization';
team_members?: string[];
tags?: string[];
auto_process?: boolean;
}
/**
* Upload document with progress tracking
*/
export async function uploadDocument(
file: File,
options: BulkUploadOptions = {},
onProgress?: (event: UploadProgressEvent) => void
) {
const formData = new FormData();
formData.append('file', file);
// Add options to form data
Object.entries(options).forEach(([key, value]) => {
if (value !== undefined) {
if (Array.isArray(value)) {
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, value.toString());
}
}
});
const uploadId = crypto.randomUUID();
const tenantInfo = getTenantInfo();
if (!tenantInfo) {
throw new Error('Tenant information not available. Please log in again.');
}
return api.upload<Document>('/api/v1/documents', formData, {
headers: {
'X-Upload-ID': uploadId,
'X-Tenant-Domain': tenantInfo.domain,
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100);
onProgress({
document_id: uploadId,
filename: file.name,
bytes_uploaded: progressEvent.loaded,
total_bytes: progressEvent.total,
percentage,
status: percentage < 100 ? 'uploading' : 'processing'
});
}
}
});
}
/**
* Get specific document
*/
export async function getDocument(documentId: string) {
// Documents are now proxied through the documents API which wraps files
return api.get<Document>(`/api/v1/documents/${documentId}`);
}
/**
* Process document (chunking and embedding generation)
*/
export async function processDocument(
documentId: string,
chunkingStrategy?: 'hybrid' | 'semantic' | 'fixed'
) {
const params = new URLSearchParams();
if (chunkingStrategy) {
params.set('chunking_strategy', chunkingStrategy);
}
return api.post<{
status: string;
document_id: string;
chunk_count: number;
vector_store_ids: string[];
}>(`/api/v1/documents/${documentId}/process${params.toString() ? `?${params.toString()}` : ''}`);
}
/**
* Delete document
*/
export async function deleteDocument(documentId: string) {
return api.delete(`/api/v1/documents/${documentId}`);
}
/**
* Get document context for query
*/
export async function getDocumentContext(
documentId: string,
query: string,
contextSize: number = 3
) {
const params = new URLSearchParams({
query,
context_size: contextSize.toString(),
});
return api.get<DocumentContext>(`/api/v1/documents/${documentId}/context?${params.toString()}`);
}
/**
* Create RAG dataset
*/
export async function createDataset(request: CreateDatasetRequest) {
return api.post<RAGDataset>('/api/v1/datasets', request);
}
/**
* List user's RAG datasets
*/
export async function listDatasets() {
return api.get<RAGDataset[]>('/api/v1/datasets');
}
/**
* Delete RAG dataset
*/
export async function deleteDataset(datasetId: string) {
return api.delete(`/api/v1/datasets/${datasetId}`);
}
/**
* Search documents using RAG
*/
export async function searchDocuments(params: {
query: string;
dataset_ids?: string[];
top_k?: number;
similarity_threshold?: number;
search_method?: 'vector' | 'hybrid' | 'keyword';
}) {
const searchParams = new URLSearchParams({
query: params.query,
});
if (params.dataset_ids && params.dataset_ids.length > 0) {
params.dataset_ids.forEach(id => searchParams.append('dataset_ids', id));
}
if (params.top_k) searchParams.set('top_k', params.top_k.toString());
if (params.similarity_threshold) searchParams.set('similarity_threshold', params.similarity_threshold.toString());
if (params.search_method) searchParams.set('search_method', params.search_method);
return api.post<SearchResponse>(`/api/v1/search?${searchParams.toString()}`);
}
/**
* Get RAG usage statistics
*/
export async function getRAGStatistics() {
return api.get<RAGStatistics>('/api/v1/statistics');
}
/**
* Upload multiple files with batch progress tracking
*/
export async function uploadMultipleDocuments(
files: File[],
options: BulkUploadOptions = {},
onProgress?: (events: UploadProgressEvent[]) => void
) {
const fileArray = Array.from(files);
const progressEvents: UploadProgressEvent[] = [];
const uploadPromises = fileArray.map(file =>
uploadDocument(file, options, (event) => {
const existingIndex = progressEvents.findIndex(e => e.document_id === event.document_id);
if (existingIndex >= 0) {
progressEvents[existingIndex] = event;
} else {
progressEvents.push(event);
}
onProgress?.(progressEvents);
})
);
return Promise.allSettled(uploadPromises);
}
/**
* Validate files before upload
*/
export function validateFiles(files: FileList | File[]): {
valid: File[];
invalid: { file: File; reason: string; }[];
} {
const fileArray = Array.from(files);
const valid: File[] = [];
const invalid: { file: File; reason: string; }[] = [];
const supportedTypes = ['pdf', 'docx', 'txt', 'md', 'csv', 'xlsx', 'pptx', 'html', 'json'];
const maxFileSize = 50 * 1024 * 1024; // 50MB
for (const file of fileArray) {
const extension = file.name.split('.').pop()?.toLowerCase();
if (!extension || !supportedTypes.includes(extension)) {
invalid.push({ file, reason: `Unsupported file type: ${extension}` });
continue;
}
if (file.size > maxFileSize) {
invalid.push({ file, reason: `File too large: ${Math.round(file.size / 1024 / 1024)}MB (max: 50MB)` });
continue;
}
if (file.size === 0) {
invalid.push({ file, reason: 'Empty file' });
continue;
}
valid.push(file);
}
return { valid, invalid };
}
/**
* Get processing status for multiple documents
*/
export async function getProcessingStatus(documentIds: string[]) {
return api.post<{
[documentId: string]: {
status: DocumentStatus;
progress: number;
stage?: string;
error?: string;
};
}>('/api/v1/documents/processing-status', { document_ids: documentIds });
}
/**
* Subscribe to real-time processing updates via WebSocket
*/
export function subscribeToProcessingUpdates(
documentIds: string[],
onUpdate: (event: ProcessingProgressEvent) => void,
onError?: (error: Error) => void
): () => void {
// Get auth token for WebSocket connection
const token = localStorage.getItem('auth_token');
if (!token) {
onError?.(new Error('No authentication token available'));
return () => {};
}
// WebSocket URL - use relative ws:// for browser, proxy handles routing
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/documents/processing`;
const ws = new WebSocket(`${wsUrl}?token=${token}&document_ids=${documentIds.join(',')}`);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as ProcessingProgressEvent;
onUpdate(data);
} catch (error) {
onError?.(new Error('Invalid WebSocket message format'));
}
};
ws.onerror = (error) => {
onError?.(new Error('WebSocket connection error'));
};
ws.onclose = (event) => {
if (event.code !== 1000) { // Not a normal close
onError?.(new Error(`WebSocket closed unexpectedly: ${event.reason}`));
}
};
// Return cleanup function
return () => {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close(1000, 'Client disconnect');
}
};
}
/**
* Reprocess document with new settings
*/
export async function reprocessDocument(
documentId: string,
options: {
chunking_strategy?: 'hybrid'; // Always hybrid for AI-driven optimization
embedding_model?: string;
},
onProgress?: (event: ProcessingProgressEvent) => void
) {
return api.post<Document>(`/api/v1/documents/${documentId}/reprocess`, options);
}
/**
* Generate summary for a document
*/
export async function generateSummary(documentId: string) {
return api.get<{
summary: string;
key_topics?: string[];
document_type?: string;
language?: string;
metadata?: Record<string, any>;
}>(`/api/v1/documents/${documentId}/summary`);
}
/**
* Search conversation history
*/
export async function searchConversationHistory(params: {
query: string;
agent_filter?: string[];
days_back?: number;
limit?: number;
}) {
const searchParams = new URLSearchParams({
query: params.query,
});
if (params.agent_filter && params.agent_filter.length > 0) {
params.agent_filter.forEach(id => searchParams.append('agent_filter', id));
}
if (params.days_back) searchParams.set('days_back', params.days_back.toString());
if (params.limit) searchParams.set('limit', params.limit.toString());
return api.get<ConversationHistoryResult[]>(`/api/v1/history/search?${searchParams.toString()}`);
}
// Create singleton for direct imports
export const documentService = {
listDocuments,
getDocument,
uploadDocument,
processDocument,
deleteDocument,
getDocumentContext,
createDataset,
listDatasets,
deleteDataset,
searchDocuments,
getRAGStatistics,
uploadMultipleDocuments,
validateFiles,
getProcessingStatus,
subscribeToProcessingUpdates,
reprocessDocument,
generateSummary,
searchConversationHistory,
getDocumentsByDataset
};

View File

@@ -0,0 +1,264 @@
/**
* GT 2.0 Games & AI Literacy Service
*
* API client for educational games, puzzles, and learning analytics.
*/
import { api } from './api';
export interface GameData {
type: string;
name: string;
description: string;
current_rating: number;
difficulty_levels: string[];
features: string[];
estimated_time: string;
skills_developed: string[];
}
export interface PuzzleData {
type: string;
name: string;
description: string;
difficulty_range: [number, number];
skills_developed: string[];
}
export interface DilemmaData {
type: string;
name: string;
description: string;
topics: string[];
skills_developed: string[];
}
export interface GameSession {
session_id: string;
game_type: string;
difficulty: string;
status: 'active' | 'completed' | 'paused';
start_time: string;
end_time?: string;
moves_played?: number;
current_position?: any;
ai_analysis?: any;
performance_metrics?: {
accuracy: number;
time_per_move: number;
blunders: number;
excellent_moves: number;
};
}
export interface PuzzleSession {
session_id: string;
puzzle_type: string;
difficulty: number;
status: 'active' | 'completed' | 'failed';
start_time: string;
end_time?: string;
attempts: number;
hints_used: number;
solution_quality?: number;
}
export interface DilemmaSession {
session_id: string;
dilemma_type: string;
topic: string;
status: 'active' | 'completed';
start_time: string;
end_time?: string;
responses: any[];
ethical_frameworks_explored: string[];
depth_score?: number;
}
export interface LearningAnalytics {
overall_progress: {
total_sessions: number;
total_time_minutes: number;
current_streak: number;
longest_streak: number;
};
game_ratings: {
chess: number;
go: number;
puzzle_level: number;
philosophical_depth: number;
};
cognitive_skills: {
strategic_thinking: number;
logical_reasoning: number;
creative_problem_solving: number;
ethical_reasoning: number;
pattern_recognition: number;
metacognitive_awareness: number;
};
thinking_style: {
system1_reliance: number;
system2_engagement: number;
intuition_accuracy: number;
reflection_frequency: number;
};
ai_collaboration: {
dependency_index: number;
prompt_engineering: number;
output_evaluation: number;
collaborative_solving: number;
};
achievements: string[];
recommendations: string[];
}
export interface StartGameRequest {
game_type: 'chess' | 'go';
difficulty: 'beginner' | 'intermediate' | 'advanced' | 'expert';
features?: string[];
}
export interface StartPuzzleRequest {
puzzle_type: 'lateral_thinking' | 'logical_deduction' | 'mathematical_reasoning' | 'spatial_reasoning';
difficulty: number;
}
export interface StartDilemmaRequest {
dilemma_type: 'ethical_frameworks' | 'game_theory' | 'ai_consciousness';
topic: string;
}
/**
* Get available games
*/
export async function getAvailableGames() {
return api.get<GameData[]>('/api/v1/games/available');
}
/**
* Get available puzzles
*/
export async function getAvailablePuzzles() {
return api.get<PuzzleData[]>('/api/v1/games/puzzles/available');
}
/**
* Get available dilemmas
*/
export async function getAvailableDilemmas() {
return api.get<DilemmaData[]>('/api/v1/games/dilemmas/available');
}
/**
* Start new game session
*/
export async function startGame(request: StartGameRequest) {
return api.post<GameSession>('/api/v1/games/start', request);
}
/**
* Start new puzzle session
*/
export async function startPuzzle(request: StartPuzzleRequest) {
return api.post<PuzzleSession>('/api/v1/games/puzzles/start', request);
}
/**
* Start new dilemma session
*/
export async function startDilemma(request: StartDilemmaRequest) {
return api.post<DilemmaSession>('/api/v1/games/dilemmas/start', request);
}
/**
* Get active game session
*/
export async function getGameSession(sessionId: string) {
return api.get<GameSession>(`/api/v1/games/sessions/${sessionId}`);
}
/**
* Make move in game
*/
export async function makeMove(sessionId: string, move: any) {
return api.post<{
success: boolean;
game_state: any;
ai_response?: any;
analysis?: any;
}>(`/api/v1/games/sessions/${sessionId}/move`, { move });
}
/**
* Submit puzzle solution
*/
export async function submitPuzzleSolution(sessionId: string, solution: any) {
return api.post<{
correct: boolean;
explanation: string;
hints?: string[];
next_difficulty?: number;
}>(`/api/v1/games/puzzles/sessions/${sessionId}/solution`, { solution });
}
/**
* Submit dilemma response
*/
export async function submitDilemmaResponse(sessionId: string, response: any) {
return api.post<{
acknowledgment: string;
follow_up_questions?: string[];
ethical_analysis?: any;
}>(`/api/v1/games/dilemmas/sessions/${sessionId}/response`, { response });
}
/**
* End game session
*/
export async function endGameSession(sessionId: string) {
return api.post<{
final_score: any;
performance_analysis: any;
skill_improvements: any;
}>(`/api/v1/games/sessions/${sessionId}/end`);
}
/**
* Get user's learning analytics
*/
export async function getLearningAnalytics() {
return api.get<LearningAnalytics>('/api/v1/games/analytics');
}
/**
* Get game history
*/
export async function getGameHistory(params?: {
game_type?: string;
limit?: number;
offset?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.game_type) searchParams.set('game_type', params.game_type);
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.offset) searchParams.set('offset', params.offset.toString());
const query = searchParams.toString();
return api.get<{
sessions: (GameSession | PuzzleSession | DilemmaSession)[];
total: number;
}>(`/api/v1/games/history${query ? `?${query}` : ''}`);
}
/**
* Get user progress for specific game type
*/
export async function getGameProgress(gameType: string) {
return api.get<{
sessions_played: number;
best_rating: number;
recent_performance: number;
skill_progression: any[];
achievements: string[];
}>(`/api/v1/games/${gameType}/progress`);
}

View File

@@ -0,0 +1,267 @@
/**
* GT 2.0 Services Index
*
* Central export for all API services with consistent error handling
* and authentication management.
*/
// Core API infrastructure
export { api, apiClient } from './api';
export type { ApiResponse } from './api';
// Authentication service
export {
login,
logout,
getAuthToken,
setAuthToken,
removeAuthToken,
getUser,
setUser,
getTenantInfo,
setTenantInfo,
isAuthenticated,
isTokenValid,
refreshToken,
ensureValidToken,
} from './auth';
export type {
User,
TenantInfo,
LoginRequest,
LoginResponse,
} from './auth';
// Agent management (legacy)
export {
getAgentTemplates,
getAgentTemplate,
listAgents,
getAgent,
createAgent,
updateAgent,
deleteAgent,
toggleFavorite,
getAgentStats,
getAgentCategories,
} from './agents';
export type {
Agent,
AgentTemplate,
CreateAgentRequest,
UpdateAgentRequest,
} from './agents';
// Enhanced agent management
export {
enhancedAgentService,
listEnhancedAgents,
getEnhancedAgent,
createEnhancedAgent,
updateEnhancedAgent,
deleteEnhancedAgent,
forkAgent,
createFromTemplate,
getPublicAgents,
getMyAgents,
getTeamAgents,
getOrgAgents,
getFeaturedAgents,
searchAgents,
getAgentsByCategory,
getUserAgentSummary,
getPersonalityProfiles,
} from './agents-enhanced';
// Agent management (primary interface) - types only, service already exported above
export type {
EnhancedAgent,
CreateEnhancedAgentRequest,
UpdateEnhancedAgentRequest,
} from './agents-enhanced';
// Agent service aliases for consistency
export { enhancedAgentService as agentService } from './agents-enhanced';
export type {
PersonalityType,
Visibility,
DatasetConnection,
AgentCategory,
AccessFilter as AgentAccessFilter,
PersonalityProfile,
ModelParameters,
ExamplePrompt,
ForkAgentRequest,
CategoryInfo,
} from './agents-enhanced';
// Document & RAG services
export {
listDocuments,
uploadDocument,
uploadMultipleDocuments,
getDocument,
processDocument,
deleteDocument,
getDocumentContext,
searchDocuments,
getRAGStatistics,
documentService,
} from './documents';
export type {
Document,
SearchResult,
SearchResponse,
DocumentContext,
RAGStatistics,
} from './documents';
// Dataset management services
export {
datasetService,
listDatasets,
getDataset,
createDataset,
updateDataset,
shareDataset,
deleteDataset,
addDocumentsToDataset,
getDatasetStats,
getMyDatasets,
getTeamDatasets,
getOrgDatasets,
searchDatasets,
getDatasetsByTag,
getUserDatasetSummary,
} from './datasets';
export type {
Dataset,
CreateDatasetRequest,
UpdateDatasetRequest,
ShareDatasetRequest,
DatasetStats,
AccessGroup,
AccessFilter,
} from './datasets';
// Conversation management
export {
listConversations,
createConversation,
getConversation,
deleteConversation,
toggleConversationArchive,
updateConversationTitle,
getConversationMessages,
sendMessage,
createChatWebSocket,
ChatWebSocket,
} from './conversations';
export type {
Message,
Conversation,
CreateConversationRequest,
SendMessageRequest,
StreamingResponse,
} from './conversations';
// Games & AI literacy
export {
getAvailableGames,
getAvailablePuzzles,
getAvailableDilemmas,
startGame,
startPuzzle,
startDilemma,
getGameSession,
makeMove,
submitPuzzleSolution,
submitDilemmaResponse,
endGameSession,
getLearningAnalytics,
getGameHistory,
getGameProgress,
} from './games';
export type {
GameData,
PuzzleData,
DilemmaData,
GameSession,
PuzzleSession,
DilemmaSession,
LearningAnalytics,
StartGameRequest,
StartPuzzleRequest,
StartDilemmaRequest,
} from './games';
// Team collaboration services
export {
listTeams,
getTeam,
createTeam,
updateTeam,
deleteTeam,
listTeamMembers,
addTeamMember,
updateMemberPermission,
removeTeamMember,
shareResourceToTeam,
unshareResourceFromTeam,
listSharedResources,
getPendingInvitations,
acceptInvitation,
declineInvitation,
getTeamPendingInvitations,
cancelInvitation,
getPendingObservableRequests,
approveObservableRequest,
revokeObservableStatus,
requestObservableStatus,
} from './teams';
export type {
Team,
TeamMember,
SharedResource,
TeamInvitation,
ObservableRequest,
TeamListResponse,
TeamResponse,
MemberListResponse,
MemberResponse,
SharedResourcesResponse,
CreateTeamRequest,
UpdateTeamRequest,
AddMemberRequest,
UpdateMemberPermissionRequest,
ShareResourceRequest,
} from './teams';
// Utility functions
export const handleApiError = (response: { error?: string; status: number }) => {
if (response.error) {
console.error(`API Error (${response.status}):`, response.error);
// Handle common error cases
if (response.status === 401) {
// Use centralized logout from auth store
if (typeof window !== 'undefined') {
import('@/stores/auth-store').then(({ useAuthStore }) => {
useAuthStore.getState().logout('unauthorized');
});
}
} else if (response.status === 403) {
// Forbidden - insufficient permissions
console.warn('Insufficient permissions for this operation');
} else if (response.status === 429) {
// Rate limited
console.warn('Rate limit exceeded. Please try again later.');
}
return response.error;
}
return null;
};
// Re-export commonly used auth functions for convenience
// Note: removeAuthToken is already exported above in the auth section

View File

@@ -0,0 +1,154 @@
import { api, type ApiResponse } from '@/services/api';
export interface ModelOption {
value: string; // model_id string for backwards compatibility
uuid?: string; // Database UUID for unique identification (new models have this)
label: string;
description: string;
provider: string;
model_type: string;
max_tokens: number;
context_window: number;
cost_per_1k_tokens: number;
latency_p50_ms: number;
health_status: string;
deployment_status: string;
}
export interface ModelsResponse {
models: ModelOption[];
total: number;
tenant_domain: string;
fallback?: boolean;
message?: string;
last_updated?: string;
}
class ModelsService {
private modelCache: ModelsResponse | null = null;
private cacheTimestamp: number = 0;
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
private readonly MAX_RETRIES = 3;
private readonly RETRY_DELAY = 1000; // 1 second
/**
* Fetch available AI models for the current tenant with caching
*/
async getAvailableModels(): Promise<ModelsResponse> {
// Check if we have valid cached data
const now = Date.now();
if (this.modelCache && (now - this.cacheTimestamp < this.CACHE_TTL)) {
console.log('🚀 Using cached models data');
return this.modelCache;
}
// Fetch fresh data with retry logic
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
try {
console.log(`🔄 Fetching models from API (attempt ${attempt}/${this.MAX_RETRIES})`);
const response = await api.get<ModelsResponse>('/api/v1/models/');
if (response.data) {
// Cache successful response
this.modelCache = response.data;
this.cacheTimestamp = now;
console.log(`✅ Successfully fetched ${response.data.models?.length || 0} models`);
if (response.data.fallback) {
console.warn('⚠️ API returned fallback models:', response.data.message);
}
return response.data;
}
throw new Error(response.error || 'Failed to fetch models');
} catch (error) {
console.error(`❌ Attempt ${attempt} failed:`, error);
if (attempt === this.MAX_RETRIES) {
// Last attempt failed, return empty
console.warn('🔄 All retry attempts failed, no models available');
const emptyResponse = {
models: [],
total: 0,
tenant_domain: 'unknown',
fallback: false,
message: 'No models available - all API requests failed'
};
// Cache empty response with shorter TTL
this.modelCache = emptyResponse;
this.cacheTimestamp = now - (this.CACHE_TTL * 0.8); // Shorter cache for errors
return emptyResponse;
}
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY * attempt));
}
}
// This should never be reached, but just in case
return {
models: [],
total: 0,
tenant_domain: 'unknown',
fallback: false,
message: 'No models available'
};
}
/**
* Clear the models cache to force fresh data on next request
*/
clearCache(): void {
this.modelCache = null;
this.cacheTimestamp = 0;
console.log('🗑️ Models cache cleared');
}
/**
* Refresh models data by clearing cache and fetching fresh data
*/
async refreshModels(): Promise<ModelsResponse> {
this.clearCache();
return await this.getAvailableModels();
}
/**
* Get details for a specific model
*/
async getModelDetails(modelId: string): Promise<ModelOption> {
try {
const response = await api.get<{ model: ModelOption }>(`/api/v1/models/${modelId}`);
if (response.data) {
return response.data.model;
}
throw new Error(response.error || 'Failed to fetch model details');
} catch (error) {
console.error(`Failed to fetch model details for ${modelId}:`, error);
throw error;
}
}
/**
* Clean model names by removing provider prefixes like "nvidia/", "groq/"
*/
private cleanModelName(name: string): string {
return name.replace(/^(nvidia|groq|openai|anthropic)\//, '');
}
/**
* Format model options for Select components
*/
formatForSelect(models: ModelOption[]): Array<{value: string, label: string, description: string}> {
return models.map(model => ({
value: model.value,
label: this.cleanModelName(model.label),
description: model.description
}));
}
}
export const modelsService = new ModelsService();

View File

@@ -0,0 +1,270 @@
/**
* GT 2.0 Task Orchestrator Service
*
* DeepAgent-inspired intelligent orchestration layer that analyzes user intent
* and selects optimal resources (datasets, history, or direct LLM) for responses.
*/
import { searchDocuments, searchConversationHistory, ConversationHistoryResult, SearchResult } from './documents';
import { api } from './api';
export interface TaskContext {
conversationId: string;
userId: string;
availableDatasets: string[];
selectedDatasets: string[];
historySearchEnabled: boolean;
agentId?: string;
}
export interface IntentAnalysis {
intent: 'question' | 'task' | 'creative' | 'analysis';
complexity: 'simple' | 'moderate' | 'complex';
needsContext: boolean;
needsHistory: boolean;
confidence: number;
reasoning: string;
}
export interface ResourceSelection {
useDatasets: boolean;
useHistory: boolean;
searchMethod: 'vector' | 'hybrid' | 'keyword';
datasetIds: string[];
historyParams?: {
days_back: number;
agent_filter?: string[];
};
}
export interface OrchestrationResult {
intent: IntentAnalysis;
resources: ResourceSelection;
contextSources: ContextSource[];
historyResults?: ConversationHistoryResult[];
searchResults?: SearchResult[];
}
export interface ContextSource {
id: string;
type: 'dataset' | 'history' | 'direct';
name: string;
relevance: number;
content?: string;
}
export class TaskOrchestrator {
private readonly INTENT_KEYWORDS = {
question: ['what', 'how', 'why', 'when', 'where', 'explain', 'tell me about'],
task: ['create', 'build', 'generate', 'write', 'make', 'develop', 'implement'],
creative: ['imagine', 'story', 'poem', 'creative', 'artistic', 'design'],
analysis: ['analyze', 'compare', 'evaluate', 'review', 'assess', 'study']
};
private readonly CONTEXT_INDICATORS = [
'based on', 'according to', 'in the document', 'from the data',
'previously', 'earlier', 'last time', 'before'
];
private readonly HISTORY_INDICATORS = [
'previously', 'earlier', 'last time', 'before', 'remember when',
'what did we discuss', 'go back to', 'from our conversation'
];
async orchestrate(query: string, context: TaskContext): Promise<OrchestrationResult> {
// Step 1: Analyze user intent
const intent = this.analyzeIntent(query);
// Step 2: Determine resource needs
const resources = await this.selectResources(query, intent, context);
// Step 3: Gather context from selected resources
const contextSources: ContextSource[] = [];
let historyResults: ConversationHistoryResult[] = [];
let searchResults: SearchResult[] = [];
// Execute dataset search if needed
if (resources.useDatasets && resources.datasetIds.length > 0) {
try {
const searchResponse = await searchDocuments({
query,
dataset_ids: resources.datasetIds,
search_method: resources.searchMethod,
top_k: 5,
similarity_threshold: 0.7
});
if (searchResponse.success) {
searchResults = searchResponse.data.results;
searchResults.forEach(result => {
contextSources.push({
id: result.chunk_id,
type: 'dataset',
name: `Document: ${result.document}`,
relevance: result.similarity,
content: result.metadata?.content || result.document
});
});
}
} catch (error) {
console.error('Dataset search failed:', error);
}
}
// Execute history search if needed
if (resources.useHistory) {
try {
const historyResponse = await searchConversationHistory({
query,
...resources.historyParams,
limit: 5
});
if (historyResponse.success) {
historyResults = historyResponse.data;
historyResults.forEach(result => {
contextSources.push({
id: result.message_id,
type: 'history',
name: `${result.conversation_title} - ${result.agent_name}`,
relevance: result.relevance_score,
content: result.content
});
});
}
} catch (error) {
console.error('History search failed:', error);
}
}
// Sort context sources by relevance
contextSources.sort((a, b) => b.relevance - a.relevance);
return {
intent,
resources,
contextSources: contextSources.slice(0, 8), // Limit to top 8 sources
historyResults,
searchResults
};
}
private analyzeIntent(query: string): IntentAnalysis {
const lowerQuery = query.toLowerCase();
const words = lowerQuery.split(/\s+/);
// Determine primary intent
let intent: IntentAnalysis['intent'] = 'question';
let maxScore = 0;
for (const [intentType, keywords] of Object.entries(this.INTENT_KEYWORDS)) {
const score = keywords.reduce((acc, keyword) => {
return acc + (lowerQuery.includes(keyword) ? 1 : 0);
}, 0);
if (score > maxScore) {
maxScore = score;
intent = intentType as IntentAnalysis['intent'];
}
}
// Determine complexity based on query length and structure
const complexity: IntentAnalysis['complexity'] =
words.length < 5 ? 'simple' :
words.length < 15 ? 'moderate' : 'complex';
// Check if context is needed
const needsContext = this.CONTEXT_INDICATORS.some(indicator =>
lowerQuery.includes(indicator)
);
// Check if history is needed
const needsHistory = this.HISTORY_INDICATORS.some(indicator =>
lowerQuery.includes(indicator)
);
const confidence = Math.min(0.9, 0.5 + (maxScore * 0.1));
return {
intent,
complexity,
needsContext: needsContext || intent === 'analysis',
needsHistory,
confidence,
reasoning: `Intent: ${intent}, Keywords found: ${maxScore}, Length: ${words.length} words`
};
}
private async selectResources(
query: string,
intent: IntentAnalysis,
context: TaskContext
): Promise<ResourceSelection> {
const resources: ResourceSelection = {
useDatasets: false,
useHistory: false,
searchMethod: 'hybrid',
datasetIds: []
};
// Use datasets if context is needed and datasets are available
if ((intent.needsContext || intent.intent === 'analysis') && context.selectedDatasets.length > 0) {
resources.useDatasets = true;
resources.datasetIds = context.selectedDatasets;
// Choose search method based on intent and complexity
if (intent.intent === 'creative') {
resources.searchMethod = 'vector'; // Better for semantic similarity
} else if (intent.complexity === 'simple') {
resources.searchMethod = 'keyword'; // Faster for simple queries
} else {
resources.searchMethod = 'hybrid'; // Best overall performance
}
}
// Use history if explicitly requested or if pattern suggests it
if (context.historySearchEnabled && (intent.needsHistory || intent.complexity === 'complex')) {
resources.useHistory = true;
resources.historyParams = {
days_back: intent.complexity === 'complex' ? 60 : 30,
agent_filter: context.agentId ? [context.agentId] : undefined
};
}
return resources;
}
/**
* Generate a prompt with context for the LLM
*/
generateContextualPrompt(
originalQuery: string,
orchestrationResult: OrchestrationResult
): string {
let prompt = originalQuery;
if (orchestrationResult.contextSources.length === 0) {
return prompt;
}
// Add context header
prompt += '\n\n--- CONTEXT ---\n';
// Add relevant sources
orchestrationResult.contextSources.forEach((source, index) => {
prompt += `\n${index + 1}. [${source.type.toUpperCase()}] ${source.name}\n`;
if (source.content) {
prompt += ` ${source.content.substring(0, 500)}${source.content.length > 500 ? '...' : ''}\n`;
}
});
prompt += '\n--- END CONTEXT ---\n\n';
prompt += 'Please answer based on the provided context when relevant. ';
prompt += 'If the context doesn\'t contain relevant information, please indicate that and provide a general response.';
return prompt;
}
}
// Singleton instance
export const taskOrchestrator = new TaskOrchestrator();

View File

@@ -0,0 +1,350 @@
/**
* GT 2.0 Team Collaboration Service
*
* API client for team management, member management, and resource sharing.
* Supports two-tier permission model:
* - Tier 1 (Team-level): 'read' or 'share' - set by team owner
* - Tier 2 (Resource-level): 'read' or 'edit' - set per-user by resource sharer
*/
import { api } from './api';
// ============================================================================
// TYPE DEFINITIONS
// ============================================================================
export interface Team {
id: string;
tenant_id: string;
name: string;
description: string;
owner_id: string;
is_owner: boolean;
can_manage: boolean;
user_permission?: 'read' | 'share'; // Current user's team permission (null if owner)
member_count: number;
shared_resource_count: number;
created_at: string;
updated_at: string;
}
export interface TeamMember {
user_id: string;
user_email: string;
user_name: string;
team_permission: 'read' | 'share' | 'manager';
resource_permissions: Record<string, 'read' | 'edit'>;
is_owner: boolean;
is_observable: boolean;
observable_consent_status: 'none' | 'pending' | 'approved' | 'revoked';
observable_consent_at?: string;
status: 'pending' | 'accepted' | 'declined';
invited_at?: string;
responded_at?: string;
joined_at: string;
}
export interface SharedResource {
resource_type: 'agent' | 'dataset';
resource_id: string;
resource_name: string;
resource_owner: string;
user_permissions: Record<string, 'read' | 'edit'>;
}
export interface TeamListResponse {
data: Team[];
total: number;
}
export interface TeamResponse {
data: Team;
}
export interface MemberListResponse {
data: TeamMember[];
total: number;
}
export interface MemberResponse {
data: TeamMember;
}
export interface SharedResourcesResponse {
data: SharedResource[];
total: number;
}
// Invitation types
export interface TeamInvitation {
id: string;
team_id: string;
team_name: string;
team_description?: string;
owner_name: string;
owner_email: string;
team_permission: 'read' | 'share' | 'manager';
invited_at: string;
}
export interface InvitationListResponse {
data: TeamInvitation[];
total: number;
}
// Request types
export interface CreateTeamRequest {
name: string;
description?: string;
}
export interface UpdateTeamRequest {
name?: string;
description?: string;
}
export interface AddMemberRequest {
user_email: string;
team_permission: 'read' | 'share' | 'manager';
}
export interface UpdateMemberPermissionRequest {
team_permission: 'read' | 'share' | 'manager';
}
export interface ShareResourceRequest {
resource_type: 'agent' | 'dataset';
resource_id: string;
user_permissions: Record<string, 'read' | 'edit'>;
}
// ============================================================================
// TEAM CRUD OPERATIONS
// ============================================================================
/**
* List all teams where the current user is owner or member
*/
export async function listTeams() {
return api.get<TeamListResponse>('/api/v1/teams');
}
/**
* Get team details by ID
*/
export async function getTeam(teamId: string) {
return api.get<TeamResponse>(`/api/v1/teams/${teamId}`);
}
/**
* Create a new team (current user becomes owner)
*/
export async function createTeam(request: CreateTeamRequest) {
return api.post<TeamResponse>('/api/v1/teams', request);
}
/**
* Update team name/description
* Requires: Team ownership or admin/developer role
*/
export async function updateTeam(teamId: string, request: UpdateTeamRequest) {
return api.put<TeamResponse>(`/api/v1/teams/${teamId}`, request);
}
/**
* Delete a team and all its memberships
* Requires: Team ownership or admin/developer role
*/
export async function deleteTeam(teamId: string) {
return api.delete<void>(`/api/v1/teams/${teamId}`);
}
// ============================================================================
// TEAM MEMBER OPERATIONS
// ============================================================================
/**
* List all members of a team with their permissions
*/
export async function listTeamMembers(teamId: string) {
return api.get<MemberListResponse>(`/api/v1/teams/${teamId}/members`);
}
/**
* Add a user to the team with specified permission
* Requires: Team ownership or admin/developer role
*/
export async function addTeamMember(teamId: string, request: AddMemberRequest) {
return api.post<MemberResponse>(`/api/v1/teams/${teamId}/members`, request);
}
/**
* Update a team member's permission level
* Requires: Team ownership or admin/developer role
*/
export async function updateMemberPermission(
teamId: string,
userId: string,
request: UpdateMemberPermissionRequest
) {
return api.put<MemberResponse>(
`/api/v1/teams/${teamId}/members/${userId}`,
request
);
}
/**
* Remove a user from the team
* Requires: Team ownership or admin/developer role
*/
export async function removeTeamMember(teamId: string, userId: string) {
return api.delete<void>(`/api/v1/teams/${teamId}/members/${userId}`);
}
// ============================================================================
// RESOURCE SHARING OPERATIONS
// ============================================================================
/**
* Share a resource (agent/dataset) to team with per-user permissions
* Requires: Team ownership or 'share' team permission
*/
export async function shareResourceToTeam(
teamId: string,
request: ShareResourceRequest
) {
return api.post<{ message: string; success: boolean }>(
`/api/v1/teams/${teamId}/share`,
request
);
}
/**
* Remove resource sharing from team
* Requires: Team ownership or 'share' team permission
*/
export async function unshareResourceFromTeam(
teamId: string,
resourceType: 'agent' | 'dataset',
resourceId: string
) {
return api.delete<void>(
`/api/v1/teams/${teamId}/share/${resourceType}/${resourceId}`
);
}
/**
* List all resources shared to a team
* Optional filter by resource type
*/
export async function listSharedResources(
teamId: string,
resourceType?: 'agent' | 'dataset'
) {
const params = resourceType ? { resource_type: resourceType } : undefined;
return api.get<SharedResourcesResponse>(
`/api/v1/teams/${teamId}/resources`,
{ params }
);
}
// ============================================================================
// INVITATION OPERATIONS
// ============================================================================
/**
* Get current user's pending team invitations
*/
export async function getPendingInvitations() {
return api.get<InvitationListResponse>('/api/v1/teams/invitations');
}
/**
* Accept a team invitation
*/
export async function acceptInvitation(invitationId: string) {
return api.post<MemberResponse>(
`/api/v1/teams/invitations/${invitationId}/accept`
);
}
/**
* Decline a team invitation
*/
export async function declineInvitation(invitationId: string) {
return api.post<void>(
`/api/v1/teams/invitations/${invitationId}/decline`
);
}
/**
* Get pending invitations for a team (owner view)
* Requires: Team ownership or admin role
*/
export async function getTeamPendingInvitations(teamId: string) {
return api.get<InvitationListResponse>(
`/api/v1/teams/${teamId}/invitations`
);
}
/**
* Cancel a pending invitation (owner only)
* Requires: Team ownership or admin role
*/
export async function cancelInvitation(teamId: string, invitationId: string) {
return api.delete<void>(
`/api/v1/teams/${teamId}/invitations/${invitationId}`
);
}
// ============================================================================
// OBSERVABLE REQUEST OPERATIONS
// ============================================================================
export interface ObservableRequest {
team_id: string;
team_name: string;
requested_by_name: string;
requested_by_email: string;
requested_at: string;
}
/**
* Get current user's pending Observable requests from team managers
*/
export async function getPendingObservableRequests() {
return api.get<{ data: ObservableRequest[] }>(
'/api/v1/teams/observable-requests'
);
}
/**
* Approve an Observable request for a specific team
* This allows team managers to view your activity on the observability dashboard
*/
export async function approveObservableRequest(teamId: string) {
return api.post<{ message: string; success: boolean }>(
`/api/v1/teams/${teamId}/observable/approve`
);
}
/**
* Revoke Observable status for a team
* Removes Observable status and prevents managers from viewing your activity
*/
export async function revokeObservableStatus(teamId: string) {
return api.delete<void>(
`/api/v1/teams/${teamId}/observable`
);
}
/**
* Request Observable status from a team member (manager/owner action)
* Sends a request that the member must approve
* Requires: Team ownership or manager permission
*/
export async function requestObservableStatus(teamId: string, userId: string) {
return api.post<{ message: string; success: boolean }>(
`/api/v1/teams/${teamId}/members/${userId}/request-observable`
);
}

View File

@@ -0,0 +1,277 @@
/**
* Two-Factor Authentication Service
*
* Handles all TFA-related API calls to the backend.
*/
import { getApiUrl } from '@/lib/utils';
import { getAuthToken } from './auth';
const API_URL = getApiUrl();
export interface TFAEnableResponse {
success: boolean;
message: string;
qr_code_uri: string;
manual_entry_key: string;
}
export interface TFAVerifySetupRequest {
code: string;
}
export interface TFAVerifySetupResponse {
success: boolean;
message: string;
}
export interface TFADisableRequest {
password: string;
}
export interface TFADisableResponse {
success: boolean;
message: string;
}
export interface TFAVerifyLoginRequest {
temp_token: string;
code: string;
}
export interface TFAVerifyLoginResponse {
success: boolean;
access_token?: string;
message?: string;
// Note: user, expires_in removed - all data is in JWT claims
}
export interface TFAStatusResponse {
tfa_enabled: boolean;
tfa_required: boolean;
tfa_status: string; // "disabled", "enabled", "enforced"
}
export interface TFASessionData {
user_email: string;
tfa_configured: boolean;
qr_code_uri?: string;
manual_entry_key?: string;
}
/**
* Get TFA session data (metadata only - no QR code) using HTTP-only session cookie
* Called from /verify-tfa page to display setup information
*/
export async function getTFASessionData(): Promise<TFASessionData> {
try {
const response = await fetch(`${API_URL}/api/v1/auth/tfa/session-data`, {
method: 'GET',
credentials: 'include', // Include HTTP-only cookies
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to get TFA session data');
}
return await response.json();
} catch (error) {
console.error('Get TFA session data error:', error);
throw error;
}
}
/**
* Get TFA QR code as PNG blob (secure: TOTP secret never exposed to JavaScript)
* Called from /verify-tfa page to display QR code via blob URL
*/
export async function getTFAQRCodeBlob(): Promise<string> {
try {
const response = await fetch(`${API_URL}/api/v1/auth/tfa/session-qr-code`, {
method: 'GET',
credentials: 'include', // Include HTTP-only cookies
});
if (!response.ok) {
throw new Error('Failed to get TFA QR code');
}
// Get PNG blob
const blob = await response.blob();
// Create object URL (will be revoked on unmount)
const blobUrl = URL.createObjectURL(blob);
return blobUrl;
} catch (error) {
console.error('Get TFA QR code error:', error);
throw error;
}
}
/**
* Enable TFA for current user (user-initiated from settings)
*/
export async function enableTFA(): Promise<TFAEnableResponse> {
const token = getAuthToken();
if (!token) {
throw new Error('Not authenticated');
}
try {
const response = await fetch(`${API_URL}/api/v1/auth/tfa/enable`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to enable TFA');
}
return await response.json();
} catch (error) {
console.error('Enable TFA error:', error);
throw error;
}
}
/**
* Verify TFA setup code and complete setup
*/
export async function verifyTFASetup(code: string): Promise<TFAVerifySetupResponse> {
const token = getAuthToken();
if (!token) {
throw new Error('Not authenticated');
}
try {
const response = await fetch(`${API_URL}/api/v1/auth/tfa/verify-setup`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Invalid verification code');
}
return await response.json();
} catch (error) {
console.error('Verify TFA setup error:', error);
throw error;
}
}
/**
* Disable TFA for current user (requires password)
*/
export async function disableTFA(password: string): Promise<TFADisableResponse> {
const token = getAuthToken();
if (!token) {
throw new Error('Not authenticated');
}
try {
const response = await fetch(`${API_URL}/api/v1/auth/tfa/disable`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to disable TFA');
}
return await response.json();
} catch (error) {
console.error('Disable TFA error:', error);
throw error;
}
}
/**
* Verify TFA code during login
* Uses HTTP-only session cookie for authentication (no temp_token needed)
*/
export async function verifyTFALogin(
code: string
): Promise<TFAVerifyLoginResponse> {
try {
const response = await fetch(`${API_URL}/api/v1/auth/tfa/verify-login`, {
method: 'POST',
credentials: 'include', // Include HTTP-only cookies
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Invalid verification code');
}
return await response.json();
} catch (error) {
console.error('Verify TFA login error:', error);
throw error;
}
}
/**
* Get TFA status for current user
*/
export async function getTFAStatus(): Promise<TFAStatusResponse> {
const token = getAuthToken();
if (!token) {
throw new Error('Not authenticated');
}
try {
const response = await fetch(`${API_URL}/api/v1/auth/tfa/status`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
// Return default if request fails
return {
tfa_enabled: false,
tfa_required: false,
tfa_status: 'disabled',
};
}
return await response.json();
} catch (error) {
console.error('Get TFA status error:', error);
return {
tfa_enabled: false,
tfa_required: false,
tfa_status: 'disabled',
};
}
}

View File

@@ -0,0 +1,82 @@
/**
* GT 2.0 User Service
*
* API client for user preferences and favorite agents management.
*/
import { api } from './api';
export interface UserPreferences {
favorite_agent_ids?: string[];
[key: string]: any;
}
export interface FavoriteAgentsResponse {
favorite_agent_ids: string[];
}
export interface CustomCategory {
name: string;
description: string;
created_at?: string;
}
export interface CustomCategoriesResponse {
categories: CustomCategory[];
}
/**
* Get current user's preferences
*/
export async function getUserPreferences() {
return api.get<{ preferences: UserPreferences }>('/api/v1/users/me/preferences');
}
/**
* Update current user's preferences (merges with existing)
*/
export async function updateUserPreferences(preferences: UserPreferences) {
return api.put('/api/v1/users/me/preferences', { preferences });
}
/**
* Get current user's favorited agent IDs
*/
export async function getFavoriteAgents() {
return api.get<FavoriteAgentsResponse>('/api/v1/users/me/favorite-agents');
}
/**
* Update current user's favorite agent IDs (replaces entire list)
*/
export async function updateFavoriteAgents(agent_ids: string[]) {
return api.put('/api/v1/users/me/favorite-agents', { agent_ids });
}
/**
* Add a single agent to user's favorites
*/
export async function addFavoriteAgent(agent_id: string) {
return api.post('/api/v1/users/me/favorite-agents/add', { agent_id });
}
/**
* Remove a single agent from user's favorites
*/
export async function removeFavoriteAgent(agent_id: string) {
return api.post('/api/v1/users/me/favorite-agents/remove', { agent_id });
}
/**
* Get current user's custom agent categories
*/
export async function getCustomCategories() {
return api.get<CustomCategoriesResponse>('/api/v1/users/me/custom-categories');
}
/**
* Update current user's custom agent categories (replaces entire list)
*/
export async function saveCustomCategories(categories: CustomCategory[]) {
return api.put('/api/v1/users/me/custom-categories', { categories });
}