chore: initialize EverOS 1.0.0
md-first memory extraction framework for AI agents. Markdown is the single source of truth; SQLite holds state and LanceDB provides the rebuildable vector + BM25 + scalar index. The codebase follows a single-direction DDD layering (entrypoints -> service -> memory -> infra, with component / core / config cross-cutting) enforced by import-linter. Engineering surface: - Coding conventions in .claude/rules/ (path-scoped) and workflows in .claude/skills/ (/commit, /new-branch, /pr). - GitHub Actions CI runs make lint + test + integration; pre-commit mirrors the gates locally (ruff, hygiene hooks, gitlint commit-msg). - Commit messages follow Conventional Commits, enforced by gitlint. - make lint also enforces datetime two-zone discipline and OpenAPI drift.
This commit is contained in:
8
use-cases/game-of-throne-demo/frontend/.dockerignore
Normal file
8
use-cases/game-of-throne-demo/frontend/.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
deploy.sh
|
||||
5
use-cases/game-of-throne-demo/frontend/.env.example
Normal file
5
use-cases/game-of-throne-demo/frontend/.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
# Local development
|
||||
VITE_API_URL=http://localhost:3001
|
||||
|
||||
# For production (.env.production), use:
|
||||
# VITE_API_URL=https://evermem-story-demo-2i6norfn7q-uc.a.run.app
|
||||
18
use-cases/game-of-throne-demo/frontend/.eslintrc.cjs
Normal file
18
use-cases/game-of-throne-demo/frontend/.eslintrc.cjs
Normal file
@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
1
use-cases/game-of-throne-demo/frontend/.gitignore
vendored
Normal file
1
use-cases/game-of-throne-demo/frontend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.vercel
|
||||
17
use-cases/game-of-throne-demo/frontend/Dockerfile
Normal file
17
use-cases/game-of-throne-demo/frontend/Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage - serve with nginx
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
use-cases/game-of-throne-demo/frontend/index.html
Normal file
13
use-cases/game-of-throne-demo/frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>A Song of Ice and Fire Q&A</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
use-cases/game-of-throne-demo/frontend/nginx.conf
Normal file
21
use-cases/game-of-throne-demo/frontend/nginx.conf
Normal file
@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
30
use-cases/game-of-throne-demo/frontend/package.json
Normal file
30
use-cases/game-of-throne-demo/frontend/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
1287
use-cases/game-of-throne-demo/frontend/src/App.css
Normal file
1287
use-cases/game-of-throne-demo/frontend/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
35
use-cases/game-of-throne-demo/frontend/src/App.tsx
Normal file
35
use-cases/game-of-throne-demo/frontend/src/App.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useCompareChat } from './hooks/useCompareChat';
|
||||
import { ComparisonChatInterface } from './components/ComparisonChatInterface';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
messages,
|
||||
currentMemories,
|
||||
isLoading,
|
||||
isRetrievingMemories,
|
||||
isLoadingFollowUps,
|
||||
error,
|
||||
comparison,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
} = useCompareChat();
|
||||
|
||||
return (
|
||||
<div className="app comparison-mode">
|
||||
<ComparisonChatInterface
|
||||
messages={messages}
|
||||
comparison={comparison}
|
||||
isLoading={isLoading}
|
||||
isRetrievingMemories={isRetrievingMemories}
|
||||
isLoadingFollowUps={isLoadingFollowUps}
|
||||
error={error}
|
||||
memories={currentMemories}
|
||||
onSendMessage={sendMessage}
|
||||
onClearChat={clearChat}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@ -0,0 +1,59 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled }: ChatInputProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (input.trim() && !disabled) {
|
||||
onSend(input.trim());
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
const scrollHeight = textareaRef.current.scrollHeight;
|
||||
const maxHeight = 5 * 24; // 5 lines * 24px line height
|
||||
textareaRef.current.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="chat-input-form">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask me anything about A Song of Ice and Fire..."
|
||||
disabled={disabled}
|
||||
className="chat-input-textarea"
|
||||
rows={1}
|
||||
maxLength={1000}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !input.trim()}
|
||||
className="chat-input-button"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
import { MessageList } from './MessageList';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ExampleQueries } from './ExampleQueries';
|
||||
import { Memory, Message } from '../types';
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
messages: Message[];
|
||||
streamingContent: string;
|
||||
isLoading: boolean;
|
||||
isRetrievingMemories: boolean;
|
||||
isLoadingFollowUps: boolean;
|
||||
error: string | null;
|
||||
memories: Memory[];
|
||||
onSendMessage: (message: string) => void;
|
||||
onClearChat: () => void;
|
||||
}
|
||||
|
||||
export function ChatInterface({
|
||||
messages,
|
||||
streamingContent,
|
||||
isLoading,
|
||||
isRetrievingMemories,
|
||||
isLoadingFollowUps,
|
||||
error,
|
||||
memories,
|
||||
onSendMessage,
|
||||
onClearChat,
|
||||
}: ChatInterfaceProps) {
|
||||
const showWelcome = messages.length === 0 && !streamingContent;
|
||||
|
||||
return (
|
||||
<div className="chat-interface">
|
||||
<div className="chat-header">
|
||||
<div className="chat-header-title">
|
||||
<div className="chat-header-main">
|
||||
<span className="evermem-logo-text">EverMind</span>
|
||||
<h1><span className="brand-evermem">EverMem</span> Story Memory Demo</h1>
|
||||
</div>
|
||||
<span className="chat-header-subtitle">A Game of Thrones</span>
|
||||
</div>
|
||||
<button onClick={onClearChat} className="clear-button" disabled={isLoading}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{showWelcome && (
|
||||
<div className="welcome-message">
|
||||
<h2>Welcome</h2>
|
||||
<p>
|
||||
See how <strong>EverMem</strong> memorizes and retrieves story details.
|
||||
Ask any question about <strong>A Game of Thrones</strong> (Book 1) and watch relevant memories surface in real-time.
|
||||
</p>
|
||||
<ExampleQueries onSelectQuery={onSendMessage} disabled={isLoading} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showWelcome && (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
isLoading={isLoading}
|
||||
isRetrievingMemories={isRetrievingMemories}
|
||||
isLoadingFollowUps={isLoadingFollowUps}
|
||||
memories={memories}
|
||||
onFollowUpClick={onSendMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatInput onSend={onSendMessage} disabled={isLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ExampleQueries } from './ExampleQueries';
|
||||
import { ComparisonView } from './ComparisonView';
|
||||
import { Memory, Message, ComparisonState } from '../types';
|
||||
|
||||
interface ComparisonChatInterfaceProps {
|
||||
messages: Message[];
|
||||
comparison: ComparisonState;
|
||||
isLoading: boolean;
|
||||
isRetrievingMemories: boolean;
|
||||
isLoadingFollowUps: boolean;
|
||||
error: string | null;
|
||||
memories: Memory[];
|
||||
onSendMessage: (message: string) => void;
|
||||
onClearChat: () => void;
|
||||
}
|
||||
|
||||
export function ComparisonChatInterface({
|
||||
messages,
|
||||
comparison,
|
||||
isLoading,
|
||||
isRetrievingMemories,
|
||||
isLoadingFollowUps,
|
||||
error,
|
||||
memories,
|
||||
onSendMessage,
|
||||
onClearChat,
|
||||
}: ComparisonChatInterfaceProps) {
|
||||
const showWelcome = messages.length === 0 && !comparison.withMemory.content && !comparison.withoutMemory.content;
|
||||
const showComparison = comparison.withMemory.content || comparison.withoutMemory.content || comparison.withMemory.isStreaming || comparison.withoutMemory.isStreaming;
|
||||
|
||||
// Get the last user message to display
|
||||
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
|
||||
|
||||
// Get follow-ups from the last assistant message
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const followUps = lastMessage?.role === 'assistant' ? lastMessage.followUps : undefined;
|
||||
|
||||
return (
|
||||
<div className="comparison-chat-interface">
|
||||
<div className="chat-header">
|
||||
<div className="chat-header-title">
|
||||
<div className="chat-header-main">
|
||||
<span className="evermem-logo-text">EverMind</span>
|
||||
<h1><span className="brand-evermem">EverMem</span> Story Memory Demo</h1>
|
||||
</div>
|
||||
<span className="chat-header-subtitle">A Game of Thrones - Side-by-Side Comparison · Powered by Claude Haiku</span>
|
||||
</div>
|
||||
<button onClick={onClearChat} className="clear-button" disabled={isLoading}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="comparison-main-content">
|
||||
{showWelcome && (
|
||||
<div className="comparison-welcome">
|
||||
<h2>See the Difference Memory Makes</h2>
|
||||
<p>
|
||||
Ask any question about <strong>A Game of Thrones</strong> and watch two responses stream side-by-side:
|
||||
</p>
|
||||
<ul className="comparison-feature-list">
|
||||
<li><span className="comparison-badge with-memory">With Memory</span> Uses EverMem to retrieve relevant story details</li>
|
||||
<li><span className="comparison-badge without-memory">Without Memory</span> Standard LLM response with no context</li>
|
||||
</ul>
|
||||
<ExampleQueries onSelectQuery={onSendMessage} disabled={isLoading} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show user's question */}
|
||||
{lastUserMessage && (
|
||||
<div className="comparison-user-question">
|
||||
<div className="comparison-user-label">Your Question</div>
|
||||
<div className="comparison-user-content">{lastUserMessage.content}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showComparison && (
|
||||
<ComparisonView
|
||||
comparison={comparison}
|
||||
memories={memories}
|
||||
isRetrievingMemories={isRetrievingMemories}
|
||||
followUps={followUps}
|
||||
isLoadingFollowUps={isLoadingFollowUps}
|
||||
onFollowUpClick={onSendMessage}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatInput onSend={onSendMessage} disabled={isLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,365 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import { Memory, ComparisonState } from '../types';
|
||||
|
||||
interface ComparisonViewProps {
|
||||
comparison: ComparisonState;
|
||||
memories: Memory[];
|
||||
isRetrievingMemories: boolean;
|
||||
followUps?: string[];
|
||||
isLoadingFollowUps: boolean;
|
||||
onFollowUpClick: (question: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface CitationProps {
|
||||
citationNumber: number;
|
||||
memory: Memory | undefined;
|
||||
}
|
||||
|
||||
function Citation({ citationNumber, memory }: CitationProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
|
||||
if (!memory) {
|
||||
return <span className="citation-badge citation-missing">memory [{citationNumber}]</span>;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
const sidePanel = document.querySelector(`[data-memory-id="${memory.id}"]`);
|
||||
if (sidePanel) {
|
||||
sidePanel.classList.add('memory-highlighted');
|
||||
sidePanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
setTimeout(() => sidePanel.classList.remove('memory-highlighted'), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="citation-wrapper">
|
||||
<span
|
||||
className={`citation-badge ${isExpanded ? 'citation-expanded' : ''}`}
|
||||
onClick={handleClick}
|
||||
title={memory.subject || `Memory ${citationNumber}`}
|
||||
>
|
||||
memory [{citationNumber}]
|
||||
</span>
|
||||
{isExpanded && (
|
||||
<span className="citation-expanded-block">
|
||||
<span className="citation-expanded-header">
|
||||
<span className="citation-expanded-badge">[{citationNumber}]</span>
|
||||
<span className="citation-expanded-title">{memory.subject || 'Memory'}</span>
|
||||
<button
|
||||
className="citation-close-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
<span className="citation-expanded-meta">
|
||||
{memory.metadata.bookTitle}
|
||||
{memory.metadata.chapterNumber && ` - Chapter ${memory.metadata.chapterNumber}`}
|
||||
{memory.metadata.chapterName && `: ${memory.metadata.chapterName}`}
|
||||
</span>
|
||||
<span className="citation-expanded-content">
|
||||
{memory.summary || memory.content}
|
||||
</span>
|
||||
{memory.originalContent && (
|
||||
<span className="citation-original-section">
|
||||
<button
|
||||
className="citation-toggle-original"
|
||||
onClick={(e) => { e.stopPropagation(); setShowOriginal(!showOriginal); }}
|
||||
>
|
||||
{showOriginal ? 'Hide original text' : 'Show original text'}
|
||||
</button>
|
||||
{showOriginal && (
|
||||
<span className="citation-original-text">{memory.originalContent}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface MemoryChipProps {
|
||||
memory: Memory;
|
||||
citationNumber: number;
|
||||
}
|
||||
|
||||
function MemoryChip({ memory, citationNumber }: MemoryChipProps) {
|
||||
const title = memory.subject || memory.metadata.chapterName || 'Memory';
|
||||
// Truncate to first ~30 chars
|
||||
const truncatedTitle = title.length > 30 ? title.slice(0, 30) + '...' : title;
|
||||
|
||||
return (
|
||||
<div className="comparison-memory-chip" data-memory-id={memory.id}>
|
||||
<span className="comparison-memory-badge">[{citationNumber}]</span>
|
||||
<span className="comparison-memory-title">{truncatedTitle}</span>
|
||||
|
||||
{/* Hover popover */}
|
||||
<div className="comparison-memory-popover">
|
||||
<div className="comparison-memory-popover-header">
|
||||
<span className="comparison-memory-popover-badge">[{citationNumber}]</span>
|
||||
<span className="comparison-memory-popover-title">{title}</span>
|
||||
</div>
|
||||
<div className="comparison-memory-meta">
|
||||
{memory.metadata.bookTitle}
|
||||
{memory.metadata.chapterNumber && ` - Chapter ${memory.metadata.chapterNumber}`}
|
||||
{memory.metadata.chapterName && `: ${memory.metadata.chapterName}`}
|
||||
</div>
|
||||
<div className="comparison-memory-summary">
|
||||
{memory.summary || memory.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CitationContentProps {
|
||||
content: string;
|
||||
memories: Memory[];
|
||||
}
|
||||
|
||||
function CitationContent({ content, memories }: CitationContentProps) {
|
||||
const parts: (string | JSX.Element)[] = [];
|
||||
const regex = /\[(\d+)\]/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(content.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const citationNumber = parseInt(match[1], 10);
|
||||
const memory = memories[citationNumber - 1];
|
||||
parts.push(
|
||||
<Citation
|
||||
key={`citation-${match.index}`}
|
||||
citationNumber={citationNumber}
|
||||
memory={memory}
|
||||
/>
|
||||
);
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.slice(lastIndex));
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
interface ComparisonPanelProps {
|
||||
title: string;
|
||||
badgeClass: string;
|
||||
content: string;
|
||||
isStreaming: boolean;
|
||||
isDone: boolean;
|
||||
memories: Memory[];
|
||||
showMemories: boolean;
|
||||
isRetrievingMemories: boolean;
|
||||
followUps?: string[];
|
||||
isLoadingFollowUps: boolean;
|
||||
onFollowUpClick: (question: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ComparisonPanel({
|
||||
title,
|
||||
badgeClass,
|
||||
content,
|
||||
isStreaming,
|
||||
memories,
|
||||
showMemories,
|
||||
isRetrievingMemories,
|
||||
followUps,
|
||||
isLoadingFollowUps,
|
||||
onFollowUpClick,
|
||||
isLoading,
|
||||
}: ComparisonPanelProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = contentRef.current.scrollHeight;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const createMarkdownComponents = useCallback((mems: Memory[]): Components => ({
|
||||
p: ({ children }) => {
|
||||
const processedChildren = processChildren(children, mems);
|
||||
return <p>{processedChildren}</p>;
|
||||
},
|
||||
li: ({ children }) => {
|
||||
const processedChildren = processChildren(children, mems);
|
||||
return <li>{processedChildren}</li>;
|
||||
},
|
||||
strong: ({ children }) => {
|
||||
const processedChildren = processChildren(children, mems);
|
||||
return <strong>{processedChildren}</strong>;
|
||||
},
|
||||
em: ({ children }) => {
|
||||
const processedChildren = processChildren(children, mems);
|
||||
return <em>{processedChildren}</em>;
|
||||
},
|
||||
}), []);
|
||||
|
||||
const processChildren = (children: React.ReactNode, mems: Memory[]): React.ReactNode => {
|
||||
if (!children) return children;
|
||||
|
||||
if (typeof children === 'string') {
|
||||
if (/\[\d+\]/.test(children)) {
|
||||
return <CitationContent content={children} memories={mems} />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children.map((child, index) => {
|
||||
if (typeof child === 'string' && /\[\d+\]/.test(child)) {
|
||||
return <CitationContent key={index} content={child} memories={mems} />;
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
const markdownComponents = createMarkdownComponents(showMemories ? memories : []);
|
||||
|
||||
return (
|
||||
<div className="comparison-panel">
|
||||
<div className="comparison-panel-header">
|
||||
<span className={`comparison-badge ${badgeClass}`}>{title}</span>
|
||||
</div>
|
||||
|
||||
{/* Compact memory panel for "With Memory" side */}
|
||||
{showMemories && (
|
||||
<div className="comparison-memories">
|
||||
{isRetrievingMemories && memories.length === 0 ? (
|
||||
<div className="comparison-memories-loading">
|
||||
<span className="follow-ups-spinner"></span>
|
||||
<span>Retrieving memories...</span>
|
||||
</div>
|
||||
) : memories.length > 0 ? (
|
||||
<div className="comparison-memories-list">
|
||||
{memories.map((memory, index) => (
|
||||
<MemoryChip key={memory.id} memory={memory} citationNumber={index + 1} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="comparison-memories-empty">No memories</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No memory placeholder for "Without Memory" side */}
|
||||
{!showMemories && (
|
||||
<div className="comparison-memories comparison-memories-none">
|
||||
<span className="comparison-no-memory-text">No memory context provided</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="comparison-content" ref={contentRef}>
|
||||
{content ? (
|
||||
<>
|
||||
<div className="message-role">The Maester</div>
|
||||
<div className="message-content">
|
||||
<ReactMarkdown components={markdownComponents}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && <span className="typing-indicator"></span>}
|
||||
</div>
|
||||
|
||||
{/* Follow-ups only for "With Memory" side */}
|
||||
{showMemories && !isStreaming && followUps && followUps.length > 0 && (
|
||||
<div className="follow-ups">
|
||||
<div className="follow-ups-label">Follow-up questions:</div>
|
||||
<div className="follow-ups-list">
|
||||
{followUps.map((question, qIndex) => (
|
||||
<button
|
||||
key={qIndex}
|
||||
className="follow-up-btn"
|
||||
onClick={() => onFollowUpClick(question)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMemories && !isStreaming && isLoadingFollowUps && !followUps && (
|
||||
<div className="follow-ups follow-ups-loading">
|
||||
<div className="follow-ups-label">
|
||||
<span className="follow-ups-spinner"></span>
|
||||
Generating follow-up questions...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : isStreaming ? (
|
||||
<>
|
||||
<div className="message-role">The Maester</div>
|
||||
<div className="message-content">
|
||||
<span className="typing-indicator">Thinking...</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComparisonView({
|
||||
comparison,
|
||||
memories,
|
||||
isRetrievingMemories,
|
||||
followUps,
|
||||
isLoadingFollowUps,
|
||||
onFollowUpClick,
|
||||
isLoading,
|
||||
}: ComparisonViewProps) {
|
||||
return (
|
||||
<div className="comparison-panels">
|
||||
<ComparisonPanel
|
||||
title="With Memory"
|
||||
badgeClass="with-memory"
|
||||
content={comparison.withMemory.content}
|
||||
isStreaming={comparison.withMemory.isStreaming && !comparison.withMemory.isDone}
|
||||
isDone={comparison.withMemory.isDone}
|
||||
memories={memories}
|
||||
showMemories={true}
|
||||
isRetrievingMemories={isRetrievingMemories}
|
||||
followUps={followUps}
|
||||
isLoadingFollowUps={isLoadingFollowUps}
|
||||
onFollowUpClick={onFollowUpClick}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<div className="comparison-divider"></div>
|
||||
|
||||
<ComparisonPanel
|
||||
title="Without Memory"
|
||||
badgeClass="without-memory"
|
||||
content={comparison.withoutMemory.content}
|
||||
isStreaming={comparison.withoutMemory.isStreaming && !comparison.withoutMemory.isDone}
|
||||
isDone={comparison.withoutMemory.isDone}
|
||||
memories={[]}
|
||||
showMemories={false}
|
||||
isRetrievingMemories={false}
|
||||
followUps={undefined}
|
||||
isLoadingFollowUps={false}
|
||||
onFollowUpClick={onFollowUpClick}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
interface ExampleQueriesProps {
|
||||
onSelectQuery: (query: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const EXAMPLE_QUERIES = [
|
||||
'What body parts did Gared lose to the cold?',
|
||||
'How many years had Will been on the Wall before the prologue events?',
|
||||
'What was Ser Waymar Royce\'s cloak made of?',
|
||||
'Describe the Other\'s sword that fought Ser Waymar.',
|
||||
];
|
||||
|
||||
export function ExampleQueries({ onSelectQuery, disabled }: ExampleQueriesProps) {
|
||||
return (
|
||||
<div className="example-queries">
|
||||
<h3>Example Questions:</h3>
|
||||
<div className="example-queries-list">
|
||||
{EXAMPLE_QUERIES.map((query, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="example-query-button"
|
||||
onClick={() => onSelectQuery(query)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{query}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { Memory } from '../types';
|
||||
|
||||
interface MemoryPanelProps {
|
||||
memories: Memory[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface MemoryCardProps {
|
||||
memory: Memory;
|
||||
citationNumber: number;
|
||||
}
|
||||
|
||||
function MemoryCard({ memory, citationNumber }: MemoryCardProps) {
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="memory-card" data-memory-id={memory.id}>
|
||||
{/* Citation badge */}
|
||||
<div className="memory-citation-badge">[{citationNumber}]</div>
|
||||
|
||||
{/* Header with book/chapter info */}
|
||||
<div className="memory-metadata">
|
||||
<span className="memory-book">{memory.metadata.bookTitle}</span>
|
||||
{(memory.metadata.chapterNumber || memory.metadata.chapterName) && (
|
||||
<span className="memory-chapter">
|
||||
{memory.metadata.chapterNumber ? `Chapter ${memory.metadata.chapterNumber}` : ''}
|
||||
{memory.metadata.chapterNumber && memory.metadata.chapterName ? ': ' : ''}
|
||||
{memory.metadata.chapterName || ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subject/Title */}
|
||||
{memory.subject && (
|
||||
<div className="memory-subject">{memory.subject}</div>
|
||||
)}
|
||||
|
||||
{/* Main content - summary or content */}
|
||||
<div className="memory-summary">
|
||||
{memory.summary || memory.content || '(no content)'}
|
||||
</div>
|
||||
|
||||
{/* Original content toggle */}
|
||||
{memory.originalContent && (
|
||||
<div className="memory-original-section">
|
||||
<button
|
||||
className="memory-toggle-btn"
|
||||
onClick={() => setShowOriginal(!showOriginal)}
|
||||
>
|
||||
{showOriginal ? 'Hide original text' : 'Show original text'}
|
||||
</button>
|
||||
{showOriginal && (
|
||||
<div className="memory-original">
|
||||
{memory.originalContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoryPanel({ memories, isLoading }: MemoryPanelProps) {
|
||||
return (
|
||||
<div className="memory-panel">
|
||||
<div className="memory-panel-header">
|
||||
<span className="memory-panel-icon">✦</span>
|
||||
<span className="memory-panel-title">Retrieved Memories</span>
|
||||
<span className="memory-panel-count">{memories.length > 0 && memories.length}</span>
|
||||
</div>
|
||||
|
||||
{isLoading && memories.length === 0 ? (
|
||||
<div className="memory-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>Retrieving memories...</p>
|
||||
</div>
|
||||
) : memories.length === 0 ? (
|
||||
<div className="memory-empty">
|
||||
<p>No memories retrieved yet. Ask a question to see relevant excerpts from the books.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="memory-list">
|
||||
{memories.map((memory, index) => (
|
||||
<MemoryCard key={memory.id} memory={memory} citationNumber={index + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import { Message, Memory } from '../types';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
streamingContent: string;
|
||||
isLoading: boolean;
|
||||
isRetrievingMemories: boolean;
|
||||
isLoadingFollowUps: boolean;
|
||||
memories: Memory[];
|
||||
onFollowUpClick: (question: string) => void;
|
||||
}
|
||||
|
||||
interface CitationProps {
|
||||
citationNumber: number;
|
||||
memory: Memory | undefined;
|
||||
}
|
||||
|
||||
function Citation({ citationNumber, memory }: CitationProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
|
||||
if (!memory) {
|
||||
// Fallback if memory not found - just show the citation number
|
||||
return <span className="citation-badge citation-missing">memory [{citationNumber}]</span>;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
// Also highlight in side panel
|
||||
const sidePanel = document.querySelector(`[data-memory-id="${memory.id}"]`);
|
||||
if (sidePanel) {
|
||||
sidePanel.classList.add('memory-highlighted');
|
||||
sidePanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
setTimeout(() => sidePanel.classList.remove('memory-highlighted'), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="citation-wrapper">
|
||||
<span
|
||||
className={`citation-badge ${isExpanded ? 'citation-expanded' : ''}`}
|
||||
onClick={handleClick}
|
||||
title={memory.subject || `Memory ${citationNumber}`}
|
||||
>
|
||||
memory [{citationNumber}]
|
||||
</span>
|
||||
{isExpanded && (
|
||||
<span className="citation-expanded-block">
|
||||
<span className="citation-expanded-header">
|
||||
<span className="citation-expanded-badge">[{citationNumber}]</span>
|
||||
<span className="citation-expanded-title">{memory.subject || 'Memory'}</span>
|
||||
<button
|
||||
className="citation-close-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
<span className="citation-expanded-meta">
|
||||
{memory.metadata.bookTitle}
|
||||
{memory.metadata.chapterNumber && ` - Chapter ${memory.metadata.chapterNumber}`}
|
||||
{memory.metadata.chapterName && `: ${memory.metadata.chapterName}`}
|
||||
</span>
|
||||
<span className="citation-expanded-content">
|
||||
{memory.summary || memory.content}
|
||||
</span>
|
||||
{memory.originalContent && (
|
||||
<span className="citation-original-section">
|
||||
<button
|
||||
className="citation-toggle-original"
|
||||
onClick={(e) => { e.stopPropagation(); setShowOriginal(!showOriginal); }}
|
||||
>
|
||||
{showOriginal ? 'Hide original text' : 'Show original text'}
|
||||
</button>
|
||||
{showOriginal && (
|
||||
<span className="citation-original-text">{memory.originalContent}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CitationContentProps {
|
||||
content: string;
|
||||
memories: Memory[];
|
||||
}
|
||||
|
||||
function CitationContent({ content, memories }: CitationContentProps) {
|
||||
// Parse text and replace [1], [2], etc. with Citation components
|
||||
const parts: (string | JSX.Element)[] = [];
|
||||
const regex = /\[(\d+)\]/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
// Add text before the citation
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(content.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Add the citation component
|
||||
const citationNumber = parseInt(match[1], 10);
|
||||
const memory = memories[citationNumber - 1]; // Citations are 1-indexed
|
||||
parts.push(
|
||||
<Citation
|
||||
key={`citation-${match.index}`}
|
||||
citationNumber={citationNumber}
|
||||
memory={memory}
|
||||
/>
|
||||
);
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
// Add remaining text after last citation
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.slice(lastIndex));
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, streamingContent, isLoading, isRetrievingMemories, isLoadingFollowUps, memories, onFollowUpClick }: MessageListProps) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
// Create custom markdown components to handle citations in text
|
||||
const createMarkdownComponents = useCallback((mems: Memory[]): Components => ({
|
||||
p: ({ children }) => {
|
||||
// Process children to handle citations
|
||||
const processedChildren = processChildren(children, mems);
|
||||
return <p>{processedChildren}</p>;
|
||||
},
|
||||
li: ({ children }) => {
|
||||
const processedChildren = processChildren(children, mems);
|
||||
return <li>{processedChildren}</li>;
|
||||
},
|
||||
strong: ({ children }) => {
|
||||
const processedChildren = processChildren(children, mems);
|
||||
return <strong>{processedChildren}</strong>;
|
||||
},
|
||||
em: ({ children }) => {
|
||||
const processedChildren = processChildren(children, mems);
|
||||
return <em>{processedChildren}</em>;
|
||||
},
|
||||
}), []);
|
||||
|
||||
// Helper to process children and replace citation patterns
|
||||
const processChildren = (children: React.ReactNode, mems: Memory[]): React.ReactNode => {
|
||||
if (!children) return children;
|
||||
|
||||
if (typeof children === 'string') {
|
||||
// Check if string contains citations
|
||||
if (/\[\d+\]/.test(children)) {
|
||||
return <CitationContent content={children} memories={mems} />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children.map((child, index) => {
|
||||
if (typeof child === 'string' && /\[\d+\]/.test(child)) {
|
||||
return <CitationContent key={index} content={child} memories={mems} />;
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
const markdownComponents = createMarkdownComponents(memories);
|
||||
|
||||
return (
|
||||
<div className="message-list">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index} className={`message message-${message.role}`}>
|
||||
<div className="message-role">
|
||||
{message.role === 'user' ? 'You' : 'The Maester'}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
{message.role === 'assistant' ? (
|
||||
<ReactMarkdown components={markdownComponents}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
{message.role === 'assistant' && (
|
||||
<>
|
||||
{/* Show follow-up questions if available */}
|
||||
{message.followUps && message.followUps.length > 0 && (
|
||||
<div className="follow-ups">
|
||||
<div className="follow-ups-label">Follow-up questions:</div>
|
||||
<div className="follow-ups-list">
|
||||
{message.followUps.map((question, qIndex) => (
|
||||
<button
|
||||
key={qIndex}
|
||||
className="follow-up-btn"
|
||||
onClick={() => onFollowUpClick(question)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Show loading indicator for follow-ups on the last message */}
|
||||
{isLoadingFollowUps && index === messages.length - 1 && !message.followUps && (
|
||||
<div className="follow-ups follow-ups-loading">
|
||||
<div className="follow-ups-label">
|
||||
<span className="follow-ups-spinner"></span>
|
||||
Generating follow-up questions...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{streamingContent && (
|
||||
<div className="message message-assistant">
|
||||
<div className="message-role">The Maester</div>
|
||||
<div className="message-content">
|
||||
<ReactMarkdown components={markdownComponents}>
|
||||
{streamingContent}
|
||||
</ReactMarkdown>
|
||||
<span className="typing-indicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !streamingContent && (
|
||||
<div className="message message-assistant">
|
||||
<div className="message-role">The Maester</div>
|
||||
<div className="message-content">
|
||||
<span className="typing-indicator">
|
||||
{isRetrievingMemories ? 'Retrieving memories...' : 'Thinking...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
use-cases/game-of-throne-demo/frontend/src/hooks/useChat.ts
Normal file
143
use-cases/game-of-throne-demo/frontend/src/hooks/useChat.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Message, Memory } from '../types';
|
||||
import { sendChatMessage } from '../services/api';
|
||||
|
||||
const STORAGE_KEY = 'chat_history';
|
||||
const MEMORIES_STORAGE_KEY = 'chat_memories';
|
||||
|
||||
export function useChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [currentMemories, setCurrentMemories] = useState<Memory[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRetrievingMemories, setIsRetrievingMemories] = useState(false);
|
||||
const [isLoadingFollowUps, setIsLoadingFollowUps] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
|
||||
// Load chat history and memories from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedMessages = localStorage.getItem(STORAGE_KEY);
|
||||
if (storedMessages) {
|
||||
try {
|
||||
setMessages(JSON.parse(storedMessages));
|
||||
} catch (e) {
|
||||
console.error('Error loading chat history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const storedMemories = localStorage.getItem(MEMORIES_STORAGE_KEY);
|
||||
if (storedMemories) {
|
||||
try {
|
||||
setCurrentMemories(JSON.parse(storedMemories));
|
||||
} catch (e) {
|
||||
console.error('Error loading memories:', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save chat history to localStorage
|
||||
const saveChatHistory = useCallback((msgs: Message[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(msgs));
|
||||
}, []);
|
||||
|
||||
// Save memories to localStorage
|
||||
const saveMemories = useCallback((memories: Memory[]) => {
|
||||
localStorage.setItem(MEMORIES_STORAGE_KEY, JSON.stringify(memories));
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim() || isLoading) return;
|
||||
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
setIsRetrievingMemories(true);
|
||||
setStreamingContent('');
|
||||
setCurrentMemories([]); // Clear memories to show loading state
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = { role: 'user', content };
|
||||
const updatedMessages = [...messages, userMessage];
|
||||
setMessages(updatedMessages);
|
||||
saveChatHistory(updatedMessages);
|
||||
|
||||
try {
|
||||
let assistantContent = '';
|
||||
|
||||
await sendChatMessage(content, messages, {
|
||||
onMemories: (memories) => {
|
||||
setCurrentMemories(memories);
|
||||
saveMemories(memories);
|
||||
setIsRetrievingMemories(false);
|
||||
},
|
||||
onToken: (token) => {
|
||||
assistantContent += token;
|
||||
setStreamingContent(assistantContent);
|
||||
},
|
||||
onDone: () => {
|
||||
// Add complete assistant message (follow-ups will be added when received)
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
};
|
||||
const finalMessages = [...updatedMessages, assistantMessage];
|
||||
setMessages(finalMessages);
|
||||
saveChatHistory(finalMessages);
|
||||
setStreamingContent('');
|
||||
setIsLoading(false);
|
||||
setIsLoadingFollowUps(true); // Start loading follow-ups
|
||||
},
|
||||
onFollowUps: (followUps) => {
|
||||
setIsLoadingFollowUps(false);
|
||||
// Update the last assistant message with follow-ups
|
||||
setMessages(prev => {
|
||||
if (prev.length === 0) return prev;
|
||||
const updated = [...prev];
|
||||
const lastIndex = updated.length - 1;
|
||||
if (updated[lastIndex].role === 'assistant') {
|
||||
updated[lastIndex] = { ...updated[lastIndex], followUps };
|
||||
saveChatHistory(updated);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
onError: (errorMessage) => {
|
||||
setError(errorMessage);
|
||||
setIsLoading(false);
|
||||
setIsRetrievingMemories(false);
|
||||
setIsLoadingFollowUps(false);
|
||||
setStreamingContent('');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
setIsLoading(false);
|
||||
setIsRetrievingMemories(false);
|
||||
setIsLoadingFollowUps(false);
|
||||
setStreamingContent('');
|
||||
}
|
||||
},
|
||||
[messages, isLoading, saveChatHistory, saveMemories]
|
||||
);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
setMessages([]);
|
||||
setCurrentMemories([]);
|
||||
setError(null);
|
||||
setStreamingContent('');
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(MEMORIES_STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
currentMemories,
|
||||
isLoading,
|
||||
isRetrievingMemories,
|
||||
isLoadingFollowUps,
|
||||
error,
|
||||
streamingContent,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Message, Memory, ComparisonState } from '../types';
|
||||
import { sendCompareMessage } from '../services/api';
|
||||
|
||||
const STORAGE_KEY = 'compare_chat_history';
|
||||
const MEMORIES_STORAGE_KEY = 'compare_chat_memories';
|
||||
|
||||
const initialComparisonState: ComparisonState = {
|
||||
withMemory: { content: '', isStreaming: false, isDone: false },
|
||||
withoutMemory: { content: '', isStreaming: false, isDone: false },
|
||||
};
|
||||
|
||||
export function useCompareChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [currentMemories, setCurrentMemories] = useState<Memory[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRetrievingMemories, setIsRetrievingMemories] = useState(false);
|
||||
const [isLoadingFollowUps, setIsLoadingFollowUps] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [comparison, setComparison] = useState<ComparisonState>(initialComparisonState);
|
||||
|
||||
// Load chat history and memories from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedMessages = localStorage.getItem(STORAGE_KEY);
|
||||
if (storedMessages) {
|
||||
try {
|
||||
setMessages(JSON.parse(storedMessages));
|
||||
} catch (e) {
|
||||
console.error('Error loading chat history:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const storedMemories = localStorage.getItem(MEMORIES_STORAGE_KEY);
|
||||
if (storedMemories) {
|
||||
try {
|
||||
setCurrentMemories(JSON.parse(storedMemories));
|
||||
} catch (e) {
|
||||
console.error('Error loading memories:', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save chat history to localStorage
|
||||
const saveChatHistory = useCallback((msgs: Message[]) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(msgs));
|
||||
}, []);
|
||||
|
||||
// Save memories to localStorage
|
||||
const saveMemories = useCallback((memories: Memory[]) => {
|
||||
localStorage.setItem(MEMORIES_STORAGE_KEY, JSON.stringify(memories));
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim() || isLoading) return;
|
||||
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
setIsRetrievingMemories(true);
|
||||
setCurrentMemories([]); // Clear memories to show loading state
|
||||
|
||||
// Reset comparison state for new message
|
||||
setComparison({
|
||||
withMemory: { content: '', isStreaming: true, isDone: false },
|
||||
withoutMemory: { content: '', isStreaming: true, isDone: false },
|
||||
});
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = { role: 'user', content };
|
||||
const updatedMessages = [...messages, userMessage];
|
||||
setMessages(updatedMessages);
|
||||
saveChatHistory(updatedMessages);
|
||||
|
||||
try {
|
||||
let withMemoryContent = '';
|
||||
let withoutMemoryContent = '';
|
||||
let pendingFollowUps: string[] | undefined;
|
||||
|
||||
await sendCompareMessage(content, messages, {
|
||||
onMemories: (memories) => {
|
||||
setCurrentMemories(memories);
|
||||
saveMemories(memories);
|
||||
setIsRetrievingMemories(false);
|
||||
},
|
||||
onToken: (stream, token) => {
|
||||
if (stream === 'withMemory') {
|
||||
withMemoryContent += token;
|
||||
setComparison(prev => ({
|
||||
...prev,
|
||||
withMemory: { ...prev.withMemory, content: withMemoryContent },
|
||||
}));
|
||||
} else {
|
||||
withoutMemoryContent += token;
|
||||
setComparison(prev => ({
|
||||
...prev,
|
||||
withoutMemory: { ...prev.withoutMemory, content: withoutMemoryContent },
|
||||
}));
|
||||
}
|
||||
},
|
||||
onStreamDone: (stream) => {
|
||||
setComparison(prev => {
|
||||
const updated = {
|
||||
...prev,
|
||||
[stream]: { ...prev[stream], isStreaming: false, isDone: true },
|
||||
};
|
||||
// When withMemory stream is done, start loading follow-ups indicator
|
||||
if (stream === 'withMemory') {
|
||||
setIsLoadingFollowUps(true);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
onFollowUps: (followUps) => {
|
||||
// Store follow-ups to be added when assistant message is created
|
||||
pendingFollowUps = followUps;
|
||||
setIsLoadingFollowUps(false);
|
||||
},
|
||||
onComplete: () => {
|
||||
// Add complete assistant message with follow-ups
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: withMemoryContent,
|
||||
followUps: pendingFollowUps,
|
||||
};
|
||||
const finalMessages = [...updatedMessages, assistantMessage];
|
||||
setMessages(finalMessages);
|
||||
saveChatHistory(finalMessages);
|
||||
setIsLoading(false);
|
||||
setIsLoadingFollowUps(false);
|
||||
},
|
||||
onError: (errorMessage) => {
|
||||
setError(errorMessage);
|
||||
setIsLoading(false);
|
||||
setIsRetrievingMemories(false);
|
||||
setIsLoadingFollowUps(false);
|
||||
setComparison(initialComparisonState);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
setIsLoading(false);
|
||||
setIsRetrievingMemories(false);
|
||||
setIsLoadingFollowUps(false);
|
||||
setComparison(initialComparisonState);
|
||||
}
|
||||
},
|
||||
[messages, isLoading, saveChatHistory, saveMemories]
|
||||
);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
setMessages([]);
|
||||
setCurrentMemories([]);
|
||||
setError(null);
|
||||
setComparison(initialComparisonState);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(MEMORIES_STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
currentMemories,
|
||||
isLoading,
|
||||
isRetrievingMemories,
|
||||
isLoadingFollowUps,
|
||||
error,
|
||||
comparison,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
};
|
||||
}
|
||||
9
use-cases/game-of-throne-demo/frontend/src/main.tsx
Normal file
9
use-cases/game-of-throne-demo/frontend/src/main.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
205
use-cases/game-of-throne-demo/frontend/src/services/api.ts
Normal file
205
use-cases/game-of-throne-demo/frontend/src/services/api.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import { Memory, Message, SSEEvent } from '../types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export interface ChatStreamCallbacks {
|
||||
onMemories: (memories: Memory[]) => void;
|
||||
onToken: (token: string) => void;
|
||||
onDone: () => void;
|
||||
onFollowUps: (followUps: string[]) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
export async function sendChatMessage(
|
||||
message: string,
|
||||
conversationHistory: Message[],
|
||||
callbacks: ChatStreamCallbacks
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
conversationHistory,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Response body is not readable');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete SSE messages
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
try {
|
||||
const event = JSON.parse(data) as SSEEvent;
|
||||
|
||||
switch (event.type) {
|
||||
case 'memories':
|
||||
if (event.memories) {
|
||||
callbacks.onMemories(event.memories);
|
||||
}
|
||||
break;
|
||||
case 'token':
|
||||
if (event.token) {
|
||||
callbacks.onToken(event.token);
|
||||
}
|
||||
break;
|
||||
case 'done':
|
||||
callbacks.onDone();
|
||||
break;
|
||||
case 'followups':
|
||||
if (event.followUps) {
|
||||
callbacks.onFollowUps(event.followUps);
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
callbacks.onError(event.message || 'An error occurred');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE event:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in chat stream:', error);
|
||||
callbacks.onError(
|
||||
error instanceof Error ? error.message : 'Connection lost. Please check your internet.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompareStreamCallbacks {
|
||||
onMemories: (memories: Memory[]) => void;
|
||||
onToken: (stream: 'withMemory' | 'withoutMemory', token: string) => void;
|
||||
onStreamDone: (stream: 'withMemory' | 'withoutMemory') => void;
|
||||
onFollowUps: (followUps: string[]) => void;
|
||||
onComplete: () => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
export async function sendCompareMessage(
|
||||
message: string,
|
||||
conversationHistory: Message[],
|
||||
callbacks: CompareStreamCallbacks
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/chat/compare`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
conversationHistory,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Response body is not readable');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete SSE messages
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
try {
|
||||
const event = JSON.parse(data) as SSEEvent;
|
||||
|
||||
switch (event.type) {
|
||||
case 'memories':
|
||||
if (event.memories) {
|
||||
callbacks.onMemories(event.memories);
|
||||
}
|
||||
break;
|
||||
case 'token':
|
||||
if (event.token && event.stream) {
|
||||
callbacks.onToken(event.stream, event.token);
|
||||
}
|
||||
break;
|
||||
case 'done':
|
||||
if (event.stream) {
|
||||
callbacks.onStreamDone(event.stream);
|
||||
}
|
||||
break;
|
||||
case 'followups':
|
||||
if (event.followUps) {
|
||||
callbacks.onFollowUps(event.followUps);
|
||||
}
|
||||
break;
|
||||
case 'complete':
|
||||
callbacks.onComplete();
|
||||
break;
|
||||
case 'error':
|
||||
callbacks.onError(event.message || 'An error occurred');
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE event:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in compare stream:', error);
|
||||
callbacks.onError(
|
||||
error instanceof Error ? error.message : 'Connection lost. Please check your internet.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkHealth(): Promise<{
|
||||
status: string;
|
||||
backend: string;
|
||||
openai: string;
|
||||
memory: string;
|
||||
}> {
|
||||
const response = await fetch(`${API_URL}/api/health`);
|
||||
return response.json();
|
||||
}
|
||||
48
use-cases/game-of-throne-demo/frontend/src/types/index.ts
Normal file
48
use-cases/game-of-throne-demo/frontend/src/types/index.ts
Normal file
@ -0,0 +1,48 @@
|
||||
export interface Memory {
|
||||
id: string;
|
||||
content: string;
|
||||
metadata: {
|
||||
bookTitle: string;
|
||||
chapterNumber?: number;
|
||||
chapterName?: string;
|
||||
};
|
||||
relevanceScore?: number;
|
||||
// Rich fields from EverMind Cloud API
|
||||
subject?: string; // Concise title/headline
|
||||
summary?: string; // Short summary paragraph
|
||||
episode?: string; // Detailed narrative with timestamps
|
||||
originalContent?: string; // The actual source text from the book
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
followUps?: string[]; // AI-generated follow-up questions
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
messages: Message[];
|
||||
currentMemories: Memory[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
type: 'memories' | 'token' | 'done' | 'followups' | 'error' | 'complete';
|
||||
stream?: 'withMemory' | 'withoutMemory';
|
||||
memories?: Memory[];
|
||||
token?: string;
|
||||
message?: string;
|
||||
followUps?: string[];
|
||||
}
|
||||
|
||||
export interface ComparisonStreamState {
|
||||
content: string;
|
||||
isStreaming: boolean;
|
||||
isDone: boolean;
|
||||
}
|
||||
|
||||
export interface ComparisonState {
|
||||
withMemory: ComparisonStreamState;
|
||||
withoutMemory: ComparisonStreamState;
|
||||
}
|
||||
1
use-cases/game-of-throne-demo/frontend/src/vite-env.d.ts
vendored
Normal file
1
use-cases/game-of-throne-demo/frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
use-cases/game-of-throne-demo/frontend/tsconfig.json
Normal file
25
use-cases/game-of-throne-demo/frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
use-cases/game-of-throne-demo/frontend/tsconfig.node.json
Normal file
10
use-cases/game-of-throne-demo/frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
9
use-cases/game-of-throne-demo/frontend/vite.config.ts
Normal file
9
use-cases/game-of-throne-demo/frontend/vite.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user