feat(gtm): add session history, database loading, and markdown file import

This commit is contained in:
2026-01-04 17:29:20 +00:00
parent 9ac2b9c466
commit d43012eeb5
4 changed files with 292 additions and 98 deletions

View File

@@ -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>
);

View File

@@ -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 });
};

View File

@@ -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;
}

View File

@@ -131,6 +131,26 @@ def get_output_lang_instruction(lang):
# --- ORCHESTRATOR PHASES ---
def list_history(payload):
projects = db_manager.get_all_projects()
return {"projects": projects}
def load_history(payload):
project_id = payload.get('projectId')
if not project_id:
raise ValueError("No projectId provided for loading history.")
data = db_manager.get_project_data(project_id)
if not data:
raise ValueError(f"Project {project_id} not found.")
return data
def delete_session(payload):
project_id = payload.get('projectId')
if not project_id:
raise ValueError("No projectId provided for deletion.")
return db_manager.delete_project(project_id)
def phase1(payload):
product_input = payload.get('productInput', '')
lang = payload.get('lang', 'de')
@@ -148,6 +168,20 @@ def phase1(payload):
analysis_content = product_input
logging.info("Input is raw text. Analyzing directly.")
# AUTOMATISCHE PROJEKTERSTELLUNG
if not project_id:
# Generiere Namen aus Input
raw_name = product_input.strip()
if raw_name.startswith('http'):
name = f"Web Analysis: {raw_name[:30]}..."
else:
name = (raw_name[:30] + "...") if len(raw_name) > 30 else raw_name
logging.info(f"Creating new project: {name}")
new_proj = db_manager.create_project(name)
project_id = new_proj['id']
logging.info(f"New Project ID: {project_id}")
sys_instr = get_system_instruction(lang)
lang_instr = get_output_lang_instruction(lang)
@@ -170,12 +204,16 @@ def phase1(payload):
try:
data = json.loads(response)
db_manager.save_gtm_result(project_id, 'phase1_result', json.dumps(data))
# WICHTIG: ID zurückgeben, damit Frontend sie speichert
data['projectId'] = project_id
return data
except json.JSONDecodeError:
logging.error(f"Failed to decode JSON from Gemini response in phase1: {response}")
error_response = {
"error": "Die Antwort des KI-Modells war kein gültiges JSON. Das passiert manchmal bei hoher Auslastung. Bitte versuchen Sie es in Kürze erneut.",
"details": response
"details": response,
"projectId": project_id # Auch bei Fehler ID zurückgeben? Besser nicht, da noch nichts gespeichert.
}
return error_response
@@ -471,6 +509,9 @@ def main():
"phase9": phase9,
"translate": translate,
"image": image,
"list_history": list_history,
"load_history": load_history,
"delete_session": delete_session,
}
mode_function = modes.get(args.mode)