feat(gtm): add session history, database loading, and markdown file import
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Phase, AppState, Phase1Data, Phase2Data, Phase3Data, Phase4Data, Phase6Data, Phase7Data, Phase8Data, Phase9Data, Language, Theme } from './types';
|
||||
import { Phase, AppState, Phase1Data, Phase2Data, Phase3Data, Phase4Data, Phase6Data, Phase7Data, Phase8Data, Phase9Data, Language, Theme, ProjectHistoryItem } from './types';
|
||||
import * as Gemini from './geminiService';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { AlertTriangle, ArrowRight, ArrowLeft, Check, Database, Globe, Search, ShieldAlert, Cpu, Building2, UserCircle, Briefcase, Zap, Terminal, Target, Crosshair, Loader2, Plus, X, Download, ShieldCheck, Image as ImageIcon, Copy, Sparkles, Upload, CheckCircle, PenTool, Eraser, Undo, Save, RefreshCw, Pencil, Trash2, LayoutTemplate, TrendingUp, Shield, Languages } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowRight, ArrowLeft, Check, Database, Globe, Search, ShieldAlert, Cpu, Building2, UserCircle, Briefcase, Zap, Terminal, Target, Crosshair, Loader2, Plus, X, Download, ShieldCheck, Image as ImageIcon, Copy, Sparkles, Upload, CheckCircle, PenTool, Eraser, Undo, Save, RefreshCw, Pencil, Trash2, LayoutTemplate, TrendingUp, Shield, Languages, Clock, History, FileText } from 'lucide-react';
|
||||
|
||||
const TRANSLATIONS = {
|
||||
en: {
|
||||
// ... existing ...
|
||||
historyTitle: 'Recent Sessions',
|
||||
loadBtn: 'Load',
|
||||
noSessions: 'No history found.',
|
||||
// ... existing ...
|
||||
phase1: 'Product & Constraints',
|
||||
phase2: 'ICP Discovery',
|
||||
phase3: 'Whale Hunting',
|
||||
@@ -92,6 +97,9 @@ const TRANSLATIONS = {
|
||||
]
|
||||
},
|
||||
de: {
|
||||
historyTitle: 'Letzte Sitzungen',
|
||||
loadBtn: 'Laden',
|
||||
noSessions: 'Keine Historie gefunden.',
|
||||
phase1: 'Produkt & Constraints',
|
||||
phase2: 'ICP Entdeckung',
|
||||
phase3: 'Whale Hunting',
|
||||
@@ -196,6 +204,9 @@ const App: React.FC = () => {
|
||||
const [loadingMessage, setLoadingMessage] = useState("");
|
||||
const [isTranslating, setIsTranslating] = useState(false);
|
||||
|
||||
// Session Management
|
||||
const [sessions, setSessions] = useState<ProjectHistoryItem[]>([]);
|
||||
|
||||
// Local state for adding new items (Human in the Loop inputs)
|
||||
// Phase 1
|
||||
const [newFeatureInput, setNewFeatureInput] = useState("");
|
||||
@@ -235,6 +246,19 @@ const App: React.FC = () => {
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Load Sessions on Mount
|
||||
useEffect(() => {
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
const res = await Gemini.listSessions();
|
||||
setSessions(res.projects);
|
||||
} catch (e) {
|
||||
console.error("Failed to load sessions", e);
|
||||
}
|
||||
};
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isLoading && !isTranslating) return;
|
||||
|
||||
@@ -297,6 +321,65 @@ const App: React.FC = () => {
|
||||
setState(s => ({ ...s, currentPhase: phase }));
|
||||
};
|
||||
|
||||
const handleLoadSession = async (projectId: string) => {
|
||||
setLoadingMessage("Loading Session...");
|
||||
setState(s => ({ ...s, isLoading: true }));
|
||||
try {
|
||||
const data = await Gemini.loadSession(projectId);
|
||||
const phases = data.phases || {};
|
||||
|
||||
setState(s => ({
|
||||
...s,
|
||||
isLoading: false,
|
||||
currentPhase: Phase.ProductAnalysis,
|
||||
projectId: projectId,
|
||||
productInput: phases.phase1_result?.rawAnalysis || "",
|
||||
phase1Result: phases.phase1_result,
|
||||
phase2Result: phases.phase2_result,
|
||||
phase3Result: phases.phase3_result,
|
||||
phase4Result: phases.phase4_result,
|
||||
phase5Result: phases.phase5_result,
|
||||
phase6Result: phases.phase6_result,
|
||||
phase7Result: phases.phase7_result,
|
||||
phase8Result: phases.phase8_result,
|
||||
phase9Result: phases.phase9_result,
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setError("Failed to load session: " + e.message);
|
||||
setState(s => ({ ...s, isLoading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (e: React.MouseEvent, projectId: string) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm("Delete this session permanently?")) return;
|
||||
|
||||
try {
|
||||
await Gemini.deleteSession(projectId);
|
||||
setSessions(prev => prev.filter(s => s.id !== projectId));
|
||||
} catch (e: any) {
|
||||
setError("Failed to delete session: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMarkdown = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
if (content) {
|
||||
setState(s => ({
|
||||
...s,
|
||||
currentPhase: Phase.AssetGeneration,
|
||||
phase5Result: { report: content }
|
||||
}));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const generateFullReportMarkdown = (): string => {
|
||||
if (!state.phase5Result || !state.phase5Result.report) return "";
|
||||
|
||||
@@ -515,7 +598,7 @@ const App: React.FC = () => {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await Gemini.analyzeProduct(state.productInput, language);
|
||||
setState(s => ({ ...s, currentPhase: Phase.ProductAnalysis, phase1Result: result, isLoading: false }));
|
||||
setState(s => ({ ...s, currentPhase: Phase.ProductAnalysis, phase1Result: result, isLoading: false, projectId: result.projectId })); // Save projectId!
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Analysis failed");
|
||||
setState(s => ({ ...s, isLoading: false }));
|
||||
@@ -825,103 +908,153 @@ const App: React.FC = () => {
|
||||
|
||||
const renderInputPhase = () => (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<div className="w-full max-w-2xl p-8 rounded-xl shadow-2xl transition-colors duration-300
|
||||
bg-white border border-slate-200
|
||||
dark:bg-robo-800 dark:border-robo-700
|
||||
">
|
||||
<h2 className="text-2xl font-bold mb-6 flex items-center gap-2 text-slate-800 dark:text-white">
|
||||
<Terminal className="text-blue-600 dark:text-robo-accent" />
|
||||
{labels.initTitle}
|
||||
</h2>
|
||||
<div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 dark:text-slate-400 mb-2">
|
||||
{labels.inputLabel}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-40 rounded-lg p-4 font-mono text-sm resize-none focus:ring-2 focus:outline-none transition-colors
|
||||
bg-slate-50 border border-slate-300 text-slate-900 focus:ring-blue-500
|
||||
dark:bg-robo-900 dark:border-robo-700 dark:text-slate-200 dark:focus:ring-robo-500"
|
||||
placeholder={labels.inputPlaceholder}
|
||||
value={state.productInput}
|
||||
onChange={(e) => setState(s => ({ ...s, productInput: e.target.value }))}
|
||||
disabled={state.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg border border-dashed transition-colors
|
||||
bg-slate-50 border-slate-300
|
||||
dark:bg-robo-900/50 dark:border-robo-600
|
||||
">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2 flex items-center gap-2">
|
||||
<ImageIcon size={16}/> {labels.uploadImage}
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
{state.productImages.map((img, idx) => (
|
||||
<div key={idx} className="relative group aspect-square rounded-lg overflow-hidden border border-slate-200 dark:border-robo-700 bg-white">
|
||||
<img src={img} alt={`Ref ${idx}`} className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={() => removeUploadedImage(idx)}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={12}/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<label className="flex flex-col items-center justify-center aspect-square rounded-lg border-2 border-dashed border-slate-300 dark:border-robo-600 hover:border-blue-400 dark:hover:border-robo-400 cursor-pointer transition-colors bg-slate-50 dark:bg-robo-900/30">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Plus className="text-slate-400 mb-1" size={24}/>
|
||||
<span className="text-xs text-slate-500">Add</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 text-center">{labels.uploadHint}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handlePhase1Submit}
|
||||
disabled={state.isLoading || !state.productInput}
|
||||
className="w-full font-bold py-3 px-4 rounded-lg transition-all flex items-center justify-center gap-2
|
||||
bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50
|
||||
dark:bg-robo-500 dark:hover:bg-robo-400"
|
||||
>
|
||||
{state.isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="animate-spin" /> {labels.processing}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{labels.startBtn} <ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{/* LEFT COLUMN: Input */}
|
||||
<div className="md:col-span-2 p-8 rounded-xl shadow-2xl transition-colors duration-300
|
||||
bg-white border border-slate-200
|
||||
dark:bg-robo-800 dark:border-robo-700
|
||||
">
|
||||
<h2 className="text-2xl font-bold mb-6 flex items-center gap-2 text-slate-800 dark:text-white">
|
||||
<Terminal className="text-blue-600 dark:text-robo-accent" />
|
||||
{labels.initTitle}
|
||||
</h2>
|
||||
|
||||
{state.isLoading && (
|
||||
<div className="mt-4 p-4 rounded-lg border font-mono text-sm animate-in fade-in slide-in-from-top-2
|
||||
bg-white border-slate-200
|
||||
dark:bg-robo-900/80 dark:border-robo-700
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-500 dark:text-slate-400 mb-2">
|
||||
{labels.inputLabel}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-40 rounded-lg p-4 font-mono text-sm resize-none focus:ring-2 focus:outline-none transition-colors
|
||||
bg-slate-50 border border-slate-300 text-slate-900 focus:ring-blue-500
|
||||
dark:bg-robo-900 dark:border-robo-700 dark:text-slate-200 dark:focus:ring-robo-500"
|
||||
placeholder={labels.inputPlaceholder}
|
||||
value={state.productInput}
|
||||
onChange={(e) => setState(s => ({ ...s, productInput: e.target.value }))}
|
||||
disabled={state.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg border border-dashed transition-colors
|
||||
bg-slate-50 border-slate-300
|
||||
dark:bg-robo-900/50 dark:border-robo-600
|
||||
">
|
||||
<div className="flex items-center gap-3 text-emerald-600 dark:text-emerald-400">
|
||||
<div className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2 flex items-center gap-2">
|
||||
<ImageIcon size={16}/> {labels.uploadImage}
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||
{state.productImages.map((img, idx) => (
|
||||
<div key={idx} className="relative group aspect-square rounded-lg overflow-hidden border border-slate-200 dark:border-robo-700 bg-white">
|
||||
<img src={img} alt={`Ref ${idx}`} className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={() => removeUploadedImage(idx)}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={12}/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<label className="flex flex-col items-center justify-center aspect-square rounded-lg border-2 border-dashed border-slate-300 dark:border-robo-600 hover:border-blue-400 dark:hover:border-robo-400 cursor-pointer transition-colors bg-slate-50 dark:bg-robo-900/30">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<Plus className="text-slate-400 mb-1" size={24}/>
|
||||
<span className="text-xs text-slate-500">Add</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 text-center">{labels.uploadHint}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handlePhase1Submit}
|
||||
disabled={state.isLoading || !state.productInput}
|
||||
className="w-full font-bold py-3 px-4 rounded-lg transition-all flex items-center justify-center gap-2
|
||||
bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50
|
||||
dark:bg-robo-500 dark:hover:bg-robo-400"
|
||||
>
|
||||
{state.isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="animate-spin" /> {labels.processing}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{labels.startBtn} <ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{state.isLoading && (
|
||||
<div className="mt-4 p-4 rounded-lg border font-mono text-sm animate-in fade-in slide-in-from-top-2
|
||||
bg-white border-slate-200
|
||||
dark:bg-robo-900/80 dark:border-robo-700
|
||||
">
|
||||
<div className="flex items-center gap-3 text-emerald-600 dark:text-emerald-400">
|
||||
<div className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span>
|
||||
</div>
|
||||
<span className="animate-pulse">{loadingMessage}</span>
|
||||
</div>
|
||||
<span className="animate-pulse">{loadingMessage}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1 w-full bg-slate-200 dark:bg-robo-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-emerald-500/50 animate-pulse w-2/3 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 h-1 w-full bg-slate-200 dark:bg-robo-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-emerald-500/50 animate-pulse w-2/3 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: History */}
|
||||
<div className="p-6 rounded-xl border flex flex-col h-full
|
||||
bg-slate-50 border-slate-200
|
||||
dark:bg-robo-900/30 dark:border-robo-700
|
||||
">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-bold flex items-center gap-2 text-slate-700 dark:text-slate-300">
|
||||
<History size={18}/> {labels.historyTitle}
|
||||
</h3>
|
||||
<label className="p-1.5 rounded-lg hover:bg-blue-100 dark:hover:bg-robo-800 text-blue-600 dark:text-robo-400 cursor-pointer transition-colors" title="Load Markdown File">
|
||||
<input type="file" accept=".md" onChange={handleLoadMarkdown} className="hidden" />
|
||||
<Upload size={16}/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-sm text-slate-400 italic text-center py-10">{labels.noSessions}</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<div key={session.id}
|
||||
onClick={() => handleLoadSession(session.id)}
|
||||
className="p-3 rounded-lg border cursor-pointer group transition-all relative
|
||||
bg-white border-slate-200 hover:border-blue-400 hover:shadow-md
|
||||
dark:bg-robo-800 dark:border-robo-700 dark:hover:border-robo-500
|
||||
">
|
||||
<button
|
||||
onClick={(e) => handleDeleteSession(e, session.id)}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-md opacity-0 group-hover:opacity-100 hover:bg-red-50 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500 transition-all"
|
||||
>
|
||||
<Trash2 size={14}/>
|
||||
</button>
|
||||
<div className="font-medium text-sm text-slate-800 dark:text-slate-200 line-clamp-1 mb-1 pr-6 group-hover:text-blue-600 dark:group-hover:text-robo-400">
|
||||
{session.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
||||
<Clock size={12}/>
|
||||
{new Date(session.updated_at + "Z").toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Phase1Data, Phase2Data, Phase3Data, Phase4Data, Phase6Data, Phase7Data, Phase8Data, Phase9Data, Language } from "./types";
|
||||
import { Phase1Data, Phase2Data, Phase3Data, Phase4Data, Phase6Data, Phase7Data, Phase8Data, Phase9Data, Language, ProjectHistoryItem } from "./types";
|
||||
|
||||
const API_BASE_URL = './api'; // Relative path to respect Nginx /gtm/ routing
|
||||
|
||||
@@ -100,4 +100,16 @@ export const translateReportToEnglish = async (reportMarkdown: string): Promise<
|
||||
export const generateConceptImage = async (prompt: string, referenceImagesBase64?: string[]): Promise<string> => {
|
||||
const result = await callApi<{ imageBase64: string }>('run', 'image', { prompt, referenceImagesBase64 });
|
||||
return result.imageBase64;
|
||||
};
|
||||
|
||||
export const listSessions = async (): Promise<{ projects: ProjectHistoryItem[] }> => {
|
||||
return callApi('run', 'list_history', {});
|
||||
};
|
||||
|
||||
export const loadSession = async (projectId: string): Promise<any> => {
|
||||
return callApi('run', 'load_history', { projectId });
|
||||
};
|
||||
|
||||
export const deleteSession = async (projectId: string): Promise<any> => {
|
||||
return callApi('run', 'delete_session', { projectId });
|
||||
};
|
||||
@@ -108,7 +108,7 @@ export interface AppState {
|
||||
phase2Result?: Phase2Data;
|
||||
phase3Result?: Phase3Data;
|
||||
phase4Result?: Phase4Data;
|
||||
phase5Result?: string; // Markdown content
|
||||
phase5Result?: { report: string }; // Markdown content wrapped in object
|
||||
phase6Result?: Phase6Data;
|
||||
phase7Result?: Phase7Data;
|
||||
phase8Result?: Phase8Data;
|
||||
@@ -116,4 +116,12 @@ export interface AppState {
|
||||
translatedReport?: string; // New field for the English translation
|
||||
language: Language;
|
||||
theme: Theme;
|
||||
projectId?: string; // Current Project ID
|
||||
}
|
||||
|
||||
export interface ProjectHistoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user