VIBN Frontend for Coolify deployment
This commit is contained in:
333
components/mission/mission-context-tree.tsx
Normal file
333
components/mission/mission-context-tree.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
Search
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
|
||||
interface ContextItem {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'insight' | 'file' | 'chat' | 'image';
|
||||
timestamp?: Date;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface InsightTheme {
|
||||
theme: string;
|
||||
description: string;
|
||||
insights: ContextItem[];
|
||||
}
|
||||
|
||||
interface CategorySection {
|
||||
id: 'insights' | 'files' | 'chats' | 'images';
|
||||
label: string;
|
||||
items: ContextItem[];
|
||||
themes?: InsightTheme[];
|
||||
}
|
||||
|
||||
interface MissionContextTreeProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function MissionContextTree({ projectId }: MissionContextTreeProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [sections, setSections] = useState<CategorySection[]>([
|
||||
{ id: 'insights', label: 'Insights', items: [] },
|
||||
{ id: 'files', label: 'Files', items: [] },
|
||||
{ id: 'chats', label: 'Chats', items: [] },
|
||||
{ id: 'images', label: 'Images', items: [] },
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchContextData();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const fetchContextData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
const headers: HeadersInit = {};
|
||||
|
||||
if (user) {
|
||||
const token = await user.getIdToken();
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.log('[MissionContextTree] No user logged in, attempting unauthenticated fetch (development mode)');
|
||||
}
|
||||
|
||||
// Fetch insights from AlloyDB knowledge chunks
|
||||
console.log('[MissionContextTree] Fetching insights from:', `/api/projects/${projectId}/knowledge/chunks`);
|
||||
const insightsResponse = await fetch(
|
||||
`/api/projects/${projectId}/knowledge/chunks`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!insightsResponse.ok) {
|
||||
console.error('[MissionContextTree] Insights fetch failed:', insightsResponse.status, await insightsResponse.text());
|
||||
}
|
||||
|
||||
const insightsData = insightsResponse.ok ? await insightsResponse.json() : { chunks: [] };
|
||||
console.log('[MissionContextTree] Insights data:', insightsData);
|
||||
|
||||
const insights: ContextItem[] = insightsData.chunks?.map((chunk: any) => ({
|
||||
id: chunk.id,
|
||||
title: chunk.content?.substring(0, 50) || 'Untitled',
|
||||
content: chunk.content,
|
||||
type: 'insight' as const,
|
||||
timestamp: chunk.created_at ? new Date(chunk.created_at) : undefined,
|
||||
})) || [];
|
||||
|
||||
// Group insights into themes using AI
|
||||
let insightThemes: InsightTheme[] = [];
|
||||
if (insights.length > 0) {
|
||||
console.log('[MissionContextTree] Grouping insights into themes...');
|
||||
try {
|
||||
const themesResponse = await fetch(
|
||||
`/api/projects/${projectId}/knowledge/themes`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ insights }),
|
||||
}
|
||||
);
|
||||
|
||||
if (themesResponse.ok) {
|
||||
const themesData = await themesResponse.json();
|
||||
console.log('[MissionContextTree] Got', themesData.themes?.length || 0, 'themes');
|
||||
|
||||
// Map themes to insights
|
||||
insightThemes = (themesData.themes || []).map((theme: any) => ({
|
||||
theme: theme.theme,
|
||||
description: theme.description,
|
||||
insights: insights.filter(i => theme.insightIds.includes(i.id)),
|
||||
}));
|
||||
} else {
|
||||
console.error('[MissionContextTree] Themes fetch failed:', themesResponse.status);
|
||||
}
|
||||
} catch (themeError) {
|
||||
console.error('[MissionContextTree] Error grouping themes:', themeError);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch files from Firebase Storage
|
||||
console.log('[MissionContextTree] Fetching files from:', `/api/projects/${projectId}/storage/files`);
|
||||
const filesResponse = await fetch(
|
||||
`/api/projects/${projectId}/storage/files`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!filesResponse.ok) {
|
||||
console.error('[MissionContextTree] Files fetch failed:', filesResponse.status, await filesResponse.text());
|
||||
}
|
||||
|
||||
const filesData = filesResponse.ok ? await filesResponse.json() : { files: [] };
|
||||
console.log('[MissionContextTree] Files data:', filesData);
|
||||
|
||||
const files: ContextItem[] = filesData.files?.map((file: any) => ({
|
||||
id: file.name,
|
||||
title: file.name,
|
||||
type: 'file' as const,
|
||||
timestamp: file.timeCreated ? new Date(file.timeCreated) : undefined,
|
||||
})) || [];
|
||||
|
||||
// Fetch chats and images from Firestore knowledge collection via API
|
||||
console.log('[MissionContextTree] Fetching knowledge items from:', `/api/projects/${projectId}/knowledge/items`);
|
||||
const knowledgeResponse = await fetch(
|
||||
`/api/projects/${projectId}/knowledge/items`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!knowledgeResponse.ok) {
|
||||
console.error('[MissionContextTree] Knowledge items fetch failed:', knowledgeResponse.status, await knowledgeResponse.text());
|
||||
}
|
||||
|
||||
const knowledgeData = knowledgeResponse.ok ? await knowledgeResponse.json() : { items: [] };
|
||||
console.log('[MissionContextTree] Knowledge items count:', knowledgeData.items?.length || 0);
|
||||
|
||||
const chats: ContextItem[] = [];
|
||||
const images: ContextItem[] = [];
|
||||
|
||||
(knowledgeData.items || []).forEach((item: any) => {
|
||||
const contextItem: ContextItem = {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: 'chat',
|
||||
timestamp: item.createdAt ? new Date(item.createdAt) : undefined,
|
||||
};
|
||||
|
||||
// Categorize based on sourceType
|
||||
if (item.sourceType === 'imported_ai_chat' || item.sourceType === 'imported_chat' || item.sourceType === 'user_chat') {
|
||||
chats.push({ ...contextItem, type: 'chat' });
|
||||
} else if (item.sourceMeta?.filename?.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) {
|
||||
images.push({ ...contextItem, type: 'image' });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[MissionContextTree] Final counts - Insights:', insights.length, 'Files:', files.length, 'Chats:', chats.length, 'Images:', images.length, 'Themes:', insightThemes.length);
|
||||
|
||||
setSections([
|
||||
{ id: 'insights', label: 'Insights', items: insights, themes: insightThemes },
|
||||
{ id: 'files', label: 'Files', items: files },
|
||||
{ id: 'chats', label: 'Chats', items: chats },
|
||||
{ id: 'images', label: 'Images', items: images },
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching context data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
const newExpanded = new Set(expandedSections);
|
||||
if (newExpanded.has(sectionId)) {
|
||||
newExpanded.delete(sectionId);
|
||||
} else {
|
||||
newExpanded.add(sectionId);
|
||||
}
|
||||
setExpandedSections(newExpanded);
|
||||
};
|
||||
|
||||
const filteredSections = sections.map(section => ({
|
||||
...section,
|
||||
items: section.items.filter(item =>
|
||||
!searchQuery || item.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search */}
|
||||
<div className="mb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-7 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Tree */}
|
||||
<div className="flex-1 overflow-auto space-y-0.5">
|
||||
{filteredSections.map((section) => {
|
||||
const isExpanded = expandedSections.has(section.id);
|
||||
|
||||
return (
|
||||
<div key={section.id}>
|
||||
{/* Section Header */}
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate">{section.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Section Items */}
|
||||
{isExpanded && (
|
||||
<div className="ml-6 space-y-0.5">
|
||||
{section.items.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground italic">
|
||||
No {section.label.toLowerCase()} yet
|
||||
</div>
|
||||
) : section.id === 'insights' && section.themes && section.themes.length > 0 ? (
|
||||
// Render insights grouped by themes
|
||||
section.themes.map((theme) => {
|
||||
const themeKey = `theme-${section.id}-${theme.theme}`;
|
||||
const isThemeExpanded = expandedSections.has(themeKey);
|
||||
|
||||
return (
|
||||
<div key={themeKey} className="space-y-0.5">
|
||||
{/* Theme Header */}
|
||||
<button
|
||||
onClick={() => toggleSection(themeKey)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted rounded transition-colors"
|
||||
>
|
||||
{isThemeExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<FolderOpen className="h-3 w-3 shrink-0 text-amber-500" />
|
||||
<span className="truncate font-medium text-xs">{theme.theme}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({theme.insights.length})</span>
|
||||
</button>
|
||||
|
||||
{/* Theme Insights */}
|
||||
{isThemeExpanded && (
|
||||
<div className="ml-5 space-y-0.5">
|
||||
{theme.insights.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs hover:bg-muted rounded transition-colors text-left",
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
title={item.content || item.title}
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Render items normally for non-insights sections
|
||||
section.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted rounded transition-colors text-left",
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
title={item.title}
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
201
components/mission/mission-idea-section.tsx
Normal file
201
components/mission/mission-idea-section.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Clock, History, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { auth, db } from "@/lib/firebase/config";
|
||||
import { doc, getDoc, collection, query, where, orderBy, getDocs } from "firebase/firestore";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface MissionRevision {
|
||||
id: string;
|
||||
content: string;
|
||||
updatedAt: Date;
|
||||
updatedBy: string;
|
||||
source: 'ai' | 'user';
|
||||
}
|
||||
|
||||
interface MissionIdeaSectionProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function MissionIdeaSection({ projectId }: MissionIdeaSectionProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [content, setContent] = useState("");
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [revisions, setRevisions] = useState<MissionRevision[]>([]);
|
||||
const [loadingRevisions, setLoadingRevisions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchMissionIdea();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const fetchMissionIdea = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
// Fetch current mission idea from project document
|
||||
const projectRef = doc(db, 'projects', projectId);
|
||||
const projectSnap = await getDoc(projectRef);
|
||||
|
||||
if (projectSnap.exists()) {
|
||||
const data = projectSnap.data();
|
||||
setContent(
|
||||
data.missionIdea ||
|
||||
"Help solo founders build and launch their products 10x faster by turning conversations into production-ready code, designs, and marketing."
|
||||
);
|
||||
setLastUpdated(data.missionIdeaUpdatedAt?.toDate() || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching mission idea:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRevisions = async () => {
|
||||
setLoadingRevisions(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return;
|
||||
|
||||
// Fetch revision history
|
||||
const revisionsRef = collection(db, 'missionRevisions');
|
||||
const revisionsQuery = query(
|
||||
revisionsRef,
|
||||
where('projectId', '==', projectId),
|
||||
orderBy('updatedAt', 'desc')
|
||||
);
|
||||
const revisionsSnap = await getDocs(revisionsQuery);
|
||||
|
||||
const revisionsList: MissionRevision[] = [];
|
||||
revisionsSnap.forEach((doc) => {
|
||||
const data = doc.data();
|
||||
revisionsList.push({
|
||||
id: doc.id,
|
||||
content: data.content,
|
||||
updatedAt: data.updatedAt?.toDate(),
|
||||
updatedBy: data.updatedBy || 'AI',
|
||||
source: data.source || 'ai',
|
||||
});
|
||||
});
|
||||
|
||||
setRevisions(revisionsList);
|
||||
} catch (error) {
|
||||
console.error('Error fetching revisions:', error);
|
||||
} finally {
|
||||
setLoadingRevisions(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Content Card */}
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<p className="text-xl font-medium leading-relaxed">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Meta Information */}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
{lastUpdated
|
||||
? `Last updated ${formatDistanceToNow(lastUpdated, { addSuffix: true })} by AI`
|
||||
: 'Not yet updated'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Revision History */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchRevisions}
|
||||
>
|
||||
<History className="h-4 w-4 mr-2" />
|
||||
View History
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[500px] sm:w-[600px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Revision History</SheetTitle>
|
||||
<SheetDescription>
|
||||
See how your mission idea has evolved over time
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-120px)] mt-6">
|
||||
{loadingRevisions ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : revisions.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<History className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No revision history yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{revisions.map((revision, index) => (
|
||||
<div
|
||||
key={revision.id}
|
||||
className="rounded-lg border bg-card p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{revision.source === 'ai' ? 'AI Update' : 'Manual Edit'}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-primary/10 text-primary text-[10px] font-medium">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>
|
||||
{formatDistanceToNow(revision.updatedAt, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">
|
||||
{revision.content}
|
||||
</p>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{revision.updatedAt.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user