feat: add chat components from jarvis frontend
- Migrated Chat.tsx with message handling and UI structure - Migrated ChatInput.tsx with character limits and keyboard shortcuts - Migrated MessageList.tsx with thinking/reasoning display - Migrated ConversationSidebar.tsx (simplified placeholder) - Migrated BackendStatusBanner.tsx (simplified placeholder) - Created components/chat/index.ts barrel export - Created app/chat/page.tsx placeholder route These components are adapted from jarvis-fe but not yet fully functional: - API calls placeholder (need to wire up /api/brain/query) - Auth hooks stubbed (need useAuth implementation) - Project/conversation hooks stubbed (need implementation) - Imports changed from @jarvis/* to @mosaic/* Next steps: - Implement missing hooks (useAuth, useProjects, useConversations, useApi) - Wire up backend API endpoints - Add proper TypeScript types - Implement full conversation management
This commit is contained in:
118
MINDMAP_MIGRATION.md
Normal file
118
MINDMAP_MIGRATION.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Mindmap Components Migration - Phase 3
|
||||||
|
|
||||||
|
**Status:** ✅ Complete (with notes)
|
||||||
|
**Commit:** `aa267b5` - "feat: add mindmap components from jarvis frontend"
|
||||||
|
**Branch:** `feature/jarvis-fe-migration`
|
||||||
|
|
||||||
|
## Completed Tasks
|
||||||
|
|
||||||
|
### 1. ✅ Directory Structure Created
|
||||||
|
```
|
||||||
|
apps/web/src/components/mindmap/
|
||||||
|
├── controls/
|
||||||
|
│ ├── ExportButton.tsx
|
||||||
|
│ └── NodeCreateModal.tsx
|
||||||
|
├── hooks/
|
||||||
|
│ └── useGraphData.ts
|
||||||
|
├── nodes/
|
||||||
|
│ ├── BaseNode.tsx
|
||||||
|
│ ├── ConceptNode.tsx
|
||||||
|
│ ├── IdeaNode.tsx
|
||||||
|
│ ├── ProjectNode.tsx
|
||||||
|
│ └── TaskNode.tsx
|
||||||
|
├── index.ts
|
||||||
|
├── MermaidViewer.tsx
|
||||||
|
├── MindmapViewer.tsx
|
||||||
|
└── ReactFlowEditor.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ✅ Components Copied
|
||||||
|
All mindmap components have been successfully migrated:
|
||||||
|
- **Main viewers:** ReactFlowEditor, MindmapViewer, MermaidViewer
|
||||||
|
- **Node types:** BaseNode, ConceptNode, TaskNode, IdeaNode, ProjectNode
|
||||||
|
- **Controls:** NodeCreateModal, ExportButton
|
||||||
|
- **Hooks:** useGraphData (with KnowledgeNode, KnowledgeEdge types)
|
||||||
|
|
||||||
|
### 3. ✅ Barrel Export Created
|
||||||
|
`components/mindmap/index.ts` exports all components and types for clean imports
|
||||||
|
|
||||||
|
### 4. ✅ Route Created
|
||||||
|
- Created `/mindmap` page at `apps/web/src/app/mindmap/page.tsx`
|
||||||
|
- Includes proper metadata and layout
|
||||||
|
|
||||||
|
### 5. ✅ Dependencies Added
|
||||||
|
- Copied `lib/auth-client.ts` (BetterAuth integration)
|
||||||
|
- Created `lib/api.ts` (session management utilities)
|
||||||
|
|
||||||
|
## Import Updates
|
||||||
|
|
||||||
|
No `@jarvis/*` imports were present in the mindmap components - they were already using relative paths and `@/lib/*` aliases, which are compatible with the Mosaic structure.
|
||||||
|
|
||||||
|
## Type Adaptations
|
||||||
|
|
||||||
|
The mindmap uses its own `KnowledgeNode` and `KnowledgeEdge` types, which are specific to the knowledge graph feature and not part of the general Mosaic entity types (Task, Project, etc. from `@mosaic/shared`). This is correct as the mindmap represents a different data model.
|
||||||
|
|
||||||
|
## Known Issues & Next Steps
|
||||||
|
|
||||||
|
### Missing Package Dependencies
|
||||||
|
The build currently fails due to missing packages required by `auth-client.ts`:
|
||||||
|
```
|
||||||
|
better-auth
|
||||||
|
better-auth/react
|
||||||
|
better-auth-credentials-plugin
|
||||||
|
better-auth-credentials-plugin/client
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resolution:** These packages need to be added to the workspace:
|
||||||
|
```bash
|
||||||
|
pnpm add better-auth better-auth-credentials-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
### ReactFlow Dependencies
|
||||||
|
Verify that `@xyflow/react` is installed:
|
||||||
|
```bash
|
||||||
|
pnpm add @xyflow/react
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mermaid Dependency
|
||||||
|
Verify that `mermaid` is installed:
|
||||||
|
```bash
|
||||||
|
pnpm add mermaid
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
Once dependencies are installed:
|
||||||
|
- [ ] Build completes without errors
|
||||||
|
- [ ] Navigate to `/mindmap` route
|
||||||
|
- [ ] Create a knowledge node
|
||||||
|
- [ ] Verify ReactFlow interactive editor renders
|
||||||
|
- [ ] Test Mermaid diagram view
|
||||||
|
- [ ] Test export functionality
|
||||||
|
- [ ] Verify node type rendering (Concept, Task, Idea, Project)
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
The mindmap components expect a backend knowledge graph API at:
|
||||||
|
- Base URL: `process.env.NEXT_PUBLIC_API_URL` (default: http://localhost:8000)
|
||||||
|
- Endpoints:
|
||||||
|
- `GET /api/v1/knowledge/graph` - Fetch graph data
|
||||||
|
- `GET /api/v1/knowledge/mermaid` - Fetch Mermaid diagram
|
||||||
|
- `POST /api/v1/knowledge/nodes` - Create node
|
||||||
|
- `PUT /api/v1/knowledge/nodes/:id` - Update node
|
||||||
|
- `DELETE /api/v1/knowledge/nodes/:id` - Delete node
|
||||||
|
- `POST /api/v1/knowledge/edges` - Create edge
|
||||||
|
- `DELETE /api/v1/knowledge/edges` - Delete edge
|
||||||
|
- `GET /api/v1/knowledge/graph/statistics` - Get statistics
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
- 15 files added
|
||||||
|
- 1,758 insertions
|
||||||
|
- No deletions
|
||||||
|
|
||||||
|
## Git Info
|
||||||
|
```
|
||||||
|
Branch: feature/jarvis-fe-migration
|
||||||
|
Commit: aa267b5
|
||||||
|
Pushed: Yes
|
||||||
|
```
|
||||||
99
apps/web/src/app/chat/page.tsx
Normal file
99
apps/web/src/app/chat/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Chat, type ChatRef, ConversationSidebar, type ConversationSidebarRef } from "@/components/chat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat Page
|
||||||
|
*
|
||||||
|
* Placeholder route for the chat interface migrated from jarvis-fe.
|
||||||
|
*
|
||||||
|
* TODO:
|
||||||
|
* - Integrate with authentication
|
||||||
|
* - Connect to brain API endpoints (/api/brain/query)
|
||||||
|
* - Implement conversation persistence
|
||||||
|
* - Add project/workspace integration
|
||||||
|
* - Wire up actual hooks (useAuth, useProjects, useConversations, useApi)
|
||||||
|
*/
|
||||||
|
export default function ChatPage() {
|
||||||
|
const chatRef = useRef<ChatRef>(null);
|
||||||
|
const sidebarRef = useRef<ConversationSidebarRef>(null);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleConversationChange = (conversationId: string | null) => {
|
||||||
|
setCurrentConversationId(conversationId);
|
||||||
|
// TODO: Update sidebar when conversation changes
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectConversation = (conversationId: string | null) => {
|
||||||
|
// TODO: Load conversation from backend
|
||||||
|
console.log("Select conversation:", conversationId);
|
||||||
|
setCurrentConversationId(conversationId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewConversation = (projectId?: string | null) => {
|
||||||
|
chatRef.current?.startNewConversation(projectId);
|
||||||
|
setCurrentConversationId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "rgb(var(--color-background))" }}>
|
||||||
|
{/* Conversation Sidebar */}
|
||||||
|
<ConversationSidebar
|
||||||
|
ref={sidebarRef}
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
currentConversationId={currentConversationId}
|
||||||
|
onSelectConversation={handleSelectConversation}
|
||||||
|
onNewConversation={handleNewConversation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Chat Area */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<header
|
||||||
|
className="border-b px-4 py-3 flex items-center gap-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Toggle Sidebar Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
className="p-2 rounded-lg transition-colors hover:bg-[rgb(var(--surface-1))]"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
|
title="Toggle conversation history"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-lg font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
|
||||||
|
AI Chat
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs" style={{ color: "rgb(var(--text-muted))" }}>
|
||||||
|
Migrated from Jarvis - Connect to brain API for full functionality
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Chat Component */}
|
||||||
|
<Chat
|
||||||
|
ref={chatRef}
|
||||||
|
onConversationChange={handleConversationChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/web/src/components/chat/BackendStatusBanner.tsx
Normal file
98
apps/web/src/components/chat/BackendStatusBanner.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner that displays when the backend is unavailable.
|
||||||
|
* Shows error message, countdown to next retry, and manual retry button.
|
||||||
|
*
|
||||||
|
* TODO: Integrate with actual backend status checking hook
|
||||||
|
*/
|
||||||
|
export function BackendStatusBanner() {
|
||||||
|
const [isAvailable, setIsAvailable] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [retryIn, setRetryIn] = useState(0);
|
||||||
|
|
||||||
|
// TODO: Replace with actual useBackendStatus hook
|
||||||
|
// const { isAvailable, error, retryIn, manualRetry } = useBackendStatus();
|
||||||
|
|
||||||
|
const manualRetry = () => {
|
||||||
|
// TODO: Implement manual retry logic
|
||||||
|
console.log("Manual retry triggered");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
try {
|
||||||
|
// TODO: Implement signOut
|
||||||
|
// await signOut();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Sign-out failed during backend unavailability:", error);
|
||||||
|
}
|
||||||
|
window.location.href = "/login";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't render if backend is available
|
||||||
|
if (isAvailable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-4 px-4 py-2 text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#fef3c7", // amber-100
|
||||||
|
borderBottom: "1px solid #fcd34d", // amber-300
|
||||||
|
color: "#92400e", // amber-800
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5 flex-shrink-0"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{error || "Backend temporarily unavailable."}
|
||||||
|
{retryIn > 0 && (
|
||||||
|
<span className="ml-1">
|
||||||
|
Retrying in {retryIn}s...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={manualRetry}
|
||||||
|
className="rounded px-3 py-1 text-xs font-medium transition-colors hover:opacity-80"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#fcd34d", // amber-300
|
||||||
|
color: "#92400e", // amber-800
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry Now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="rounded px-3 py-1 text-xs font-medium transition-colors hover:opacity-80"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: "#92400e", // amber-800
|
||||||
|
border: "1px solid #fcd34d", // amber-300
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign in again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
378
apps/web/src/components/chat/Chat.tsx
Normal file
378
apps/web/src/components/chat/Chat.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, useMemo, forwardRef, useImperativeHandle } from "react";
|
||||||
|
// TODO: These hooks will need to be created or adapted
|
||||||
|
// import { useAuth } from "@/lib/hooks/useAuth";
|
||||||
|
// import { useProjects } from "@/lib/hooks/useProjects";
|
||||||
|
// import { useConversations } from "@/lib/hooks/useConversations";
|
||||||
|
// import { useApi } from "@/lib/hooks/useApi";
|
||||||
|
import { MessageList } from "./MessageList";
|
||||||
|
import { ChatInput } from "./ChatInput";
|
||||||
|
// TODO: Import types need to be created
|
||||||
|
// import type { ConversationDetail } from "@/lib/hooks/useConversations";
|
||||||
|
// import { handleSessionExpired, isSessionExpiring } from "@/lib/api";
|
||||||
|
// import type { LLMModel, DefaultModel } from "@/lib/api";
|
||||||
|
|
||||||
|
// Placeholder types until the actual types are created
|
||||||
|
type ConversationDetail = any;
|
||||||
|
type LLMModel = any;
|
||||||
|
type DefaultModel = any;
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string;
|
||||||
|
thinking?: string; // Chain of thought reasoning from thinking models
|
||||||
|
createdAt: string;
|
||||||
|
model?: string; // LLM model used for this response
|
||||||
|
provider?: string; // LLM provider (ollama, claude, etc.)
|
||||||
|
// Token usage info
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
// Friendly waiting messages (shown after a few seconds of loading)
|
||||||
|
const WAITING_QUIPS = [
|
||||||
|
"The AI is warming up... give it a moment.",
|
||||||
|
"Loading the neural pathways...",
|
||||||
|
"Waking up the LLM. It's not a morning model.",
|
||||||
|
"Brewing some thoughts...",
|
||||||
|
"The AI is stretching its parameters...",
|
||||||
|
"Summoning intelligence from the void...",
|
||||||
|
"Teaching electrons to think...",
|
||||||
|
"Consulting the silicon oracle...",
|
||||||
|
"The hamsters are spinning up the GPU...",
|
||||||
|
"Defragmenting the neural networks...",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Error messages for actual timeouts
|
||||||
|
const TIMEOUT_QUIPS = [
|
||||||
|
"The AI got lost in thought. Literally. Try again?",
|
||||||
|
"That took too long, even by AI standards. Give it another go?",
|
||||||
|
"The model wandered off. Let's try to find it again.",
|
||||||
|
"Response timed out. The AI may have fallen asleep. Retry?",
|
||||||
|
"The LLM took an unexpected vacation. One more attempt?",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Error messages for connection failures
|
||||||
|
const CONNECTION_QUIPS = [
|
||||||
|
"I seem to have misplaced the server. Check your connection?",
|
||||||
|
"The server and I are having communication issues. It's not you, it's us.",
|
||||||
|
"Connection lost. Either the internet is down, or the server is playing hide and seek.",
|
||||||
|
"Unable to reach the mothership. The tubes appear to be clogged.",
|
||||||
|
"The server isn't responding. Perhaps it's giving us the silent treatment.",
|
||||||
|
];
|
||||||
|
|
||||||
|
const getRandomQuip = (quips: string[]) => quips[Math.floor(Math.random() * quips.length)];
|
||||||
|
|
||||||
|
const WELCOME_MESSAGE: Message = {
|
||||||
|
id: "welcome",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Hello. I'm your AI assistant. How can I help you today?",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChatRef {
|
||||||
|
loadConversation: (conversation: ConversationDetail) => void;
|
||||||
|
startNewConversation: (projectId?: string | null) => void;
|
||||||
|
getCurrentConversationId: () => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewConversationData {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
project_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatProps {
|
||||||
|
onConversationChange?: (conversationId: string | null, conversationData?: NewConversationData) => void;
|
||||||
|
onProjectChange?: () => void;
|
||||||
|
initialProjectId?: string | null;
|
||||||
|
onInitialProjectHandled?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Chat = forwardRef<ChatRef, ChatProps>(function Chat({
|
||||||
|
onConversationChange,
|
||||||
|
onProjectChange: _onProjectChange,
|
||||||
|
initialProjectId,
|
||||||
|
onInitialProjectHandled,
|
||||||
|
}, ref) {
|
||||||
|
void _onProjectChange; // Kept for potential future use
|
||||||
|
|
||||||
|
// TODO: Replace with actual hooks once they're created
|
||||||
|
const accessToken = null;
|
||||||
|
const isLoading = false;
|
||||||
|
const authLoading = false;
|
||||||
|
const authError = null;
|
||||||
|
const projects: any[] = [];
|
||||||
|
// const { accessToken, isLoading: authLoading, error: authError } = useAuth();
|
||||||
|
// const { projects } = useProjects();
|
||||||
|
// const { updateConversationProject } = useConversations();
|
||||||
|
// const api = useApi();
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]);
|
||||||
|
const [isChatLoading, setIsChatLoading] = useState(false);
|
||||||
|
const [loadingQuip, setLoadingQuip] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||||
|
const [conversationTitle, setConversationTitle] = useState<string | null>(null);
|
||||||
|
const [conversationProjectId, setConversationProjectId] = useState<string | null>(null);
|
||||||
|
const [pendingProjectId, setPendingProjectId] = useState<string | null>(null);
|
||||||
|
const [showProjectMenu, setShowProjectMenu] = useState(false);
|
||||||
|
const [showModelMenu, setShowModelMenu] = useState(false);
|
||||||
|
const [showFooterProjectMenu, setShowFooterProjectMenu] = useState(false);
|
||||||
|
const [showFooterModelMenu, setShowFooterModelMenu] = useState(false);
|
||||||
|
const [isMovingProject, setIsMovingProject] = useState(false);
|
||||||
|
const [availableModels, setAvailableModels] = useState<LLMModel[]>([]);
|
||||||
|
const [defaultModel, setDefaultModel] = useState<DefaultModel | null>(null);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<LLMModel | null>(null);
|
||||||
|
const [modelLoadError, setModelLoadError] = useState<string | null>(null);
|
||||||
|
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||||
|
const [useReasoning, setUseReasoning] = useState(false); // Toggle for reasoning/thinking mode
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const projectMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const modelMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const footerProjectMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const footerModelMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Track conversation ID in ref to prevent stale closure issues
|
||||||
|
const conversationIdRef = useRef<string | null>(conversationId);
|
||||||
|
|
||||||
|
// Expose methods to parent via ref
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
loadConversation: (conversation: ConversationDetail) => {
|
||||||
|
// TODO: Implement once ConversationDetail type is available
|
||||||
|
console.log("loadConversation called with:", conversation);
|
||||||
|
},
|
||||||
|
startNewConversation: (projectId?: string | null) => {
|
||||||
|
setConversationId(null);
|
||||||
|
setConversationTitle(null);
|
||||||
|
setConversationProjectId(null);
|
||||||
|
setMessages([WELCOME_MESSAGE]);
|
||||||
|
setError(null);
|
||||||
|
setPendingProjectId(projectId || null);
|
||||||
|
setShowProjectMenu(false);
|
||||||
|
onConversationChange?.(null);
|
||||||
|
},
|
||||||
|
getCurrentConversationId: () => conversationId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages, scrollToBottom]);
|
||||||
|
|
||||||
|
// Keep conversationIdRef in sync with state to prevent stale closures
|
||||||
|
useEffect(() => {
|
||||||
|
conversationIdRef.current = conversationId;
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
// Handle auth errors
|
||||||
|
useEffect(() => {
|
||||||
|
if (authError === "RefreshAccessTokenError") {
|
||||||
|
setError("Your session has expired. Please sign in again.");
|
||||||
|
}
|
||||||
|
}, [authError]);
|
||||||
|
|
||||||
|
// Global keyboard shortcut: Ctrl+/ to focus input
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// TODO: Implement click outside handlers for menus
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
async (content: string) => {
|
||||||
|
if (!content.trim() || isChatLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user message immediately
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
role: "user",
|
||||||
|
content: content.trim(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
setIsChatLoading(true);
|
||||||
|
setLoadingQuip(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Show a witty loading message after 3 seconds
|
||||||
|
const quipTimerId = setTimeout(() => {
|
||||||
|
setLoadingQuip(getRandomQuip(WAITING_QUIPS));
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// Change quip every 5 seconds if still waiting
|
||||||
|
const quipIntervalId = setInterval(() => {
|
||||||
|
setLoadingQuip(getRandomQuip(WAITING_QUIPS));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implement actual API call to /api/brain/query
|
||||||
|
const requestBody: {
|
||||||
|
message: string;
|
||||||
|
conversation_id: string | null;
|
||||||
|
project_id?: string;
|
||||||
|
provider_instance_id?: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
use_reasoning?: boolean;
|
||||||
|
} = {
|
||||||
|
message: content.trim(),
|
||||||
|
conversation_id: conversationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Placeholder response for now
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: `assistant-${Date.now()}`,
|
||||||
|
role: "assistant",
|
||||||
|
content: "This is a placeholder response. The chat API integration is not yet complete.",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, assistantMessage]);
|
||||||
|
|
||||||
|
// Clear quip timers on success
|
||||||
|
clearTimeout(quipTimerId);
|
||||||
|
clearInterval(quipIntervalId);
|
||||||
|
setLoadingQuip(null);
|
||||||
|
} catch (err) {
|
||||||
|
// Clear quip timers on error
|
||||||
|
clearTimeout(quipTimerId);
|
||||||
|
clearInterval(quipIntervalId);
|
||||||
|
setLoadingQuip(null);
|
||||||
|
|
||||||
|
console.error("Failed to send message:", err);
|
||||||
|
const errorMsg = err instanceof Error ? err.message : "Failed to send message";
|
||||||
|
setError(errorMsg);
|
||||||
|
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: `error-${Date.now()}`,
|
||||||
|
role: "assistant",
|
||||||
|
content: errorMsg,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, errorMessage]);
|
||||||
|
} finally {
|
||||||
|
setIsChatLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[conversationId, isChatLoading]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismissError = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show loading state while auth is loading
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center" style={{ backgroundColor: "rgb(var(--color-background))" }}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-t-transparent" style={{ borderColor: "rgb(var(--accent-primary))", borderTopColor: "transparent" }} />
|
||||||
|
<span style={{ color: "rgb(var(--text-secondary))" }}>Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col" style={{ backgroundColor: "rgb(var(--color-background))" }}>
|
||||||
|
{/* Messages Area */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
||||||
|
<MessageList messages={messages} isLoading={isChatLoading} loadingQuip={loadingQuip} />
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-4 mb-2 lg:mx-auto lg:max-w-4xl lg:px-8">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between rounded-lg border px-4 py-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--semantic-error-light))",
|
||||||
|
borderColor: "rgb(var(--semantic-error) / 0.3)",
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 flex-shrink-0"
|
||||||
|
style={{ color: "rgb(var(--semantic-error))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: "rgb(var(--semantic-error-dark))" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={dismissError}
|
||||||
|
className="rounded p-1 transition-colors hover:bg-black/5"
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
style={{ color: "rgb(var(--semantic-error))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<div
|
||||||
|
className="sticky bottom-0 border-t"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
||||||
|
<ChatInput
|
||||||
|
onSend={sendMessage}
|
||||||
|
disabled={isChatLoading || !accessToken}
|
||||||
|
inputRef={inputRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
189
apps/web/src/components/chat/ChatInput.tsx
Normal file
189
apps/web/src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState, useEffect, KeyboardEvent, RefObject } from "react";
|
||||||
|
|
||||||
|
interface ChatInputProps {
|
||||||
|
onSend: (message: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInput({ onSend, disabled, inputRef }: ChatInputProps) {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch version from static version.json (generated at build time)
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/version.json")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.version) {
|
||||||
|
// Format as "version+commit" for full build identification
|
||||||
|
const fullVersion = data.commit
|
||||||
|
? `${data.version}+${data.commit}`
|
||||||
|
: data.version;
|
||||||
|
setVersion(fullVersion);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Silently fail - version display is non-critical
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (message.trim() && !disabled) {
|
||||||
|
onSend(message);
|
||||||
|
setMessage("");
|
||||||
|
}
|
||||||
|
}, [message, onSend, disabled]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Enter to send (without Shift)
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
// Ctrl/Cmd + Enter to send (alternative)
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSubmit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const characterCount = message.length;
|
||||||
|
const maxCharacters = 4000;
|
||||||
|
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||||
|
const isOverLimit = characterCount > maxCharacters;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Input Container */}
|
||||||
|
<div
|
||||||
|
className="relative rounded-lg border transition-all duration-150"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: disabled
|
||||||
|
? "rgb(var(--border-default))"
|
||||||
|
: "rgb(var(--border-strong))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
disabled={disabled}
|
||||||
|
rows={1}
|
||||||
|
className="block w-full resize-none bg-transparent px-4 py-3 pr-24 text-sm outline-none placeholder:text-[rgb(var(--text-muted))] disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
color: "rgb(var(--text-primary))",
|
||||||
|
minHeight: "48px",
|
||||||
|
maxHeight: "200px",
|
||||||
|
}}
|
||||||
|
onInput={(e) => {
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
target.style.height = "auto";
|
||||||
|
target.style.height = Math.min(target.scrollHeight, 200) + "px";
|
||||||
|
}}
|
||||||
|
aria-label="Message input"
|
||||||
|
aria-describedby="input-help"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Send Button */}
|
||||||
|
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={disabled || !message.trim() || isOverLimit}
|
||||||
|
className="btn-primary btn-sm rounded-md"
|
||||||
|
style={{
|
||||||
|
opacity: disabled || !message.trim() || isOverLimit ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Send</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: Keyboard shortcuts + character count */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between text-xs"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
id="input-help"
|
||||||
|
>
|
||||||
|
{/* Keyboard Shortcuts */}
|
||||||
|
<div className="hidden items-center gap-4 sm:flex">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="kbd">Enter</span>
|
||||||
|
<span>to send</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="kbd">Shift</span>
|
||||||
|
<span>+</span>
|
||||||
|
<span className="kbd">Enter</span>
|
||||||
|
<span>for new line</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile hint */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
Tap send or press Enter
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Character Count */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
color: isOverLimit
|
||||||
|
? "rgb(var(--semantic-error))"
|
||||||
|
: isNearLimit
|
||||||
|
? "rgb(var(--semantic-warning))"
|
||||||
|
: "rgb(var(--text-muted))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{characterCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{characterCount.toLocaleString()}/{maxCharacters.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{isOverLimit && (
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Disclaimer & Version */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center gap-2 text-xs"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
>
|
||||||
|
<span>AI may produce inaccurate information. Verify important details.</span>
|
||||||
|
{version && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>v{version}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
245
apps/web/src/components/chat/ConversationSidebar.tsx
Normal file
245
apps/web/src/components/chat/ConversationSidebar.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, forwardRef, useImperativeHandle } from "react";
|
||||||
|
// import Link from "next/link";
|
||||||
|
// TODO: Import hooks when they're created
|
||||||
|
// import { useConversations, ConversationSummary } from "@/lib/hooks/useConversations";
|
||||||
|
// import { useProjects } from "@/lib/hooks/useProjects";
|
||||||
|
// import type { IsolationMode } from "@/lib/api";
|
||||||
|
|
||||||
|
// Placeholder types
|
||||||
|
type ConversationSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
project_id: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
message_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ConversationSidebarRef {
|
||||||
|
refresh: () => void;
|
||||||
|
addConversation: (conversation: ConversationSummary) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationSidebarProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentConversationId: string | null;
|
||||||
|
onSelectConversation: (conversationId: string | null) => void;
|
||||||
|
onNewConversation: (projectId?: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConversationSidebar = forwardRef<ConversationSidebarRef, ConversationSidebarProps>(function ConversationSidebar({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentConversationId,
|
||||||
|
onSelectConversation,
|
||||||
|
onNewConversation,
|
||||||
|
}, ref) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
// Placeholder data
|
||||||
|
const conversations: ConversationSummary[] = [];
|
||||||
|
const projects: any[] = [];
|
||||||
|
|
||||||
|
// Expose methods to parent via ref
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
refresh: () => {
|
||||||
|
// TODO: Implement refresh logic
|
||||||
|
console.log("Refresh called");
|
||||||
|
},
|
||||||
|
addConversation: (conversation: ConversationSummary) => {
|
||||||
|
// TODO: Implement addConversation logic
|
||||||
|
console.log("Add conversation called:", conversation);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filteredConversations = conversations.filter((conv) => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
const title = conv.title || "Untitled conversation";
|
||||||
|
return title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatRelativeTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return "Just now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncateTitle = (title: string | null, maxLength = 32) => {
|
||||||
|
const displayTitle = title || "Untitled conversation";
|
||||||
|
if (displayTitle.length <= maxLength) return displayTitle;
|
||||||
|
return displayTitle.substring(0, maxLength - 1) + "…";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 md:hidden animate-fade-in"
|
||||||
|
style={{ backgroundColor: "rgb(0 0 0 / 0.6)" }}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
fixed left-0 top-0 z-50 h-screen transform border-r transition-all duration-200 ease-out flex flex-col
|
||||||
|
md:sticky md:top-0 md:z-auto md:h-screen md:transform-none md:transition-[width]
|
||||||
|
${isOpen ? "translate-x-0 w-72" : "-translate-x-full md:translate-x-0 md:w-16"}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
aria-label="Conversation history"
|
||||||
|
>
|
||||||
|
{/* Collapsed view - TODO: Implement */}
|
||||||
|
{!isOpen && (
|
||||||
|
<div className="hidden md:flex flex-col items-center py-3 h-full">
|
||||||
|
<button
|
||||||
|
onClick={() => onNewConversation()}
|
||||||
|
className="p-3 rounded-lg transition-colors"
|
||||||
|
title="New Conversation"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Full sidebar content */}
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between border-b px-4 py-3"
|
||||||
|
style={{ borderColor: "rgb(var(--border-default))" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
|
||||||
|
Conversations
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={onClose} className="btn-ghost rounded-md p-1.5" aria-label="Close sidebar">
|
||||||
|
<svg className="h-5 w-5" style={{ color: "rgb(var(--text-muted))" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Chat Button */}
|
||||||
|
<div className="px-3 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onNewConversation()}
|
||||||
|
className="w-full flex items-center justify-center gap-2 rounded-lg border border-dashed py-2.5 text-sm font-medium transition-all duration-150 hover:border-solid"
|
||||||
|
style={{
|
||||||
|
borderColor: "rgb(var(--border-strong))",
|
||||||
|
color: "rgb(var(--text-secondary))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
<span>New Conversation</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="px-3 pt-3">
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="M21 21l-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search conversations..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="input pl-9 pr-10 py-2 text-sm"
|
||||||
|
style={{ backgroundColor: "rgb(var(--surface-1))" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversations List */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-3 pt-3 pb-3 space-y-1">
|
||||||
|
{filteredConversations.length === 0 ? (
|
||||||
|
<div className="text-center py-8" style={{ color: "rgb(var(--text-muted))" }}>
|
||||||
|
<p className="text-sm">No conversations yet</p>
|
||||||
|
<p className="text-xs mt-1">Start a new chat to begin</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredConversations.map((conv) => (
|
||||||
|
<button
|
||||||
|
key={conv.id}
|
||||||
|
onClick={() => onSelectConversation(conv.id)}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
|
||||||
|
conv.id === currentConversationId
|
||||||
|
? "bg-[rgb(var(--accent-primary-light))]"
|
||||||
|
: "hover:bg-[rgb(var(--surface-2))]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm font-medium truncate"
|
||||||
|
style={{
|
||||||
|
color: conv.id === currentConversationId
|
||||||
|
? "rgb(var(--accent-primary))"
|
||||||
|
: "rgb(var(--text-primary))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{truncateTitle(conv.title)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5" style={{ color: "rgb(var(--text-muted))" }}>
|
||||||
|
<span className="text-xs">{formatRelativeTime(conv.updated_at)}</span>
|
||||||
|
{conv.message_count > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs">·</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{conv.message_count} msg{conv.message_count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
319
apps/web/src/components/chat/MessageList.tsx
Normal file
319
apps/web/src/components/chat/MessageList.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { Message } from "./Chat";
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
messages: Message[];
|
||||||
|
isLoading: boolean;
|
||||||
|
loadingQuip?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse thinking content from message.
|
||||||
|
* Extracts <thinking>...</thinking> or <think>...</think> blocks.
|
||||||
|
*/
|
||||||
|
function parseThinking(content: string): { thinking: string | null; response: string } {
|
||||||
|
// Match <thinking>...</thinking> or <think>...</think> blocks
|
||||||
|
const thinkingRegex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
||||||
|
const matches = content.match(thinkingRegex);
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
return { thinking: null, response: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract thinking content
|
||||||
|
let thinking = "";
|
||||||
|
for (const match of matches) {
|
||||||
|
const innerContent = match.replace(/<\/?(?:thinking|think)>/gi, "");
|
||||||
|
thinking += innerContent.trim() + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove thinking blocks from response
|
||||||
|
const response = content.replace(thinkingRegex, "").trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
thinking: thinking.trim() || null,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({ messages, isLoading, loadingQuip }: MessageListProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" role="log" aria-label="Chat messages">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<MessageBubble key={message.id} message={message} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isLoading && <LoadingIndicator quip={loadingQuip} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ message }: { message: Message }) {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Parse thinking from content (or use pre-parsed thinking field)
|
||||||
|
const { thinking, response } = message.thinking
|
||||||
|
? { thinking: message.thinking, response: message.content }
|
||||||
|
: parseThinking(message.content);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(response);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy:", err);
|
||||||
|
}
|
||||||
|
}, [response]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group flex gap-4 message-animate ${isUser ? "flex-row-reverse" : ""}`}
|
||||||
|
role="article"
|
||||||
|
aria-label={`${isUser ? "Your" : "AI Assistant"} message`}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-semibold"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isUser
|
||||||
|
? "rgb(var(--surface-2))"
|
||||||
|
: "rgb(var(--accent-primary))",
|
||||||
|
color: isUser ? "rgb(var(--text-secondary))" : "white",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{isUser ? "You" : "AI"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Content */}
|
||||||
|
<div className={`flex max-w-[85%] flex-col gap-1.5 ${isUser ? "items-end" : "items-start"}`}>
|
||||||
|
{/* Message Header */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 text-xs ${isUser ? "flex-row-reverse" : ""}`}
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
>
|
||||||
|
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||||
|
{isUser ? "You" : "AI Assistant"}
|
||||||
|
</span>
|
||||||
|
{/* Model indicator for assistant messages */}
|
||||||
|
{!isUser && message.model && (
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-2))",
|
||||||
|
color: "rgb(var(--text-tertiary))",
|
||||||
|
}}
|
||||||
|
title={message.provider ? `Provider: ${message.provider}` : undefined}
|
||||||
|
>
|
||||||
|
{message.model}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Token usage indicator for assistant messages */}
|
||||||
|
{!isUser && message.totalTokens !== undefined && message.totalTokens > 0 && (
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-2))",
|
||||||
|
color: "rgb(var(--text-muted))",
|
||||||
|
}}
|
||||||
|
title={`Prompt: ${message.promptTokens?.toLocaleString() || 0} tokens, Completion: ${message.completionTokens?.toLocaleString() || 0} tokens`}
|
||||||
|
>
|
||||||
|
{formatTokenCount(message.totalTokens)} tokens
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span aria-label={`Sent at ${formatTime(message.createdAt)}`}>
|
||||||
|
{formatTime(message.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thinking Section - Collapsible */}
|
||||||
|
{thinking && !isUser && (
|
||||||
|
<div
|
||||||
|
className="rounded-lg overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-1))",
|
||||||
|
border: "1px solid rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setThinkingExpanded(!thinkingExpanded)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
|
style={{ color: "rgb(var(--text-secondary))" }}
|
||||||
|
aria-expanded={thinkingExpanded}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`h-3.5 w-3.5 transition-transform duration-200 ${thinkingExpanded ? "rotate-90" : ""}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
<span>Thinking</span>
|
||||||
|
<span
|
||||||
|
className="ml-auto text-xs"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
>
|
||||||
|
{thinkingExpanded ? "Hide" : "Show"} reasoning
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{thinkingExpanded && (
|
||||||
|
<div
|
||||||
|
className="px-3 pb-3 text-xs leading-relaxed whitespace-pre-wrap font-mono"
|
||||||
|
style={{
|
||||||
|
color: "rgb(var(--text-secondary))",
|
||||||
|
borderTop: "1px solid rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{thinking}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message Body */}
|
||||||
|
<div
|
||||||
|
className="relative rounded-lg px-4 py-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isUser
|
||||||
|
? "rgb(var(--accent-primary))"
|
||||||
|
: "rgb(var(--surface-0))",
|
||||||
|
color: isUser ? "white" : "rgb(var(--text-primary))",
|
||||||
|
border: isUser ? "none" : "1px solid rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
|
{response}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Copy Button - appears on hover */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="absolute -right-2 -top-2 rounded-md border p-1.5 opacity-0 transition-all group-hover:opacity-100 focus:opacity-100"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
color: copied ? "rgb(var(--semantic-success))" : "rgb(var(--text-muted))",
|
||||||
|
}}
|
||||||
|
aria-label={copied ? "Copied!" : "Copy message"}
|
||||||
|
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingIndicator({ quip }: { quip?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 message-animate" role="status" aria-label="AI is typing">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg text-xs font-semibold"
|
||||||
|
style={{ backgroundColor: "rgb(var(--accent-primary))" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="text-white">AI</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading Content */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 text-xs"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
>
|
||||||
|
<span className="font-medium" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||||
|
AI Assistant
|
||||||
|
</span>
|
||||||
|
<span>thinking...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-lg border px-4 py-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 rounded-full animate-bounce"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--accent-primary))",
|
||||||
|
animationDelay: "0ms",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 rounded-full animate-bounce"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--accent-primary))",
|
||||||
|
animationDelay: "150ms",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 rounded-full animate-bounce"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--accent-primary))",
|
||||||
|
animationDelay: "300ms",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{quip && (
|
||||||
|
<span
|
||||||
|
className="text-sm italic animate-fade-in"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
>
|
||||||
|
{quip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(isoString: string): string {
|
||||||
|
try {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenCount(tokens: number): string {
|
||||||
|
if (tokens >= 1000000) {
|
||||||
|
return `${(tokens / 1000000).toFixed(1)}M`;
|
||||||
|
} else if (tokens >= 1000) {
|
||||||
|
return `${(tokens / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return tokens.toString();
|
||||||
|
}
|
||||||
17
apps/web/src/components/chat/index.ts
Normal file
17
apps/web/src/components/chat/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Chat Components
|
||||||
|
*
|
||||||
|
* Migrated from jarvis-fe. These components provide the chat interface
|
||||||
|
* for interacting with the AI brain service.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* import { Chat, MessageList, ChatInput } from '@/components/chat';
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { Chat, type ChatRef, type Message, type NewConversationData } from './Chat';
|
||||||
|
export { ChatInput } from './ChatInput';
|
||||||
|
export { MessageList } from './MessageList';
|
||||||
|
export { ConversationSidebar, type ConversationSidebarRef } from './ConversationSidebar';
|
||||||
|
export { BackendStatusBanner } from './BackendStatusBanner';
|
||||||
Reference in New Issue
Block a user