Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements

This commit is contained in:
ajaysi
2026-02-28 20:06:26 +05:30
parent 08a1f4a1d8
commit 4828274cbf
162 changed files with 19489 additions and 4300 deletions

View File

@@ -1,6 +1,6 @@
import axios, { AxiosResponse } from 'axios';
import { emitApiEvent } from '../utils/apiEvents';
import { getApiUrl } from '../api/client';
import { getApiUrl, getAuthTokenGetter } from '../api/client';
import {
SystemHealth,
APIStats,
@@ -32,27 +32,37 @@ const monitoringAPI = axios.create({
},
});
// Request interceptor for authentication
monitoringAPI.interceptors.request.use(
(config) => {
// Add auth token if available
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
async (config) => {
try {
const tokenGetter = getAuthTokenGetter();
if (tokenGetter) {
const token = await tokenGetter();
if (token) {
config.headers = config.headers || {};
(config.headers as any).Authorization = `Bearer ${token}`;
}
} else {
const legacyToken = localStorage.getItem('auth_token');
if (legacyToken) {
config.headers = config.headers || {};
(config.headers as any).Authorization = `Bearer ${legacyToken}`;
}
}
} catch (e) {
console.error('Monitoring API: Error getting auth token', e);
}
// Add user ID to ALL requests for billing tracking
const userId = localStorage.getItem('user_id') || 'demo-user';
// Add user_id as query parameter for billing tracking
if (config.params) {
config.params.user_id = userId;
(config.params as any).user_id = userId;
} else {
config.params = { user_id: userId };
}
// Also add as header for additional tracking
config.headers['X-User-ID'] = userId;
config.headers = config.headers || {};
(config.headers as any)['X-User-ID'] = userId;
return config;
},
@@ -61,7 +71,6 @@ monitoringAPI.interceptors.request.use(
}
);
// Response interceptor for error handling
monitoringAPI.interceptors.response.use(
(response: AxiosResponse) => {
return response;
@@ -69,13 +78,7 @@ monitoringAPI.interceptors.response.use(
(error) => {
console.error('Monitoring API Error:', error);
// Handle specific error cases
if (error.response?.status === 401) {
// Unauthorized - redirect to login
localStorage.removeItem('auth_token');
window.location.href = '/login';
} else if (error.response?.status === 503) {
// Service unavailable
if (error.response?.status === 503) {
console.warn('Monitoring service temporarily unavailable');
}

View File

@@ -10,7 +10,6 @@ import {
CreateProjectResult,
Fact,
Knobs,
Line,
PodcastAnalysis,
PodcastEstimate,
Query,
@@ -128,6 +127,9 @@ const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => {
url: source.url || "",
date: source.published_at || "Unknown",
confidence: typeof (source as any).credibility_score === "number" ? (source as any).credibility_score : Math.max(0.5, 0.85 - idx * 0.02),
image: source.image,
author: source.author,
highlights: source.highlights,
}));
};
@@ -140,6 +142,8 @@ type ExaSource = {
summary?: string;
source_type?: string;
index?: number;
image?: string;
author?: string;
};
type ExaResearchResult = {
@@ -151,15 +155,20 @@ type ExaResearchResult = {
content?: string;
};
const mapExaResearchResponse = (response: ExaResearchResult): Research => {
const mapExaResearchResponse = (response: any): Research => {
const factCards = mapSourcesToFacts(response.sources);
const summary =
response.content?.slice(0, 1200) ||
(response.search_queries && response.search_queries.length
? `Research completed for queries: ${response.search_queries.join(", ")}`
: "Research completed. Review fact cards for details.");
// Use backend summary if available, otherwise use full content (no truncation) or fallback text
const summary = response.summary || response.content || "Research completed.";
const keyInsights = (response.key_insights || []).map((insight: any) => ({
title: insight.title || "Insight",
content: insight.content || "",
source_indices: insight.source_indices || []
}));
return {
summary,
keyInsights,
factCards,
mappedAngles: [],
searchQueries: response.search_queries,
@@ -170,58 +179,6 @@ const mapExaResearchResponse = (response: ExaResearchResult): Research => {
};
};
const splitIntoLines = (text: string, speakers: number): Line[] => {
const sentences = text
.split(/(?<=[.?!])\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 4);
if (!sentences.length) {
return [
{
id: createId("line"),
speaker: "Host",
text: text || "Let's dive into todays topic.",
},
];
}
return sentences.map((sentence, idx) => ({
id: createId("line"),
speaker: idx % speakers === 0 ? "Host" : `Guest ${((idx % speakers) + 1).toString()}`,
text: sentence,
}));
};
// Unused helper functions - kept for reference but not currently used
// const storySceneToPodcastScene = (scene: StoryScene, knobs: Knobs, speakers: number): Scene => {
// const text = scene.description || scene.audio_narration || scene.image_prompt || scene.title || "Narration";
// return {
// id: `scene-${scene.scene_number || createId("scene")}`,
// title: scene.title || `Scene ${scene.scene_number}`,
// duration: Math.max(20, knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target),
// lines: splitIntoLines(text, Math.max(1, speakers)),
// approved: false,
// };
// };
// const ensureScenes = (outline: StorySetupGenerationResponse["options"] | StoryScene[] | string | undefined): StoryScene[] => {
// if (!outline) return [];
// if (typeof outline === "string") {
// return [
// {
// scene_number: 1,
// title: outline.slice(0, 60),
// description: outline,
// image_prompt: outline,
// audio_narration: outline,
// } as StoryScene,
// ];
// }
// if (Array.isArray(outline)) {
// return outline as StoryScene[];
// }
// return [];
// };
const ensurePreflight = async (operation: PreflightOperation) => {
const result = await checkPreflight(operation);
if (!result.can_proceed) {
@@ -232,14 +189,24 @@ const ensurePreflight = async (operation: PreflightOperation) => {
};
export const podcastApi = {
async createProject(payload: CreateProjectPayload): Promise<CreateProjectResult> {
async createProject(payload: CreateProjectPayload, bible?: any, feedback?: string): Promise<CreateProjectResult> {
const storyIdea = payload.ideaOrUrl || "AI marketing for small businesses";
await ensurePreflight({
provider: "gemini",
operation_type: "podcast_analysis",
tokens_requested: 1500,
actual_provider_name: "gemini",
});
// Podcast-specific analysis (not story setup)
const analysisResp = await aiApiClient.post("/api/podcast/analyze", {
idea: storyIdea,
duration: payload.duration,
speakers: payload.speakers,
bible: bible,
avatar_url: payload.avatarUrl,
feedback: feedback, // Pass feedback to backend
});
const outlines = (analysisResp.data?.suggested_outlines || []).map((o: any, idx: number) => ({
@@ -255,11 +222,24 @@ export const podcastApi = {
suggestedOutlines: outlines,
suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs },
titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean),
research_queries: analysisResp.data?.research_queries || [],
exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined,
};
const researchConfig = await getResearchConfig().catch(() => null);
const queries = mapPersonaQueries(researchConfig?.research_persona, storyIdea);
// Use AI-generated queries if available, fallback to legacy mapping
let queries: Query[] = [];
if (analysis.research_queries && analysis.research_queries.length > 0) {
queries = analysis.research_queries.map(rq => ({
id: createId("q"),
query: rq.query,
rationale: rq.rationale,
needsRecentStats: /202[45]|latest|trend/i.test(rq.query)
}));
} else {
queries = mapPersonaQueries(researchConfig?.research_persona, storyIdea);
}
const projectId = createId("podcast");
const estimate = estimateCosts({
@@ -276,15 +256,25 @@ export const podcastApi = {
analysis,
estimate,
queries,
bible: analysisResp.data?.bible || undefined,
avatar_url: analysisResp.data?.avatar_url || null,
avatar_prompt: analysisResp.data?.avatar_prompt || null,
};
},
async enhanceIdea(params: { idea: string; bible?: any }): Promise<{ enhanced_idea: string; rationale: string }> {
const response = await aiApiClient.post("/api/podcast/idea/enhance", params);
return response.data;
},
async runResearch(params: {
projectId: string;
topic: string;
approvedQueries: Query[];
provider?: ResearchProvider;
exaConfig?: ResearchConfig;
bible?: any;
analysis?: PodcastAnalysis | null;
onProgress?: (message: string) => void;
}): Promise<{ research: Research; raw: any }> {
const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean);
@@ -317,12 +307,14 @@ export const podcastApi = {
topic: params.topic || keywords[0],
queries: keywords,
exa_config: sanitizedExaConfig,
bible: params.bible,
analysis: params.analysis,
});
const exaResult = response.data as ExaResearchResult;
if (params.onProgress) {
if (params.onProgress) {
params.onProgress("Deep research completed with Exa.");
}
}
const mapped = mapExaResearchResponse(exaResult);
return { research: mapped, raw: exaResult };
},
@@ -334,6 +326,9 @@ export const podcastApi = {
knobs: Knobs;
speakers: number;
durationMinutes: number;
bible?: any;
outline?: any;
analysis?: PodcastAnalysis | null;
}): Promise<Script> {
await ensurePreflight({
provider: "gemini",
@@ -347,6 +342,9 @@ export const podcastApi = {
duration_minutes: params.durationMinutes,
speakers: params.speakers,
research: params.research,
bible: params.bible,
outline: params.outline,
analysis: params.analysis,
});
const scenes = response.data?.scenes || [];
@@ -564,11 +562,17 @@ export const podcastApi = {
duration: number;
speakers: number;
budget_cap: number;
avatar_url?: string | null;
}): Promise<any> {
const response = await aiApiClient.post("/api/podcast/projects", params);
return response.data;
},
async updateProject(projectId: string, updates: any): Promise<any> {
const response = await aiApiClient.put(`/api/podcast/projects/${projectId}`, updates);
return response.data;
},
async deleteProject(projectId: string): Promise<void> {
await aiApiClient.delete(`/api/podcast/projects/${projectId}`);
},
@@ -619,6 +623,7 @@ export const podcastApi = {
sceneTitle: string;
audioUrl: string;
avatarImageUrl?: string;
bible?: any;
resolution?: string;
prompt?: string;
seed?: number;
@@ -630,6 +635,7 @@ export const podcastApi = {
scene_title: params.sceneTitle,
audio_url: params.audioUrl,
avatar_image_url: params.avatarImageUrl,
bible: params.bible,
resolution: params.resolution || "720p",
prompt: params.prompt,
seed: params.seed ?? -1,
@@ -692,6 +698,7 @@ export const podcastApi = {
sceneTitle: string;
sceneContent?: string;
baseAvatarUrl?: string;
bible?: any;
idea?: string;
width?: number;
height?: number;
@@ -715,6 +722,7 @@ export const podcastApi = {
scene_title: params.sceneTitle,
scene_content: params.sceneContent,
base_avatar_url: params.baseAvatarUrl || null,
bible: params.bible,
idea: params.idea || null,
width: params.width || 1024,
height: params.height || 1024,
@@ -752,8 +760,8 @@ export const podcastApi = {
scene_ids: params.sceneIds,
scene_audio_urls: params.sceneAudioUrls,
});
return response.data;
},
return response.data;
},
async uploadAvatar(file: File, projectId?: string): Promise<{ avatar_url: string; avatar_filename: string }> {
const formData = new FormData();

View File

@@ -35,10 +35,93 @@ export interface StoryGenerationRequest {
audio_lang?: string;
audio_slow?: boolean;
audio_rate?: number;
anime_bible?: AnimeStoryBible | null;
}
export interface StorySetupGenerationRequest {
story_idea: string;
story_mode?: 'marketing' | 'pure';
story_template?: string | null;
brand_context?: {
brand_name?: string | null;
writing_tone?: string | null;
audience_description?: string | null;
} | null;
}
export interface StoryIdeaEnhanceRequest {
story_idea: string;
story_mode?: 'marketing' | 'pure';
story_template?: string | null;
brand_context?: {
brand_name?: string | null;
writing_tone?: string | null;
audience_description?: string | null;
} | null;
fiction_variant?: string | null;
narrative_energy?: string | null;
}
export interface StoryIdeaEnhanceSuggestion {
idea: string;
whats_missing: string;
why_choose: string;
}
export interface StoryIdeaEnhanceResponse {
suggestions: StoryIdeaEnhanceSuggestion[];
success: boolean;
}
export interface StoryContextResponse {
canonical_profile: Record<string, any> | null;
website_url?: string | null;
research_preferences?: Record<string, any> | null;
brand_context?: {
brand_name?: string | null;
writing_tone?: string | null;
audience_description?: string | null;
} | null;
brand_assets?: {
avatar_url?: string | null;
voice_preview_url?: string | null;
custom_voice_id?: string | null;
} | null;
persona_enabled?: boolean;
has_persona_context?: boolean;
}
export interface AnimeCharacter {
id: string;
name: string;
age_range: string;
role: string;
look: string;
outfit_palette: string;
personality_tags: string[];
}
export interface AnimeWorld {
setting: string;
era: string;
tech_or_magic_level: string;
core_rules: string[];
}
export interface AnimeVisualStyle {
style_preset: string;
camera_style: string;
color_mood: string;
lighting: string;
line_style: string;
extra_tags: string[];
}
export interface AnimeStoryBible {
story_id?: string;
main_cast: AnimeCharacter[];
world: AnimeWorld;
visual_style: AnimeVisualStyle;
}
export interface StorySetupOption {
@@ -96,6 +179,7 @@ export interface StoryOutlineResponse {
success: boolean;
task_id?: string;
is_structured?: boolean;
anime_bible?: AnimeStoryBible;
}
export interface StoryContentResponse {
@@ -261,6 +345,97 @@ export interface ResumeAnimateSceneRequest {
duration?: 5 | 10;
}
export interface AnimeSceneTextRequest {
scene: StoryScene;
persona: string;
story_setting: string;
character_input: string;
plot_elements: string;
writing_style: string;
story_tone: string;
narrative_pov: string;
audience_age_group: string;
content_rating: string;
anime_bible?: AnimeStoryBible | null;
}
export interface AnimeSceneTextResponse {
scene: StoryScene;
success: boolean;
}
export interface AnimeSceneGenerateRequest {
premise: string;
persona: string;
story_setting: string;
character_input: string;
plot_elements: string;
writing_style: string;
story_tone: string;
narrative_pov: string;
audience_age_group: string;
content_rating: string;
anime_bible: AnimeStoryBible;
previous_scenes?: StoryScene[] | null;
target_scene_number?: number | null;
}
export interface AnimeSceneGenerateResponse {
scene: StoryScene;
success: boolean;
}
export interface StoryProjectSummary {
id: number;
project_id: string;
user_id: string;
title?: string | null;
story_mode?: string | null;
story_template?: string | null;
setup?: Record<string, any> | null;
outline?: Record<string, any> | null;
scenes?: Record<string, any>[] | null;
story_content?: Record<string, any> | null;
anime_bible?: AnimeStoryBible | null;
media_state?: Record<string, any> | null;
current_phase?: string | null;
status: string;
is_favorite: boolean;
is_complete: boolean;
created_at: string;
updated_at: string;
}
export interface StoryProjectListResponse {
projects: StoryProjectSummary[];
total: number;
limit: number;
offset: number;
}
export interface CreateStoryProjectRequest {
project_id: string;
title?: string | null;
story_mode?: string | null;
story_template?: string | null;
setup?: Record<string, any> | null;
}
export interface UpdateStoryProjectRequest {
title?: string | null;
story_mode?: string | null;
story_template?: string | null;
setup?: Record<string, any> | null;
outline?: Record<string, any> | null;
scenes?: Record<string, any>[] | null;
story_content?: Record<string, any> | null;
anime_bible?: AnimeStoryBible | null;
media_state?: Record<string, any> | null;
current_phase?: string | null;
status?: string | null;
is_complete?: boolean | null;
}
class StoryWriterApi {
/**
* Generate 3 story setup options from a user's story idea
@@ -275,6 +450,24 @@ class StoryWriterApi {
return response.data;
}
async enhanceStoryIdea(
request: StoryIdeaEnhanceRequest
): Promise<StoryIdeaEnhanceResponse> {
const response = await aiApiClient.post<StoryIdeaEnhanceResponse>(
"/api/story/enhance-idea",
request
);
return response.data;
}
/**
* Get onboarding-based story context for Story Studio
*/
async getStoryContext(): Promise<StoryContextResponse> {
const response = await aiApiClient.get<StoryContextResponse>("/api/story/context");
return response.data;
}
/**
* Generate a story premise
*/
@@ -441,6 +634,71 @@ class StoryWriterApi {
return response.data;
}
async refineAnimeSceneText(request: AnimeSceneTextRequest): Promise<AnimeSceneTextResponse> {
const response = await aiApiClient.post<AnimeSceneTextResponse>(
"/api/story/anime/scene-text",
request
);
return response.data;
}
async generateAnimeSceneFromBible(
request: AnimeSceneGenerateRequest
): Promise<AnimeSceneGenerateResponse> {
const response = await aiApiClient.post<AnimeSceneGenerateResponse>(
"/api/story/anime/scene-generate",
request
);
return response.data;
}
async createStoryProject(
payload: CreateStoryProjectRequest
): Promise<StoryProjectSummary> {
const response = await aiApiClient.post<StoryProjectSummary>("/api/story/projects", payload);
return response.data;
}
async loadStoryProject(projectId: string): Promise<StoryProjectSummary> {
const response = await aiApiClient.get<StoryProjectSummary>(`/api/story/projects/${projectId}`);
return response.data;
}
async updateStoryProject(
projectId: string,
payload: UpdateStoryProjectRequest
): Promise<StoryProjectSummary> {
const response = await aiApiClient.put<StoryProjectSummary>(
`/api/story/projects/${projectId}`,
payload
);
return response.data;
}
async listStoryProjects(params?: {
status?: string;
favorites_only?: boolean;
limit?: number;
offset?: number;
order_by?: "updated_at" | "created_at";
}): Promise<StoryProjectListResponse> {
const response = await aiApiClient.get<StoryProjectListResponse>("/api/story/projects", {
params,
});
return response.data;
}
async deleteStoryProject(projectId: string): Promise<void> {
await aiApiClient.delete(`/api/story/projects/${projectId}`);
}
async toggleStoryProjectFavorite(projectId: string): Promise<StoryProjectSummary> {
const response = await aiApiClient.post<StoryProjectSummary>(
`/api/story/projects/${projectId}/favorite`
);
return response.data;
}
private buildAbsoluteUrl(path: string): string {
if (!path) return path;
if (path.startsWith('http://') || path.startsWith('https://')) {