feat: Brainstorm Topics with GSC + Issue #518 fixes + Blog Editor enhancements
Issue #518 - Subscription not updating after checkout: - Fix stale closure in SubscriptionContext checkout polling (use subscriptionRef) - Move checkout success polling from InitialRouteHandler into SubscriptionContext - Remove redundant polling code from InitialRouteHandler - Fix plan label: 'Free' instead of 'No Plan', proper capitalization - Add plan refresh button in UserBadge - Add 'View Costing Details' to UserBadge dropdown - Rename 'ALwrity Podcast Maker' to 'Podcast Creator' across UI - Clean subscription=success URL param after verification Blog Writer WYSIWYG Editor enhancements: - Per-section preview toggle (view/edit icons) - Enhanced hover-based toolbar - Circular SVG progress stats bar with detailed tooltip - Research tool chips in stats bar footer - Per-section TTS with useTextToSpeech hook (browser native) - Full blog preview modal with print/PDF support - PlayAllTTSButton: sequential playback with progress bar - OnThisPageNav: floating sidebar with scroll tracking - Section data attributes for scroll anchoring GSC Brainstorm Topics feature: - Backend: gsc_brainstorm_service.py (rule-based + LLM recommendations) - Backend: POST /gsc/brainstorm endpoint with 3-word minimum validation - Frontend: gscBrainstorm.ts API client - Frontend: useGSCBrainstormConnection hook (popup OAuth, no /onboarding redirect) - Frontend: useGSCBrainstorm hook (connect check + brainstorm call) - Frontend: GSCBrainstormModal (3-tab results: Opportunities, Gaps, AI Recs) - Frontend: BrainstormButton (visible at 3+ words, GSC connect overlay) - Wire BrainstormButton into ManualResearchForm and ResearchAction - Add blog_writer to gsc_auth router features for ALWRITY_ENABLED_FEATURES
This commit is contained in:
@@ -69,6 +69,9 @@ export interface BlogOutlineSection {
|
||||
references: ResearchSource[];
|
||||
target_words?: number;
|
||||
keywords: string[];
|
||||
chart_data?: Record<string, any>;
|
||||
chart_url?: string;
|
||||
chart_id?: string;
|
||||
}
|
||||
|
||||
export interface SourceMappingStats {
|
||||
@@ -529,6 +532,62 @@ export const blogWriterApi = {
|
||||
}
|
||||
};
|
||||
|
||||
export const saveBlogToAssetLibrary = async (params: {
|
||||
title: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
blogType?: string;
|
||||
wordCount?: number;
|
||||
sectionCount?: number;
|
||||
model?: string;
|
||||
generationTimeMs?: number;
|
||||
}): Promise<{ assetId: number } | null> => {
|
||||
try {
|
||||
const assetMetadata = {
|
||||
blog_type: params.blogType || 'medium',
|
||||
word_count: params.wordCount,
|
||||
section_count: params.sectionCount,
|
||||
model: params.model,
|
||||
generation_time_ms: params.generationTimeMs,
|
||||
};
|
||||
|
||||
const tags = ['blog', 'ai_generated', ...(params.keywords || []).slice(0, 5)];
|
||||
|
||||
const searchResponse = await aiApiClient.get('/api/content-assets/', {
|
||||
params: {
|
||||
asset_type: 'text',
|
||||
source_module: 'blog_writer',
|
||||
search: params.title,
|
||||
limit: 50,
|
||||
},
|
||||
});
|
||||
|
||||
const existingAsset = searchResponse.data.assets?.find(
|
||||
(asset: any) =>
|
||||
asset.asset_metadata?.blog_type &&
|
||||
asset.title === params.title
|
||||
);
|
||||
|
||||
if (existingAsset) {
|
||||
const updateResponse = await aiApiClient.put(`/api/content-assets/${existingAsset.id}`, {
|
||||
title: params.title,
|
||||
description: params.description || `Blog: ${params.title}`,
|
||||
tags,
|
||||
asset_metadata: {
|
||||
...existingAsset.asset_metadata,
|
||||
...assetMetadata,
|
||||
},
|
||||
});
|
||||
return { assetId: updateResponse.data.id };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error('[blogWriterApi] saveBlogToAssetLibrary failed:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Medium blog generation (≤1000 words)
|
||||
export interface MediumSectionOutlinePayload {
|
||||
id: string;
|
||||
|
||||
@@ -95,8 +95,15 @@ class BlogWriterCacheService {
|
||||
Array.from(outlineIdsSet).every(id => cachedIds.has(id));
|
||||
|
||||
if (!idsMatch) {
|
||||
console.log('Cached content does not match outline structure');
|
||||
return null;
|
||||
// Self-heal: remap cached values to outline IDs and re-cache for future lookups
|
||||
const values: string[] = Object.values(parsedSections);
|
||||
const normalized: Record<string, string> = {};
|
||||
outlineIds.forEach((id, idx) => {
|
||||
normalized[id] = (values[idx] || '') as string;
|
||||
});
|
||||
this.cacheContent(normalized, outlineIds);
|
||||
console.log(`Cache hit for content after key normalization (${Object.keys(normalized).length} sections)`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
console.log(`Cache hit for content (${Object.keys(parsedSections).length} sections)`);
|
||||
|
||||
79
frontend/src/services/chartApi.ts
Normal file
79
frontend/src/services/chartApi.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { aiApiClient, getAuthTokenGetter } from '../api/client';
|
||||
|
||||
export interface ChartGenerateRequest {
|
||||
chart_data?: Record<string, any>;
|
||||
chart_type?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
text?: string;
|
||||
section_heading?: string;
|
||||
section_key_points?: string[];
|
||||
}
|
||||
|
||||
export interface ChartGenerateResponse {
|
||||
preview_url: string;
|
||||
chart_id: string;
|
||||
chart_type?: string;
|
||||
chart_data?: Record<string, any>;
|
||||
title?: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
class ChartApiService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
const url = process.env.REACT_APP_API_URL;
|
||||
if (process.env.NODE_ENV === 'production' && !url) {
|
||||
throw new Error('REACT_APP_API_URL environment variable is required for production');
|
||||
}
|
||||
this.baseUrl = url || 'http://localhost:8000';
|
||||
}
|
||||
|
||||
async generateChartExplicit(params: {
|
||||
chart_data: Record<string, any>;
|
||||
chart_type: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}): Promise<ChartGenerateResponse> {
|
||||
const { data } = await aiApiClient.post('/api/charts/generate', {
|
||||
chart_data: params.chart_data,
|
||||
chart_type: params.chart_type,
|
||||
title: params.title || '',
|
||||
subtitle: params.subtitle || '',
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async generateChartFromText(text: string, title?: string, section_heading?: string, section_key_points?: string[]): Promise<ChartGenerateResponse> {
|
||||
const { data } = await aiApiClient.post('/api/charts/generate', {
|
||||
text,
|
||||
title: title || '',
|
||||
section_heading,
|
||||
section_key_points,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full preview URL for a chart image.
|
||||
* Appends auth token as query param so browser <img> tags can load it.
|
||||
*/
|
||||
async getPreviewUrl(previewUrl: string): Promise<string> {
|
||||
if (!previewUrl) return '';
|
||||
const fullUrl = previewUrl.startsWith('http') ? previewUrl : `${this.baseUrl}${previewUrl}`;
|
||||
const tokenGetter = getAuthTokenGetter();
|
||||
if (!tokenGetter) return fullUrl;
|
||||
try {
|
||||
const token = await tokenGetter();
|
||||
if (token) {
|
||||
const separator = fullUrl.includes('?') ? '&' : '?';
|
||||
return `${fullUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
} catch {}
|
||||
return fullUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export const chartApi = new ChartApiService();
|
||||
export default chartApi;
|
||||
@@ -75,6 +75,7 @@ export interface HealthCheckResponse {
|
||||
|
||||
class HallucinationDetectorService {
|
||||
private baseUrl: string;
|
||||
private authTokenGetter: (() => Promise<string | null>) | null = null;
|
||||
|
||||
constructor() {
|
||||
const getApiBaseUrl = () => {
|
||||
@@ -87,6 +88,21 @@ class HallucinationDetectorService {
|
||||
this.baseUrl = getApiBaseUrl();
|
||||
}
|
||||
|
||||
setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {
|
||||
this.authTokenGetter = getter;
|
||||
}
|
||||
|
||||
private async getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.authTokenGetter) {
|
||||
const token = await this.authTokenGetter();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect hallucinations in the provided text.
|
||||
*/
|
||||
@@ -98,9 +114,7 @@ class HallucinationDetectorService {
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: await this.getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
@@ -138,9 +152,7 @@ class HallucinationDetectorService {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/extract-claims`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: await this.getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
@@ -169,9 +181,7 @@ class HallucinationDetectorService {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/verify-claim`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: await this.getAuthHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
|
||||
69
frontend/src/services/linkApi.ts
Normal file
69
frontend/src/services/linkApi.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { aiApiClient } from '../api/client';
|
||||
|
||||
export interface LinkSearchRequest {
|
||||
query: string;
|
||||
link_type: 'internal' | 'external';
|
||||
site_url?: string;
|
||||
num_results?: number;
|
||||
}
|
||||
|
||||
export interface LinkSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
text: string;
|
||||
publishedDate: string;
|
||||
author: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface LinkSearchResponse {
|
||||
results: LinkSearchResult[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface RewordRequest {
|
||||
section_text: string;
|
||||
selected_text?: string;
|
||||
section_heading?: string;
|
||||
links: Array<{ url: string; title: string }>;
|
||||
}
|
||||
|
||||
export interface RewordResponse {
|
||||
reworded_text: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
class LinkApiService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
const url = process.env.REACT_APP_API_URL;
|
||||
if (process.env.NODE_ENV === 'production' && !url) {
|
||||
throw new Error('REACT_APP_API_URL environment variable is required for production');
|
||||
}
|
||||
this.baseUrl = url || 'http://localhost:8000';
|
||||
}
|
||||
|
||||
async searchLinks(params: LinkSearchRequest): Promise<LinkSearchResponse> {
|
||||
const { data } = await aiApiClient.post('/api/links/search', {
|
||||
query: params.query,
|
||||
link_type: params.link_type,
|
||||
site_url: params.site_url || '',
|
||||
num_results: params.num_results || 5,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async rewordWithLinks(params: RewordRequest): Promise<RewordResponse> {
|
||||
const { data } = await aiApiClient.post('/api/links/reword', {
|
||||
section_text: params.section_text,
|
||||
selected_text: params.selected_text,
|
||||
section_heading: params.section_heading,
|
||||
links: params.links,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export const linkApi = new LinkApiService();
|
||||
export default linkApi;
|
||||
@@ -20,6 +20,7 @@ export interface WASuggestResponse {
|
||||
|
||||
class WritingAssistantService {
|
||||
private baseUrl: string;
|
||||
private authTokenGetter: (() => Promise<string | null>) | null = null;
|
||||
constructor() {
|
||||
const getApiBaseUrl = () => {
|
||||
const url = process.env.REACT_APP_API_URL;
|
||||
@@ -31,10 +32,25 @@ class WritingAssistantService {
|
||||
this.baseUrl = getApiBaseUrl();
|
||||
}
|
||||
|
||||
setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {
|
||||
this.authTokenGetter = getter;
|
||||
}
|
||||
|
||||
private async getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.authTokenGetter) {
|
||||
const token = await this.authTokenGetter();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async suggest(text: string): Promise<WASuggestion[]> {
|
||||
const resp = await fetch(`${this.baseUrl}/api/writing-assistant/suggest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: await this.getAuthHeaders(),
|
||||
body: JSON.stringify({ text, max_results: 1 })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
Reference in New Issue
Block a user