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:
Elliot Chen
2026-06-05 22:35:51 +08:00
commit 518b8eca85
636 changed files with 160553 additions and 0 deletions

View File

@ -0,0 +1,8 @@
node_modules
dist
.env
.env.local
*.log
.git
.gitignore
deploy.sh

View 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

View 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 },
],
},
}

View File

@ -0,0 +1 @@
.vercel

View 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;"]

View 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>

View 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";
}
}

View 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"
}
}

File diff suppressed because it is too large Load Diff

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
};
}

View File

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

View 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>,
)

View 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();
}

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

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
},
})