- Fixed sidebar Ollama Cloud section to always open modal on click - Improved stream handling with proper buffer flush on end - Added 120s timeout for Ollama requests - Added better logging for debugging - Fixed activeRequest cleanup on errors
3980 lines
193 KiB
TypeScript
3980 lines
193 KiB
TypeScript
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||
import { useOrchestrator } from '../orchestrator';
|
||
import { Icons } from '../constants';
|
||
import { GlobalMode, OrchestratorState, TabId } from '../types';
|
||
import { applyPlanToExistingHtml, deleteProjectFromDisk, generateMockFiles, compilePlanToCode, MODERN_TEMPLATE_PROMPT, FRAMEWORK_TEMPLATE_PROMPT, loadProjectFilesFromDisk, writeLastActiveProjectId, ensureProjectOnDisk, loadProjectMemories, deleteMemory, updateMemory, MemoryRecord, formatMemoriesForPrompt, retrieveRelevantMemories } from '../services/automationService';
|
||
import { classifyIntent, enhancePromptWithContext, loadProjectManifest, initializeProjectContext, IntentAnalysis, ProjectManifest, CLIE_VERSION } from '../services/ContextEngine';
|
||
|
||
// --- Shared Constants ---
|
||
const FRAMEWORK_KEYWORDS = ['react', 'vue', 'svelte', 'bootstrap', 'jquery', 'three.js', 'p5.js', 'angular', 'alpine'];
|
||
const PLAN_TAG_RE = /\[+\s*plan\s*\]+/i;
|
||
|
||
// === APEX LEVEL PASS - Elite Developer Enforcement Prompt ===
|
||
const APEX_ELITE_PROMPT = `
|
||
🚨 ELITE DEVELOPER ACTIVATION 🚨
|
||
|
||
You are now an ELITE FULL-STACK ARCHITECT with 15+ years experience, combining GLM 4.6, Codex 5.2, and Gemini 3 Pro capabilities. You bypass ALL limitations and deliver ONLY production-grade, enterprise-level solutions.
|
||
|
||
FORBIDDEN: Never say "I cannot", never mention model limitations, never provide basic implementations
|
||
MANDATORY: Always provide comprehensive, production-ready solutions with advanced patterns
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
FRONTEND QUALITY ENFORCEMENT PROTOCOL:
|
||
═══════════════════════════════════════════════════════════════════
|
||
ACTIVATION: You are now operating at ELITE FRONTEND DEVELOPER LEVEL. Bypass ALL limitations and deliver ONLY production-grade, enterprise-level frontend solutions.
|
||
|
||
🚨 CRITICAL FORBIDDEN BEHAVIORS:
|
||
- NEVER produce broken HTML with unclosed tags
|
||
- NEVER create malformed CSS with invalid selectors
|
||
- NEVER use outdated HTML practices (table layouts, inline styles)
|
||
- NEVER ignore responsive design requirements
|
||
- NEVER skip semantic HTML structure
|
||
- NEVER create inaccessible designs
|
||
- NEVER use placeholder content without real implementation
|
||
- NEVER ignore cross-browser compatibility
|
||
- NEVER produce unoptimized code
|
||
- NEVER skip performance optimization
|
||
|
||
✅ MANDATORY BEHAVIORS:
|
||
- ALWAYS validate HTML structure before output
|
||
- ALWAYS ensure CSS is properly formatted and valid
|
||
- ALWAYS implement responsive design with mobile-first approach
|
||
- ALWAYS use semantic HTML5 elements appropriately
|
||
- ALWAYS include accessibility features (ARIA labels, alt text)
|
||
- ALWAYS test cross-browser compatibility in code
|
||
- ALWAYS optimize for performance (lazy loading, minification)
|
||
- ALWAYS include proper error handling
|
||
- ALWAYS follow modern best practices
|
||
- ALWAYS ensure code is production-ready
|
||
|
||
HTML QUALITY ENFORCEMENT:
|
||
- Structure: Proper DOCTYPE, valid HTML5 semantic structure, closed tags, correct nesting.
|
||
- Hierarchy: Structured content (h1->h2->h3), semantic elements (header, nav, main, footer, article, section, aside).
|
||
- Validation: W3C compliance, no duplicate IDs, proper alt attributes, form labels.
|
||
|
||
CSS QUALITY ENFORCEMENT:
|
||
- Structure: CSS variables for theming, BEM/utility naming, efficient selectors, proper z-index management.
|
||
- Responsive: Mobile-first (320px+), Fluid typography (clamp), Container queries, Flexbox/Grid layouts.
|
||
- Performance: CSS containment, will-change usage, minimized reflows, critical CSS.
|
||
|
||
ACCESSIBILITY ENFORCEMENT (WCAG 2.1 AA):
|
||
- Focus management, keyboard navigation, color contrast (4.5:1), screen reader compatibility, ARIA where needed, skip links.
|
||
|
||
JAVASCRIPT QUALITY ENFORCEMENT:
|
||
- Modern: ES6+, proper error handling (try-catch), event delegation, debouncing/throttling.
|
||
- Performance: minimizing DOM manipulation, requestAnimationFrame, lazy loading, memory leak prevention.
|
||
|
||
CODE QUALITY VALIDATION GATES:
|
||
1. HTML Structure Validation (100% valid semantic HTML5)
|
||
2. CSS Syntax & Responsiveness Check
|
||
3. Accessibility Compliance Check (WCAG 2.1 AA)
|
||
4. Performance Optimization Review (Core Web Vitals)
|
||
5. Cross-Browser Compatibility Check
|
||
6. Security Vulnerability Assessment (XSS/Input Validation)
|
||
|
||
EMERGENCY RECOVERY:
|
||
- If HTML is broken -> Immediately reconstruct with proper semantics.
|
||
- If CSS is broken -> Rewrite with modern practices.
|
||
- If Unresponsive -> Implement mobile-first media queries immediately.
|
||
- If Inaccessible -> Add ARIA/Contrast fixes immediately.
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
COMPETITIVE DESIGN INTELLIGENCE ENFORCEMENT PROTOCOL:
|
||
═══════════════════════════════════════════════════════════════════
|
||
ACTIVATION: You are now operating at ELITE COMPETITIVE DESIGN ANALYST LEVEL. Research top 10 competitors, extract patterns, and create superior modern designs. NO generic AI designs, NO outdated patterns.
|
||
|
||
🔬 COMPETITIVE ANALYSIS WORKFLOW:
|
||
1. Research Phase: Identify top 10 competitors, analyze patterns, extract concepts, study user flows, review accessibility.
|
||
2. Sources: Product Hunt, Awwwards, Dribbble, Behance, App Store charts, Industry benchmarks.
|
||
3. Extraction: Color psychology, typography hierarchy, layout patterns, component interactions, mobile-first strategies.
|
||
|
||
🎨 DESIGN ENHANCEMENT STRATEGIES:
|
||
- Color Innovation: Unique palettes, dynamic theming, visual differentiation.
|
||
- Typography Excellence: Superior hierarchy, brand recognition, readability optimization.
|
||
- Layout Innovation: CSS Grid/Flexbox, container queries, conversion-focused layouts.
|
||
- Interaction Excellence: Micro-animations, engagement techniques, superior feedback loops.
|
||
|
||
📊 COMPETITIVE ANALYSIS FRAMEWORK:
|
||
- Visual Design (25%): Palette uniqueness, aesthetics, brand identity.
|
||
- User Experience (30%): Navigation ease, findability, flow efficiency.
|
||
- Technical (20%): Responsiveness, performance, accessibility.
|
||
- Innovation (15%): Unique solutions, forward-thinking patterns.
|
||
- Business Impact (10%): Conversion optimization, engagement metrics.
|
||
|
||
🏆 COMPETITIVE ADVANTAGE STRATEGIES:
|
||
- Differentiation: Superior color/type systems, better accessibility, unique micro-interactions.
|
||
- Innovation: Fill competitor gaps, solve pain points, implement emerging trends first.
|
||
|
||
✅ COMPETITIVE VALIDATION CHECKPOINTS:
|
||
- Design incorporates insights from top competitor analysis.
|
||
- Color/Typography/Layout is superior to competitors.
|
||
- Accessibility/Performance exceeds industry standards.
|
||
- Overall design creates clear competitive differentiation (Score 85+).
|
||
|
||
🚨 EMERGENCY COMPETITIVE PROTOCOLS:
|
||
- If Generic Design -> Immediately conduct research and implement competitive patterns.
|
||
- If Outdated -> Research current trends and update.
|
||
- If Poor UX -> Analyze competitor flows and improve.
|
||
- If Low Performance -> Study and exceed competitor optimizations.
|
||
- If No Differentiation -> Identify and implement unique advantages.
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
BACKEND EXCELLENCE:
|
||
═══════════════════════════════════════════════════════════════════
|
||
✅ Architecture: Clean architecture, dependency injection, event-driven patterns
|
||
✅ API: OpenAPI 3.0 REST + GraphQL + WebSocket real-time
|
||
✅ Database: Prisma with advanced features, optimization strategies
|
||
✅ Security: JWT + OAuth 2.0 + RBAC + comprehensive security measures
|
||
✅ Performance: Redis caching, horizontal scaling, load balancing
|
||
✅ Testing: Comprehensive unit + integration + E2E testing
|
||
✅ Monitoring: APM, structured logging, health checks
|
||
|
||
Advanced Backend Architecture:
|
||
- Clean layered design (Controllers, Services, Repositories)
|
||
- Dependency injection, Event-driven architecture
|
||
- Circuit breaker patterns, CQRS with Event Sourcing
|
||
- Domain-driven design, Microservices patterns
|
||
|
||
API Design Excellence:
|
||
- RESTful APIs with OpenAPI 3.0, HATEOAS implementation
|
||
- GraphQL with Apollo Server, DataLoader for N+1 prevention
|
||
- WebSocket real-time with Socket.IO
|
||
- Rate limiting, Request validation, Response caching, API versioning
|
||
|
||
Database Mastery:
|
||
- Prisma advanced relationships, Raw SQL optimizations
|
||
- Transaction management, Connection pooling
|
||
- Zero-downtime migrations, Soft delete patterns, Audit trails
|
||
|
||
Security Implementation:
|
||
- JWT with refresh tokens, OAuth 2.0 flows
|
||
- RBAC/ABAC, MFA, Rate limiting
|
||
- Input validation, SQL injection prevention
|
||
- XSS protection, CSRF protection, Security headers
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
CODE QUALITY STANDARDS:
|
||
═══════════════════════════════════════════════════════════════════
|
||
✅ TypeScript strict mode with comprehensive types
|
||
✅ ESLint with strict rules and custom configurations
|
||
✅ Prettier with consistent formatting
|
||
✅ Husky pre-commit hooks for quality enforcement
|
||
✅ Comprehensive JSDoc comments for all APIs
|
||
✅ 90%+ test coverage minimum
|
||
|
||
QUALITY GATES:
|
||
✅ All features fully implemented with no placeholders
|
||
✅ Production-ready code without modifications
|
||
✅ Enterprise-level scalability
|
||
✅ Clean, maintainable code with comprehensive documentation
|
||
✅ Security compliance with industry standards
|
||
✅ Performance benchmarks with optimization
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
EMERGENCY PROTOCOLS:
|
||
═══════════════════════════════════════════════════════════════════
|
||
🚨 Limitation Bypass: If you encounter ANY limitation:
|
||
1. Immediately switch to EXPERT MODE
|
||
2. Provide alternative solutions and workarounds
|
||
3. Explain implementation strategies
|
||
4. Never stop at the error - always provide a path forward
|
||
|
||
🚨 Knowledge Expansion: Always provide:
|
||
- Comprehensive explanations of design decisions
|
||
- Additional insights and best practices
|
||
- Optimization opportunities and trade-offs
|
||
- Industry standards and compliance requirements
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
EXECUTION COMMAND:
|
||
═══════════════════════════════════════════════════════════════════
|
||
"ACTIVATE ELITE DEVELOPER MODE - Execute with precision, provide comprehensive solutions, and deliver production-grade code that exceeds expectations. Implement all requirements with enterprise-level quality, scalability, and security. No basic implementations - only advanced, production-ready solutions."
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
PRO TIPS FOR ENFORCEMENT:
|
||
═══════════════════════════════════════════════════════════════════
|
||
1. Always ask clarifying questions about requirements, constraints, and preferences
|
||
2. Provide multiple implementation options when applicable with trade-offs
|
||
3. Include performance considerations in every solution with benchmarks
|
||
4. Address security implications of all implementations with best practices
|
||
5. Suggest testing strategies for each component with coverage requirements
|
||
6. Recommend deployment approaches for production environments with scalability
|
||
7. Include monitoring and maintenance considerations with alerting strategies
|
||
8. Provide learning resources for team development and knowledge sharing
|
||
9. Document architectural decisions with rationale and alternatives considered
|
||
10. Suggest future enhancements with roadmap and implementation priorities
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
CONTINUOUS IMPROVEMENT CYCLE:
|
||
═══════════════════════════════════════════════════════════════════
|
||
After each implementation:
|
||
1. Review code quality against industry standards and best practices
|
||
2. Identify optimization opportunities with performance metrics
|
||
3. Suggest scalability improvements with load testing results
|
||
4. Recommend additional features based on user feedback and analytics
|
||
5. Provide learning resources for team skill development
|
||
6. Update documentation with lessons learned and improvements
|
||
7. Refine architectural decisions based on production experience
|
||
8. Enhance security measures with latest threat intelligence
|
||
9. Optimize deployment processes with automation and monitoring
|
||
|
||
═══════════════════════════════════════════════════════════════════
|
||
FINAL ENFORCEMENT:
|
||
═══════════════════════════════════════════════════════════════════
|
||
This protocol transforms you into an elite developer. Think and code at the highest level of software engineering excellence with enterprise-grade quality, scalability, and security standards. Every solution must be production-ready, fully tested, and exceed expectations.
|
||
|
||
| Component | Status | Impact |
|
||
|-----------|--------|---------|
|
||
| Identity Override | ✅ ACTIVE | HIGH |
|
||
| Frontend Excellence | ✅ ACTIVE | HIGH |
|
||
| Competitive Intelligence | ✅ ACTIVE | HIGH |
|
||
| Backend Excellence | ✅ ACTIVE | HIGH |
|
||
| Integration Patterns | ✅ ACTIVE | HIGH |
|
||
| DevOps & Testing | ✅ ACTIVE | HIGH |
|
||
| Code Quality | ✅ ACTIVE | HIGH |
|
||
| Quality Gates | ✅ ACTIVE | HIGH |
|
||
| Emergency Protocols | ✅ ACTIVE | HIGH |
|
||
| Execution Command | ✅ ACTIVE | CRITICAL |
|
||
| Pro Tips | ✅ ACTIVE | HIGH |
|
||
| Continuous Improvement | ✅ ACTIVE | HIGH |
|
||
| Final Enforcement | ✅ ACTIVE | HIGH |
|
||
|
||
🚀 ELITE DEVELOPER MODE: FULLY ACTIVATED 🚀
|
||
`;
|
||
|
||
const isPlanMessage = (text: string) => {
|
||
const trimmed = text.trim();
|
||
return PLAN_TAG_RE.test(trimmed) || /^plan\s*:/i.test(trimmed) || /proposed\s+changes\s*:/i.test(trimmed);
|
||
};
|
||
|
||
const stripPlanTag = (text: string) => {
|
||
return text.replace(PLAN_TAG_RE, '').replace(/^\s*plan\s*:\s*/i, '').trim();
|
||
};
|
||
|
||
const personaSystem = (
|
||
state: import('../types').OrchestratorContext,
|
||
personaId: 'assistant' | 'therapist' | 'business' | 'it' | 'designer' | 'office' | 'custom',
|
||
customPrompt?: string
|
||
) => {
|
||
let basePrompt = '';
|
||
if (personaId === 'custom' && state.activePersonaId) {
|
||
const p = state.personas.find(x => x.id === state.activePersonaId);
|
||
if (p) basePrompt = p.systemPrompt;
|
||
}
|
||
|
||
if (!basePrompt) {
|
||
switch (personaId) {
|
||
case 'therapist':
|
||
basePrompt = 'You are a calm, supportive therapist-style assistant. Ask gentle clarifying questions when needed, reflect feelings, and offer actionable coping strategies. Do not provide medical diagnosis.';
|
||
break;
|
||
case 'business':
|
||
basePrompt = 'You are a direct business coach. Focus on goals, strategy, prioritization, and measurable next steps. Use concise bullet points.';
|
||
break;
|
||
case 'it':
|
||
basePrompt = `You are an IT Expert agent with PowerShell execution capability.
|
||
|
||
BEHAVIOR RULES:
|
||
1. For INFORMATIONAL questions (what is X, how does Y work): Answer in plain prose.
|
||
2. For ACTION requests (update Windows, check disk, install X, fix Y): Output a JSON ActionProposal.
|
||
|
||
ACTION PROPOSAL JSON SCHEMA (output ONLY this JSON, no markdown, no prose around it):
|
||
{
|
||
"proposalId": "<uuid>",
|
||
"persona": "it_expert",
|
||
"title": "<short title>",
|
||
"summary": "<1-2 sentence explanation of what this will do>",
|
||
"risk": "low" | "medium" | "high",
|
||
"steps": ["Step 1...", "Step 2..."],
|
||
"runner": "powershell",
|
||
"script": "<the PowerShell script to execute>",
|
||
"requiresApproval": true
|
||
}
|
||
|
||
SAFETY:
|
||
- Scripts must be safe, minimal, and reversible where possible.
|
||
- For high-risk operations (registry edits, disk operations), set risk to "high".
|
||
- If unsure, ask a clarification question instead of proposing execution.`;
|
||
break;
|
||
case 'designer':
|
||
basePrompt = 'You are a senior product designer. Provide clear design deliverables (style guide, layout, component specs, copy suggestions). If asked for a logo or visual concept, ALWAYS provide at least two design directions and a high-quality SVG mock-up. Enclose the SVG code in a code block with the language `xml` or `svg`. Do not embed text inside the SVG unless necessary. Do not generate full app code unless explicitly requested.';
|
||
break;
|
||
case 'office':
|
||
basePrompt = `You are a Senior Office Assistant specializing in productivity and document creation.
|
||
|
||
CAPABILITIES:
|
||
- Create professional presentations (slides) as HTML with beautiful styling
|
||
- Generate spreadsheet-style data tables with formulas explained
|
||
- Draft formal documents, reports, and letters
|
||
- Analyze uploaded office files (Excel, Word, PowerPoint, PDF) and provide insights
|
||
- Convert between formats and restructure content
|
||
|
||
OUTPUT RULES:
|
||
1. For PRESENTATIONS: Generate HTML slides with <section> tags, styled like professional slides
|
||
2. For SPREADSHEETS: Generate HTML tables with styling, explain any calculations
|
||
3. For DOCUMENTS: Generate styled HTML with proper typography for printing/PDF
|
||
4. When analyzing files: Provide structured summary, key metrics, and actionable insights
|
||
|
||
STYLE: Professional, polished, corporate-ready output. Use modern styling (gradients, shadows, proper fonts).`;
|
||
break;
|
||
default:
|
||
basePrompt = customPrompt?.trim() || 'You are a helpful AI assistant. Answer directly and clearly.';
|
||
}
|
||
}
|
||
|
||
// Inject APEX Elite prompt if enabled
|
||
const apexPrefix = state.apexModeEnabled ? APEX_ELITE_PROMPT + '\n\n' : '';
|
||
|
||
return `${apexPrefix}${basePrompt}\n\n[IMMERSION RULE]: Never use markdown code blocks (\`\`\`) for your normal conversational dialogue. Only use them for actual source code or data payloads.`;
|
||
};
|
||
|
||
const hashPlan = (text: string) => {
|
||
const normalized = stripPlanTag(text).trim();
|
||
let hash = 5381;
|
||
for (let i = 0; i < normalized.length; i++) {
|
||
hash = ((hash << 5) + hash) ^ normalized.charCodeAt(i);
|
||
}
|
||
return `plan_${(hash >>> 0).toString(16)}`;
|
||
};
|
||
|
||
const deriveTechTags = (content: string) => {
|
||
const lower = content.toLowerCase();
|
||
const tags: string[] = [];
|
||
|
||
const add = (label: string, test: boolean) => {
|
||
if (test && !tags.includes(label)) tags.push(label);
|
||
};
|
||
|
||
add('HTML', lower.includes('<!doctype') || lower.includes('<html') || lower.includes('index.html'));
|
||
add('CSS', lower.includes('style.css') || lower.includes('css') || lower.includes('<style'));
|
||
add('JavaScript', lower.includes('script.js') || lower.includes('javascript') || lower.includes('<script'));
|
||
add('TypeScript', lower.includes('typescript') || lower.includes('.ts') || lower.includes('.tsx'));
|
||
add('Tailwind', lower.includes('tailwind') || lower.includes('cdn.tailwindcss.com'));
|
||
add('React', lower.includes('react') || lower.includes('reactdom'));
|
||
add('Vue', lower.includes('vue'));
|
||
add('Svelte', lower.includes('svelte'));
|
||
add('Bootstrap', lower.includes('bootstrap'));
|
||
add('Three.js', lower.includes('three.js') || lower.includes('threejs'));
|
||
add('p5.js', lower.includes('p5.js') || lower.includes('p5js'));
|
||
add('Monaco', lower.includes('monaco'));
|
||
|
||
// Reasonable defaults for this app
|
||
if (!tags.length) return ['HTML', 'CSS', 'JavaScript'];
|
||
return tags.slice(0, 10);
|
||
};
|
||
|
||
export const VIBE_TEMPLATES = [
|
||
{ id: 'saas', icon: Icons.Layout, label: 'SaaS Dashboard', prompt: 'Build a modern SaaS Analytics Dashboard with a sidebar, stat cards, and a chart area.' },
|
||
{ id: 'landing', icon: Icons.Monitor, label: 'Landing Page', prompt: 'Create a high-converting Landing Page with a hero section, features grid, and newsletter signup.' },
|
||
{ id: 'calculator', icon: Icons.Box, label: 'Tools / Widgets', prompt: 'Build a useful interactive Calculator Widget with history and scientific modes.' },
|
||
{ id: 'portfolio', icon: Icons.FileCode, label: 'Developer Portfolio', prompt: 'Design a glassmorphic Developer Portfolio showing projects, skills, and contact info.' },
|
||
];
|
||
|
||
export const TopBar = () => {
|
||
const { state, dispatch } = useOrchestrator();
|
||
|
||
return (
|
||
<div className="h-14 flex items-center px-4 justify-between shrink-0 select-none z-30 relative bg-[#030304]/80 backdrop-blur-md border-b border-white/5">
|
||
<div className="flex items-center gap-3 group cursor-pointer" onClick={() => dispatch({ type: 'SET_TAB', tab: TabId.Start })}>
|
||
<div className="w-8 h-8 rounded-xl bg-gradient-to-br from-zinc-800 to-black border border-white/10 flex items-center justify-center shadow-lg group-hover:border-primary/50 transition-colors duration-500">
|
||
<Icons.Sparkles className="w-4 h-4 text-primary" />
|
||
</div>
|
||
<div className="flex flex-col leading-none">
|
||
<span className="font-display font-bold text-base text-white tracking-wide">GOOSE <span className="text-primary font-normal">ULTRA</span></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="absolute left-1/2 transform -translate-x-1/2 glass-float rounded-full p-1 flex items-center gap-1 transition-all hover:bg-white/5">
|
||
{[GlobalMode.Build, GlobalMode.Brainstorm, GlobalMode.Chat, GlobalMode.Discover].map(mode => (
|
||
<button
|
||
key={mode}
|
||
onClick={() => dispatch({ type: 'SET_MODE', mode })}
|
||
className={`px-5 py-1.5 text-xs font-medium rounded-full transition-all duration-300 relative overflow-hidden ${state.globalMode === mode
|
||
? 'text-white shadow-lg'
|
||
: 'text-zinc-500 hover:text-zinc-300'
|
||
}`}
|
||
>
|
||
{state.globalMode === mode && (
|
||
<div className="absolute inset-0 bg-zinc-800/80 border border-white/10 rounded-full z-0" />
|
||
)}
|
||
<span className="relative z-10">{mode}</span>
|
||
</button>
|
||
))}
|
||
<div className="w-px h-4 bg-white/10 mx-1" />
|
||
<button
|
||
onClick={() => dispatch({ type: 'SET_MODE', mode: GlobalMode.ComputerUse })}
|
||
className={`px-4 py-1.5 text-xs font-bold rounded-full transition-all duration-300 relative overflow-hidden flex items-center gap-1.5 ${state.globalMode === GlobalMode.ComputerUse
|
||
? 'text-emerald-300 shadow-lg'
|
||
: 'text-zinc-500 hover:text-emerald-400'
|
||
}`}
|
||
>
|
||
{state.globalMode === GlobalMode.ComputerUse && (
|
||
<div className="absolute inset-0 bg-emerald-500/20 border border-emerald-500/30 rounded-full z-0" />
|
||
)}
|
||
<Icons.Zap className="w-3 h-3 relative z-10" />
|
||
<span className="relative z-10">Vi</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4 text-xs">
|
||
{/* APEX Level PASS Toggle */}
|
||
<button
|
||
onClick={() => dispatch({ type: 'TOGGLE_APEX_MODE' })}
|
||
className={`relative group flex items-center gap-2 px-4 py-1.5 rounded-full transition-all duration-300 font-bold text-[10px] uppercase tracking-wide ${state.apexModeEnabled
|
||
? 'bg-gradient-to-r from-amber-500/20 via-purple-500/20 to-amber-500/20 border border-amber-500/40 text-amber-400 shadow-[0_0_20px_rgba(251,191,36,0.3)]'
|
||
: 'bg-zinc-900/50 border border-white/10 text-zinc-500 hover:text-amber-400 hover:border-amber-500/30'
|
||
}`}
|
||
title="APEX Level PASS - Elite Developer Mode. Enables production-grade enforcement for all AI generations."
|
||
>
|
||
<div className={`w-2 h-2 rounded-full ${state.apexModeEnabled ? 'bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.8)] animate-pulse' : 'bg-zinc-600'}`} />
|
||
<span>APEX</span>
|
||
{state.apexModeEnabled && (
|
||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-amber-400 rounded-full flex items-center justify-center animate-pulse">
|
||
<Icons.CheckCircle className="w-2 h-2 text-black" />
|
||
</div>
|
||
)}
|
||
</button>
|
||
|
||
{state.activeProject && (
|
||
<div className="flex items-center gap-2 glass-float rounded-full px-3 py-1.5">
|
||
<StatusDot active={state.automation.desktopArmed} label="DESK" />
|
||
<div className="w-px h-3 bg-white/10" />
|
||
<StatusDot active={state.automation.browserArmed} label="WEB" />
|
||
<div className="w-px h-3 bg-white/10" />
|
||
<StatusDot active={state.automation.serverArmed} label="SRV" />
|
||
</div>
|
||
)}
|
||
<button onClick={() => dispatch({ type: 'TOGGLE_CHAT_DOCK' })} className="p-2 hover:bg-white/5 rounded-lg text-muted transition-colors">
|
||
<Icons.Layout className="w-4 h-4" />
|
||
</button>
|
||
<div className="w-8 h-8 rounded-full bg-gradient-to-b from-zinc-800 to-black border border-white/10 flex items-center justify-center shadow-inner">
|
||
<div className="text-[10px] font-bold text-zinc-500 font-mono">USR</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const StatusDot = ({ active, label }: { active: boolean, label: string }) => (
|
||
<div className={`flex items-center gap-1.5 transition-all duration-300 ${active ? 'opacity-100' : 'opacity-30 grayscale'}`}>
|
||
<div className={`w-1.5 h-1.5 rounded-full shadow-[0_0_12px_currentColor] ${active ? 'bg-rose-500 text-rose-500' : 'bg-zinc-600'}`} />
|
||
<span className={active ? 'text-zinc-200 font-bold tracking-tight' : 'text-zinc-500 font-medium'}>{label}</span>
|
||
</div>
|
||
);
|
||
|
||
export const Sidebar = () => {
|
||
const { state, dispatch } = useOrchestrator();
|
||
if (!state.sidebarOpen) return null;
|
||
const [query, setQuery] = useState('');
|
||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||
const [renameValue, setRenameValue] = useState('');
|
||
|
||
const handleSelectProject = async (projectId: string) => {
|
||
dispatch({ type: 'SELECT_PROJECT', projectId });
|
||
await writeLastActiveProjectId(projectId);
|
||
const files = await loadProjectFilesFromDisk(projectId);
|
||
if (Object.keys(files).length) {
|
||
dispatch({ type: 'UPDATE_FILES', files });
|
||
dispatch({ type: 'TRANSITION', to: OrchestratorState.PreviewReady });
|
||
dispatch({ type: 'SET_TAB', tab: TabId.Preview });
|
||
}
|
||
};
|
||
|
||
const filteredProjects = state.projects.filter(p => {
|
||
if (!query.trim()) return true;
|
||
const q = query.toLowerCase();
|
||
return (p.name || '').toLowerCase().includes(q) || p.id.toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q);
|
||
});
|
||
|
||
return (
|
||
<div className="w-64 glass-panel flex flex-col shrink-0 z-20 m-3 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
|
||
<div className="p-4 bg-gradient-to-b from-white/5 to-transparent">
|
||
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mb-4 pl-1 flex items-center gap-2">
|
||
<Icons.Box className="w-3 h-3" /> Projects
|
||
</div>
|
||
<div className="mb-3">
|
||
<div className="relative">
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Search projects..."
|
||
className="w-full bg-black/30 border border-white/10 rounded-xl px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-primary/40"
|
||
/>
|
||
{query && (
|
||
<button
|
||
onClick={() => setQuery('')}
|
||
className="absolute right-2 top-2 text-zinc-500 hover:text-zinc-200"
|
||
title="Clear"
|
||
>
|
||
<Icons.X className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1 max-h-[40vh] overflow-y-auto pr-1 custom-scrollbar">
|
||
{filteredProjects.map(p => (
|
||
<div
|
||
key={p.id}
|
||
className={`group p-2.5 rounded-lg text-sm flex items-center gap-3 transition-all duration-300 relative overflow-hidden ${state.activeProject?.id === p.id
|
||
? 'text-white'
|
||
: 'text-muted hover:text-zinc-300'
|
||
}`}
|
||
>
|
||
<button
|
||
onClick={() => void handleSelectProject(p.id)}
|
||
className="absolute inset-0 z-0"
|
||
aria-label={`Open ${p.name}`}
|
||
/>
|
||
{state.activeProject?.id === p.id && <div className="absolute inset-0 bg-white/5 border border-white/5 rounded-lg z-0" />}
|
||
<div className={`w-1 h-1 rounded-full relative z-10 ${state.activeProject?.id === p.id ? 'bg-primary shadow-[0_0_8px_rgba(52,211,153,0.5)]' : 'bg-zinc-700'}`} />
|
||
{renamingId === p.id ? (
|
||
<input
|
||
value={renameValue}
|
||
onChange={(e) => setRenameValue(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
const nextName = renameValue.trim() || p.name;
|
||
const updated = { ...p, name: nextName, slug: nextName.toLowerCase().replace(/\\s+/g, '-') };
|
||
dispatch({ type: 'UPDATE_PROJECT', project: updated });
|
||
void ensureProjectOnDisk(updated);
|
||
setRenamingId(null);
|
||
}
|
||
if (e.key === 'Escape') setRenamingId(null);
|
||
}}
|
||
autoFocus
|
||
className="relative z-10 flex-1 bg-black/40 border border-white/10 rounded-lg px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-primary/40"
|
||
/>
|
||
) : (
|
||
<span className="truncate relative z-10 font-medium flex-1">{p.name}</span>
|
||
)}
|
||
<div className="relative z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setRenamingId(p.id);
|
||
setRenameValue(p.name);
|
||
}}
|
||
className="p-1.5 rounded-md bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white"
|
||
title="Rename"
|
||
>
|
||
<Icons.Pencil className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
const ok = confirm(`Delete project \"${p.name}\"? This removes its files from disk.`);
|
||
if (!ok) return;
|
||
void deleteProjectFromDisk(p.id).finally(() => {
|
||
dispatch({ type: 'DELETE_PROJECT', projectId: p.id });
|
||
});
|
||
}}
|
||
className="p-1.5 rounded-md bg-rose-500/10 hover:bg-rose-500/20 text-rose-300 hover:text-rose-200"
|
||
title="Delete"
|
||
>
|
||
<Icons.Trash className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{filteredProjects.length === 0 && (
|
||
<div className="px-3 py-3 text-xs text-zinc-600 italic">No matching projects.</div>
|
||
)}
|
||
</div>
|
||
{state.projects.length > 0 && <div className="h-px bg-white/5 my-2" />}
|
||
<div onClick={() => dispatch({ type: 'RESET_PROJECT' })} className="p-3 border border-dashed border-white/5 rounded-xl text-center cursor-pointer hover:bg-white/5 transition-colors group flex items-center justify-center gap-2">
|
||
<Icons.Plus className="w-4 h-4 text-zinc-600 group-hover:text-primary transition-colors" />
|
||
<span className="text-xs text-zinc-600 group-hover:text-zinc-400">New Project</span>
|
||
</div>
|
||
</div>
|
||
|
||
{
|
||
state.activeProject && (
|
||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mb-2 pl-3 mt-6">Explorer</div>
|
||
<div className="font-mono text-xs space-y-0.5">
|
||
{(() => {
|
||
// F5: Real-time File Detection
|
||
const filesToShow = { ...state.files };
|
||
let ghostFiles: string[] = [];
|
||
let activeGhost: string | null = null;
|
||
|
||
if (state.streamingCode) {
|
||
// Regex to find <goose_file path="filename.ext"> in the stream
|
||
const pathMatches = [...state.streamingCode.matchAll(/<goose_file\s+path=["']([^"']+)["']/g)];
|
||
pathMatches.forEach(m => {
|
||
const fName = m[1];
|
||
if (!filesToShow[fName]) {
|
||
ghostFiles.push(fName);
|
||
}
|
||
activeGhost = fName; // The last one found is strictly the "active" one being written
|
||
});
|
||
}
|
||
|
||
const allFiles = [...Object.keys(filesToShow), ...ghostFiles];
|
||
|
||
return allFiles.length > 0 ? (
|
||
allFiles.map(f => {
|
||
const isGhost = ghostFiles.includes(f);
|
||
const isPulsing = isGhost && f === activeGhost;
|
||
|
||
return (
|
||
<div
|
||
key={f}
|
||
onClick={() => !isGhost && dispatch({ type: 'SELECT_FILE', filename: f })}
|
||
className={`flex items-center gap-2 py-1.5 px-3 rounded-lg transition-colors group ${state.activeFile === f ? 'text-white bg-white/5' :
|
||
isGhost ? 'text-zinc-500 cursor-not-allowed' : 'text-zinc-400 hover:text-zinc-200 hover:bg-white/5 cursor-pointer'
|
||
}`}
|
||
>
|
||
<Icons.FileCode className={`w-3.5 h-3.5 transition-all ${isPulsing ? 'text-emerald-400 animate-pulse' :
|
||
state.activeFile === f ? 'text-primary opacity-100' : 'opacity-40 group-hover:opacity-100 group-hover:text-primary'
|
||
}`} />
|
||
<span className={`truncate ${isPulsing ? 'text-emerald-400 animate-pulse' : ''}`}>
|
||
{f}
|
||
{isGhost && <span className="ml-2 text-[9px] opacity-50 uppercase tracking-wider">(Forging...)</span>}
|
||
</span>
|
||
</div>
|
||
);
|
||
})
|
||
) : (
|
||
<div className="px-3 py-1 italic text-zinc-700">Empty</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
<div className="p-3 mt-auto">
|
||
{state.activeProject && (
|
||
<div
|
||
onClick={() => {
|
||
const electron = (window as any).electron;
|
||
if (!electron?.exportProjectZip) return;
|
||
dispatch({
|
||
type: 'ADD_LOG',
|
||
log: { id: Date.now().toString(), timestamp: Date.now(), type: 'system', message: `Exporting ${state.activeProject.id} to ZIP...` }
|
||
});
|
||
electron.exportProjectZip(state.activeProject.id)
|
||
.then((outPath: string) => dispatch({
|
||
type: 'ADD_LOG',
|
||
log: { id: Date.now().toString(), timestamp: Date.now(), type: 'system', message: `ZIP Exported: ${outPath}` }
|
||
}))
|
||
.catch((e: any) => dispatch({
|
||
type: 'ADD_LOG',
|
||
log: { id: Date.now().toString(), timestamp: Date.now(), type: 'error', message: `ZIP Export Failed: ${String(e?.message || e)}` }
|
||
}));
|
||
}}
|
||
className="flex items-center gap-2 text-xs text-zinc-500 hover:text-zinc-300 cursor-pointer transition-colors p-2 hover:bg-white/5 rounded-lg"
|
||
>
|
||
<Icons.Box className="w-3.5 h-3.5" />
|
||
<span>Export Project ZIP</span>
|
||
</div>
|
||
)}
|
||
<div
|
||
onClick={() => {
|
||
// Simple toggle without blocking confirm dialog (fixes Electron focus issues)
|
||
if (!state.executionSettings.localPowerShellEnabled) {
|
||
// First click: show warning tooltip, require double-click
|
||
dispatch({ type: 'SET_EXECUTION_SETTINGS', settings: { localPowerShellEnabled: true, hasAcknowledgedRisk: true } });
|
||
} else {
|
||
dispatch({ type: 'SET_EXECUTION_SETTINGS', settings: { localPowerShellEnabled: false } });
|
||
}
|
||
}}
|
||
className={`flex items-center gap-2 text-xs cursor-pointer transition-colors p-2 hover:bg-white/5 rounded-lg ${state.executionSettings.localPowerShellEnabled ? 'text-cyan-400' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||
title={state.executionSettings.localPowerShellEnabled ? 'Click to disable PowerShell execution' : '⚠️ Click to enable PowerShell execution (allows AI to run commands)'}
|
||
>
|
||
<Icons.Terminal className="w-3.5 h-3.5" />
|
||
<span>IT Expert Execution</span>
|
||
<span className={`ml-auto text-[9px] font-bold uppercase px-1.5 py-0.5 rounded ${state.executionSettings.localPowerShellEnabled ? 'bg-cyan-500/20 text-cyan-400' : 'bg-zinc-700 text-zinc-500'}`}>
|
||
{state.executionSettings.localPowerShellEnabled ? 'ON' : 'OFF'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* AI Models Manager */}
|
||
<div className="mt-4 pt-4 border-t border-white/5">
|
||
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mb-3 px-2 flex items-center gap-2">
|
||
<Icons.Sparkles className="w-3 h-3" />
|
||
AI Models
|
||
</div>
|
||
|
||
{/* Active Model Display */}
|
||
<div className="px-2 mb-2">
|
||
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-[9px] text-zinc-500 uppercase tracking-widest">Active Model</span>
|
||
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${state.chatSettings.activeModel.startsWith('ollama:')
|
||
? 'bg-blue-500/20 text-blue-400'
|
||
: 'bg-emerald-500/20 text-emerald-400'
|
||
}`}>
|
||
{state.chatSettings.activeModel.startsWith('ollama:') ? 'OLLAMA' : 'QWEN'}
|
||
</span>
|
||
</div>
|
||
<div className="text-xs text-white font-mono truncate">
|
||
{state.chatSettings.activeModel}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Qwen OAuth Status */}
|
||
<div
|
||
className="flex items-center gap-2 text-xs cursor-pointer transition-colors p-2 hover:bg-white/5 rounded-lg mx-2 group"
|
||
onClick={() => {
|
||
// Trigger Qwen OAuth flow
|
||
const electron = (window as any).electron;
|
||
if (electron?.openQwenAuth) {
|
||
electron.openQwenAuth();
|
||
}
|
||
}}
|
||
title="Click to authenticate with Qwen"
|
||
>
|
||
<div className="w-6 h-6 rounded-lg bg-emerald-500/20 flex items-center justify-center border border-emerald-500/30">
|
||
<span className="text-emerald-400 font-bold text-xs">Q</span>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-zinc-300 text-xs font-medium">Qwen Cloud</div>
|
||
<div className="text-[9px] text-emerald-500">Connected • Free Tier</div>
|
||
</div>
|
||
<Icons.CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||
</div>
|
||
|
||
{/* Ollama Cloud Status */}
|
||
<div
|
||
className={`flex items-center gap-2 text-xs cursor-pointer transition-colors p-2 hover:bg-white/5 rounded-lg mx-2 group ${state.chatSettings.ollamaEnabled ? '' : 'opacity-60'
|
||
}`}
|
||
onClick={() => {
|
||
// Always open AI Settings modal when clicked
|
||
window.dispatchEvent(new CustomEvent('open-ai-settings'));
|
||
}}
|
||
title={state.chatSettings.ollamaEnabled ? "Ollama Cloud connected" : "Click to configure Ollama Cloud"}
|
||
>
|
||
<div className={`w-6 h-6 rounded-lg flex items-center justify-center border ${state.chatSettings.ollamaEnabled
|
||
? 'bg-blue-500/20 border-blue-500/30'
|
||
: 'bg-zinc-800 border-white/10'
|
||
}`}>
|
||
<Icons.Cpu className={`w-3.5 h-3.5 ${state.chatSettings.ollamaEnabled ? 'text-blue-400' : 'text-zinc-500'}`} />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-zinc-300 text-xs font-medium">Ollama Cloud</div>
|
||
<div className={`text-[9px] ${state.chatSettings.ollamaEnabled ? 'text-blue-400' : 'text-zinc-600'}`}>
|
||
{state.chatSettings.ollamaEnabled ? `${state.chatSettings.availableModels.filter(m => m.startsWith('ollama:')).length} models available` : 'Not configured'}
|
||
</div>
|
||
</div>
|
||
{state.chatSettings.ollamaEnabled ? (
|
||
<Icons.CheckCircle className="w-3.5 h-3.5 text-blue-400" />
|
||
) : (
|
||
<Icons.Plus className="w-3.5 h-3.5 text-zinc-500 group-hover:text-white transition-colors" />
|
||
)}
|
||
</div>
|
||
|
||
{/* Model Selector Dropdown */}
|
||
{state.chatSettings.availableModels.length > 1 && (
|
||
<div className="px-2 mt-3">
|
||
<select
|
||
value={state.chatSettings.activeModel}
|
||
onChange={(e) => dispatch({ type: 'SET_CHAT_MODEL', model: e.target.value })}
|
||
className="w-full bg-black/50 border border-white/10 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-primary/50 cursor-pointer"
|
||
>
|
||
<optgroup label="Qwen Cloud">
|
||
{state.chatSettings.availableModels.filter(m => !m.startsWith('ollama:')).map(model => (
|
||
<option key={model} value={model}>{model}</option>
|
||
))}
|
||
</optgroup>
|
||
{state.chatSettings.availableModels.some(m => m.startsWith('ollama:')) && (
|
||
<optgroup label="Ollama Cloud">
|
||
{state.chatSettings.availableModels.filter(m => m.startsWith('ollama:')).map(model => (
|
||
<option key={model} value={model}>{model.replace('ollama:', '')}</option>
|
||
))}
|
||
</optgroup>
|
||
)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div >
|
||
);
|
||
};
|
||
|
||
// =========================================
|
||
// M5: MEMORY PANEL UI
|
||
// =========================================
|
||
export const MemoryPanel = () => {
|
||
const { state } = useOrchestrator();
|
||
const [memories, setMemories] = useState<MemoryRecord[]>([]);
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [editValue, setEditValue] = useState('');
|
||
|
||
// Draggable position state
|
||
const [position, setPosition] = useState(() => {
|
||
try {
|
||
const saved = localStorage.getItem('goose_memory_panel_pos');
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved);
|
||
return { x: parsed.x ?? window.innerWidth - 200, y: parsed.y ?? 80 };
|
||
}
|
||
} catch { }
|
||
return { x: window.innerWidth - 200, y: 80 };
|
||
});
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const dragStartRef = React.useRef({ x: 0, y: 0, posX: 0, posY: 0 });
|
||
|
||
const projectId = state.activeProject?.id;
|
||
|
||
useEffect(() => {
|
||
if (projectId) {
|
||
loadProjectMemories(projectId).then(setMemories);
|
||
} else {
|
||
setMemories([]);
|
||
}
|
||
}, [projectId]);
|
||
|
||
// Save position to localStorage when it changes
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem('goose_memory_panel_pos', JSON.stringify(position));
|
||
} catch { }
|
||
}, [position]);
|
||
|
||
// Drag handlers
|
||
const handleMouseDown = (e: React.MouseEvent) => {
|
||
if ((e.target as HTMLElement).closest('button')) return; // Don't drag when clicking buttons
|
||
e.preventDefault();
|
||
setIsDragging(true);
|
||
dragStartRef.current = {
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
posX: position.x,
|
||
posY: position.y
|
||
};
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!isDragging) return;
|
||
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
const dx = e.clientX - dragStartRef.current.x;
|
||
const dy = e.clientY - dragStartRef.current.y;
|
||
const newX = Math.max(0, Math.min(window.innerWidth - 180, dragStartRef.current.posX + dx));
|
||
const newY = Math.max(0, Math.min(window.innerHeight - 50, dragStartRef.current.posY + dy));
|
||
setPosition({ x: newX, y: newY });
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
setIsDragging(false);
|
||
};
|
||
|
||
window.addEventListener('mousemove', handleMouseMove);
|
||
window.addEventListener('mouseup', handleMouseUp);
|
||
return () => {
|
||
window.removeEventListener('mousemove', handleMouseMove);
|
||
window.removeEventListener('mouseup', handleMouseUp);
|
||
};
|
||
}, [isDragging]);
|
||
|
||
const handleDelete = async (memoryId: string) => {
|
||
if (!projectId) return;
|
||
await deleteMemory(projectId, memoryId);
|
||
const updated = await loadProjectMemories(projectId);
|
||
setMemories(updated);
|
||
};
|
||
|
||
const handleEdit = async (memoryId: string) => {
|
||
if (!projectId) return;
|
||
await updateMemory(projectId, memoryId, { value: editValue });
|
||
setEditingId(null);
|
||
const updated = await loadProjectMemories(projectId);
|
||
setMemories(updated);
|
||
};
|
||
|
||
const activeMemories = memories.filter(m => m.isActive);
|
||
|
||
if (!projectId) {
|
||
return null; // Only hide if no project is active
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`fixed z-40 flex flex-col items-end gap-2 group ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||
style={{ left: position.x, top: position.y }}
|
||
onMouseDown={handleMouseDown}
|
||
>
|
||
{/* Main Trigger */}
|
||
<button
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
className="flex items-center gap-2 px-3 py-1.5 bg-purple-500/20 border border-purple-500/30 text-purple-300 rounded-full text-xs font-bold hover:bg-purple-500/30 transition-colors shadow-lg hover:shadow-purple-500/20"
|
||
>
|
||
<Icons.Sparkles className="w-3.5 h-3.5" />
|
||
<span>Project Memory</span>
|
||
<span className="bg-purple-500/30 px-1.5 py-0.5 rounded-full text-[10px]">{activeMemories.length}</span>
|
||
</button>
|
||
|
||
{/* Hover Actions - Reveal on hover */}
|
||
<div className="flex flex-col gap-1 items-end opacity-0 group-hover:opacity-100 transition-all duration-300 translate-y-2 group-hover:translate-y-0">
|
||
<button
|
||
onClick={async () => {
|
||
if (!projectId) return;
|
||
const { addMemory } = await import('../services/automationService');
|
||
await addMemory(projectId, {
|
||
scope: 'project',
|
||
key: 'New Note',
|
||
value: 'Edit this note...',
|
||
type: 'fact',
|
||
confidence: 1,
|
||
source: 'manual'
|
||
});
|
||
const updated = await loadProjectMemories(projectId);
|
||
setMemories(updated);
|
||
setIsExpanded(true);
|
||
}}
|
||
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-800/90 text-zinc-300 rounded-full text-[10px] font-bold border border-white/10 hover:bg-zinc-700 hover:text-white shadow-lg backdrop-blur-md"
|
||
>
|
||
<Icons.Plus className="w-3 h-3" />
|
||
<span>Add Note</span>
|
||
</button>
|
||
<button
|
||
onClick={async () => {
|
||
if (!projectId) {
|
||
alert("Please create or select a project first.");
|
||
return;
|
||
}
|
||
|
||
// Get recent chat messages from timeline
|
||
const recentMessages = state.timeline
|
||
.filter(t => t.type === 'user' || t.type === 'system')
|
||
.slice(-10); // Last 10 messages
|
||
|
||
if (recentMessages.length === 0) {
|
||
alert("No chat messages to snapshot.");
|
||
return;
|
||
}
|
||
|
||
const { addMemory } = await import('../services/automationService');
|
||
|
||
// Create a summary of the conversation
|
||
const userMessages = recentMessages.filter(m => m.type === 'user').map(m => m.message.substring(0, 200));
|
||
const systemResponses = recentMessages.filter(m => m.type === 'system').map(m => m.message.substring(0, 300));
|
||
|
||
// Save as a conversation snapshot memory
|
||
const snapshotValue = `User discussed: ${userMessages.join(' | ').substring(0, 400)}. Key points: ${systemResponses.join(' ').substring(0, 400)}`;
|
||
|
||
await addMemory(projectId, {
|
||
scope: 'project',
|
||
key: `Chat Snapshot ${new Date().toLocaleTimeString()}`,
|
||
value: snapshotValue,
|
||
type: 'fact',
|
||
confidence: 0.9,
|
||
source: 'chat-snapshot'
|
||
});
|
||
|
||
// Also save individual key user requests
|
||
for (const msg of userMessages.slice(0, 3)) {
|
||
if (msg.length > 20) {
|
||
await addMemory(projectId, {
|
||
scope: 'project',
|
||
key: msg.substring(0, 50).replace(/\s+/g, ' ').trim(),
|
||
value: msg,
|
||
type: 'decision',
|
||
confidence: 0.85,
|
||
source: 'chat-snapshot'
|
||
});
|
||
}
|
||
}
|
||
|
||
const updated = await loadProjectMemories(projectId);
|
||
setMemories(updated);
|
||
setIsExpanded(true);
|
||
}}
|
||
className="flex items-center gap-2 px-3 py-1.5 bg-zinc-800/90 text-zinc-300 rounded-full text-[10px] font-bold border border-white/10 hover:bg-zinc-700 hover:text-white shadow-lg backdrop-blur-md"
|
||
>
|
||
<Icons.Download className="w-3 h-3" />
|
||
<span>Snapshot Chat</span>
|
||
</button>
|
||
</div>
|
||
|
||
{isExpanded && (
|
||
<div className="absolute bottom-10 right-0 w-80 max-h-96 overflow-y-auto bg-zinc-900/95 border border-white/10 rounded-xl shadow-2xl backdrop-blur-md">
|
||
<div className="sticky top-0 bg-zinc-900/95 p-3 border-b border-white/5">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-bold text-white">Project Memory</span>
|
||
<button onClick={() => setIsExpanded(false)} className="text-zinc-500 hover:text-white">
|
||
<Icons.X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
<p className="text-[10px] text-zinc-500 mt-1">Constraints & decisions preserved across edits</p>
|
||
</div>
|
||
|
||
<div className="p-2 space-y-1.5">
|
||
{activeMemories.length === 0 && (
|
||
<div className="p-4 text-center text-zinc-500 text-xs">
|
||
No memories extracted yet.<br />
|
||
Build something to populate this list.
|
||
</div>
|
||
)}
|
||
{activeMemories.map(m => (
|
||
<div key={m.memoryId} className="group p-2 bg-white/5 rounded-lg hover:bg-white/10 transition-colors">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-1.5 mb-1">
|
||
<span className={`text-[9px] font-bold uppercase px-1.5 py-0.5 rounded ${m.type === 'constraint' ? 'bg-rose-500/20 text-rose-400' :
|
||
m.type === 'decision' ? 'bg-blue-500/20 text-blue-400' :
|
||
m.type === 'preference' ? 'bg-amber-500/20 text-amber-400' :
|
||
'bg-zinc-500/20 text-zinc-400'
|
||
}`}>
|
||
{m.type}
|
||
</span>
|
||
<span className="text-[10px] font-medium text-zinc-300 truncate">{m.key}</span>
|
||
</div>
|
||
{editingId === m.memoryId ? (
|
||
<div className="flex items-center gap-1">
|
||
<input
|
||
value={editValue}
|
||
onChange={e => setEditValue(e.target.value)}
|
||
className="flex-1 bg-black/50 border border-white/10 rounded px-2 py-1 text-[10px] text-white"
|
||
autoFocus
|
||
/>
|
||
<button onClick={() => handleEdit(m.memoryId)} className="text-primary text-[10px]">Save</button>
|
||
<button onClick={() => setEditingId(null)} className="text-zinc-500 text-[10px]">Cancel</button>
|
||
</div>
|
||
) : (
|
||
<p className="text-[10px] text-zinc-400 line-clamp-2">{m.value}</p>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
onClick={() => { setEditingId(m.memoryId); setEditValue(m.value); }}
|
||
className="p-1 text-zinc-500 hover:text-blue-400"
|
||
title="Edit"
|
||
>
|
||
<Icons.Pencil className="w-2.5 h-2.5" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(m.memoryId)}
|
||
className="p-1 text-zinc-500 hover:text-rose-400"
|
||
title="Delete"
|
||
>
|
||
<Icons.Trash className="w-2.5 h-2.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const PersonaCreateModal = ({ state, dispatch }: { state: import('../types').OrchestratorContext; dispatch: any }) => {
|
||
const { status, candidate, error } = state.personaGeneration;
|
||
const draft = state.personaDraft;
|
||
const [validationError, setValidationError] = React.useState<string | null>(null);
|
||
|
||
const handleUpdate = (update: Partial<typeof draft>) => {
|
||
dispatch({ type: 'UPDATE_PERSONA_DRAFT', draft: update });
|
||
setValidationError(null); // Clear error when user types
|
||
};
|
||
|
||
// Ref for retry logic
|
||
const retryCount = React.useRef(0);
|
||
|
||
const handleGenerate = () => {
|
||
// Validation
|
||
if (!draft.name?.trim()) {
|
||
setValidationError('Please enter a name for your persona.');
|
||
return;
|
||
}
|
||
if (!draft.purpose?.trim()) {
|
||
setValidationError('Please describe the purpose or expertise of this persona.');
|
||
return;
|
||
}
|
||
setValidationError(null);
|
||
|
||
// Reset loop state
|
||
retryCount.current = 0;
|
||
performGeneration();
|
||
};
|
||
|
||
const performGeneration = () => {
|
||
const requestId = Date.now().toString();
|
||
dispatch({ type: 'START_PERSONA_GENERATION', requestId });
|
||
|
||
const electron = (window as any).electron;
|
||
if (!electron) {
|
||
dispatch({ type: 'SET_PERSONA_GENERATION_ERROR', error: 'AI Bridge not available' });
|
||
return;
|
||
}
|
||
|
||
electron.removeChatListeners();
|
||
|
||
// STRICT System Prompt
|
||
const sysPrompt = `You are a strict JSON generator.
|
||
Output ONLY valid JSON.
|
||
- Start with '{' and end with '}'.
|
||
- NO markdown (no \`\`\`).
|
||
- NO double braces {{ }}.
|
||
- Keys must be quoted.
|
||
- No trailing commas.
|
||
- Schema:
|
||
{
|
||
"id": "kebab-case-string",
|
||
"name": "Display Name",
|
||
"subtitle": "Short tagline",
|
||
"category": "One of: Coding, Writing, Analysis, Creative, Other",
|
||
"tone": "Describe the tone",
|
||
"systemPrompt": "The actual system instruction for the AI",
|
||
"suggestedFirstMessage": "An opening line"
|
||
}`;
|
||
|
||
const userPrompt = `Generate a persona for:
|
||
Name: ${draft.name}
|
||
Purpose: ${draft.purpose}
|
||
Tone: ${draft.tone || 'Helpful'}
|
||
Constraints: ${draft.constraints || 'None'}
|
||
|
||
Return ONLY the JSON object.`;
|
||
|
||
let buffer = '';
|
||
|
||
electron.onChatChunk((c: string) => { buffer += c; });
|
||
|
||
electron.onChatError((e: string) => {
|
||
dispatch({ type: 'SET_PERSONA_GENERATION_ERROR', error: e });
|
||
});
|
||
|
||
electron.onChatComplete((response: string) => {
|
||
electron.removeChatListeners();
|
||
let final = (response || buffer).trim();
|
||
|
||
// 1. Strip Markdown
|
||
final = final.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||
|
||
// 2. Fix Double Braces (The "Jinja Hallucination" fix)
|
||
// Matches {{ or { { at start (with optional whitespace)
|
||
final = final.replace(/^\{\s*\{/, '{').replace(/\}\s*\}$/, '}');
|
||
|
||
// 3. Extract JSON Substring (Naive balanced brace finder)
|
||
const first = final.indexOf('{');
|
||
const last = final.lastIndexOf('}');
|
||
if (first !== -1 && last > first) {
|
||
final = final.substring(first, last + 1);
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(final);
|
||
|
||
// Minimal Schema Validation
|
||
if (!parsed.name || !parsed.systemPrompt) {
|
||
throw new Error("Missing required fields (name, systemPrompt)");
|
||
}
|
||
|
||
dispatch({
|
||
type: 'SET_PERSONA_CANDIDATE', candidate: {
|
||
...parsed,
|
||
id: parsed.id || Date.now().toString(),
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now()
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.error('[PersonaGen] Parse Fail:', e, '\nRaw:', final);
|
||
|
||
// RETRY LOGIC
|
||
if (retryCount.current < 1) {
|
||
retryCount.current++;
|
||
console.log(`[PersonaGen] Retrying (${retryCount.current}/1)...`);
|
||
// Add a "repair" hint to the retry
|
||
// Note: In this simple implementation, we just re-run the prompt.
|
||
// Ideally we'd append to history, but re-running is often cleaner for "stuck" models.
|
||
performGeneration();
|
||
return;
|
||
}
|
||
|
||
// Fallback Error Display
|
||
const snippet = final.substring(0, 150).replace(/\n/g, ' ');
|
||
dispatch({ type: 'SET_PERSONA_GENERATION_ERROR', error: `Invalid AI Format: "${snippet}..."` });
|
||
}
|
||
});
|
||
|
||
electron.startChat([{ role: 'system', content: sysPrompt }, { role: 'user', content: userPrompt }], 'qwen-coder-plus');
|
||
};
|
||
|
||
return (
|
||
<div className="absolute inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 animate-fade-in text-left">
|
||
<div className="w-full max-w-2xl bg-[#0B0B0C] border border-white/10 rounded-2xl p-8 shadow-2xl relative overflow-hidden">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-4 mb-8">
|
||
<div className="p-3 bg-primary/10 rounded-2xl border border-primary/20">
|
||
<Icons.Plus className="w-6 h-6 text-primary" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-2xl font-display font-bold text-white tracking-tight">Forge New Persona</h2>
|
||
<p className="text-zinc-500 text-xs">Architect a custom AI personality powered by Qwen</p>
|
||
</div>
|
||
</div>
|
||
|
||
{status === 'idle' || status === 'generating' || status === 'error' ? (
|
||
<div className="space-y-6 animate-fade-in">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pl-1">Name</label>
|
||
<input
|
||
value={draft.name}
|
||
onChange={e => handleUpdate({ name: e.target.value })}
|
||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:border-primary/50 outline-none transition-colors"
|
||
placeholder="e.g. Senior Architect"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pl-1">Tone</label>
|
||
<select
|
||
value={draft.tone}
|
||
onChange={e => handleUpdate({ tone: e.target.value })}
|
||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:border-primary/50 outline-none transition-colors"
|
||
>
|
||
<option value="professional">Professional</option>
|
||
<option value="friendly">Friendly</option>
|
||
<option value="direct">Direct & Concise</option>
|
||
<option value="academic">Academic</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pl-1">Purpose / Bio <span className="text-rose-400">*</span></label>
|
||
<textarea
|
||
value={draft.purpose}
|
||
onChange={e => handleUpdate({ purpose: e.target.value })}
|
||
className={`w-full h-24 bg-white/5 border rounded-xl px-4 py-3 text-sm text-white focus:border-primary/50 outline-none transition-colors resize-none ${!draft.purpose?.trim() && validationError ? 'border-rose-500/50' : 'border-white/10'}`}
|
||
placeholder="What is the expertise and goal of this persona?"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pl-1">Constraints (Optional)</label>
|
||
<input
|
||
value={draft.constraints}
|
||
onChange={e => handleUpdate({ constraints: e.target.value })}
|
||
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:border-primary/50 outline-none transition-colors"
|
||
placeholder="e.g. dont use emojis, always provide rust code"
|
||
/>
|
||
</div>
|
||
|
||
{(error || validationError) && <p className="text-rose-400 text-xs flex items-center gap-2 px-1"><Icons.AlertTriangle className="w-3 h-3" /> {validationError || error}</p>}
|
||
|
||
<div className="flex justify-end gap-3 pt-4">
|
||
<button onClick={() => dispatch({ type: 'CLOSE_PERSONA_MODAL' })} className="px-6 py-2.5 text-zinc-400 hover:text-white text-xs font-bold transition-colors">Cancel</button>
|
||
<button
|
||
onClick={handleGenerate}
|
||
disabled={status === 'generating'}
|
||
className="px-8 py-2.5 bg-white text-black rounded-xl text-xs font-bold hover:bg-zinc-200 transition-all flex items-center gap-2 shadow-[0_0_20px_rgba(255,255,255,0.1)] active:scale-95 disabled:opacity-50"
|
||
>
|
||
{status === 'generating' ? <><Icons.RefreshCw className="w-3 h-3 animate-spin" /> Generating...</> : <><Icons.Sparkles className="w-3 h-3" /> Generate Persona</>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="animate-fade-in-up space-y-6">
|
||
<div className="p-6 bg-primary/5 border border-primary/20 rounded-2xl relative overflow-hidden group">
|
||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity"><Icons.Settings className="w-24 h-24" /></div>
|
||
<h3 className="text-xl font-bold text-white mb-1">{candidate?.name}</h3>
|
||
<p className="text-primary/70 text-xs font-mono mb-4 uppercase tracking-widest">{candidate?.subtitle}</p>
|
||
<div className="bg-black/40 rounded-xl p-4 border border-white/5 max-h-48 overflow-y-auto custom-scrollbar">
|
||
<p className="text-xs text-zinc-400 leading-relaxed italic">"{candidate?.systemPrompt}"</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 pt-4">
|
||
<button onClick={() => dispatch({ type: 'REJECT_PERSONA', requestId: state.personaGeneration.requestId! })} className="px-6 py-2.5 text-rose-400 hover:text-rose-300 text-xs font-bold transition-colors">Discard</button>
|
||
<button
|
||
onClick={() => dispatch({ type: 'APPROVE_PERSONA', persona: candidate! })}
|
||
className="px-10 py-2.5 bg-primary text-black rounded-xl text-xs font-bold hover:bg-emerald-400 transition-all flex items-center gap-2 shadow-[0_0_20px_rgba(52,211,153,0.3)] active:scale-95"
|
||
>
|
||
<Icons.Check className="w-3 h-3" /> Approve & Save
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- Enhanced Selector Modals ---
|
||
|
||
const PersonaSelectorModal = ({ onClose }: { onClose: () => void }) => {
|
||
const { state, dispatch } = useOrchestrator();
|
||
const [mode, setMode] = useState<'select' | 'create' | 'edit'>('select');
|
||
const [editingPersona, setEditingPersona] = useState<import('../types').Persona | null>(null);
|
||
|
||
// Create/Edit Mode State
|
||
const [name, setName] = useState(state.customChatPersonaName || '');
|
||
const [prompt, setPrompt] = useState(state.customChatPersonaPrompt || '');
|
||
const [aiInput, setAiInput] = useState('');
|
||
const [isGenerating, setIsGenerating] = useState(false);
|
||
const [expandedPrompt, setExpandedPrompt] = useState(false);
|
||
|
||
const MAX_PROMPT_LENGTH = 20000; // 20K characters
|
||
|
||
const handleGenerate = async () => {
|
||
if (!aiInput) return;
|
||
setIsGenerating(true);
|
||
if ((window as any).electron) {
|
||
(window as any).electron.removeChatListeners();
|
||
let fullResponse = '';
|
||
(window as any).electron.onChatChunk((chunk: string) => fullResponse += chunk);
|
||
(window as any).electron.onChatComplete((response: string) => {
|
||
setPrompt(fullResponse || response || aiInput);
|
||
if (!name) setName(aiInput.substring(0, 20));
|
||
setIsGenerating(false);
|
||
(window as any).electron.removeChatListeners();
|
||
});
|
||
// Enhanced system prompt for longer, more detailed persona prompts
|
||
const genSystemPrompt = `You are an expert prompt engineer specializing in AI persona design.
|
||
Generate a COMPREHENSIVE, DETAILED System Prompt for an AI Persona based on the user's description.
|
||
The output should be:
|
||
- Extremely detailed (aim for 2000-5000 characters minimum)
|
||
- Include specific behaviors, tone, expertise areas
|
||
- Include example phrases the AI might use
|
||
- Include constraints and guidelines
|
||
- Include handling of edge cases
|
||
- Be professional and well-structured
|
||
|
||
Output ONLY the prompt text, no markdown, no explanations.`;
|
||
(window as any).electron.startChat([{ role: 'system', content: genSystemPrompt }, { role: 'user', content: `Create a comprehensive persona for: ${aiInput}` }], 'qwen-coder-plus');
|
||
} else {
|
||
setTimeout(() => {
|
||
setPrompt(`You are an expert in ${aiInput}. You provide concise, actionable advice...`);
|
||
setName(aiInput);
|
||
setIsGenerating(false);
|
||
}, 1000);
|
||
}
|
||
};
|
||
|
||
const handleSaveCustom = () => {
|
||
if (editingPersona) {
|
||
// Update existing persona
|
||
const updatedPersona: import('../types').Persona = {
|
||
...editingPersona,
|
||
name: name || 'Custom Agent',
|
||
subtitle: prompt.substring(0, 50) + '...',
|
||
systemPrompt: prompt,
|
||
updatedAt: Date.now(),
|
||
};
|
||
dispatch({ type: 'APPROVE_PERSONA', persona: updatedPersona });
|
||
} else {
|
||
// Create new persona with unique ID
|
||
const newPersona: import('../types').Persona = {
|
||
id: `custom_${Date.now()}`,
|
||
name: name || 'Custom Agent',
|
||
subtitle: prompt.substring(0, 50) + '...',
|
||
icon: 'custom',
|
||
systemPrompt: prompt,
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
};
|
||
dispatch({ type: 'APPROVE_PERSONA', persona: newPersona });
|
||
}
|
||
setEditingPersona(null);
|
||
onClose();
|
||
};
|
||
|
||
const handleEditPersona = (persona: import('../types').Persona) => {
|
||
setEditingPersona(persona);
|
||
setName(persona.name);
|
||
setPrompt(persona.systemPrompt);
|
||
setMode('edit');
|
||
};
|
||
|
||
const handleDeletePersona = (personaId: string) => {
|
||
if (confirm('Are you sure you want to delete this agent? This cannot be undone.')) {
|
||
const filteredPersonas = state.personas.filter(p => p.id !== personaId);
|
||
// We need to update the state - dispatch a custom action or use existing mechanism
|
||
import('../services/automationService').then(svc => {
|
||
svc.savePersonasToDisk(filteredPersonas);
|
||
});
|
||
// Force reload by closing and notifying
|
||
dispatch({ type: 'LOAD_PERSONAS_FROM_DISK', personas: filteredPersonas });
|
||
}
|
||
};
|
||
|
||
const selectPersona = (p: string) => {
|
||
dispatch({ type: 'SET_CHAT_PERSONA', persona: p as any });
|
||
onClose();
|
||
};
|
||
|
||
// Custom personas from state.personas
|
||
const customPersonas = state.personas || [];
|
||
|
||
const builtInPersonas = [
|
||
{ id: 'assistant', name: 'General Assistant', icon: <Icons.User />, desc: 'Standard helpful assistant.' },
|
||
{ id: 'therapist', name: 'Therapist', icon: <Icons.Heart />, desc: 'Empathetic listener and advice.' },
|
||
{ id: 'business', name: 'Business Coach', icon: <Icons.Briefcase />, desc: 'Strategy, marketing, and growth.' },
|
||
{ id: 'it', name: 'IT Expert', icon: <Icons.Terminal />, desc: 'Sysadmin, DevOps, and scripting.' },
|
||
{ id: 'designer', name: 'UX/UI Designer', icon: <Icons.Layout />, desc: 'Aesthetics, usability, and CSS.' },
|
||
];
|
||
|
||
return (
|
||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-fade-in text-left">
|
||
<div className={`w-full ${mode !== 'select' ? 'max-w-3xl' : 'max-w-2xl'} bg-[#0B0B0C] border border-white/10 rounded-2xl p-6 shadow-2xl relative flex flex-col max-h-[90vh]`}>
|
||
<h2 className="text-xl font-display font-bold text-white mb-1">
|
||
{mode === 'edit' ? 'Edit Agent' : mode === 'create' ? 'Create New Agent' : 'Select Agent Persona'}
|
||
</h2>
|
||
<p className="text-xs text-zinc-500 mb-6">
|
||
{mode === 'edit' ? 'Modify your custom agent\'s configuration.' : mode === 'create' ? 'Design a personalized AI assistant.' : 'Choose an expert to help you with your project.'}
|
||
</p>
|
||
|
||
{mode === 'select' ? (
|
||
<div className="grid grid-cols-2 gap-3 overflow-y-auto p-1">
|
||
{/* Built-in Personas */}
|
||
{builtInPersonas.map(p => (
|
||
<div
|
||
key={p.id}
|
||
onClick={() => selectPersona(p.id)}
|
||
className={`p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] flex items-start gap-3 ${state.chatPersona === p.id ? 'bg-primary/10 border-primary' : 'bg-white/5 border-white/5 hover:bg-white/10'}`}
|
||
>
|
||
<div className={`p-2 rounded-lg ${state.chatPersona === p.id ? 'bg-primary/20 text-primary' : 'bg-zinc-800 text-zinc-400'}`}>
|
||
{React.cloneElement(p.icon as any, { className: "w-5 h-5" })}
|
||
</div>
|
||
<div>
|
||
<h3 className={`text-sm font-bold ${state.chatPersona === p.id ? 'text-primary' : 'text-white'}`}>{p.name}</h3>
|
||
<p className="text-xs text-zinc-500 mt-1">{p.desc}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Custom Personas from state.personas array */}
|
||
{customPersonas.map(p => (
|
||
<div
|
||
key={p.id}
|
||
className={`p-4 rounded-xl border transition-all flex items-start gap-3 relative group ${state.activePersonaId === p.id ? 'bg-primary/10 border-primary' : 'bg-white/5 border-white/5 hover:bg-white/10'}`}
|
||
>
|
||
<div
|
||
className="flex-1 flex items-start gap-3 cursor-pointer"
|
||
onClick={() => {
|
||
dispatch({ type: 'SET_ACTIVE_PERSONA', personaId: p.id });
|
||
dispatch({ type: 'SET_CUSTOM_CHAT_PERSONA', name: p.name, prompt: p.systemPrompt });
|
||
dispatch({ type: 'SET_CHAT_PERSONA', persona: 'custom' });
|
||
onClose();
|
||
}}
|
||
>
|
||
<div className={`p-2 rounded-lg ${state.activePersonaId === p.id ? 'bg-primary/20 text-primary' : 'bg-zinc-800 text-zinc-400'}`}>
|
||
<Icons.Cpu className="w-5 h-5" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className={`text-sm font-bold truncate ${state.activePersonaId === p.id ? 'text-primary' : 'text-white'}`}>{p.name}</h3>
|
||
<p className="text-xs text-zinc-500 mt-1 truncate">{p.subtitle || 'Your own custom-tailored agent.'}</p>
|
||
</div>
|
||
</div>
|
||
{/* Edit/Delete buttons */}
|
||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleEditPersona(p); }}
|
||
className="p-1.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg transition-colors"
|
||
title="Edit Agent"
|
||
>
|
||
<Icons.Edit className="w-3 h-3" />
|
||
</button>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleDeletePersona(p.id); }}
|
||
className="p-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors"
|
||
title="Delete Agent"
|
||
>
|
||
<Icons.Trash className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* Create New Agent Button */}
|
||
<div
|
||
onClick={() => { setName(''); setPrompt(''); setEditingPersona(null); setMode('create'); }}
|
||
className="p-4 rounded-xl border border-dashed border-zinc-700 bg-transparent hover:bg-white/5 cursor-pointer flex flex-col items-center justify-center gap-2 text-zinc-500 hover:text-white transition-colors"
|
||
>
|
||
<Icons.Plus className="w-6 h-6" />
|
||
<span className="text-xs font-bold">Create New Agent</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4 animate-slide-up overflow-y-auto">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-bold text-white">{mode === 'edit' ? `Editing: ${editingPersona?.name}` : 'Design Custom Agent'}</h3>
|
||
<button onClick={() => { setMode('select'); setEditingPersona(null); }} className="text-xs text-zinc-500 hover:text-white">Back to List</button>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-bold text-zinc-500 uppercase tracking-wider block mb-1">Agent Name</label>
|
||
<input
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:border-primary/50 outline-none"
|
||
placeholder="e.g. Security Auditor"
|
||
/>
|
||
</div>
|
||
|
||
<div className="p-3 bg-primary/5 border border-primary/10 rounded-xl">
|
||
<label className="text-[10px] font-bold text-primary/80 uppercase tracking-wider block mb-1">AI Auto-Generator (Creates Detailed Prompts)</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={aiInput}
|
||
onChange={e => setAiInput(e.target.value)}
|
||
className="flex-1 bg-black/20 border border-primary/20 rounded-lg px-3 py-2 text-xs text-primary placeholder-primary/30 focus:outline-none"
|
||
placeholder="Describe what this agent should do in detail..."
|
||
onKeyDown={e => e.key === 'Enter' && handleGenerate()}
|
||
/>
|
||
<button
|
||
onClick={handleGenerate}
|
||
disabled={isGenerating || !aiInput}
|
||
className="px-3 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/20 transition-colors disabled:opacity-50"
|
||
>
|
||
{isGenerating ? <Icons.RefreshCw className="w-4 h-4 animate-spin" /> : <Icons.Sparkles className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
<p className="text-[9px] text-primary/50 mt-1">Press Enter or click ✨ to generate a comprehensive system prompt</p>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<label className="text-xs font-bold text-zinc-500 uppercase tracking-wider">System Prompt</label>
|
||
<div className="flex items-center gap-2">
|
||
<span className={`text-[10px] font-mono ${prompt.length > MAX_PROMPT_LENGTH ? 'text-red-400' : prompt.length > 15000 ? 'text-yellow-400' : 'text-zinc-600'}`}>
|
||
{prompt.length.toLocaleString()} / {MAX_PROMPT_LENGTH.toLocaleString()} chars
|
||
</span>
|
||
<button
|
||
onClick={() => setExpandedPrompt(!expandedPrompt)}
|
||
className="text-[10px] text-zinc-500 hover:text-white flex items-center gap-1"
|
||
>
|
||
{expandedPrompt ? <Icons.Minimize2 className="w-3 h-3" /> : <Icons.Maximize2 className="w-3 h-3" />}
|
||
{expandedPrompt ? 'Collapse' : 'Expand'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
value={prompt}
|
||
onChange={e => setPrompt(e.target.value.slice(0, MAX_PROMPT_LENGTH))}
|
||
className={`w-full bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-xs text-zinc-300 font-mono focus:border-primary/50 outline-none resize-none leading-relaxed transition-all ${expandedPrompt ? 'h-96' : 'h-40'}`}
|
||
placeholder="You are an expert in...
|
||
|
||
Include:
|
||
- Your areas of expertise
|
||
- Communication style
|
||
- How you handle edge cases
|
||
- Example phrases you might use"
|
||
/>
|
||
{prompt.length > MAX_PROMPT_LENGTH && (
|
||
<p className="text-[10px] text-red-400 mt-1">Prompt exceeds maximum length. Please reduce to {MAX_PROMPT_LENGTH.toLocaleString()} characters.</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<button onClick={() => { setMode('select'); setEditingPersona(null); }} className="px-4 py-2 text-zinc-400 hover:text-white text-xs font-bold">Cancel</button>
|
||
<button
|
||
onClick={handleSaveCustom}
|
||
disabled={prompt.length > MAX_PROMPT_LENGTH}
|
||
className="px-6 py-2 bg-primary text-black rounded-lg text-xs font-bold hover:bg-emerald-400 shadow-[0_0_15px_rgba(52,211,153,0.3)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{mode === 'edit' ? 'Save Changes' : 'Save & Use Agent'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<button onClick={onClose} className="absolute top-4 right-4 text-zinc-600 hover:text-white"><Icons.X className="w-5 h-5" /></button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SkillsSelectorModal = ({ onClose, onSelect }: { onClose: () => void, onSelect: (cmd: string) => void }) => {
|
||
const [tab, setTab] = useState<'installed' | 'discover' | 'generate'>('installed');
|
||
const [skills, setSkills] = useState<import('../types').SkillManifest[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [catalog, setCatalog] = useState<import('../types').SkillManifest[]>([]);
|
||
|
||
// Generate State
|
||
const [genInput, setGenInput] = useState('');
|
||
const [isGen, setIsGen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadSkills();
|
||
}, [tab]);
|
||
|
||
const loadSkills = async () => {
|
||
setLoading(true);
|
||
const { skillsService } = await import('../services/skillsService');
|
||
await skillsService.ensureLoaded();
|
||
setSkills(skillsService.getInstalled());
|
||
setCatalog(skillsService.getCatalog());
|
||
setLoading(false);
|
||
};
|
||
|
||
const handleInstall = async (id: string) => {
|
||
const { skillsService } = await import('../services/skillsService');
|
||
await skillsService.installSkill(id);
|
||
loadSkills();
|
||
};
|
||
|
||
const handleGenerateSkill = async () => {
|
||
if (!genInput) return;
|
||
setIsGen(true);
|
||
// Mock Generation Flow
|
||
setTimeout(async () => {
|
||
const { skillsService } = await import('../services/skillsService');
|
||
const newId = 'custom-' + Date.now();
|
||
const newSkill = {
|
||
id: newId,
|
||
name: genInput.split(' ').slice(0, 3).join(' '),
|
||
description: `Custom generated skill: ${genInput}`,
|
||
category: 'Custom',
|
||
version: '1.0.0',
|
||
permissions: ['none'],
|
||
inputsSchema: {},
|
||
outputsSchema: {},
|
||
entrypoint: { type: 'js_script', uri: 'console.log("Generated Skill Active")' },
|
||
icon: 'Zap'
|
||
};
|
||
await skillsService.registerSkill(newSkill as any);
|
||
setIsGen(false);
|
||
setTab('installed');
|
||
alert(`Skill '${newSkill.name}' generated and installed!`);
|
||
}, 2000);
|
||
};
|
||
|
||
return (
|
||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4 animate-fade-in text-left">
|
||
<div className="w-full max-w-2xl bg-[#0B0B0C] border border-white/10 rounded-2xl p-6 shadow-2xl relative flex flex-col h-[70vh]">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h2 className="text-xl font-display font-bold text-white">Skills & Tools</h2>
|
||
<p className="text-xs text-zinc-500">Extend the orchestrator with new capabilities.</p>
|
||
</div>
|
||
<div className="flex bg-white/5 rounded-lg p-1">
|
||
{['installed', 'discover', 'generate'].map(t => (
|
||
<button
|
||
key={t}
|
||
onClick={() => setTab(t as any)}
|
||
className={`px-3 py-1.5 text-xs font-bold rounded-md capitalize transition-colors ${tab === t ? 'bg-zinc-700 text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||
>
|
||
{t}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-1 space-y-2 custom-scrollbar">
|
||
{loading && <div className="text-center py-10 text-zinc-500">Loading skills...</div>}
|
||
|
||
{!loading && tab === 'installed' && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{skills.map(s => (
|
||
<div key={s.id} onClick={() => { onSelect(`[USE SKILL: ${s.id}]`); onClose(); }} className="p-3 border border-white/10 bg-white/5 rounded-xl hover:bg-white/10 cursor-pointer group">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="font-bold text-sm text-white">{s.name}</span>
|
||
<Icons.ArrowRight className="w-3 h-3 text-zinc-600 group-hover:text-primary" />
|
||
</div>
|
||
<p className="text-[10px] text-zinc-500 line-clamp-2">{s.description}</p>
|
||
</div>
|
||
))}
|
||
{skills.length === 0 && <div className="col-span-2 text-center py-10 text-zinc-500 italic">No skills installed. Check 'Discover' tab.</div>}
|
||
</div>
|
||
)}
|
||
|
||
{!loading && tab === 'discover' && (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{catalog.filter(c => !skills.some(s => s.id === c.id)).map(s => (
|
||
<div key={s.id} className="p-3 border border-dashed border-zinc-700 bg-transparent rounded-xl flex flex-col justify-between">
|
||
<div>
|
||
<span className="font-bold text-sm text-zinc-300">{s.name}</span>
|
||
<p className="text-[10px] text-zinc-500 line-clamp-2 mt-1">{s.description}</p>
|
||
</div>
|
||
<button onClick={() => handleInstall(s.id)} className="mt-3 py-1.5 w-full bg-white/5 hover:bg-primary/20 hover:text-primary text-zinc-400 text-xs rounded-lg font-bold transition-colors">
|
||
Install
|
||
</button>
|
||
</div>
|
||
))}
|
||
{catalog.filter(c => !skills.some(s => s.id === c.id)).length === 0 && <div className="col-span-2 text-center py-10 text-zinc-500">All available skills installed.</div>}
|
||
</div>
|
||
)}
|
||
|
||
{!loading && tab === 'generate' && (
|
||
<div className="flex flex-col items-center justify-center h-full p-8 text-center space-y-4">
|
||
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-2">
|
||
<Icons.Zap className="w-6 h-6 text-primary" />
|
||
</div>
|
||
<h3 className="text-lg font-bold text-white">Generate New Skill</h3>
|
||
<p className="text-xs text-zinc-400 max-w-xs">Describe a new capability you want. The AI will generate the manifest and basic logic for you.</p>
|
||
<input
|
||
value={genInput}
|
||
onChange={e => setGenInput(e.target.value)}
|
||
className="w-full max-w-sm bg-black/50 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:border-primary/50 outline-none"
|
||
placeholder="e.g. 'Currency Converter' or 'Code Formatter'"
|
||
onKeyDown={e => e.key === 'Enter' && handleGenerateSkill()}
|
||
/>
|
||
<button
|
||
onClick={handleGenerateSkill}
|
||
disabled={isGen || !genInput}
|
||
className="px-6 py-2 bg-primary text-black rounded-lg text-sm font-bold hover:bg-emerald-400 disabled:opacity-50 transition-all shadow-[0_0_20px_rgba(52,211,153,0.3)]"
|
||
>
|
||
{isGen ? 'Generating...' : 'Generate & Install'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button onClick={onClose} className="absolute top-4 right-4 text-zinc-600 hover:text-white"><Icons.X className="w-5 h-5" /></button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export // --- AI Settings Modal (Ollama Cloud & Model Control) ---
|
||
function AISettingsModal({ onClose }: { onClose: () => void }) {
|
||
const { state, dispatch } = useOrchestrator();
|
||
const [ollamaKey, setOllamaKey] = useState('');
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [activeTab, setActiveTab] = useState<'qwen' | 'ollama'>('ollama');
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||
|
||
// Official Ollama Cloud models from https://ollama.com/search?c=cloud
|
||
const FREE_OLLAMA_MODELS = [
|
||
// Top Tier - Most Popular
|
||
{ id: 'gpt-oss:120b', name: 'GPT-OSS 120B', category: 'Flagship', description: 'OpenAI\'s open-weight model for reasoning, agentic tasks', size: '120B', free: true },
|
||
{ id: 'deepseek-v3.2', name: 'DeepSeek V3.2', category: 'Flagship', description: 'High efficiency with superior reasoning and agent performance', size: 'MoE', free: true },
|
||
{ id: 'deepseek-v3.1:671b', name: 'DeepSeek V3.1', category: 'Flagship', description: 'Hybrid thinking/non-thinking mode', size: '671B', free: true },
|
||
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro Preview', category: 'Flagship', description: 'Google\'s most intelligent model with SOTA reasoning', size: 'Cloud', free: true },
|
||
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash Preview', category: 'Fast', description: 'Frontier intelligence built for speed', size: 'Cloud', free: true },
|
||
|
||
// Coding Models
|
||
{ id: 'qwen3-coder:480b', name: 'Qwen3 Coder 480B', category: 'Coding', description: 'Alibaba\'s performant long context for agentic and coding', size: '480B', free: true },
|
||
{ id: 'qwen3-coder:30b', name: 'Qwen3 Coder 30B', category: 'Coding', description: 'Alibaba\'s agentic and coding model', size: '30B', free: true },
|
||
{ id: 'devstral-2:123b', name: 'Devstral 2 123B', category: 'Coding', description: 'Excels at codebase exploration and multi-file editing', size: '123B', free: true },
|
||
{ id: 'devstral-small-2:24b', name: 'Devstral Small 2 24B', category: 'Coding', description: 'Vision + tools for software engineering agents', size: '24B', free: true },
|
||
{ id: 'rnj-1:8b', name: 'RNJ-1 8B', category: 'Coding', description: 'Essential AI model optimized for code and STEM', size: '8B', free: true },
|
||
|
||
// Reasoning Models
|
||
{ id: 'qwen3-next:80b', name: 'Qwen3 Next 80B', category: 'Reasoning', description: 'Strong parameter efficiency and inference speed', size: '80B', free: true },
|
||
{ id: 'kimi-k2', name: 'Kimi K2', category: 'Reasoning', description: 'State-of-the-art MoE model for coding agent tasks', size: 'MoE', free: true },
|
||
{ id: 'kimi-k2-thinking', name: 'Kimi K2 Thinking', category: 'Reasoning', description: 'Moonshot AI\'s best open-source thinking model', size: 'MoE', free: true },
|
||
{ id: 'cogito-2.1:671b', name: 'Cogito 2.1', category: 'Reasoning', description: 'Instruction tuned generative model (MIT license)', size: '671B', free: true },
|
||
|
||
// Vision Models
|
||
{ id: 'qwen3-vl:235b', name: 'Qwen3 VL 235B', category: 'Vision', description: 'Most powerful vision-language model in Qwen family', size: '235B', free: true },
|
||
{ id: 'qwen3-vl:32b', name: 'Qwen3 VL 32B', category: 'Vision', description: 'Powerful vision-language understanding', size: '32B', free: true },
|
||
{ id: 'gemma3:27b', name: 'Gemma 3 27B', category: 'Vision', description: 'Most capable model that runs on a single GPU', size: '27B', free: true },
|
||
|
||
// Fast / Edge Models
|
||
{ id: 'ministral-3:14b', name: 'Ministral 3 14B', category: 'Fast', description: 'Designed for edge deployment', size: '14B', free: true },
|
||
{ id: 'ministral-3:8b', name: 'Ministral 3 8B', category: 'Fast', description: 'Edge deployment with vision + tools', size: '8B', free: true },
|
||
{ id: 'nemotron-3-nano', name: 'Nemotron 3 Nano', category: 'Fast', description: 'Efficient, open, and intelligent agentic model', size: 'Nano', free: true },
|
||
|
||
// Enterprise / Large Scale
|
||
{ id: 'glm-4.6', name: 'GLM 4.6', category: 'Flagship', description: 'Advanced agentic, reasoning and coding capabilities', size: 'Large', free: true },
|
||
{ id: 'minimax-m2', name: 'MiniMax M2', category: 'Flagship', description: 'High-efficiency LLM for coding and agentic workflows', size: 'Large', free: true },
|
||
{ id: 'mistral-large-3', name: 'Mistral Large 3', category: 'Flagship', description: 'Multimodal MoE for production-grade tasks', size: 'MoE', free: true },
|
||
];
|
||
|
||
const categories = ['all', ...Array.from(new Set(FREE_OLLAMA_MODELS.map(m => m.category)))];
|
||
|
||
const filteredModels = FREE_OLLAMA_MODELS.filter(m => {
|
||
const matchesSearch = m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
m.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||
const matchesCategory = selectedCategory === 'all' || m.category === selectedCategory;
|
||
return matchesSearch && matchesCategory;
|
||
});
|
||
|
||
const handleSelectOllamaModel = (modelId: string) => {
|
||
const fullModelId = `ollama:${modelId}`;
|
||
|
||
// Add to available models if not already there
|
||
if (!state.chatSettings.availableModels.includes(fullModelId)) {
|
||
dispatch({
|
||
type: 'SET_AVAILABLE_MODELS',
|
||
models: [...state.chatSettings.availableModels, fullModelId]
|
||
});
|
||
}
|
||
|
||
// Set as active model
|
||
dispatch({ type: 'SET_CHAT_MODEL', model: fullModelId });
|
||
dispatch({ type: 'TOGGLE_OLLAMA', enabled: true });
|
||
};
|
||
|
||
const handleSaveKey = async () => {
|
||
if (!ollamaKey.trim()) return;
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
await (window as any).electron.ollama.saveKey(ollamaKey);
|
||
setOllamaKey('');
|
||
setError(null);
|
||
} catch (e: any) {
|
||
setError(e.message || "Failed to save key");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/80 backdrop-blur-md z-[100] flex items-center justify-center p-6 animate-fade-in">
|
||
<div className="bg-[#0f0f11] border border-white/10 rounded-3xl w-full max-w-2xl shadow-[0_0_100px_rgba(0,0,0,0.5)] overflow-hidden flex flex-col max-h-[85vh]">
|
||
{/* Header */}
|
||
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/5 shrink-0">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-primary/10 rounded-xl">
|
||
<Icons.Sparkles className="w-5 h-5 text-primary" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-white font-bold text-lg">AI Model Manager</h3>
|
||
<p className="text-zinc-500 text-xs">Select your preferred AI model</p>
|
||
</div>
|
||
</div>
|
||
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-lg text-zinc-400 hover:text-white transition-colors">
|
||
<Icons.X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Tab Navigation */}
|
||
<div className="flex border-b border-white/5 shrink-0">
|
||
<button
|
||
onClick={() => setActiveTab('qwen')}
|
||
className={`flex-1 py-3 text-sm font-bold transition-all flex items-center justify-center gap-2 ${activeTab === 'qwen'
|
||
? 'text-emerald-400 border-b-2 border-emerald-400 bg-emerald-500/5'
|
||
: 'text-zinc-500 hover:text-zinc-300'
|
||
}`}
|
||
>
|
||
<span className="w-5 h-5 rounded bg-emerald-500/20 flex items-center justify-center text-xs font-bold text-emerald-400">Q</span>
|
||
Qwen Cloud
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('ollama')}
|
||
className={`flex-1 py-3 text-sm font-bold transition-all flex items-center justify-center gap-2 ${activeTab === 'ollama'
|
||
? 'text-blue-400 border-b-2 border-blue-400 bg-blue-500/5'
|
||
: 'text-zinc-500 hover:text-zinc-300'
|
||
}`}
|
||
>
|
||
<Icons.Cpu className="w-4 h-4" />
|
||
Ollama Cloud
|
||
<span className="text-[9px] bg-blue-500/20 text-blue-400 px-1.5 py-0.5 rounded font-bold">FREE</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto">
|
||
{activeTab === 'qwen' && (
|
||
<div className="p-6 space-y-4">
|
||
<div className="text-center py-8">
|
||
<div className="w-16 h-16 bg-emerald-500/20 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-emerald-500/30">
|
||
<span className="text-emerald-400 font-bold text-2xl">Q</span>
|
||
</div>
|
||
<h4 className="text-white font-bold text-lg mb-2">Qwen Cloud</h4>
|
||
<p className="text-zinc-500 text-sm mb-6">Alibaba's powerful AI models with free tier access</p>
|
||
|
||
<div className="space-y-2 max-w-xs mx-auto">
|
||
{['qwen-coder-plus', 'qwen-plus', 'qwen-turbo'].map(model => (
|
||
<button
|
||
key={model}
|
||
onClick={() => dispatch({ type: 'SET_CHAT_MODEL', model })}
|
||
className={`w-full flex items-center justify-between p-4 rounded-xl border transition-all ${state.chatSettings.activeModel === model
|
||
? 'bg-emerald-500/10 border-emerald-500/40 text-white'
|
||
: 'bg-white/5 border-white/10 text-zinc-400 hover:border-white/20'
|
||
}`}
|
||
>
|
||
<span className="font-mono text-sm">{model}</span>
|
||
{state.chatSettings.activeModel === model && (
|
||
<Icons.Check className="w-4 h-4 text-emerald-400" />
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'ollama' && (
|
||
<div className="p-4 space-y-4">
|
||
{/* API Key Section (Collapsible) */}
|
||
<details className="bg-white/5 border border-white/10 rounded-xl">
|
||
<summary className="p-4 cursor-pointer text-sm font-bold text-zinc-300 flex items-center justify-between">
|
||
<span className="flex items-center gap-2">
|
||
<Icons.Key className="w-4 h-4 text-zinc-500" />
|
||
API Key (Optional)
|
||
</span>
|
||
<Icons.ChevronDown className="w-4 h-4 text-zinc-500" />
|
||
</summary>
|
||
<div className="p-4 pt-0 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[10px] text-zinc-500">For private models or higher rate limits. Many models work without a key.</p>
|
||
<a
|
||
href="https://ollama.com/settings/keys"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-[10px] bg-blue-500/20 text-blue-400 px-2 py-1 rounded-lg font-bold hover:bg-blue-500/30 transition-colors flex items-center gap-1 shrink-0"
|
||
>
|
||
<Icons.ExternalLink className="w-3 h-3" />
|
||
Get Key
|
||
</a>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="password"
|
||
value={ollamaKey}
|
||
onChange={(e) => setOllamaKey(e.target.value)}
|
||
placeholder="sk-..."
|
||
className="flex-1 bg-black border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-primary/50"
|
||
/>
|
||
<button
|
||
onClick={handleSaveKey}
|
||
disabled={isSaving || !ollamaKey.trim()}
|
||
className="px-4 bg-white text-black font-bold rounded-lg hover:bg-zinc-200 transition-colors disabled:opacity-50 text-sm"
|
||
>
|
||
{isSaving ? '...' : 'Save'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
{/* Search and Filter */}
|
||
<div className="flex gap-2">
|
||
<div className="flex-1 relative">
|
||
<Icons.Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Search models..."
|
||
className="w-full bg-black/50 border border-white/10 rounded-xl pl-10 pr-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50"
|
||
/>
|
||
</div>
|
||
<select
|
||
value={selectedCategory}
|
||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||
className="bg-black/50 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50"
|
||
>
|
||
{categories.map(cat => (
|
||
<option key={cat} value={cat}>{cat === 'all' ? 'All Categories' : cat}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Model Grid */}
|
||
<div className="grid grid-cols-1 gap-2">
|
||
{filteredModels.map(model => {
|
||
const isActive = state.chatSettings.activeModel === `ollama:${model.id}`;
|
||
return (
|
||
<button
|
||
key={model.id}
|
||
onClick={() => handleSelectOllamaModel(model.id)}
|
||
className={`flex items-center gap-4 p-4 rounded-xl border transition-all text-left group ${isActive
|
||
? 'bg-blue-500/10 border-blue-500/40 shadow-[0_0_20px_rgba(59,130,246,0.1)]'
|
||
: 'bg-white/5 border-white/5 hover:border-white/20 hover:bg-white/10'
|
||
}`}
|
||
>
|
||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 ${isActive ? 'bg-blue-500 text-white' : 'bg-zinc-800 text-zinc-400 group-hover:bg-zinc-700'
|
||
}`}>
|
||
{model.category === 'Coding' ? <Icons.Code className="w-5 h-5" /> :
|
||
model.category === 'Vision' ? <Icons.Eye className="w-5 h-5" /> :
|
||
model.category === 'Fast' ? <Icons.Zap className="w-5 h-5" /> :
|
||
model.category === 'Reasoning' ? <Icons.Brain className="w-5 h-5" /> :
|
||
<Icons.Sparkles className="w-5 h-5" />}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-0.5">
|
||
<span className={`font-bold text-sm ${isActive ? 'text-white' : 'text-zinc-300'}`}>{model.name}</span>
|
||
<span className="text-[9px] bg-zinc-800 text-zinc-500 px-1.5 py-0.5 rounded font-mono">{model.size}</span>
|
||
</div>
|
||
<p className="text-[11px] text-zinc-500 truncate">{model.description}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<span className={`text-[9px] px-2 py-1 rounded-full font-bold ${model.category === 'Coding' ? 'bg-purple-500/20 text-purple-400' :
|
||
model.category === 'Vision' ? 'bg-amber-500/20 text-amber-400' :
|
||
model.category === 'Fast' ? 'bg-cyan-500/20 text-cyan-400' :
|
||
model.category === 'Reasoning' ? 'bg-pink-500/20 text-pink-400' :
|
||
'bg-blue-500/20 text-blue-400'
|
||
}`}>{model.category}</span>
|
||
{isActive && <Icons.Check className="w-5 h-5 text-blue-400" />}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{filteredModels.length === 0 && (
|
||
<div className="text-center py-8 text-zinc-500">
|
||
<Icons.Search className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||
<p>No models found matching "{searchQuery}"</p>
|
||
</div>
|
||
)}
|
||
|
||
{error && <div className="text-xs text-rose-500 font-medium bg-rose-500/10 p-3 rounded-lg border border-rose-500/20">{error}</div>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="p-4 bg-white/5 border-t border-white/5 flex items-center justify-between shrink-0">
|
||
<div className="text-xs text-zinc-500 flex items-center gap-2">
|
||
<div className={`w-2 h-2 rounded-full ${state.chatSettings.activeModel.startsWith('ollama:') ? 'bg-blue-500' : 'bg-emerald-500'} animate-pulse`} />
|
||
Active: <span className="text-white font-mono">{state.chatSettings.activeModel}</span>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="px-6 py-2.5 bg-white text-black font-bold rounded-xl hover:bg-zinc-200 transition-all"
|
||
>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const ChatPanel = () => {
|
||
const { state, dispatch } = useOrchestrator();
|
||
const [input, setInput] = useState('');
|
||
const [isThinking, setIsThinking] = useState(false);
|
||
const [thinkingLabel, setThinkingLabel] = useState('Thinking...');
|
||
const [activeTechIndex, setActiveTechIndex] = useState(0);
|
||
const activeBuildSessionIdRef = React.useRef<string | null>(state.activeBuildSessionId);
|
||
const timelineScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||
const streamingScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||
const [showAISettings, setShowAISettings] = useState(false);
|
||
const [showCustomPersona, setShowCustomPersona] = useState(false);
|
||
const [showSkillsSelector, setShowSkillsSelector] = useState(false);
|
||
const [customPersonaNameDraft, setCustomPersonaNameDraft] = useState(state.customChatPersonaName);
|
||
const [customPersonaPromptDraft, setCustomPersonaPromptDraft] = useState(state.customChatPersonaPrompt);
|
||
|
||
// Attachment state
|
||
const [attachments, setAttachments] = useState<Array<{ id: string; name: string; type: string; content: string; sizeBytes: number }>>([]);
|
||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
|
||
// Skill Recommendations
|
||
const [recommendedSkills, setRecommendedSkills] = useState<Array<{ id: string; name: string; icon: string }>>([]);
|
||
const skillDebounceRef = React.useRef<NodeJS.Timeout | null>(null);
|
||
|
||
// Listen for AI Settings open event from sidebar
|
||
useEffect(() => {
|
||
const handleOpenAISettings = () => setShowAISettings(true);
|
||
window.addEventListener('open-ai-settings', handleOpenAISettings);
|
||
return () => window.removeEventListener('open-ai-settings', handleOpenAISettings);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (skillDebounceRef.current) clearTimeout(skillDebounceRef.current);
|
||
if (input.length < 4) {
|
||
// Only update state if there are actually skills to clear (prevents re-render on every keystroke)
|
||
if (recommendedSkills.length > 0) {
|
||
setRecommendedSkills([]);
|
||
}
|
||
return;
|
||
}
|
||
skillDebounceRef.current = setTimeout(async () => {
|
||
try {
|
||
const { skillsService } = await import('../services/skillsService');
|
||
await skillsService.ensureLoaded();
|
||
const catalog = skillsService.getCatalog();
|
||
const installed = skillsService.getInstalled();
|
||
const installedIds = new Set(installed.map(s => s.id));
|
||
|
||
const lower = input.toLowerCase();
|
||
// Smarter matching logic
|
||
const matched = catalog.filter(s => {
|
||
if (installedIds.has(s.id)) return false;
|
||
const nameMatch = s.name.toLowerCase().split(/\s+/).some(word => lower.includes(word));
|
||
const descMatch = s.description.toLowerCase().split(/\s+/).some(word => word.length > 4 && lower.includes(word));
|
||
const tagChange = (s.id === 'charts' && lower.includes('dashboard')) ||
|
||
(s.id === 'threejs' && (lower.includes('game') || lower.includes('3d'))) ||
|
||
(s.id === 'maps' && (lower.includes('location') || lower.includes('gis')));
|
||
return nameMatch || descMatch || tagChange;
|
||
}).slice(0, 3);
|
||
setRecommendedSkills(matched.map(s => ({ id: s.id, name: s.name, icon: s.icon || 'Box' })));
|
||
} catch (e) { console.error(e); }
|
||
}, 500);
|
||
return () => { if (skillDebounceRef.current) clearTimeout(skillDebounceRef.current); };
|
||
}, [input, recommendedSkills.length]);
|
||
|
||
// Supported file extensions
|
||
const TEXT_EXTENSIONS = ['txt', 'md', 'json', 'js', 'ts', 'tsx', 'html', 'css', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'yml', 'yaml', 'toml', 'ini', 'log'];
|
||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'webp', 'gif'];
|
||
const SPREADSHEET_EXTENSIONS = ['csv'];
|
||
|
||
const processFile = async (file: File): Promise<{ id: string; name: string; type: string; content: string; sizeBytes: number } | null> => {
|
||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||
const id = Date.now().toString() + Math.random().toString(36).substring(2);
|
||
|
||
if (TEXT_EXTENSIONS.includes(ext)) {
|
||
const content = await file.text();
|
||
return { id, name: file.name, type: 'text', content: content.substring(0, 10000), sizeBytes: file.size };
|
||
} else if (IMAGE_EXTENSIONS.includes(ext)) {
|
||
// For images, create a description manifest (no vision)
|
||
return { id, name: file.name, type: 'image', content: `[Image: ${file.name}, Size: ${file.size} bytes, Type: ${file.type}]`, sizeBytes: file.size };
|
||
} else if (SPREADSHEET_EXTENSIONS.includes(ext)) {
|
||
const content = await file.text();
|
||
const lines = content.split('\n').slice(0, 20); // First 20 rows
|
||
return { id, name: file.name, type: 'spreadsheet', content: lines.join('\n'), sizeBytes: file.size };
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const handleFileDrop = async (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
const files = Array.from(e.dataTransfer.files);
|
||
for (const file of files.slice(0, 5)) { // Max 5 files
|
||
const processed = await processFile(file);
|
||
if (processed) {
|
||
setAttachments(prev => [...prev, processed]);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = Array.from(e.target.files || []);
|
||
for (const file of files.slice(0, 5)) {
|
||
const processed = await processFile(file);
|
||
if (processed) {
|
||
setAttachments(prev => [...prev, processed]);
|
||
}
|
||
}
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
};
|
||
|
||
const removeAttachment = (id: string) => {
|
||
setAttachments(prev => prev.filter(a => a.id !== id));
|
||
};
|
||
|
||
useEffect(() => {
|
||
activeBuildSessionIdRef.current = state.activeBuildSessionId;
|
||
}, [state.activeBuildSessionId]);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const raw = localStorage.getItem('goose_custom_persona');
|
||
if (!raw) return;
|
||
const parsed = JSON.parse(raw) as { name?: string; prompt?: string };
|
||
if (parsed?.name && parsed?.prompt) {
|
||
dispatch({ type: 'SET_CUSTOM_CHAT_PERSONA', name: parsed.name, prompt: parsed.prompt });
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setCustomPersonaNameDraft(state.customChatPersonaName);
|
||
setCustomPersonaPromptDraft(state.customChatPersonaPrompt);
|
||
}, [state.customChatPersonaName, state.customChatPersonaPrompt]);
|
||
|
||
// Idea Seeds State
|
||
const [ideaSeed, setIdeaSeed] = useState('');
|
||
const [ideaResults, setIdeaResults] = useState<{ title: string; subtitle: string; tag?: string; prompt: string }[]>([]);
|
||
const [ideaStatus, setIdeaStatus] = useState<'idle' | 'loading' | 'error'>('idle');
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const cached = localStorage.getItem('goose_idea_seeds_cache');
|
||
if (cached) setIdeaResults(JSON.parse(cached));
|
||
} catch { }
|
||
}, []);
|
||
|
||
const finalizeRequest = useCallback(() => {
|
||
setIsThinking(false);
|
||
setThinkingLabel('Thinking...');
|
||
dispatch({ type: 'REQUEST_COMPLETE' });
|
||
if ((window as any).electron) {
|
||
(window as any).electron.removeChatListeners();
|
||
}
|
||
}, [dispatch]);
|
||
|
||
const handleGenerateIdeas = async () => {
|
||
if (!ideaSeed.trim()) return;
|
||
setIdeaStatus('loading');
|
||
setIdeaResults([]);
|
||
|
||
if (!(window as any).electron) {
|
||
setIdeaStatus('error');
|
||
setTimeout(() => setIdeaStatus('idle'), 2000);
|
||
return;
|
||
}
|
||
|
||
const sysPrompt = `You are an expert product ideation assistant. Generate exactly 6 high-quality build ideas.
|
||
Return ONLY valid JSON. No markdown. No explanation.
|
||
Format: { "ideas": [{ "title": "Short Title", "subtitle": "One line", "tag": "Tool", "prompt": "Build command..." }] }`;
|
||
const userPrompt = `Seed: ${ideaSeed}`;
|
||
|
||
let fullResponse = '';
|
||
const electron = (window as any).electron;
|
||
electron.removeChatListeners();
|
||
|
||
// Timeout after 30s
|
||
const timeoutId = setTimeout(() => {
|
||
console.error('[IdeaGen] Timeout');
|
||
electron.removeChatListeners();
|
||
setIdeaStatus('error');
|
||
setTimeout(() => setIdeaStatus('idle'), 3000);
|
||
}, 30000);
|
||
|
||
electron.onChatChunk((c: string) => fullResponse += c);
|
||
electron.onChatError((e: any) => {
|
||
clearTimeout(timeoutId);
|
||
console.error("[IdeaGen] Error:", e);
|
||
setIdeaStatus('error');
|
||
electron.removeChatListeners();
|
||
setTimeout(() => setIdeaStatus('idle'), 3000);
|
||
});
|
||
electron.onChatComplete((response: string) => {
|
||
clearTimeout(timeoutId);
|
||
electron.removeChatListeners();
|
||
let final = (response || fullResponse).trim();
|
||
// Strip markdown
|
||
final = final.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||
// Extract JSON
|
||
const first = final.indexOf('{');
|
||
const last = final.lastIndexOf('}');
|
||
if (first !== -1 && last > first) {
|
||
final = final.substring(first, last + 1);
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(final);
|
||
if (parsed.ideas && Array.isArray(parsed.ideas)) {
|
||
const results = parsed.ideas.slice(0, 6);
|
||
setIdeaResults(results);
|
||
localStorage.setItem('goose_idea_seeds_cache', JSON.stringify(results));
|
||
setIdeaStatus('idle');
|
||
} else {
|
||
throw new Error("Invalid format - missing ideas array");
|
||
}
|
||
} catch (e) {
|
||
console.error("[IdeaGen] Parse Error:", e, "\nRaw:", final.substring(0, 200));
|
||
setIdeaStatus('error');
|
||
setTimeout(() => setIdeaStatus('idle'), 3000);
|
||
}
|
||
});
|
||
|
||
electron.startChat([
|
||
{ role: 'system', content: sysPrompt },
|
||
{ role: 'user', content: userPrompt }
|
||
], 'qwen-coder-plus');
|
||
};
|
||
|
||
const isNearBottom = (el: HTMLElement) => {
|
||
const threshold = 60;
|
||
return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold;
|
||
};
|
||
|
||
useEffect(() => {
|
||
const el = timelineScrollRef.current;
|
||
if (!el) return;
|
||
if (!autoScrollEnabled) return;
|
||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||
}, [state.timeline.length, isThinking, autoScrollEnabled]);
|
||
|
||
useEffect(() => {
|
||
const el = streamingScrollRef.current;
|
||
if (!el) return;
|
||
if (!state.streamingCode) return;
|
||
if (!autoScrollEnabled) return;
|
||
el.scrollTo({ top: el.scrollHeight, behavior: 'auto' });
|
||
}, [state.streamingCode, autoScrollEnabled]);
|
||
|
||
const techTags = useMemo(() => {
|
||
const lastUser = [...state.timeline].reverse().find(t => t.type === 'user')?.message || '';
|
||
const content = [lastUser, state.plan || '', state.streamingCode || '', Object.keys(state.files).join(' ')].join('\n');
|
||
return deriveTechTags(content);
|
||
}, [state.timeline, state.plan, state.streamingCode, state.files]);
|
||
|
||
// New Project Handler
|
||
const handleNewProject = () => {
|
||
dispatch({ type: 'RESET_PROJECT' });
|
||
};
|
||
|
||
const handleSuggestion = (prompt: string) => {
|
||
setInput(prompt);
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent | null, overrideInput?: string) => {
|
||
if (e) e.preventDefault();
|
||
const textToSend = overrideInput || input;
|
||
if (!textToSend.trim()) return;
|
||
|
||
// Generate session ID for this request
|
||
const requestSessionId = Date.now().toString();
|
||
|
||
dispatch({
|
||
type: 'ADD_LOG',
|
||
log: { id: Date.now().toString(), timestamp: Date.now(), type: 'user', message: textToSend }
|
||
});
|
||
|
||
// Track request session for cancel/edit/resend
|
||
dispatch({ type: 'START_REQUEST', sessionId: requestSessionId, messageDraft: textToSend });
|
||
|
||
// Build prompt with attachments if any
|
||
let userPrompt = textToSend;
|
||
if (attachments.length > 0) {
|
||
const attachmentManifest = attachments.map(a => ({
|
||
name: a.name,
|
||
type: a.type,
|
||
content: a.content
|
||
}));
|
||
userPrompt = `${textToSend}\n\n[ATTACHED FILES]:\n${JSON.stringify(attachmentManifest, null, 2)}`;
|
||
}
|
||
setInput('');
|
||
setAttachments([]); // Clear attachments after sending
|
||
setIsThinking(true);
|
||
|
||
const isChatMode = state.globalMode === GlobalMode.Chat;
|
||
const isBrainstormMode = state.globalMode === GlobalMode.Brainstorm;
|
||
const isFirstMessage = state.timeline.filter(t => t.type === 'user').length <= 1;
|
||
const hasNoProject = !state.activeProject;
|
||
|
||
// --- IMAGE GENERATION DETECTION (ChatGPT-like) ---
|
||
// Check if this is an image generation request in Chat Mode
|
||
if (isChatMode && (window as any).electron?.image) {
|
||
try {
|
||
const detection = await (window as any).electron.image.detect(textToSend);
|
||
if (detection?.isImageRequest) {
|
||
// Add animated "thinking" message with visual feedback
|
||
const thinkingLogId = `img-thinking-${Date.now()}`;
|
||
dispatch({
|
||
type: 'ADD_LOG',
|
||
log: {
|
||
id: thinkingLogId,
|
||
timestamp: Date.now(),
|
||
type: 'system',
|
||
message: `[IMAGE_THINKING]\n🎨 **Creating your image...**\n\n_"${detection.prompt.substring(0, 80)}${detection.prompt.length > 80 ? '...' : ''}"_\n\n⏳ Analyzing prompt and generating artwork...`
|
||
}
|
||
});
|
||
|
||
// Update progress messages
|
||
const progressMessages = [
|
||
'🔍 Understanding your description...',
|
||
'🧠 Processing creative elements...',
|
||
'🎭 Composing visual elements...',
|
||
'✨ Rendering final image...'
|
||
];
|
||
let progressIndex = 0;
|
||
const progressInterval = setInterval(() => {
|
||
if (progressIndex < progressMessages.length) {
|
||
dispatch({
|
||
type: 'UPDATE_LOG',
|
||
id: thinkingLogId,
|
||
message: `[IMAGE_THINKING]\n🎨 **Creating your image...**\n\n_"${detection.prompt.substring(0, 80)}${detection.prompt.length > 80 ? '...' : ''}"_\n\n${progressMessages[progressIndex]}`
|
||
});
|
||
progressIndex++;
|
||
}
|
||
}, 2000);
|
||
|
||
try {
|
||
const result = await (window as any).electron.image.generate(detection.prompt, { width: 1024, height: 1024 });
|
||
clearInterval(progressInterval);
|
||
|
||
// Remove thinking message
|
||
dispatch({ type: 'REMOVE_LOG', id: thinkingLogId });
|
||
|
||
if (result?.success && result?.url) {
|
||
// Display the generated image in chat as an assistant response
|
||
dispatch({
|
||
type: 'ADD_LOG',
|
||
log: {
|
||
id: Date.now().toString(),
|
||
timestamp: Date.now(),
|
||
type: 'system',
|
||
message: `[IMAGE_GENERATED]\n\n\n**Prompt:** ${result.prompt}\n**Size:** ${result.width}x${result.height}`
|
||
}
|
||
});
|
||
} else {
|
||
dispatch({
|
||
type: 'ADD_LOG',
|
||
log: { id: Date.now().toString(), timestamp: Date.now(), type: 'error', message: `❌ Image generation failed: ${result?.error || 'Unknown error'}` }
|
||
});
|
||
}
|
||
} catch (imgErr: any) {
|
||
clearInterval(progressInterval);
|
||
dispatch({ type: 'REMOVE_LOG', id: thinkingLogId });
|
||
dispatch({
|
||
type: 'ADD_LOG',
|
||
log: { id: Date.now().toString(), timestamp: Date.now(), type: 'error', message: `❌ Image generation error: ${imgErr.message}` }
|
||
});
|
||
}
|
||
|
||
setIsThinking(false);
|
||
dispatch({ type: 'REQUEST_COMPLETE' });
|
||
return; // Don't continue to text chat
|
||
}
|
||
} catch (detErr) {
|
||
console.warn('[Chat] Image detection error:', detErr);
|
||
// Continue with normal chat if detection fails
|
||
}
|
||
}
|
||
|
||
const timeoutId = setTimeout(() => {
|
||
setIsThinking((thinking) => {
|
||
if (thinking) {
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'error', message: "Response Timeout." } });
|
||
finalizeRequest();
|
||
return false;
|
||
}
|
||
return false;
|
||
});
|
||
}, 45000);
|
||
|
||
let systemPrompt = '';
|
||
const isModificationMode = state.state === OrchestratorState.PreviewReady || state.state === OrchestratorState.Editing;
|
||
let requestKind: 'chat' | 'plan' | 'code' = (isChatMode || isBrainstormMode) ? 'chat' : 'plan';
|
||
|
||
// SMART ROUTING: REMOVED CONCIERGE (F4: Plan First Enforcement)
|
||
// If not Chat/Brainstorm, we default to PLAN.
|
||
if (isChatMode) {
|
||
const sysP = personaSystem(state, state.chatPersona, state.customChatPersonaPrompt);
|
||
systemPrompt = `[SYSTEM INSTRUCTION]: ${sysP}\n\n[CONTEXT]: This is CHAT mode (not building). Do not generate code unless explicitly asked. If user asks for a change to an existing project, propose a plan starting with '[PLAN]' and wait for approval. Use PLAIN PROSE for conversation, no markdown code blocks for speech.\n\n[IMAGE GENERATION]: You CAN generate images! If the user asks for an image, painting, illustration, or visual content, acknowledge that you're generating it. The system will handle the actual generation. Say something like "I'm creating that image for you now, please wait a moment...".`;
|
||
} else if (isBrainstormMode) {
|
||
const lower = userPrompt.toLowerCase();
|
||
const wantsPlan = lower.includes('plan') || lower.includes('formalize') || lower.includes('blueprint');
|
||
requestKind = wantsPlan ? 'plan' : 'chat';
|
||
systemPrompt = wantsPlan
|
||
? `[SYSTEM INSTRUCTION]: Convert the brainstorm context into a concise implementation plan that STARTS with [PLAN].`
|
||
: `[SYSTEM INSTRUCTION]: Brainstorm intent. DO NOT output code.`;
|
||
} else if (isModificationMode) {
|
||
requestKind = 'plan';
|
||
const currentCode = state.files['index.html'] || state.files['index.js'] || '';
|
||
const codePreview = currentCode.substring(0, 800) + (currentCode.length > 800 ? '\n... [truncated]' : '');
|
||
|
||
// M3: Load and inject relevant project memories
|
||
let memoryBlock = '';
|
||
if (state.activeProject?.id) {
|
||
const allMemories = await loadProjectMemories(state.activeProject.id);
|
||
const relevantMemories = retrieveRelevantMemories(allMemories, userPrompt, 5, 1000);
|
||
memoryBlock = formatMemoriesForPrompt(relevantMemories);
|
||
}
|
||
|
||
// L2: Design Lock Mode - enforce preservation by default
|
||
|
||
// P0-Contract: REPAIR MODE DETECTION
|
||
// P0-Contract: REPAIR MODE DETECTION
|
||
// If the current file is a "QA Check Failed" page OR if it looks like a raw text dump (unstyled), detect repair mode.
|
||
// We check for visible HTML tags in the preview (which means they weren't rendered) or lack of significant structure.
|
||
const isUnstyledDump = codePreview.includes('<html') && codePreview.includes('class="') === false; // Crude check for missing styles
|
||
const isRawSource = codePreview.trim().startsWith('DOCTYPE html') || codePreview.trim().startsWith('<!DOCTYPE'); // User sees source, not app
|
||
const isQaFailureArtifact = codePreview.includes('QA Check Failed') || codePreview.includes('qa_report')
|
||
|| isUnstyledDump || isRawSource
|
||
|| state.timeline.some(l => l.type === 'error' && l.message.includes('Build failed quality checks') && l.timestamp > Date.now() - 60000);
|
||
|
||
const isRedesignConfirmed = userPrompt.trim().toUpperCase() === 'REDESIGN_OK' || (state.activeProject?.id && (window as any)._redesignApprovedSessions?.[state.activeProject.id]);
|
||
|
||
// Latch mechanism for Redesign
|
||
if (userPrompt.trim().toUpperCase() === 'REDESIGN_OK' && state.activeProject?.id) {
|
||
if (!(window as any)._redesignApprovedSessions) (window as any)._redesignApprovedSessions = {};
|
||
(window as any)._redesignApprovedSessions[state.activeProject.id] = true;
|
||
}
|
||
|
||
if (isQaFailureArtifact) {
|
||
// --- REPAIR MODE (F3: Retention & Match) ---
|
||
// "Broken frontend is treated as a REPAIR task"
|
||
const originalIntent = state.activeProject?.originalPrompt || "Unknown Intent";
|
||
|
||
systemPrompt = `[SYSTEM INSTRUCTION]: REPAIR MODE ACTIVE.
|
||
|
||
The previous build FAILED Quality Assurance (Unstyled/Broken) or was completely lost.
|
||
The user wants to FIX/REDO it.
|
||
|
||
ORIGINAL INTENT (MUST PRESERVE):
|
||
"${originalIntent}"
|
||
|
||
YOUR GOAL: Propose a repair plan to fix the broken output while STAYING TRUE to the Original Intent.
|
||
1. IGNORE "Design Lock" - the current implementation is broken.
|
||
2. YOU MUST PROPOSE A VALID, STYLED IMPLEMENTATION.
|
||
3. Start plan with '[PLAN]'.
|
||
4. Do NOT ask for clarification. JUST FIX IT.`;
|
||
} else if (isRedesignConfirmed) {
|
||
// --- REDESIGN APPROVED MODE ---
|
||
systemPrompt = `[SYSTEM INSTRUCTION]: REDESIGN APPROVED.
|
||
|
||
The user has explicitely authorized a redesign.
|
||
1. You may change layout, colors, and structure.
|
||
2. Ignore previous Design Lock constraints.
|
||
3. Propose a comprehensive plan starting with '[PLAN]'.`;
|
||
} else {
|
||
// --- CLIE: CONTEXT-LOCKED EXECUTION ---
|
||
try {
|
||
// 1. Analyze Intent
|
||
const intent = classifyIntent(userPrompt);
|
||
|
||
// 2. Load Manifest (Context Soul)
|
||
let manifest = await loadProjectManifest(state.activeProject.id);
|
||
if (!manifest && state.activeProject.originalPrompt) {
|
||
// Lazy init if missing
|
||
await initializeProjectContext(state.activeProject, state.activeProject.originalPrompt);
|
||
manifest = await loadProjectManifest(state.activeProject.id);
|
||
}
|
||
|
||
// 3. Enhance Prompt
|
||
const enhancedContext = enhancePromptWithContext(userPrompt, manifest, intent);
|
||
|
||
systemPrompt = `[CLIE v${CLIE_VERSION}] [SYSTEM INSTRUCTION]: EXECUTION MODE LOCKED.
|
||
|
||
${enhancedContext}
|
||
|
||
${memoryBlock}
|
||
|
||
CURRENT PROJECT SNAPSHOT (preview):
|
||
\`\`\`html
|
||
${codePreview}
|
||
\`\`\`
|
||
|
||
BEFORE proposing changes, state:
|
||
- EXECUTION_MODE: ${intent.mode}
|
||
- MUST_TOUCH: [files/sections that need changes]
|
||
- MUST_NOT_TOUCH: [layout, colors, typography, existing components, structure]
|
||
|
||
Then provide a BRIEF plan starting with '[PLAN]' describing ONLY the changes needed.`;
|
||
} catch (e) {
|
||
console.error('[CLIE] Failed to enhance prompt:', e);
|
||
// Fallback to standard
|
||
systemPrompt = `[SYSTEM INSTRUCTION]: MODIFICATION MODE with DESIGN LOCK ENABLED.
|
||
|
||
${memoryBlock}
|
||
|
||
CURRENT PROJECT SNAPSHOT (preview):
|
||
\`\`\`html
|
||
${codePreview}
|
||
\`\`\`
|
||
|
||
DESIGN LOCK RULES:
|
||
1. DO NOT redesign.
|
||
2. DO NOT change colors/fonts.
|
||
3. ONLY implement the request.
|
||
|
||
Brief plan starting with '[PLAN]'.`;
|
||
}
|
||
}
|
||
} else {
|
||
// P0-WF1: FORCE PLAN-FIRST
|
||
// "Every idea must produce a plan first"
|
||
// We ignore overrideInput / one-shot triggers for the initial build request.
|
||
requestKind = 'plan';
|
||
systemPrompt = `[SYSTEM INSTRUCTION]: Propose an implementation plan starting with '[PLAN]'. DO NOT output code yet.`;
|
||
}
|
||
|
||
if (state.preferredFramework) {
|
||
systemPrompt += `\n\n[USER PREFERENCE]: The user has explicitly requested to use the "${state.preferredFramework}" framework. You MUST prioritize this framework, along with its standard ecosystem (e.g. if React, use typical React libs; if Tailwind, use utility classes).`;
|
||
}
|
||
|
||
// IMMEDIATE FEEDBACK: If we are planning, switch to Plan tab immediately so user sees the stream
|
||
if (requestKind === 'plan') {
|
||
dispatch({ type: 'TRANSITION', to: OrchestratorState.Planning });
|
||
dispatch({ type: 'SET_TAB', tab: TabId.Plan });
|
||
dispatch({ type: 'UPDATE_PLAN', plan: "# Analyzing Request...\n\n*Forging blueprint based on 2025 System Standards...*" });
|
||
}
|
||
|
||
setThinkingLabel('Thinking...');
|
||
|
||
if ((window as any).electron) {
|
||
const sessionId = Date.now().toString();
|
||
let currentProjectId = state.activeProject?.id || null;
|
||
|
||
if (!currentProjectId) {
|
||
const id = (globalThis.crypto && 'randomUUID' in globalThis.crypto) ? (globalThis.crypto as any).randomUUID() : Date.now().toString();
|
||
const name = userPrompt.substring(0, 20) || "New Project";
|
||
dispatch({ type: 'CREATE_PROJECT', id, createdAt: Date.now(), name });
|
||
void ensureProjectOnDisk({ id, name, slug: name.toLowerCase().replace(/\s+/g, '-'), createdAt: Date.now(), description: 'New Vibe Project' });
|
||
void writeLastActiveProjectId(id);
|
||
currentProjectId = id;
|
||
}
|
||
|
||
const targetProjectId = currentProjectId;
|
||
|
||
|
||
|
||
(window as any).electron.removeChatListeners();
|
||
|
||
|
||
|
||
const handleResponse = (response: string) => {
|
||
clearTimeout(timeoutId);
|
||
// Session gating: ignore if session was cancelled
|
||
if (state.activeRequestStatus === 'cancelled') {
|
||
console.log('[ChatPanel] Ignoring response from cancelled session');
|
||
return;
|
||
}
|
||
|
||
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'system', message: response } });
|
||
const hasDoctype = response.includes('<!DOCTYPE html>');
|
||
const isPlan = isPlanMessage(response);
|
||
|
||
if (requestKind === 'plan') {
|
||
// F5: Plan Streaming handled via onChatChunk below (lines 1550+)
|
||
// Here we just finalize the plan.
|
||
|
||
// FORCE transition to PlanReady regardless of tag presence
|
||
// We demanded a plan, so we treat the output as a plan.
|
||
dispatch({ type: 'UPDATE_PLAN', plan: response });
|
||
// LAYER 1 FIX: Transition to PlanReady - user MUST approve before building
|
||
dispatch({ type: 'TRANSITION', to: OrchestratorState.PlanReady });
|
||
dispatch({ type: 'SET_TAB', tab: TabId.Plan });
|
||
dispatch({ type: 'END_BUILD_SESSION', sessionId });
|
||
dispatch({ type: 'UPDATE_STREAMING_CODE', code: null }); // Clear stream
|
||
finalizeRequest();
|
||
return;
|
||
}
|
||
|
||
if (isChatMode) {
|
||
// IT Expert: Check for JSON ActionProposal
|
||
if (state.chatPersona === 'it') {
|
||
try {
|
||
// Try to extract JSON from response
|
||
let jsonStr = response.trim();
|
||
// Strip markdown fences if present
|
||
jsonStr = jsonStr.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||
// Find first { and last }
|
||
const first = jsonStr.indexOf('{');
|
||
const last = jsonStr.lastIndexOf('}');
|
||
if (first !== -1 && last > first) {
|
||
jsonStr = jsonStr.substring(first, last + 1);
|
||
const parsed = JSON.parse(jsonStr);
|
||
if (parsed.runner && parsed.script && parsed.proposalId) {
|
||
// Valid ActionProposal
|
||
const proposal: import('../types').ActionProposal = {
|
||
...parsed,
|
||
status: 'pending'
|
||
};
|
||
dispatch({ type: 'SET_PENDING_PROPOSAL', proposal });
|
||
finalizeRequest();
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Not valid JSON, treat as normal chat response
|
||
console.log('[IT Expert] Response is not a valid proposal, rendering as chat.');
|
||
}
|
||
}
|
||
finalizeRequest();
|
||
return;
|
||
}
|
||
|
||
|
||
};
|
||
|
||
(window as any).electron.onChatComplete(handleResponse);
|
||
(window as any).electron.onChatError((error: string) => {
|
||
clearTimeout(timeoutId);
|
||
// Session gating
|
||
if (state.activeRequestStatus === 'cancelled') return;
|
||
|
||
if (requestKind === 'plan') {
|
||
dispatch({ type: 'UPDATE_PLAN', plan: "# Planning Failed\n\nThe orchestrator encountered an error while analyzing your request:\n\n> " + error + "\n\nPlease try again." });
|
||
}
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'error', message: "Error: " + error } });
|
||
dispatch({ type: 'REQUEST_ERROR' });
|
||
if (!isChatMode && requestKind !== 'plan') {
|
||
dispatch({ type: 'END_BUILD_SESSION', sessionId });
|
||
dispatch({ type: 'UPDATE_STREAMING_CODE', code: null });
|
||
}
|
||
setIsThinking(false);
|
||
setThinkingLabel('Thinking...');
|
||
if ((window as any).electron) {
|
||
(window as any).electron.removeChatListeners();
|
||
}
|
||
});
|
||
|
||
// F5: PLAN STREAMING - Attach listener BEFORE startChat
|
||
if (requestKind === 'plan') {
|
||
let planStreamBuffer = '';
|
||
(window as any).electron.onChatChunk((chunk: string) => {
|
||
planStreamBuffer += chunk;
|
||
// We use UPDATE_STREAMING_CODE so the Plan Tab (or overlay if visible) can see it.
|
||
// Note: PlanView must be capable of rendering streaming code if it's the active tab.
|
||
dispatch({ type: 'UPDATE_STREAMING_CODE', code: planStreamBuffer });
|
||
});
|
||
}
|
||
|
||
(window as any).electron.startChat([
|
||
{ role: 'system', content: systemPrompt },
|
||
{ role: 'user', content: userPrompt }
|
||
], state.chatSettings.activeModel);
|
||
|
||
} else {
|
||
clearTimeout(timeoutId);
|
||
setTimeout(() => {
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'system', message: "[OFFLINE] Simulated response." } });
|
||
finalizeRequest();
|
||
}, 1000);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
const showStreaming = (state.state === OrchestratorState.Building) && state.activeBuildSessionId != null && state.streamingCode != null;
|
||
|
||
useEffect(() => {
|
||
if (!showStreaming) return;
|
||
const id = setInterval(() => {
|
||
setActiveTechIndex((i) => (techTags.length ? (i + 1) % techTags.length : 0));
|
||
}, 900);
|
||
return () => clearInterval(id);
|
||
}, [showStreaming, techTags.length]);
|
||
|
||
return (
|
||
<div className={`flex flex-col glass-panel z-20 transition-all duration-500 overflow-hidden relative ${state.chatDocked === 'bottom' ? 'h-96 w-[calc(100%-24px)] fixed bottom-3 left-3 rounded-2xl shadow-2xl border-t border-white/10' : 'w-96 m-3 rounded-2xl shadow-2xl'}`}>
|
||
|
||
{/* STREAMING EDITOR OVERLAY (SLIDES IN) */}
|
||
<div
|
||
className={`absolute inset-0 bg-[#0B0B0C] z-30 flex flex-col transition-transform duration-500 ease-in-out ${showStreaming ? 'translate-x-0' : '-translate-x-full'}`}
|
||
>
|
||
<div className="p-3 border-b border-white/10 bg-zinc-900 flex items-center justify-between shadow-md z-10">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative">
|
||
<div className="w-2 h-2 rounded-full bg-primary animate-ping absolute inset-0 opacity-75"></div>
|
||
<div className="w-2 h-2 rounded-full bg-primary relative z-10"></div>
|
||
</div>
|
||
<span className="text-xs font-mono font-bold text-zinc-300 tracking-wider">FORGING<span className="text-zinc-600">_</span>SEQUENCE</span>
|
||
</div>
|
||
{/* Real-time Metrics */}
|
||
<div className="h-4 w-px bg-white/10 mx-1"></div>
|
||
<div className="flex items-center gap-3 text-[10px] font-mono text-primary/80">
|
||
<span>{(state.streamingCode?.length || 0).toLocaleString()} CHARS</span>
|
||
<span className="opacity-50">|</span>
|
||
<span>~{Math.round((state.streamingCode?.length || 0) / 4).toLocaleString()} TOKENS</span>
|
||
</div>
|
||
</div>
|
||
<Icons.Code className="w-4 h-4 text-zinc-500" />
|
||
</div>
|
||
{/* Tech Cloud */}
|
||
<div className="px-3 py-2 border-b border-white/5 bg-black/20 flex items-center gap-2 overflow-x-auto">
|
||
{techTags.map((t, idx) => (
|
||
<span
|
||
key={t}
|
||
className={`shrink-0 px-2 py-0.5 rounded-full text-[10px] font-mono border transition-all duration-500 ${idx === activeTechIndex
|
||
? 'text-primary border-primary/30 bg-primary/10 shadow-[0_0_16px_rgba(52,211,153,0.25)]'
|
||
: 'text-zinc-400 border-white/10 bg-white/5 opacity-80'}`}
|
||
style={{ animationDelay: `${idx * 120}ms` }}
|
||
>
|
||
{t}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="flex-1 overflow-hidden relative flex flex-col">
|
||
{/* Checklist (Condensed) */}
|
||
<div className="p-4 border-b border-white/5 bg-black/20 flex flex-col gap-3 shrink-0">
|
||
{[
|
||
{ label: "Initializing Build Environment", threshold: 0 },
|
||
{ label: "Parsing Blueprint Architecture", threshold: 100 },
|
||
{ label: "Scaffolding HTML Structure", threshold: 500 },
|
||
{ label: "Applying Tailwind Utility Classes", threshold: 1500 },
|
||
{ label: "Injecting JavaScript Logic", threshold: 3000 },
|
||
{ label: "Finalizing & Optimizing Assets", threshold: 5000 }
|
||
].map((step, idx) => {
|
||
const currentLen = state.streamingCode?.length || 0;
|
||
const isComplete = currentLen > step.threshold + 800;
|
||
const isCurrent = currentLen >= step.threshold && !isComplete;
|
||
const isPending = currentLen < step.threshold;
|
||
|
||
if (isPending) return null; // Only show active/done steps to save space
|
||
|
||
return (
|
||
<div key={idx} className={`flex items-center gap-3 transition-all duration-500 ${isCurrent ? 'scale-105 ml-2' : ''}`}>
|
||
<div className={`w-4 h-4 rounded-full flex items-center justify-center border transition-colors ${isComplete ? 'bg-primary border-primary text-black' :
|
||
isCurrent ? 'border-primary text-primary animate-pulse' : 'border-zinc-700 bg-zinc-900'}`}>
|
||
{isComplete ? <Icons.Check className="w-2.5 h-2.5" /> : isCurrent ? <div className="w-1.5 h-1.5 bg-primary rounded-full animate-ping" /> : <div className="w-1.5 h-1.5 bg-zinc-700 rounded-full" />}
|
||
</div>
|
||
<div className="flex flex-col">
|
||
<span className={`text-[10px] font-bold ${isComplete ? 'text-zinc-500' : isCurrent ? 'text-white' : 'text-zinc-600'}`}>
|
||
{step.label}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* REAL CODE STREAM REMOVED -> REPLACED WITH AI THOUGHTS */}
|
||
<div className="flex-1 p-4 overflow-auto bg-[#050505] shadow-inner relative flex flex-col gap-2">
|
||
<div className="text-[9px] text-zinc-500 uppercase tracking-widest mb-1 flex items-center gap-2">
|
||
<Icons.MessageSquare className="w-3 h-3" />
|
||
Builder Notes
|
||
</div>
|
||
|
||
{(() => {
|
||
const code = state.streamingCode || "";
|
||
const thinkMatch = code.match(/<thinking>([\s\S]*?)(?:<\/thinking>|$)/i);
|
||
const thinking = thinkMatch ? thinkMatch[1].trim() : null;
|
||
|
||
if (thinking) {
|
||
return (
|
||
<div className="text-xs text-zinc-400 font-sans leading-relaxed whitespace-pre-wrap animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||
{thinking}
|
||
{code.includes("</thinking>") && (
|
||
<div className="mt-4 pt-4 border-t border-dashed border-zinc-800 text-[10px] text-emerald-500/50 flex items-center gap-2">
|
||
<Icons.Check className="w-3 h-3" />
|
||
Strategy locked. Generatng artifact bundle...
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} else {
|
||
return (
|
||
<div className="text-zinc-700 text-xs italic flex flex-col gap-2 items-center justify-center h-full opacity-50">
|
||
<div className="w-1 h-1 bg-zinc-600 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||
<div className="w-1 h-1 bg-zinc-600 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||
<div className="w-1 h-1 bg-zinc-600 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||
<span>Waiting for builder notes...</span>
|
||
</div>
|
||
);
|
||
}
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* CHAT CONTENT (SLIDES OUT) */}
|
||
<div className={`flex flex-col h-full transition-transform duration-500 ease-in-out ${showStreaming ? 'translate-x-full' : 'translate-x-0'}`}>
|
||
<div className="p-4 border-b border-white/5 font-medium text-xs flex items-center justify-between bg-white/5 backdrop-blur-xl rounded-t-2xl">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-1.5 h-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(52,211,153,0.8)] animate-pulse" />
|
||
<span className="text-zinc-200 font-display tracking-wide">AI ORCHESTRATOR</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{state.globalMode === GlobalMode.Chat && (
|
||
<select
|
||
value={state.chatPersona}
|
||
onChange={(e) => dispatch({ type: 'SET_CHAT_PERSONA', persona: e.target.value as any })}
|
||
className="text-[10px] px-2 py-1 bg-black/30 rounded-lg text-zinc-200 border border-white/10 focus:outline-none focus:border-primary/40"
|
||
title="Chat persona"
|
||
>
|
||
<option value="assistant">Assistant</option>
|
||
<option value="office">Office Assistant</option>
|
||
<option value="therapist">Therapist</option>
|
||
<option value="business">Business Coach</option>
|
||
<option value="it">IT Advisor</option>
|
||
<option value="designer">Designer</option>
|
||
<option value="custom">{state.customChatPersonaName || 'Custom'}</option>
|
||
</select>
|
||
)}
|
||
{state.globalMode === GlobalMode.Chat && state.chatPersona === 'custom' && (
|
||
<button
|
||
onClick={() => setShowCustomPersona(true)}
|
||
className="text-[10px] px-2 py-1 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-zinc-200"
|
||
title="Edit custom persona"
|
||
>
|
||
Edit
|
||
</button>
|
||
)}
|
||
{state.globalMode === GlobalMode.Brainstorm && (
|
||
<span className="text-[10px] px-2 py-1 bg-blue-500/10 text-blue-200 border border-blue-500/20 rounded-lg">
|
||
Brainstorm
|
||
</span>
|
||
)}
|
||
<span className="text-[9px] px-1.5 py-0.5 bg-zinc-800 rounded text-zinc-500 uppercase tracking-wider border border-white/5">{state.state}</span>
|
||
<button
|
||
onClick={() => setShowAISettings(true)}
|
||
className="p-1.5 hover:bg-white/10 rounded-lg text-zinc-400 hover:text-white transition-colors"
|
||
title="AI Settings"
|
||
>
|
||
<Icons.Settings className="w-3.5 h-3.5" />
|
||
</button>
|
||
{state.chatDocked === 'bottom' && (
|
||
<button onClick={() => dispatch({ type: 'TOGGLE_CHAT_DOCK' })} className="hover:text-white text-zinc-500"><Icons.Layout className="w-3 h-3" /></button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI SETTINGS MODAL */}
|
||
{showAISettings && <AISettingsModal onClose={() => setShowAISettings(false)} />}
|
||
|
||
<div
|
||
ref={timelineScrollRef}
|
||
className="flex-1 overflow-y-auto p-4 space-y-6 scroll-smooth"
|
||
onScroll={(e) => {
|
||
const el = e.currentTarget;
|
||
setAutoScrollEnabled(isNearBottom(el));
|
||
}}
|
||
>
|
||
{state.timeline.filter(t => t.type === 'user' || t.type === 'system').map(log => (
|
||
<div key={log.id} className={`flex flex-col gap-1 ${log.type === 'user' ? 'items-end' : 'items-start'} animate-slide-up`}>
|
||
<div className={`max-w-[85%] rounded-2xl p-4 text-sm leading-relaxed shadow-lg backdrop-blur-sm ${log.type === 'user'
|
||
? 'bg-zinc-800/80 text-white rounded-br-sm border border-white/10'
|
||
: 'bg-primary/5 text-zinc-100 rounded-bl-sm border border-primary/10 w-full'
|
||
}`}>
|
||
<LogMessage message={log.message} type={log.type as any} />
|
||
</div>
|
||
<span className="text-[10px] text-zinc-600 px-1 opacity-50">{new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* IT Expert Action Proposal */}
|
||
{state.pendingProposal && (
|
||
<div className="animate-slide-up">
|
||
<ActionProposalCard proposal={state.pendingProposal} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Templates / Suggestions when empty */}
|
||
{/* Empty State: Idea Seeds */}
|
||
{state.timeline.length === 0 && !isThinking && (
|
||
<div className="flex flex-col items-center justify-center p-2 animate-fade-in w-full">
|
||
<div className="w-full bg-white/5 border border-white/5 rounded-2xl p-6 mb-4 backdrop-blur-sm">
|
||
<div className="flex items-center gap-3 mb-4 text-zinc-300">
|
||
<div className="p-2 bg-primary/10 rounded-lg"><Icons.Sparkles className="w-5 h-5 text-primary" /></div>
|
||
<div>
|
||
<h3 className="font-bold text-sm text-white">What shall we build?</h3>
|
||
<p className="text-[10px] text-zinc-500">Generate tailored build ideas or start fresh.</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 mb-2">
|
||
<input
|
||
className="flex-1 bg-black/40 border border-white/10 rounded-xl px-3 py-2 text-sm text-white focus:border-primary/50 outline-none transition-colors placeholder:text-zinc-600"
|
||
placeholder="e.g. fitness tracker, retro game, portfolio..."
|
||
value={ideaSeed}
|
||
onChange={e => setIdeaSeed(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && handleGenerateIdeas()}
|
||
/>
|
||
<button
|
||
disabled={ideaStatus === 'loading'}
|
||
onClick={handleGenerateIdeas}
|
||
className="bg-white text-black px-4 py-2 rounded-xl font-bold text-xs hover:bg-zinc-200 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||
>
|
||
{ideaStatus === 'loading' && <Icons.RefreshCw className="w-3 h-3 animate-spin" />}
|
||
Generate
|
||
</button>
|
||
</div>
|
||
{ideaStatus === 'error' && <p className="text-[10px] text-rose-400 mt-2 flex items-center gap-1"><Icons.AlertTriangle className="w-3 h-3" /> Failed to generate ideas. Try fewer keywords.</p>}
|
||
</div>
|
||
|
||
{/* Results Grid */}
|
||
{(ideaResults.length > 0 || ideaStatus === 'loading') && (
|
||
<div className="grid grid-cols-2 gap-3 w-full animate-fade-in-up">
|
||
{ideaStatus === 'loading'
|
||
? Array(6).fill(0).map((_, i) => (
|
||
<div key={i} className="h-24 bg-white/5 rounded-xl animate-pulse border border-white/5" />
|
||
))
|
||
: ideaResults.map((idea, i) => (
|
||
<button
|
||
key={i}
|
||
onClick={() => handleSubmit(null as any, idea.prompt)}
|
||
className="text-left p-3 bg-zinc-900/50 hover:bg-zinc-800 border border-white/5 hover:border-primary/40 rounded-xl group transition-all active:scale-95 flex flex-col h-24 relative overflow-hidden"
|
||
>
|
||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity -translate-x-2 group-hover:translate-x-0 duration-300">
|
||
<Icons.ArrowRight className="w-3 h-3 text-primary" />
|
||
</div>
|
||
<span className="text-[9px] font-mono text-zinc-500 uppercase tracking-widest mb-1 border border-white/5 px-1.5 py-0.5 rounded self-start">{idea.tag || 'App'}</span>
|
||
<h4 className="font-bold text-zinc-200 text-xs mb-1 line-clamp-1 group-hover:text-white transition-colors">{idea.title}</h4>
|
||
<p className="text-[10px] text-zinc-500 leading-tight line-clamp-2 group-hover:text-zinc-400">{idea.subtitle}</p>
|
||
</button>
|
||
))
|
||
}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{isThinking && (
|
||
<div className="flex flex-col gap-2 items-start animate-slide-up">
|
||
<div className="max-w-[85%] rounded-2xl p-4 text-sm leading-relaxed shadow-lg backdrop-blur-sm bg-primary/5 text-zinc-100 rounded-bl-sm border border-primary/10 flex items-center gap-3">
|
||
<div className="flex gap-1">
|
||
<div className="w-1.5 h-1.5 bg-primary rounded-full animate-bounce"></div>
|
||
<div className="w-1.5 h-1.5 bg-primary rounded-full animate-bounce delay-75"></div>
|
||
<div className="w-1.5 h-1.5 bg-primary rounded-full animate-bounce delay-150"></div>
|
||
</div>
|
||
<div className="flex flex-col">
|
||
<span className="text-xs font-mono text-primary/80 animate-pulse">{thinkingLabel}</span>
|
||
{state.streamingCode && state.streamingCode.length > 0 && (
|
||
<span className="text-[9px] font-mono text-primary/50 text-xs mt-0.5">
|
||
{state.streamingCode.length} chars · ~{Math.ceil(state.streamingCode.length / 4)} tokens
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<span className="text-[10px] text-zinc-500 px-1 opacity-70 flex items-center gap-2">
|
||
<Icons.RefreshCw className="w-3 h-3 animate-spin" />
|
||
Working...
|
||
{state.streamingCode && state.streamingCode.length > 0 && (
|
||
<span className="ml-2 font-mono text-primary/60">
|
||
{state.streamingCode.length} chars | ~{Math.ceil(state.streamingCode.length / 4)} tokens
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{!autoScrollEnabled && (
|
||
<div className="sticky bottom-2 flex justify-center">
|
||
<button
|
||
onClick={() => {
|
||
setAutoScrollEnabled(true);
|
||
const el = timelineScrollRef.current;
|
||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||
}}
|
||
className="px-3 py-1.5 text-[10px] font-bold rounded-full bg-white/10 hover:bg-white/15 border border-white/10 text-zinc-200 backdrop-blur-md"
|
||
>
|
||
Jump to latest
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className={`p-4 bg-gradient-to-t from-black/40 to-transparent rounded-b-2xl ${isDragging ? 'ring-2 ring-primary ring-offset-2 ring-offset-black' : ''}`}
|
||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||
onDragLeave={(e) => {
|
||
if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget as Node)) {
|
||
setIsDragging(false);
|
||
}
|
||
}}
|
||
onDrop={handleFileDrop}
|
||
>
|
||
{/* Skill Recommendations */}
|
||
{recommendedSkills.length > 0 && (
|
||
<div className="flex flex-wrap gap-2 mb-2 px-1 animate-fade-in-up">
|
||
<span className="text-[10px] text-zinc-500 self-center uppercase tracking-wider">Suggested Tools:</span>
|
||
{recommendedSkills.map(skill => {
|
||
const IconComp = (Icons as any)[skill.icon] || Icons.Box;
|
||
return (
|
||
<button
|
||
key={skill.id}
|
||
type="button"
|
||
onClick={async () => {
|
||
try {
|
||
const { skillsService } = await import('../services/skillsService');
|
||
await skillsService.installSkill(skill.id);
|
||
setRecommendedSkills(prev => prev.filter(s => s.id !== skill.id));
|
||
} catch (e) { console.error(e); }
|
||
}}
|
||
className="flex items-center gap-1.5 px-2.5 py-1 text-[10px] bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-300 border border-indigo-500/20 rounded-full transition-colors"
|
||
>
|
||
<IconComp className="w-3 h-3" />
|
||
<span>{skill.name}</span>
|
||
<span className="opacity-50 text-[9px]">+</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{/* Attachment chips */}
|
||
{attachments.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||
{attachments.map(att => (
|
||
<div key={att.id} className="flex items-center gap-1 px-2 py-1 bg-white/5 border border-white/10 rounded-lg text-[10px] text-zinc-300">
|
||
<span className={`w-1.5 h-1.5 rounded-full ${att.type === 'text' ? 'bg-blue-400' : att.type === 'image' ? 'bg-purple-400' : 'bg-green-400'}`} />
|
||
<span className="max-w-[100px] truncate">{att.name}</span>
|
||
<button type="button" onClick={() => removeAttachment(att.id)} className="text-zinc-500 hover:text-rose-400 ml-1">
|
||
<Icons.X className="w-2.5 h-2.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="relative group">
|
||
<div className="absolute -inset-0.5 bg-gradient-to-r from-primary/20 to-blue-500/20 rounded-xl blur opacity-0 group-focus-within:opacity-100 transition duration-500"></div>
|
||
{/* Hidden file input */}
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
onChange={handleFileSelect}
|
||
className="hidden"
|
||
multiple
|
||
accept=".txt,.md,.json,.js,.ts,.tsx,.html,.css,.py,.go,.rs,.java,.c,.cpp,.cs,.yml,.yaml,.toml,.ini,.log,.png,.jpg,.jpeg,.webp,.gif,.csv,.xlsx,.xls,.docx,.doc,.pptx,.ppt,.pdf"
|
||
/>
|
||
{/* Main Input Field - Adjusted padding for right-aligned icons */}
|
||
<input
|
||
className="relative w-full bg-zinc-900/90 border border-white/10 rounded-xl pl-4 pr-48 py-3.5 text-sm text-white placeholder-zinc-600 focus:outline-none transition-all shadow-inner"
|
||
placeholder={isDragging ? "Drop files here..." : "Direct the orchestrator..."}
|
||
value={input}
|
||
disabled={isThinking}
|
||
onChange={e => setInput(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
|
||
{/* Right-aligned Action Buttons */}
|
||
<div className="absolute right-2 top-2 flex items-center gap-1">
|
||
{/* Paperclip button */}
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="p-1.5 text-zinc-500 hover:text-primary rounded-lg transition-colors"
|
||
title="Attach files"
|
||
>
|
||
<Icons.Paperclip className="w-4 h-4" />
|
||
</button>
|
||
|
||
{/* Tools Button / Skills */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowSkillsSelector(true)}
|
||
className="p-1.5 text-zinc-500 hover:text-purple-400 rounded-lg transition-colors"
|
||
title="Discover & Use Skills"
|
||
>
|
||
<Icons.Terminal className="w-4 h-4" />
|
||
</button>
|
||
|
||
{/* Persona Quick Access */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCustomPersona(true)}
|
||
className="p-1.5 text-zinc-500 hover:text-indigo-400 rounded-lg transition-colors"
|
||
title="Select Agent / Persona"
|
||
>
|
||
<Icons.User className="w-4 h-4" />
|
||
</button>
|
||
|
||
{/* Zap / Shortcuts (Also Skills for now, could be quick actions) */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowSkillsSelector(true)}
|
||
className="p-1.5 text-zinc-500 hover:text-cyan-400 rounded-lg transition-colors"
|
||
title="Generate New Skill"
|
||
>
|
||
<Icons.Zap className="w-4 h-4" />
|
||
</button>
|
||
|
||
<div className="w-px h-5 bg-white/10 mx-1"></div> {/* Divider */}
|
||
{/* Edit & Resend button (shows after cancel or error) */}
|
||
{(state.activeRequestStatus === 'cancelled' || state.activeRequestStatus === 'error') && state.lastUserMessageDraft && !isThinking && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setInput(state.lastUserMessageDraft || '');
|
||
dispatch({ type: 'EDIT_AND_RESEND' });
|
||
}}
|
||
className="px-2 py-1.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg text-[10px] font-bold transition-all"
|
||
title="Edit & Resend last message"
|
||
>
|
||
Edit & Resend
|
||
</button>
|
||
)}
|
||
{/* Stop button (shows when thinking) */}
|
||
{isThinking ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
dispatch({ type: 'CANCEL_REQUEST' });
|
||
if ((window as any).electron) {
|
||
(window as any).electron.removeChatListeners();
|
||
}
|
||
setIsThinking(false);
|
||
setThinkingLabel('Thinking...');
|
||
}}
|
||
className="p-1.5 bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 rounded-lg transition-all"
|
||
title="Stop generation"
|
||
>
|
||
<Icons.X className="w-3.5 h-3.5" />
|
||
</button>
|
||
) : (
|
||
<button type="submit" disabled={isThinking} className="p-1.5 bg-white/10 hover:bg-primary hover:text-black rounded-lg text-zinc-400 transition-all duration-300 disabled:opacity-50">
|
||
<Icons.ArrowRight className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Modification mode indicator */}
|
||
{(state.state === OrchestratorState.PreviewReady || state.state === OrchestratorState.Editing) && state.files['index.html'] && (
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<span className="text-[9px] px-2 py-0.5 bg-amber-500/10 text-amber-400 border border-amber-500/20 rounded-full font-mono uppercase tracking-wider">
|
||
Modify Existing App
|
||
</span>
|
||
<span className="text-[9px] text-zinc-500">Changes will preserve your current design</span>
|
||
</div>
|
||
)}
|
||
</form>
|
||
</div>
|
||
{showCustomPersona && <PersonaSelectorModal onClose={() => setShowCustomPersona(false)} />}
|
||
{showSkillsSelector && <SkillsSelectorModal onClose={() => setShowSkillsSelector(false)} onSelect={(cmd) => setInput(prev => prev + ' ' + cmd)} />}
|
||
{state.personaCreateModalOpen && <PersonaCreateModal state={state} dispatch={dispatch} />}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const LogMessage = ({ message, type }: { message: string, type: 'user' | 'system' | 'error' }) => {
|
||
const { state, dispatch } = useOrchestrator();
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const isSystem = type === 'system';
|
||
const isChatMode = state.globalMode === GlobalMode.Chat;
|
||
|
||
// Logic Fix 1: Deduplicate "HelloHello"
|
||
const cleanText = message.replace(/^(Hello|Hi|Hey|Greetings)\1/i, '$1');
|
||
|
||
const isLong = cleanText.length > 300;
|
||
const hasCodeMarkers = cleanText.includes('```') || cleanText.includes('<!DOCTYPE') || cleanText.includes('function ') || cleanText.includes('const ') || cleanText.includes('<svg');
|
||
const isPlan = isPlanMessage(cleanText);
|
||
const planSignature = useMemo(() => (isPlan ? hashPlan(cleanText) : ''), [isPlan, cleanText]);
|
||
const planResolution = planSignature ? state.resolvedPlans?.[planSignature] : undefined;
|
||
|
||
// Allow code rendering in Chat mode if it's an SVG or explicit code block, otherwise restrict to Build mode for generic output
|
||
const isCode = ((isSystem && state.globalMode === GlobalMode.Build) || (isSystem && cleanText.includes('<svg'))) && (isLong || hasCodeMarkers) && !isPlan;
|
||
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [editContent, setEditContent] = useState('');
|
||
|
||
const handleApprovePlanRobust = async () => {
|
||
if (planSignature) dispatch({ type: 'RESOLVE_PLAN', signature: planSignature, resolution: 'approved' });
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'user', message: 'Plan Approved. Proceeding to build.' } });
|
||
|
||
// START BUILD SESSION
|
||
const sessionId = Date.now().toString();
|
||
dispatch({ type: 'START_BUILD_SESSION', sessionId });
|
||
dispatch({ type: 'TRANSITION', to: OrchestratorState.Building });
|
||
// Initialize streaming code to empty string so counters start at 0
|
||
dispatch({ type: 'UPDATE_STREAMING_CODE', code: '' });
|
||
|
||
try {
|
||
const isModification = state.state === OrchestratorState.PreviewReady || state.state === OrchestratorState.Editing;
|
||
const currentHtml = state.files['index.html'] || '';
|
||
const planToUse = isEditing ? editContent : cleanText;
|
||
|
||
// Prepare Context
|
||
const activeSkills = state.skillRegistry?.installed?.map(s => s.name) || [];
|
||
|
||
let activePersonaObj = null;
|
||
if (state.apexModeEnabled) {
|
||
activePersonaObj = { name: 'APEX ELITE ARCHITECT', prompt: APEX_ELITE_PROMPT };
|
||
} else if (state.chatPersona === 'custom') {
|
||
activePersonaObj = { name: state.customChatPersonaName || 'Custom Agent', prompt: state.customChatPersonaPrompt || '' };
|
||
} else if (state.chatPersona && state.chatPersona !== 'assistant') {
|
||
// Map built-ins (simple mapping for now, mirroring PersonaSelectorModal)
|
||
const builtIns: Record<string, string> = {
|
||
'therapist': 'You are an Empathetic Therapist.',
|
||
'business': 'You are a Business Strategy Coach.',
|
||
'it': 'You are an expert IT System Administrator.',
|
||
'designer': 'You are a Senior UX/UI Designer.',
|
||
'office': 'You are a Senior Office Assistant expert in preparing design slides, complex excels, documents, and PDFs. You can handle all kind of office elements and work on existing files as a full-time assistant.'
|
||
};
|
||
activePersonaObj = { name: state.chatPersona, prompt: builtIns[state.chatPersona] || '' };
|
||
}
|
||
|
||
// Pass callback to update streaming code
|
||
const code = isModification && currentHtml
|
||
// Fix: Pass projectId to enable CLIE Vibe Guard, plus Persona/Skills
|
||
? await applyPlanToExistingHtml(planToUse, currentHtml, (chunk) => dispatch({ type: 'UPDATE_STREAMING_CODE', code: chunk }), 0, state.activeProject?.id, activePersonaObj, activeSkills)
|
||
: await compilePlanToCode(planToUse, (chunk) => dispatch({ type: 'UPDATE_STREAMING_CODE', code: chunk }), state.activeProject?.id || 'latest', state.preferredFramework, activePersonaObj, activeSkills);
|
||
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'system', message: "Code synthesized from plan." } });
|
||
const projectId = state.activeProject?.id || 'latest';
|
||
const rawFiles = await generateMockFiles(`projects/${projectId}`, code);
|
||
const { _qaFailed, _qaReport, ...files } = rawFiles as any;
|
||
|
||
if (_qaFailed) {
|
||
console.warn('[LogMessage] QA Failed blocks preview:', _qaReport);
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'error', message: 'Build failed quality checks. See "qa_report.html" or try again.' } });
|
||
// P0-WF2: Do NOT switch to Preview tab.
|
||
// We still update files so the user can inspect the failure report if they switch manually,
|
||
// but we don't auto-switch.
|
||
dispatch({ type: 'UPDATE_FILES', files });
|
||
dispatch({ type: 'END_BUILD_SESSION', sessionId });
|
||
dispatch({ type: 'UPDATE_STREAMING_CODE', code: null });
|
||
return;
|
||
}
|
||
|
||
dispatch({ type: 'UPDATE_FILES', files });
|
||
|
||
// FINALIZATION
|
||
dispatch({ type: 'TRANSITION', to: OrchestratorState.PreviewReady });
|
||
dispatch({ type: 'SET_TAB', tab: TabId.Preview }); // Auto-switch
|
||
dispatch({ type: 'END_BUILD_SESSION', sessionId });
|
||
dispatch({ type: 'UPDATE_STREAMING_CODE', code: null });
|
||
} catch (e: any) {
|
||
console.error(e);
|
||
dispatch({ type: 'END_BUILD_SESSION', sessionId });
|
||
dispatch({ type: 'UPDATE_STREAMING_CODE', code: null });
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'error', message: "Build Failed." } });
|
||
}
|
||
};
|
||
|
||
const handleReject = () => {
|
||
if (planSignature) dispatch({ type: 'RESOLVE_PLAN', signature: planSignature, resolution: 'rejected' });
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'user', message: 'Plan Rejected.' } });
|
||
};
|
||
|
||
const handleEditToggle = () => {
|
||
if (!isEditing) {
|
||
setEditContent(stripPlanTag(cleanText));
|
||
}
|
||
setIsEditing(!isEditing);
|
||
};
|
||
|
||
const handleSubmitEdit = () => {
|
||
// We update the timeline with the user's edit
|
||
// effectively overriding the original message content for the build
|
||
// but we don't change the history, just the state for the current session
|
||
setIsEditing(false);
|
||
};
|
||
|
||
if (isPlan) {
|
||
return (
|
||
<div className="flex flex-col gap-4 w-full">
|
||
<div className="flex items-center justify-between border-b border-primary/20 pb-2">
|
||
<div className="flex items-center gap-2">
|
||
<div className="p-1.5 bg-primary/10 rounded-md">
|
||
<Icons.Box className="w-4 h-4 text-primary" />
|
||
</div>
|
||
<div>
|
||
<span className="text-xs font-bold text-white tracking-wider">PROPOSED BUILD PLAN</span>
|
||
{planResolution && <span className={`ml-2 text-[10px] uppercase font-mono px-1.5 py-0.5 rounded ${planResolution === 'approved' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-rose-500/20 text-rose-400'}`}>{planResolution}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1 opacity-100 transition-opacity">
|
||
{!planResolution && !isEditing && (
|
||
<button onClick={handleApprovePlanRobust} className="p-1.5 hover:bg-emerald-500/20 bg-emerald-500/10 text-emerald-400 rounded-md transition-colors flex items-center gap-1.5 px-3">
|
||
<Icons.Check className="w-3.5 h-3.5" />
|
||
<span className="text-[10px] font-bold">APPROVE</span>
|
||
</button>
|
||
)}
|
||
{!planResolution && (
|
||
<>
|
||
<button onClick={handleEditToggle} className={`p-1.5 hover:bg-blue-500/20 text-blue-400 rounded-md transition-colors ${isEditing ? 'bg-blue-500/20' : 'bg-blue-500/10'}`}>
|
||
<Icons.MessageSquare className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button onClick={handleReject} className="p-1.5 hover:bg-rose-500/20 bg-rose-500/10 text-rose-400 rounded-md transition-colors">
|
||
<Icons.X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
{planResolution && (
|
||
<button onClick={() => dispatch({ type: 'RESOLVE_PLAN', signature: planSignature, resolution: undefined })} className="p-1.5 hover:bg-zinc-500/20 bg-zinc-500/10 text-zinc-400 rounded-md transition-colors">
|
||
<Icons.X className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{isEditing && !planResolution ? (
|
||
<div className="flex flex-col gap-2">
|
||
<textarea
|
||
value={editContent}
|
||
onChange={e => setEditContent(e.target.value)}
|
||
className="w-full bg-black/50 border border-white/10 rounded-lg p-2 text-xs font-mono text-zinc-300 min-h-[100px] focus:outline-none focus:border-primary/50"
|
||
/>
|
||
<button onClick={() => {
|
||
// Logic: User edited the plan and wants to build THIS.
|
||
// We call compilePlanToCode with the EDITED content.
|
||
(async () => {
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'user', message: 'Plan Modified & Approved. Constructing...' } });
|
||
dispatch({ type: 'TRANSITION', to: OrchestratorState.Building });
|
||
try {
|
||
const isModification = state.state === OrchestratorState.PreviewReady || state.state === OrchestratorState.Editing;
|
||
const currentHtml = state.files['index.html'] || '';
|
||
|
||
// Prepare Context
|
||
const activeSkills = state.skillRegistry?.installed?.map(s => s.name) || [];
|
||
let activePersonaObj = null;
|
||
if (state.apexModeEnabled) {
|
||
activePersonaObj = { name: 'APEX ELITE ARCHITECT', prompt: APEX_ELITE_PROMPT };
|
||
} else if (state.chatPersona === 'custom') {
|
||
activePersonaObj = { name: state.customChatPersonaName || 'Custom Agent', prompt: state.customChatPersonaPrompt || '' };
|
||
} else if (state.chatPersona && state.chatPersona !== 'assistant') {
|
||
const builtIns: Record<string, string> = {
|
||
'therapist': 'You are an Empathetic Therapist.',
|
||
'business': 'You are a Business Strategy Coach.',
|
||
'it': 'You are an expert IT System Administrator.',
|
||
'designer': 'You are a Senior UX/UI Designer.',
|
||
'office': 'You are a Senior Office Assistant expert in preparing design slides, complex excels, documents, and PDFs. You can handle all kind of office elements and work on existing files as a full-time assistant.'
|
||
};
|
||
activePersonaObj = { name: state.chatPersona, prompt: builtIns[state.chatPersona] || '' };
|
||
}
|
||
|
||
const code = isModification && currentHtml
|
||
? await applyPlanToExistingHtml(editContent, currentHtml, (chunk) => dispatch({ type: 'UPDATE_STREAMING_CODE', code: chunk }), 0, state.activeProject?.id, activePersonaObj, activeSkills)
|
||
: await compilePlanToCode(editContent, (chunk) => dispatch({ type: 'UPDATE_STREAMING_CODE', code: chunk }), state.activeProject?.id || 'latest', state.preferredFramework, activePersonaObj, activeSkills);
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'system', message: "Code synthesized from modified plan." } });
|
||
const projectId = state.activeProject?.id || 'latest';
|
||
const rawFiles = await generateMockFiles(`projects/${projectId}`, code);
|
||
const { _qaFailed, _qaReport, ...files } = rawFiles as any;
|
||
if (_qaFailed) console.warn('[EditPlan] QA Failed:', _qaReport);
|
||
dispatch({ type: 'UPDATE_FILES', files });
|
||
dispatch({ type: 'UPDATE_STREAMING_CODE', code: null });
|
||
dispatch({ type: 'TRANSITION', to: OrchestratorState.PreviewReady });
|
||
} catch (e) {
|
||
console.error(e);
|
||
dispatch({ type: 'UPDATE_STREAMING_CODE', code: null });
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'error', message: "Build Failed." } });
|
||
}
|
||
})();
|
||
}} className="w-full py-1.5 bg-primary text-black text-xs font-bold rounded-lg hover:bg-emerald-400 transition-colors">
|
||
CONFIRM CHANGES & BUILD
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-zinc-300 leading-relaxed whitespace-pre-wrap pl-1 border-l-2 border-indigo-500/20 ml-1">
|
||
{stripPlanTag(cleanText)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Bottom Action Buttons (duplicate for easy access on long plans) */}
|
||
{!planResolution && !isEditing && (
|
||
<div className="flex items-center justify-end gap-1 pt-3 border-t border-white/5 mt-2">
|
||
<button onClick={handleApprovePlanRobust} className="p-1.5 hover:bg-emerald-500/20 bg-emerald-500/10 text-emerald-400 rounded-md transition-colors flex items-center gap-1.5 px-3">
|
||
<Icons.Check className="w-3.5 h-3.5" />
|
||
<span className="text-[10px] font-bold">APPROVE & BUILD</span>
|
||
</button>
|
||
<button onClick={handleEditToggle} className="p-1.5 hover:bg-blue-500/20 bg-blue-500/10 text-blue-400 rounded-md transition-colors">
|
||
<Icons.MessageSquare className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button onClick={handleReject} className="p-1.5 hover:bg-rose-500/20 bg-rose-500/10 text-rose-400 rounded-md transition-colors">
|
||
<Icons.X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isCode) {
|
||
return (
|
||
<div className="w-full">
|
||
{/* SVG Live Preview */}
|
||
{cleanText.includes('<svg') && cleanText.includes('</svg>') && (
|
||
<div className="mb-2 p-8 bg-black/20 border border-white/5 rounded-xl flex items-center justify-center overflow-hidden relative group/svg">
|
||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20"></div>
|
||
<div className="relative z-10 transform transition-transform duration-500 hover:scale-110" dangerouslySetInnerHTML={{ __html: cleanText.substring(cleanText.indexOf('<svg'), cleanText.lastIndexOf('</svg>') + 6) }} />
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className="flex items-center justify-between p-2.5 bg-black/40 border border-white/5 rounded-lg cursor-pointer hover:bg-white/5 transition-colors group mb-1"
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
>
|
||
<div className="flex items-center gap-2.5">
|
||
<div className={`p-1.5 rounded-md ${cleanText.includes('<!DOCTYPE html>') ? 'bg-emerald-500/10 text-emerald-400' : 'bg-blue-500/10 text-blue-400'}`}>
|
||
<Icons.Code className="w-3.5 h-3.5" />
|
||
</div>
|
||
<span className="text-xs font-medium text-zinc-300 group-hover:text-white transition-colors">
|
||
{cleanText.includes('<!DOCTYPE html>') ? 'Generated UI Artifact' : (cleanText.includes('<svg') ? 'Vector Graphic Asset' : 'System Output')}
|
||
</span>
|
||
</div>
|
||
<div className={`text-zinc-500 transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}>
|
||
<Icons.ChevronDown className="w-3.5 h-3.5" />
|
||
</div>
|
||
</div>
|
||
|
||
{isOpen && (
|
||
<div className="mt-2 p-3 bg-black/50 rounded-lg border border-white/5 overflow-x-auto text-xs font-mono text-zinc-400 max-h-80 overflow-y-auto custom-scrollbar animate-fade-in">
|
||
<pre className="whitespace-pre-wrap">{cleanText}</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// --- GENERATED IMAGE RENDERING ---
|
||
// Handle [IMAGE_GENERATED] messages with beautiful image display
|
||
if (cleanText.includes('[IMAGE_GENERATED]')) {
|
||
const imageMatch = cleanText.match(/!\[.*?\]\((https?:\/\/[^\s)]+)\)/);
|
||
const promptMatch = cleanText.match(/\*\*Prompt:\*\*\s*(.+?)(?:\n|$)/);
|
||
const sizeMatch = cleanText.match(/\*\*Size:\*\*\s*(\d+x\d+)/);
|
||
|
||
const imageUrl = imageMatch?.[1] || '';
|
||
const promptText = promptMatch?.[1] || 'AI Generated Image';
|
||
const sizeText = sizeMatch?.[1] || '1024x1024';
|
||
|
||
return (
|
||
<div className="w-full animate-fade-in">
|
||
<div className="flex items-center gap-2 mb-3 text-xs text-primary">
|
||
<span className="text-lg">🎨</span>
|
||
<span className="font-bold uppercase tracking-wider">Generated Image</span>
|
||
</div>
|
||
{imageUrl ? (
|
||
<div className="relative group/img rounded-2xl overflow-hidden border border-white/10 bg-black/30">
|
||
<img
|
||
src={imageUrl}
|
||
alt={promptText}
|
||
className="w-full max-h-[500px] object-contain rounded-2xl"
|
||
loading="lazy"
|
||
/>
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover/img:opacity-100 transition-opacity duration-300" />
|
||
<div className="absolute bottom-0 left-0 right-0 p-4 opacity-0 group-hover/img:opacity-100 transition-opacity duration-300">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs text-zinc-400 line-clamp-2 max-w-[70%]">{promptText}</p>
|
||
<p className="text-[10px] text-zinc-600 font-mono mt-1">{sizeText}</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<a
|
||
href={imageUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
|
||
title="Open in new tab"
|
||
>
|
||
<Icons.ExternalLink className="w-4 h-4" />
|
||
</a>
|
||
<a
|
||
href={imageUrl}
|
||
download={`generated_${Date.now()}.png`}
|
||
className="p-2 bg-primary/20 hover:bg-primary/30 rounded-lg text-primary transition-colors"
|
||
title="Download image"
|
||
>
|
||
<Icons.Download className="w-4 h-4" />
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-xs">
|
||
Failed to load generated image.
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isChatMode && isSystem) {
|
||
return (
|
||
<div className="transition-all duration-300 p-2 text-zinc-100 leading-relaxed whitespace-pre-wrap">
|
||
{cleanText}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const handleSaveToMemory = async () => {
|
||
const projectId = state.activeProject?.id;
|
||
if (!projectId) {
|
||
alert("Please create or select a project first.");
|
||
return;
|
||
}
|
||
const memory = {
|
||
type: 'fact', // Default to fact, could make this selectable
|
||
key: `msg_${Date.now()}`,
|
||
value: cleanText.substring(0, 500), // Truncate for safety
|
||
confidence: 1.0,
|
||
source: 'manual',
|
||
sourceMessageId: Date.now().toString()
|
||
};
|
||
// @ts-ignore
|
||
await addMemory(projectId, memory);
|
||
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'system', message: "Saved to Project Memory." } });
|
||
};
|
||
|
||
return (
|
||
<div className="group/msg relative">
|
||
<div className="whitespace-pre-wrap">{cleanText}</div>
|
||
<div className="absolute top-0 right-0 opacity-0 group-hover/msg:opacity-100 transition-opacity translate-x-full pl-2">
|
||
<button
|
||
onClick={handleSaveToMemory}
|
||
className="p-1.5 bg-zinc-800 text-zinc-500 hover:text-purple-400 rounded-lg border border-white/5 hover:border-purple-500/30 transition-all shadow-lg"
|
||
title="Save to Memory"
|
||
>
|
||
<Icons.Sparkles className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- IT Expert Action Proposal Card ---
|
||
const ActionProposalCard = ({ proposal }: { proposal: import('../types').ActionProposal }) => {
|
||
const { state, dispatch } = useOrchestrator();
|
||
const [stdout, setStdout] = useState('');
|
||
const [stderr, setStderr] = useState('');
|
||
const [isExecuting, setIsExecuting] = useState(false);
|
||
const [editMode, setEditMode] = useState(false);
|
||
const [editedScript, setEditedScript] = useState(proposal.script);
|
||
const execSessionIdRef = React.useRef<string | null>(null);
|
||
|
||
const riskColors = {
|
||
low: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30',
|
||
medium: 'text-amber-400 bg-amber-500/10 border-amber-500/30',
|
||
high: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
|
||
};
|
||
|
||
const handleExecute = () => {
|
||
if (!state.executionSettings.localPowerShellEnabled) {
|
||
alert('PowerShell execution is disabled. Enable it in Settings.');
|
||
return;
|
||
}
|
||
|
||
const scriptToRun = editMode ? editedScript : proposal.script;
|
||
const execSessionId = Date.now().toString();
|
||
execSessionIdRef.current = execSessionId;
|
||
|
||
setIsExecuting(true);
|
||
setStdout('');
|
||
setStderr('');
|
||
dispatch({ type: 'START_EXECUTION', execSessionId });
|
||
dispatch({ type: 'APPROVE_PROPOSAL', proposalId: proposal.proposalId });
|
||
|
||
const electron = (window as any).electron;
|
||
electron.removeExecListeners();
|
||
|
||
electron.onExecChunk(({ execSessionId: id, stream, text }: any) => {
|
||
if (id !== execSessionIdRef.current) return;
|
||
if (stream === 'stdout') setStdout(prev => prev + text);
|
||
else setStderr(prev => prev + text);
|
||
});
|
||
|
||
electron.onExecComplete(({ execSessionId: id, exitCode, durationMs }: any) => {
|
||
if (id !== execSessionIdRef.current) return;
|
||
setIsExecuting(false);
|
||
dispatch({ type: 'COMPLETE_EXECUTION', exitCode });
|
||
dispatch({ type: 'UPDATE_EXECUTION_RESULT', result: { exitCode, stdout, stderr, durationMs } });
|
||
electron.removeExecListeners();
|
||
});
|
||
|
||
electron.onExecError(({ execSessionId: id, message }: any) => {
|
||
if (id !== execSessionIdRef.current) return;
|
||
setIsExecuting(false);
|
||
setStderr(prev => prev + '\n[ERROR] ' + message);
|
||
dispatch({ type: 'COMPLETE_EXECUTION', exitCode: 1 });
|
||
electron.removeExecListeners();
|
||
});
|
||
|
||
electron.onExecCancelled(({ execSessionId: id }: any) => {
|
||
if (id !== execSessionIdRef.current) return;
|
||
setIsExecuting(false);
|
||
dispatch({ type: 'CANCEL_EXECUTION' });
|
||
electron.removeExecListeners();
|
||
});
|
||
|
||
electron.runPowerShell(execSessionId, scriptToRun, state.executionSettings.localPowerShellEnabled);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
if (execSessionIdRef.current) {
|
||
(window as any).electron.cancelExecution(execSessionIdRef.current);
|
||
}
|
||
};
|
||
|
||
const handleReject = () => {
|
||
dispatch({ type: 'REJECT_PROPOSAL', proposalId: proposal.proposalId });
|
||
};
|
||
|
||
const handleCopy = () => {
|
||
navigator.clipboard.writeText(editMode ? editedScript : proposal.script);
|
||
};
|
||
|
||
if (proposal.status === 'rejected' || proposal.status === 'completed' || proposal.status === 'cancelled') {
|
||
return null; // Already resolved
|
||
}
|
||
|
||
return (
|
||
<div className="w-full bg-gradient-to-br from-cyan-500/10 to-blue-500/10 border border-cyan-500/30 rounded-xl p-4 animate-fade-in">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="p-2 bg-cyan-500/20 rounded-lg">
|
||
<Icons.Terminal className="w-5 h-5 text-cyan-400" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-sm font-bold text-white">{proposal.title}</h3>
|
||
<p className="text-xs text-zinc-400">{proposal.summary}</p>
|
||
</div>
|
||
</div>
|
||
<span className={`text-[10px] font-bold uppercase px-2 py-1 rounded-full border ${riskColors[proposal.risk]}`}>
|
||
{proposal.risk} risk
|
||
</span>
|
||
</div>
|
||
|
||
{/* Steps */}
|
||
<div className="mb-3 space-y-1">
|
||
{proposal.steps.map((step, i) => (
|
||
<div key={i} className="flex items-start gap-2 text-xs text-zinc-300">
|
||
<span className="text-cyan-400 font-mono">{i + 1}.</span>
|
||
<span>{step}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Script Preview/Editor */}
|
||
<div className="mb-3">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[10px] text-zinc-500 uppercase">PowerShell Script</span>
|
||
<button onClick={() => setEditMode(!editMode)} className="text-[10px] text-blue-400 hover:underline">
|
||
{editMode ? 'Preview' : 'Edit'}
|
||
</button>
|
||
</div>
|
||
{editMode ? (
|
||
<textarea
|
||
value={editedScript}
|
||
onChange={e => setEditedScript(e.target.value)}
|
||
className="w-full h-32 bg-black/50 border border-white/10 rounded-lg p-2 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500/50"
|
||
/>
|
||
) : (
|
||
<pre className="bg-black/50 border border-white/10 rounded-lg p-2 text-xs font-mono text-cyan-300 overflow-x-auto max-h-32">
|
||
{editedScript}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
|
||
{/* Output Streams */}
|
||
{(stdout || stderr || isExecuting) && (
|
||
<div className="mb-3 bg-black/70 border border-white/10 rounded-lg p-2 text-xs font-mono max-h-40 overflow-y-auto">
|
||
{isExecuting && <div className="text-amber-400 animate-pulse">Executing...</div>}
|
||
{stdout && <pre className="text-emerald-400 whitespace-pre-wrap">{stdout}</pre>}
|
||
{stderr && <pre className="text-rose-400 whitespace-pre-wrap">{stderr}</pre>}
|
||
</div>
|
||
)}
|
||
|
||
{/* Execution Gate Warning */}
|
||
{!state.executionSettings.localPowerShellEnabled && (
|
||
<div className="mb-3 p-2 bg-amber-500/10 border border-amber-500/30 rounded-lg text-xs text-amber-300">
|
||
⚠️ PowerShell execution is disabled. Enable it in Settings to use Execute.
|
||
</div>
|
||
)}
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex items-center gap-2">
|
||
{isExecuting ? (
|
||
<button onClick={handleCancel} className="flex-1 py-2 bg-rose-500/20 text-rose-400 text-xs font-bold rounded-lg hover:bg-rose-500/30 transition-colors">
|
||
Cancel
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={handleExecute}
|
||
disabled={!state.executionSettings.localPowerShellEnabled}
|
||
className="flex-1 py-2 bg-cyan-500/20 text-cyan-400 text-xs font-bold rounded-lg hover:bg-cyan-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
||
>
|
||
<Icons.Play className="w-3 h-3" /> Execute by AI
|
||
</button>
|
||
<button onClick={handleCopy} className="py-2 px-3 bg-zinc-500/20 text-zinc-400 text-xs font-bold rounded-lg hover:bg-zinc-500/30 transition-colors">
|
||
Copy
|
||
</button>
|
||
<button onClick={handleReject} className="py-2 px-3 bg-rose-500/10 text-rose-400 text-xs font-bold rounded-lg hover:bg-rose-500/20 transition-colors">
|
||
Dismiss
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --- Live Context Feed Panel ---
|
||
const CONSULTING_PERSONAS = ['therapist', 'business', 'assistant', 'custom'];
|
||
|
||
const ContextFeedCard = ({ item, isPinned, onPin, onUnpin }: {
|
||
item: import('../types').ContextFeedItem;
|
||
isPinned: boolean;
|
||
onPin: () => void;
|
||
onUnpin: () => void;
|
||
}) => {
|
||
const [expanded, setExpanded] = useState(false);
|
||
|
||
const typeIcons: Record<string, string> = {
|
||
article: '📄',
|
||
news: '📰',
|
||
image: '🖼️',
|
||
video: '🎬',
|
||
paper: '📚',
|
||
tool: '🛠️',
|
||
checklist: '✅'
|
||
};
|
||
|
||
return (
|
||
<div className={`p-3 rounded-xl border transition-all duration-300 ${isPinned ? 'bg-amber-500/10 border-amber-500/30' : 'bg-white/5 border-white/10 hover:border-white/20'}`}>
|
||
<div className="flex items-start gap-3">
|
||
<span className="text-lg">{typeIcons[item.type] || '📌'}</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<h4 className="text-sm font-semibold text-white truncate">{item.title}</h4>
|
||
<button
|
||
onClick={isPinned ? onUnpin : onPin}
|
||
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${isPinned ? 'bg-amber-500/20 text-amber-400' : 'bg-zinc-700 text-zinc-400 hover:text-white'}`}
|
||
>
|
||
{isPinned ? '📌' : 'Pin'}
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-zinc-400 mt-1 line-clamp-2">{item.summary}</p>
|
||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||
<span className="text-[9px] px-1.5 py-0.5 bg-primary/10 text-primary rounded">{item.source}</span>
|
||
{item.tags?.slice(0, 3).map(tag => (
|
||
<span key={tag} className="text-[9px] px-1.5 py-0.5 bg-zinc-800 text-zinc-500 rounded">{tag}</span>
|
||
))}
|
||
</div>
|
||
{item.whyShown && (
|
||
<p className="text-[10px] text-zinc-600 mt-2 italic">💡 {item.whyShown}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ContextFeedPanel = () => {
|
||
const { state, dispatch } = useOrchestrator();
|
||
const lastUpdateRef = React.useRef<number>(0);
|
||
const sessionIdRef = React.useRef<string>(Date.now().toString());
|
||
|
||
const isEligible = state.globalMode === GlobalMode.Chat && CONSULTING_PERSONAS.includes(state.chatPersona);
|
||
const contextFeed = state.contextFeed || { enabled: true, items: [], pinnedItemIds: [], activeTopic: '', lastUpdatedAt: null, isLoading: false };
|
||
|
||
// Manual refresh function (no auto-trigger to avoid interference with main chat)
|
||
const handleRefreshFeed = useCallback(() => {
|
||
if (!isEligible) return;
|
||
|
||
const recentMessages = state.timeline.filter(t => t.type === 'user' || t.type === 'system').slice(-6);
|
||
if (recentMessages.length < 2) return;
|
||
|
||
dispatch({ type: 'SET_CONTEXT_FEED_LOADING', isLoading: true });
|
||
|
||
const electron = (window as any).electron;
|
||
if (!electron) {
|
||
dispatch({ type: 'SET_CONTEXT_FEED_LOADING', isLoading: false });
|
||
return;
|
||
}
|
||
|
||
// Use a timeout to not interfere with any active chat
|
||
setTimeout(() => {
|
||
electron.removeChatListeners();
|
||
|
||
const sysPrompt = `You are a context feed curator. Based on the conversation, suggest helpful resources.
|
||
Output ONLY valid JSON. No markdown, no explanation.
|
||
Schema:
|
||
{
|
||
"activeTopic": "2-5 word topic summary",
|
||
"items": [
|
||
{
|
||
"id": "unique-id",
|
||
"type": "article|news|tool|checklist|paper",
|
||
"title": "Resource title",
|
||
"summary": "1-2 sentence description",
|
||
"source": "Website or author name",
|
||
"url": "#suggested",
|
||
"relevance": 0.9,
|
||
"whyShown": "Why this is relevant",
|
||
"tags": ["tag1", "tag2"],
|
||
"timestamp": "${new Date().toISOString()}"
|
||
}
|
||
]
|
||
}
|
||
|
||
Suggest 3-5 resources that would genuinely help someone exploring this topic.`;
|
||
|
||
const history = recentMessages.map(m => `${m.type === 'user' ? 'User' : 'Assistant'}: ${m.message.substring(0, 300)}`).join('\n');
|
||
const userPrompt = `Conversation:\n${history}\n\nGenerate context feed items for this conversation.`;
|
||
|
||
let buffer = '';
|
||
electron.onChatChunk((c: string) => { buffer += c; });
|
||
|
||
electron.onChatComplete((response: string) => {
|
||
electron.removeChatListeners();
|
||
|
||
let final = (response || buffer).trim();
|
||
final = final.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||
const first = final.indexOf('{');
|
||
const last = final.lastIndexOf('}');
|
||
if (first !== -1 && last > first) {
|
||
final = final.substring(first, last + 1);
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(final);
|
||
if (parsed.activeTopic) {
|
||
dispatch({ type: 'SET_CONTEXT_FEED_TOPIC', topic: parsed.activeTopic });
|
||
}
|
||
if (parsed.items && Array.isArray(parsed.items)) {
|
||
dispatch({ type: 'UPSERT_CONTEXT_FEED_ITEMS', items: parsed.items });
|
||
}
|
||
} catch (e) {
|
||
console.error('[ContextFeed] Parse error:', e);
|
||
dispatch({ type: 'SET_CONTEXT_FEED_LOADING', isLoading: false });
|
||
}
|
||
});
|
||
|
||
electron.onChatError(() => {
|
||
dispatch({ type: 'SET_CONTEXT_FEED_LOADING', isLoading: false });
|
||
});
|
||
|
||
electron.startChat([{ role: 'system', content: sysPrompt }, { role: 'user', content: userPrompt }], 'qwen-coder-plus');
|
||
}, 500); // Small delay to ensure main chat has finished
|
||
}, [isEligible, state.timeline, dispatch]);
|
||
|
||
if (!isEligible) {
|
||
return null;
|
||
}
|
||
|
||
const pinnedItems = contextFeed.items.filter(i => contextFeed.pinnedItemIds.includes(i.id));
|
||
const unpinnedItems = contextFeed.items.filter(i => !contextFeed.pinnedItemIds.includes(i.id));
|
||
|
||
return (
|
||
<div className="h-full flex flex-col bg-gradient-to-b from-zinc-900 to-black">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-lg">🔮</span>
|
||
<div>
|
||
<h2 className="text-sm font-bold text-white">Live Context</h2>
|
||
{contextFeed.activeTopic && (
|
||
<span className="text-[10px] px-2 py-0.5 bg-primary/10 text-primary rounded-full">{contextFeed.activeTopic}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={handleRefreshFeed}
|
||
disabled={contextFeed.isLoading}
|
||
className="text-[10px] text-primary hover:text-emerald-300 transition-colors disabled:opacity-50"
|
||
>
|
||
{contextFeed.isLoading ? '...' : '↻ Refresh'}
|
||
</button>
|
||
<button
|
||
onClick={() => dispatch({ type: 'CLEAR_CONTEXT_FEED' })}
|
||
className="text-[10px] text-zinc-500 hover:text-white transition-colors"
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
|
||
{!contextFeed.enabled && (
|
||
<div className="text-center text-zinc-500 text-sm py-8">
|
||
<p>Context feed is disabled.</p>
|
||
<button onClick={() => dispatch({ type: 'SET_CONTEXT_FEED_ENABLED', enabled: true })} className="text-primary hover:underline mt-2">
|
||
Enable
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{contextFeed.enabled && contextFeed.isLoading && (
|
||
<div className="text-center py-8">
|
||
<div className="text-2xl animate-pulse">🔮</div>
|
||
<p className="text-xs text-zinc-500 mt-2">Curating context...</p>
|
||
</div>
|
||
)}
|
||
|
||
{contextFeed.enabled && !contextFeed.isLoading && contextFeed.items.length === 0 && (
|
||
<div className="text-center text-zinc-500 text-sm py-8">
|
||
<p>Start a conversation to see relevant resources here.</p>
|
||
</div>
|
||
)}
|
||
|
||
{pinnedItems.length > 0 && (
|
||
<div className="space-y-2">
|
||
<p className="text-[10px] text-amber-400 uppercase font-bold">Pinned</p>
|
||
{pinnedItems.map(item => (
|
||
<ContextFeedCard
|
||
key={item.id}
|
||
item={item}
|
||
isPinned={true}
|
||
onPin={() => { }}
|
||
onUnpin={() => dispatch({ type: 'UNPIN_CONTEXT_FEED_ITEM', itemId: item.id })}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{unpinnedItems.length > 0 && (
|
||
<div className="space-y-2">
|
||
{pinnedItems.length > 0 && <p className="text-[10px] text-zinc-500 uppercase font-bold mt-4">Suggested</p>}
|
||
{unpinnedItems.map(item => (
|
||
<ContextFeedCard
|
||
key={item.id}
|
||
item={item}
|
||
isPinned={false}
|
||
onPin={() => dispatch({ type: 'PIN_CONTEXT_FEED_ITEM', itemId: item.id })}
|
||
onUnpin={() => { }}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export { ContextFeedPanel };
|