(simplified - assumes ordered lists come after unordered processing)
+
+ // Paragraphs (double newlines)
+ html = html.replace(/\n\n/g, '');
+ html = `
${html}
`;
+
+ // Clean up empty paragraphs
+ html = html.replace(/<\/p>/g, '');
+ html = html.replace(/
()/g, '$1');
+ html = html.replace(/(<\/h[1-3]>)<\/p>/g, '$1');
+ html = html.replace(/(
)/g, '$1');
+ html = html.replace(/(<\/ul>)<\/p>/g, '$1');
+ html = html.replace(/(
)/g, '$1');
+ html = html.replace(/(<\/ol>)<\/p>/g, '$1');
+ html = html.replace(/(
)/g, '$1');
+ html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
+ html = html.replace(/(
]*\/>)<\/p>/g, '$1');
+ html = html.replace(/(
{
diff --git a/frontend/src/hooks/usePhaseNavigation.ts b/frontend/src/hooks/usePhaseNavigation.ts
index bd67e2b7..ce153de3 100644
--- a/frontend/src/hooks/usePhaseNavigation.ts
+++ b/frontend/src/hooks/usePhaseNavigation.ts
@@ -24,23 +24,24 @@ export const usePhaseNavigation = (
// Initialize from localStorage if available
// If no research exists, default to empty string to show landing page
// Only default to 'research' if research already exists (resuming a session)
+ const VALID_PHASES = ['research', 'outline', 'content', 'seo', 'publish'];
+
const getInitialPhase = (): string => {
try {
if (typeof window !== 'undefined') {
const stored = window.localStorage.getItem('blogwriter_current_phase');
if (stored) {
- // If stored phase is 'research' but no research exists, show landing page instead
if (stored === 'research' && !research) {
- return ''; // Return empty to show landing page
+ return '';
}
- // For other phases, use stored value (user might be in middle of outline/content/seo/publish)
- // Even if research doesn't exist, allow other phases to be restored (edge case)
return stored;
}
+ const hashPhase = window.location.hash.replace('#', '');
+ if (hashPhase && VALID_PHASES.includes(hashPhase)) {
+ return hashPhase;
+ }
}
} catch {}
- // Default to empty string to show landing page when no research exists
- // Will be set to 'research' when user clicks "Start Research"
return research ? 'research' : '';
};
diff --git a/frontend/src/hooks/usePolling.ts b/frontend/src/hooks/usePolling.ts
index 96ea2992..16da8a0b 100644
--- a/frontend/src/hooks/usePolling.ts
+++ b/frontend/src/hooks/usePolling.ts
@@ -139,7 +139,7 @@ export function usePolling(
onError?.(status.error || 'Task failed');
// Check if this is a subscription error and trigger modal
- if (status.error_status === 429 || status.error_status === 402) {
+ if (status.error_status === 429 || status.error_status === 402 || status.error_status === 403) {
console.log('usePolling: Detected subscription error in task status', {
error_status: status.error_status,
error_data: status.error_data,
@@ -186,7 +186,7 @@ export function usePolling(
// Check if this is an axios error with subscription limit status
// This is a fallback in case the interceptor doesn't catch it
const axiosError = err as any;
- if (axiosError?.response?.status === 429 || axiosError?.response?.status === 402) {
+ if (axiosError?.response?.status === 429 || axiosError?.response?.status === 402 || axiosError?.response?.status === 403) {
// Trigger subscription error handler (modal will show)
// Note: The interceptor may have already called this, but we call it again to be safe
const handled = await triggerSubscriptionError(axiosError);
diff --git a/frontend/src/hooks/useTextToSpeech.ts b/frontend/src/hooks/useTextToSpeech.ts
index 3cb9ebd3..cca6be4c 100644
--- a/frontend/src/hooks/useTextToSpeech.ts
+++ b/frontend/src/hooks/useTextToSpeech.ts
@@ -140,7 +140,10 @@ export const useTextToSpeech = (): UseTextToSpeechReturn => {
};
utterance.onerror = (event) => {
- console.error('Speech synthesis error:', event.error);
+ // Ignore 'interrupted' errors (happens when stopping speech or switching sections)
+ if (event.error !== 'interrupted') {
+ console.error('Speech synthesis error:', event.error);
+ }
globalIsSpeaking = false;
globalIsPaused = false;
globalCurrentText = null;
diff --git a/frontend/src/hooks/useWixConnection.ts b/frontend/src/hooks/useWixConnection.ts
index 669e31e2..43c6a784 100644
--- a/frontend/src/hooks/useWixConnection.ts
+++ b/frontend/src/hooks/useWixConnection.ts
@@ -1,14 +1,22 @@
-/**
- * Wix Connection Hook
- * Manages Wix connection state and operations
- */
-
import { useState, useEffect, useCallback } from 'react';
-import { useAuth } from '@clerk/clerk-react';
-import { wixAPI, WixStatus } from '../api/wix';
+import { apiClient } from '../api/client';
+
+export interface WixSite {
+ id: string;
+ blog_url: string;
+ blog_id: string;
+ created_at: string;
+ scope: string;
+}
+
+export interface WixStatus {
+ connected: boolean;
+ sites: WixSite[];
+ total_sites: number;
+ error?: string;
+}
export const useWixConnection = () => {
- const { getToken } = useAuth();
const [status, setStatus] = useState({
connected: false,
sites: [],
@@ -16,74 +24,50 @@ export const useWixConnection = () => {
});
const [isLoading, setIsLoading] = useState(false);
- // Set up auth token getter for Wix API
- useEffect(() => {
- wixAPI.setAuthTokenGetter(async () => {
- try {
- const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
- if (template) {
- // @ts-ignore Clerk types allow options object
- return await getToken({ template });
- }
- return await getToken();
- } catch {
- return null;
- }
- });
- }, [getToken]);
-
const checkStatus = useCallback(async () => {
setIsLoading(true);
try {
- // Check sessionStorage for Wix tokens and site info
- const connectedFlag = sessionStorage.getItem('wix_connected') === 'true';
- const tokensRaw = sessionStorage.getItem('wix_tokens');
- const siteInfoRaw = sessionStorage.getItem('wix_site_info');
-
- if (connectedFlag && tokensRaw) {
- let siteInfo: any = {};
- try {
- if (siteInfoRaw) {
- siteInfo = JSON.parse(siteInfoRaw);
- }
- } catch (e) {
- // Ignore parse errors
+ try {
+ const resp = await apiClient.get('/api/wix/connection/status');
+ if (resp.data?.connected) {
+ const siteInfo = resp.data.site_info;
+ const sites: WixSite[] = siteInfo ? [{
+ id: siteInfo.siteId || siteInfo.site_id || 'wix-site-1',
+ blog_url: siteInfo.url || siteInfo.viewUrl || 'Connected Wix Site',
+ blog_id: 'wix-blog',
+ created_at: siteInfo.createdAt || new Date().toISOString(),
+ scope: 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE'
+ }] : [];
+ setStatus({ connected: true, sites, total_sites: sites.length });
+ return;
}
+ } catch {}
- // Set connected status with site information
- setStatus({
- connected: true,
- sites: [{
- id: siteInfo.siteId || siteInfo.site_id || 'wix-site-1',
- blog_url: siteInfo.url || siteInfo.viewUrl || 'Connected Wix Site',
- blog_id: 'wix-blog',
- created_at: siteInfo.createdAt || new Date().toISOString(),
- scope: 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE'
- }],
- total_sites: 1
- });
-
+ const connectedFlag = sessionStorage.getItem('wix_connected') === 'true'
+ || localStorage.getItem('wix_connected') === 'true';
+
+ if (connectedFlag) {
+ const siteInfoRaw = sessionStorage.getItem('wix_site_info');
+ let siteInfo: any = {};
+ try { if (siteInfoRaw) siteInfo = JSON.parse(siteInfoRaw); } catch {}
+ const sites: WixSite[] = [{
+ id: siteInfo.siteId || siteInfo.site_id || 'wix-site-1',
+ blog_url: siteInfo.url || siteInfo.viewUrl || 'Connected Wix Site',
+ blog_id: 'wix-blog',
+ created_at: siteInfo.createdAt || new Date().toISOString(),
+ scope: 'BLOG.CREATE-DRAFT,BLOG.PUBLISH,MEDIA.MANAGE'
+ }];
+ setStatus({ connected: true, sites, total_sites: 1 });
} else {
- setStatus({
- connected: false,
- sites: [],
- total_sites: 0,
- error: 'No Wix connection found'
- });
+ setStatus({ connected: false, sites: [], total_sites: 0 });
}
- } catch (error) {
- setStatus({
- connected: false,
- sites: [],
- total_sites: 0,
- error: 'Error checking connection status'
- });
+ } catch {
+ setStatus({ connected: false, sites: [], total_sites: 0, error: 'Error checking connection status' });
} finally {
setIsLoading(false);
}
}, []);
- // Check status on mount
useEffect(() => {
checkStatus();
}, [checkStatus]);
diff --git a/frontend/src/hooks/useWixPublish.ts b/frontend/src/hooks/useWixPublish.ts
new file mode 100644
index 00000000..3cac33ea
--- /dev/null
+++ b/frontend/src/hooks/useWixPublish.ts
@@ -0,0 +1,247 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { apiClient } from '../api/client';
+import { BlogSEOMetadataResponse } from '../services/blogWriterApi';
+
+export interface WixStatus {
+ connected: boolean;
+ has_permissions: boolean;
+ site_info?: any;
+}
+
+export interface WixPublishResult {
+ success: boolean;
+ url?: string;
+ post_id?: string;
+ message: string;
+ action_required?: string;
+}
+
+export function useWixPublish() {
+ const [wixStatus, setWixStatus] = useState(null);
+ const [checkingWix, setCheckingWix] = useState(false);
+ const [publishingWix, setPublishingWix] = useState(false);
+ const [showWixConnectModal, setShowWixConnectModal] = useState(false);
+ const pendingPublishRef = useRef<(() => Promise) | null>(null);
+
+ const checkWixStatus = useCallback(async () => {
+ setCheckingWix(true);
+ try {
+ if (typeof window.name === 'string' && window.name.startsWith('WIX_RESULT::')) {
+ try {
+ const payload = JSON.parse(atob(window.name.replace('WIX_RESULT::', '')));
+ if (payload.access_token) {
+ localStorage.setItem('wix_access_token', payload.access_token);
+ }
+ localStorage.setItem('wix_connected', 'true');
+ sessionStorage.setItem('wix_connected', 'true');
+ window.name = '';
+ setWixStatus({ connected: true, has_permissions: true, site_info: payload.site_info });
+ return;
+ } catch {}
+ }
+
+ try {
+ const resp = await apiClient.get('/api/wix/connection/status');
+ if (resp.data?.connected) {
+ setWixStatus({
+ connected: true,
+ has_permissions: resp.data.has_permissions ?? true,
+ site_info: resp.data.site_info,
+ });
+ return;
+ }
+ } catch {}
+
+ if (localStorage.getItem('wix_connected') === 'true') {
+ setWixStatus({ connected: true, has_permissions: true });
+ return;
+ }
+
+ if (sessionStorage.getItem('wix_connected') === 'true') {
+ setWixStatus({ connected: true, has_permissions: true });
+ return;
+ }
+
+ const params = new URLSearchParams(window.location.search);
+ if (params.get('wix_connected') === 'true') {
+ localStorage.setItem('wix_connected', 'true');
+ sessionStorage.setItem('wix_connected', 'true');
+ setWixStatus({ connected: true, has_permissions: true });
+ window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
+ return;
+ }
+
+ setWixStatus({ connected: false, has_permissions: false });
+ } catch {
+ setWixStatus({ connected: false, has_permissions: false });
+ } finally {
+ setCheckingWix(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ checkWixStatus();
+ }, [checkWixStatus]);
+
+ useEffect(() => {
+ const handler = (e: StorageEvent) => {
+ if (e.key === 'wix_connected' && e.newValue === 'true') {
+ setWixStatus({ connected: true, has_permissions: true });
+ setShowWixConnectModal(false);
+ }
+ if (e.key === 'wix_access_token' && e.newValue) {
+ setWixStatus(prev => prev ? prev : { connected: true, has_permissions: true });
+ }
+ };
+ window.addEventListener('storage', handler);
+
+ const msgHandler = (e: MessageEvent) => {
+ if (e.data?.type === 'WIX_OAUTH_SUCCESS' && e.data?.success) {
+ if (e.data.access_token) localStorage.setItem('wix_access_token', e.data.access_token);
+ localStorage.setItem('wix_connected', 'true');
+ sessionStorage.setItem('wix_connected', 'true');
+ setWixStatus({ connected: true, has_permissions: true, site_info: e.data.site_info });
+ setShowWixConnectModal(false);
+ }
+ };
+ window.addEventListener('message', msgHandler);
+
+ return () => {
+ window.removeEventListener('storage', handler);
+ window.removeEventListener('message', msgHandler);
+ };
+ }, []);
+
+ const publishToWix = useCallback(async (
+ content: string,
+ metadata: BlogSEOMetadataResponse | null,
+ explicitTitle?: string,
+ ): Promise => {
+ const title = explicitTitle
+ || metadata?.seo_title
+ || content.match(/^#\s+(.+)$/m)?.[1]
+ || content.match(/^##\s+(.+)$/m)?.[1]?.replace(/^\d+[\.\)]\s*/, '')
+ || 'Blog Post';
+
+ let coverImageUrl: string | undefined;
+ if (metadata?.open_graph?.image) {
+ const img = metadata.open_graph.image;
+ if (typeof img === 'string' && (img.startsWith('http://') || img.startsWith('https://'))) {
+ coverImageUrl = img;
+ }
+ }
+
+ try {
+ // Include access_token as fallback. The backend DB may not have tokens
+ // if the OAuth callback ran in a new tab where Clerk wasn't initialized.
+ // Tokens may be in sessionStorage (same-tab) or localStorage (cross-tab).
+ let accessToken: string | undefined;
+ try {
+ if (typeof window.name === 'string' && window.name.startsWith('WIX_RESULT::')) {
+ const payload = JSON.parse(atob(window.name.replace('WIX_RESULT::', '')));
+ accessToken = payload.access_token || undefined;
+ if (payload.access_token) localStorage.setItem('wix_access_token', payload.access_token);
+ window.name = '';
+ }
+ } catch {}
+ if (!accessToken) {
+ try {
+ const raw = sessionStorage.getItem('wix_tokens');
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ accessToken = parsed.accessToken?.value || parsed.access_token || undefined;
+ }
+ } catch {}
+ }
+ if (!accessToken) {
+ try {
+ accessToken = localStorage.getItem('wix_access_token') || undefined;
+ } catch {}
+ }
+
+ const response = await apiClient.post('/api/wix/publish', {
+ title,
+ content,
+ cover_image_url: coverImageUrl,
+ category_names: metadata?.blog_categories || [],
+ tag_names: metadata?.blog_tags || [],
+ publish: true,
+ ...(accessToken ? { access_token: accessToken } : {}),
+ seo_metadata: metadata ? {
+ seo_title: metadata.seo_title,
+ meta_description: metadata.meta_description,
+ focus_keyword: metadata.focus_keyword,
+ blog_tags: metadata.blog_tags || [],
+ social_hashtags: metadata.social_hashtags || [],
+ open_graph: metadata.open_graph || {},
+ twitter_card: metadata.twitter_card || {},
+ canonical_url: metadata.canonical_url,
+ } : undefined,
+ });
+
+ if (response.data.success) {
+ const url = response.data.url;
+ return {
+ success: true,
+ url,
+ post_id: response.data.post_id,
+ message: url
+ ? `Blog post published to Wix! View it here: ${url}`
+ : 'Blog post published successfully to Wix!',
+ };
+ }
+ return {
+ success: false,
+ message: response.data.error || 'Failed to publish to Wix',
+ };
+ } catch (error: any) {
+ if (error.response?.status === 401 || error.response?.status === 403) {
+ pendingPublishRef.current = async () => publishToWix(content, metadata);
+ setShowWixConnectModal(true);
+ return {
+ success: false,
+ message: 'Wix tokens expired. Please reconnect your Wix account.',
+ action_required: 'reconnect_wix',
+ };
+ }
+ return {
+ success: false,
+ message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`,
+ };
+ }
+ }, []);
+
+ const handleWixConnectionSuccess = useCallback(async () => {
+ await checkWixStatus();
+ const fn = pendingPublishRef.current;
+ if (fn) {
+ pendingPublishRef.current = null;
+ setTimeout(async () => {
+ try {
+ setPublishingWix(true);
+ await fn();
+ } catch {} finally {
+ setPublishingWix(false);
+ }
+ }, 500);
+ }
+ }, [checkWixStatus]);
+
+ const closeWixConnectModal = useCallback(() => {
+ setShowWixConnectModal(false);
+ pendingPublishRef.current = null;
+ }, []);
+
+ return {
+ wixStatus,
+ checkingWix,
+ publishingWix,
+ setPublishingWix,
+ checkWixStatus,
+ publishToWix,
+ showWixConnectModal,
+ setShowWixConnectModal,
+ closeWixConnectModal,
+ handleWixConnectionSuccess,
+ };
+}
diff --git a/frontend/src/services/blogWriterApi.ts b/frontend/src/services/blogWriterApi.ts
index e4bb1120..b2e23956 100644
--- a/frontend/src/services/blogWriterApi.ts
+++ b/frontend/src/services/blogWriterApi.ts
@@ -69,6 +69,9 @@ export interface BlogOutlineSection {
references: ResearchSource[];
target_words?: number;
keywords: string[];
+ chart_data?: Record;
+ 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;
diff --git a/frontend/src/services/blogWriterCache.ts b/frontend/src/services/blogWriterCache.ts
index 73913b2f..1fb8b417 100644
--- a/frontend/src/services/blogWriterCache.ts
+++ b/frontend/src/services/blogWriterCache.ts
@@ -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 = {};
+ 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)`);
diff --git a/frontend/src/services/chartApi.ts b/frontend/src/services/chartApi.ts
new file mode 100644
index 00000000..8027d853
--- /dev/null
+++ b/frontend/src/services/chartApi.ts
@@ -0,0 +1,79 @@
+import { aiApiClient, getAuthTokenGetter } from '../api/client';
+
+export interface ChartGenerateRequest {
+ chart_data?: Record;
+ 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;
+ 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;
+ chart_type: string;
+ title?: string;
+ subtitle?: string;
+ }): Promise {
+ 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 {
+ 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
tags can load it.
+ */
+ async getPreviewUrl(previewUrl: string): Promise {
+ 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;
\ No newline at end of file
diff --git a/frontend/src/services/hallucinationDetectorService.ts b/frontend/src/services/hallucinationDetectorService.ts
index 171413f4..2f97e347 100644
--- a/frontend/src/services/hallucinationDetectorService.ts
+++ b/frontend/src/services/hallucinationDetectorService.ts
@@ -75,6 +75,7 @@ export interface HealthCheckResponse {
class HallucinationDetectorService {
private baseUrl: string;
+ private authTokenGetter: (() => Promise) | null = null;
constructor() {
const getApiBaseUrl = () => {
@@ -87,6 +88,21 @@ class HallucinationDetectorService {
this.baseUrl = getApiBaseUrl();
}
+ setAuthTokenGetter(getter: (() => Promise) | null) {
+ this.authTokenGetter = getter;
+ }
+
+ private async getAuthHeaders(): Promise> {
+ const headers: Record = { '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),
});
diff --git a/frontend/src/services/linkApi.ts b/frontend/src/services/linkApi.ts
new file mode 100644
index 00000000..432bde92
--- /dev/null
+++ b/frontend/src/services/linkApi.ts
@@ -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 {
+ 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 {
+ 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;
\ No newline at end of file
diff --git a/frontend/src/services/writingAssistantService.ts b/frontend/src/services/writingAssistantService.ts
index 69557839..a22155fb 100644
--- a/frontend/src/services/writingAssistantService.ts
+++ b/frontend/src/services/writingAssistantService.ts
@@ -20,6 +20,7 @@ export interface WASuggestResponse {
class WritingAssistantService {
private baseUrl: string;
+ private authTokenGetter: (() => Promise) | 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) | null) {
+ this.authTokenGetter = getter;
+ }
+
+ private async getAuthHeaders(): Promise> {
+ const headers: Record = { '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 {
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) {
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 07c8e04e..d68fb2dc 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -418,4 +418,25 @@ html.blog-writer-page {
.blog-writer-container .MuiTextField-root .MuiOutlinedInput-root fieldset {
border-color: rgba(26, 26, 26, 0.23) !important;
+}
+
+/* Hide CopilotKit Web Inspector button and announcement globally */
+cpk-web-inspector {
+ display: none !important;
+ visibility: hidden !important;
+ pointer-events: none !important;
+ position: absolute !important;
+ left: -9999px !important;
+ width: 0 !important;
+ height: 0 !important;
+ overflow: hidden !important;
+}
+
+[class*="copilotkit"] [class*="announcement"],
+[class*="copilotkit"] [class*="announce"],
+.announcement-preview,
+[data-announcement],
+.cpk-announcement {
+ display: none !important;
+ visibility: hidden !important;
}
\ No newline at end of file
diff --git a/frontend/src/utils/wixTokenUtils.ts b/frontend/src/utils/wixTokenUtils.ts
deleted file mode 100644
index 902a0dfe..00000000
--- a/frontend/src/utils/wixTokenUtils.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-/**
- * Wix Token Utilities
- * Functions for validating and refreshing Wix OAuth tokens
- */
-
-import { apiClient } from '../api/client';
-
-interface WixTokens {
- accessToken?: {
- value: string;
- expiresAt?: string;
- };
- refreshToken?: {
- value: string;
- };
- access_token?: string;
- refresh_token?: string;
- expires_in?: number;
-}
-
-interface TokenValidationResult {
- valid: boolean;
- accessToken: string | null;
- needsRefresh: boolean;
- needsReconnect: boolean;
-}
-
-/**
- * Get Wix tokens from sessionStorage
- */
-export function getWixTokens(): WixTokens | null {
- try {
- const tokensRaw = sessionStorage.getItem('wix_tokens');
- if (!tokensRaw) return null;
- return JSON.parse(tokensRaw);
- } catch (error) {
- console.error('Error parsing Wix tokens:', error);
- return null;
- }
-}
-
-/**
- * Extract access token from token structure
- */
-export function extractAccessToken(tokens: WixTokens | null): string | null {
- if (!tokens) return null;
- return tokens.accessToken?.value || tokens.access_token || null;
-}
-
-/**
- * Extract refresh token from token structure
- */
-export function extractRefreshToken(tokens: WixTokens | null): string | null {
- if (!tokens) return null;
- return tokens.refreshToken?.value || tokens.refresh_token || null;
-}
-
-/**
- * Refresh Wix access token using refresh token
- */
-export async function refreshWixToken(refreshToken: string): Promise {
- try {
- const response = await apiClient.post('/api/wix/refresh-token', {
- refresh_token: refreshToken
- });
-
- if (response.data.success) {
- // Create new token structure matching Wix SDK format
- const newTokens: WixTokens = {
- accessToken: {
- value: response.data.access_token
- },
- refreshToken: {
- value: response.data.refresh_token || refreshToken // Keep old refresh token if new one not provided
- },
- access_token: response.data.access_token,
- refresh_token: response.data.refresh_token || refreshToken
- };
-
- // Update sessionStorage
- try {
- sessionStorage.setItem('wix_tokens', JSON.stringify(newTokens));
- sessionStorage.setItem('wix_connected', 'true');
- } catch (e) {
- console.error('Error saving refreshed tokens:', e);
- }
-
- return newTokens;
- }
-
- return null;
- } catch (error: any) {
- console.error('Error refreshing Wix token:', error);
- return null;
- }
-}
-
-/**
- * Check if token is expired based on expiresAt timestamp
- */
-function isTokenExpired(tokens: WixTokens): boolean {
- if (tokens.accessToken?.expiresAt) {
- try {
- const expiresAt = new Date(tokens.accessToken.expiresAt);
- return expiresAt < new Date();
- } catch (e) {
- // If we can't parse, assume not expired (will validate during publish)
- return false;
- }
- }
- // If no expiration info, we can't tell - assume valid for now
- // Real validation happens during actual API call
- return false;
-}
-
-/**
- * Validate and refresh Wix tokens proactively
- * Returns access token if valid, or null if needs reconnection
- *
- * Strategy:
- * 1. Check if tokens exist
- * 2. Check if token is expired (if expiration info available)
- * 3. If expired, attempt refresh
- * 4. If refresh fails or no refresh token, needs reconnection
- * 5. Real validation happens during actual publish (we catch 401/403 errors)
- */
-export async function validateAndRefreshWixTokens(): Promise {
- const tokens = getWixTokens();
-
- if (!tokens) {
- return {
- valid: false,
- accessToken: null,
- needsRefresh: false,
- needsReconnect: true
- };
- }
-
- const accessToken = extractAccessToken(tokens);
- const refreshToken = extractRefreshToken(tokens);
-
- if (!accessToken) {
- return {
- valid: false,
- accessToken: null,
- needsRefresh: false,
- needsReconnect: true
- };
- }
-
- // Check if token is expired (if we have expiration info)
- const expired = isTokenExpired(tokens);
-
- if (!expired) {
- // Token appears valid (not expired or no expiration info)
- // We'll do real validation during publish
- return {
- valid: true,
- accessToken: accessToken,
- needsRefresh: false,
- needsReconnect: false
- };
- }
-
- // Token is expired, try to refresh
- if (!refreshToken) {
- return {
- valid: false,
- accessToken: null,
- needsRefresh: false,
- needsReconnect: true
- };
- }
-
- // Attempt to refresh token
- const refreshedTokens = await refreshWixToken(refreshToken);
-
- if (refreshedTokens) {
- const newAccessToken = extractAccessToken(refreshedTokens);
- if (newAccessToken) {
- return {
- valid: true,
- accessToken: newAccessToken,
- needsRefresh: true,
- needsReconnect: false
- };
- }
- }
-
- // Refresh failed, needs reconnection
- return {
- valid: false,
- accessToken: null,
- needsRefresh: false,
- needsReconnect: true
- };
-}
-