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:
535
apps/tenant-app/src/services/agents-enhanced.ts
Normal file
535
apps/tenant-app/src/services/agents-enhanced.ts
Normal 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;
|
||||
317
apps/tenant-app/src/services/agents.ts
Normal file
317
apps/tenant-app/src/services/agents.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
344
apps/tenant-app/src/services/api.ts
Normal file
344
apps/tenant-app/src/services/api.ts
Normal 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),
|
||||
};
|
||||
514
apps/tenant-app/src/services/auth.ts
Normal file
514
apps/tenant-app/src/services/auth.ts
Normal 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;
|
||||
}
|
||||
88
apps/tenant-app/src/services/categories.ts
Normal file
88
apps/tenant-app/src/services/categories.ts
Normal 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}`);
|
||||
}
|
||||
325
apps/tenant-app/src/services/chat-service.ts
Normal file
325
apps/tenant-app/src/services/chat-service.ts
Normal 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);
|
||||
}
|
||||
246
apps/tenant-app/src/services/conversations.ts
Normal file
246
apps/tenant-app/src/services/conversations.ts
Normal 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);
|
||||
}
|
||||
285
apps/tenant-app/src/services/datasets.ts
Normal file
285
apps/tenant-app/src/services/datasets.ts
Normal 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);
|
||||
524
apps/tenant-app/src/services/documents.ts
Normal file
524
apps/tenant-app/src/services/documents.ts
Normal 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
|
||||
};
|
||||
264
apps/tenant-app/src/services/games.ts
Normal file
264
apps/tenant-app/src/services/games.ts
Normal 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`);
|
||||
}
|
||||
267
apps/tenant-app/src/services/index.ts
Normal file
267
apps/tenant-app/src/services/index.ts
Normal 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
|
||||
154
apps/tenant-app/src/services/models-service.ts
Normal file
154
apps/tenant-app/src/services/models-service.ts
Normal 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();
|
||||
270
apps/tenant-app/src/services/orchestrator.ts
Normal file
270
apps/tenant-app/src/services/orchestrator.ts
Normal 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();
|
||||
350
apps/tenant-app/src/services/teams.ts
Normal file
350
apps/tenant-app/src/services/teams.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
277
apps/tenant-app/src/services/tfa.ts
Normal file
277
apps/tenant-app/src/services/tfa.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
82
apps/tenant-app/src/services/user.ts
Normal file
82
apps/tenant-app/src/services/user.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user