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:
21
use-cases/game-of-throne-demo/LICENSE
Normal file
21
use-cases/game-of-throne-demo/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 EverMind
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
167
use-cases/game-of-throne-demo/README.md
Normal file
167
use-cases/game-of-throne-demo/README.md
Normal file
@ -0,0 +1,167 @@
|
||||
# EverMem Story Memory Demo
|
||||
|
||||
> Built on [EverCore](https://github.com/EverMind-AI/EverOS/) - Open-source AI memory infrastructure
|
||||
|
||||
A demonstration web application showcasing [EverMem](https://evermind.ai)'s AI memory infrastructure through an interactive Q&A experience with "A Game of Thrones" (Book 1).
|
||||
|
||||
Ask questions about the book and watch two AI responses stream side-by-side: one **with memory** using EverMem to retrieve relevant passages, and one **without memory** using only the LLM's training data. See the difference memory makes.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Side-by-Side Comparison**: Watch two responses stream simultaneously - with and without memory context
|
||||
- **Memory-Grounded Responses**: See exactly which book passages are used to answer questions
|
||||
- **Real-time Streaming**: Token-by-token AI response streaming via SSE
|
||||
- **Interactive Memory Chips**: Hover over memory chips to see full excerpt details and metadata
|
||||
- **Follow-up Suggestions**: AI-generated follow-up questions after each response
|
||||
- **Dark Theme UI**: Modern, clean interface inspired by EverMind's design
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: React 18 + TypeScript + Vite
|
||||
- **Backend**: Node.js + Express + Bun
|
||||
- **AI**: Claude Haiku (via OpenRouter)
|
||||
- **Memory**: [EverMind Cloud API](https://evermind.ai)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) (latest version)
|
||||
- OpenAI API key (or OpenRouter API key)
|
||||
- EverMind Cloud API key (apply at [EverCore Cloud](https://console.evermind.ai/))
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd evermem-story-demo
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
**Backend** (`backend/.env`):
|
||||
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
Edit `backend/.env`:
|
||||
```bash
|
||||
OPENAI_API_KEY=your-openrouter-api-key
|
||||
OPENAI_MODEL=anthropic/claude-3-haiku
|
||||
PORT=3001
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# EverMind Cloud
|
||||
USE_EVERMEMOS=true
|
||||
EVERMEMOS_URL=https://api.evermind.ai
|
||||
EVERMEMOS_API_KEY=your-evermind-api-key
|
||||
```
|
||||
|
||||
**Frontend** (`frontend/.env`):
|
||||
|
||||
```bash
|
||||
cp frontend/.env.example frontend/.env
|
||||
```
|
||||
|
||||
The default `VITE_API_URL=http://localhost:3001` should work for local development.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Start both frontend and backend
|
||||
bun run dev
|
||||
```
|
||||
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend: http://localhost:3001
|
||||
|
||||
## Loading Novel Content
|
||||
|
||||
Before using the demo, you need to load novel content into EverMind Cloud.
|
||||
|
||||
### Quick Test with Sample
|
||||
|
||||
A sample file with 5 chapters is included for testing:
|
||||
|
||||
```bash
|
||||
bun run load-novel-cloud \
|
||||
--file sample/got-sample.txt \
|
||||
--book-title "A Game of Thrones" \
|
||||
--book-abbrev "got" \
|
||||
--api-key YOUR_EVERMIND_API_KEY
|
||||
```
|
||||
|
||||
### Full Book
|
||||
|
||||
For the complete experience, obtain the full novel text file and load it:
|
||||
|
||||
```bash
|
||||
bun run load-novel-cloud \
|
||||
--file path/to/got.txt \
|
||||
--book-title "A Game of Thrones" \
|
||||
--book-abbrev "got" \
|
||||
--api-key YOUR_EVERMIND_API_KEY
|
||||
```
|
||||
|
||||
The script:
|
||||
- Detects chapter boundaries automatically (PROLOGUE, character names in caps)
|
||||
- Splits text into paragraphs
|
||||
- Uploads to EverMind Cloud with metadata
|
||||
- Supports resumption if interrupted
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── frontend/ # React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI components
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── services/ # API client
|
||||
│ │ └── types/ # TypeScript types
|
||||
│ └── public/ # Static assets
|
||||
├── backend/ # Express backend
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API endpoints
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ └── utils/ # Utilities
|
||||
├── scripts/ # CLI tools
|
||||
│ ├── load-novel-cloud.ts # Load novel to EverMind
|
||||
│ └── clear-memories-cloud.ts
|
||||
└── sample/ # Sample data for testing
|
||||
└── got-sample.txt # 5 chapters from Book 1
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start dev servers
|
||||
bun run dev
|
||||
|
||||
# Frontend only
|
||||
bun run dev:frontend
|
||||
|
||||
# Backend only
|
||||
bun run dev:backend
|
||||
|
||||
# Type check
|
||||
bun run type-check
|
||||
|
||||
# Lint
|
||||
bun run lint
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [EverMind](https://evermind.ai) for the memory infrastructure
|
||||
- George R.R. Martin for "A Song of Ice and Fire"
|
||||
8
use-cases/game-of-throne-demo/backend/.dockerignore
Normal file
8
use-cases/game-of-throne-demo/backend/.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.eslintrc.cjs
|
||||
12
use-cases/game-of-throne-demo/backend/.env.example
Normal file
12
use-cases/game-of-throne-demo/backend/.env.example
Normal file
@ -0,0 +1,12 @@
|
||||
# OpenAI / OpenRouter Configuration
|
||||
OPENAI_API_KEY=your-openrouter-api-key-here
|
||||
OPENAI_MODEL=anthropic/claude-3-haiku
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# EverMind Cloud Configuration
|
||||
USE_EVERMEMOS=true
|
||||
EVERMEMOS_URL=https://api.evermind.ai
|
||||
EVERMEMOS_API_KEY=your-evermind-api-key-here
|
||||
17
use-cases/game-of-throne-demo/backend/.eslintrc.cjs
Normal file
17
use-cases/game-of-throne-demo/backend/.eslintrc.cjs
Normal file
@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { node: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
}
|
||||
20
use-cases/game-of-throne-demo/backend/Dockerfile
Normal file
20
use-cases/game-of-throne-demo/backend/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
# Use official Bun image
|
||||
FROM oven/bun:1 AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
FROM base AS deps
|
||||
COPY package.json bun.lockb* ./
|
||||
RUN bun install --frozen-lockfile || bun install
|
||||
|
||||
# Build stage (copy source)
|
||||
FROM base AS runner
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Cloud Run uses PORT env var (default 8080)
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the server
|
||||
CMD ["bun", "run", "src/server.ts"]
|
||||
28
use-cases/game-of-throne-demo/backend/package.json
Normal file
28
use-cases/game-of-throne-demo/backend/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "bun src/server.ts",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint . --ext ts --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"openai": "^4.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"eslint": "^8.55.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
274
use-cases/game-of-throne-demo/backend/src/data/mockMemories.ts
Normal file
274
use-cases/game-of-throne-demo/backend/src/data/mockMemories.ts
Normal file
@ -0,0 +1,274 @@
|
||||
import { Memory } from '../services/IMemoryService.js';
|
||||
|
||||
export const mockMemories: Memory[] = [
|
||||
{
|
||||
id: 'got-ch01-1',
|
||||
content: 'The morning had dawned clear and cold, with a crispness that hinted at the end of summer. They set forth at daybreak to see a man beheaded, twenty in all, and Bran rode among them, nervous with excitement. This was the first time he had been deemed old enough to go with his lord father and his brothers to see the king\'s justice done.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 1,
|
||||
chapterName: 'Bran I',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-ch01-2',
|
||||
content: 'The man had been taken outside a small holdfast in the hills. Robb thought he was a wildling, his sword sworn to Mance Rayder, the King-beyond-the-Wall. It made Bran\'s skin prickle to think of it. He remembered the hearth tales Old Nan told them. The wildlings were cruel men, she said, slavers and slayers and thieves. They consorted with giants and ghouls, stole girl children in the dead of night, and drank blood from polished horns.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 1,
|
||||
chapterName: 'Bran I',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-ch49-1',
|
||||
content: '"If I did, my word would be as hollow as an empty suit of armor. My life is not so precious to me as that." "Pity." The eunuch stood. "And your daughter\'s life, my lord? How precious is that?" A chill pierced Ned\'s heart. "My daughter..." "Surely you did not think I\'d forgotten about your sweet innocent, my lord? The queen most certainly has not."',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 49,
|
||||
chapterName: 'Eddard XIV',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-ch58-1',
|
||||
content: 'High atop the Hill of Rhaenys, the Dragonpit wore the sunset like a crown of fire. Below, the streets of King\'s Landing were a labyrinth of shadows. Arya kept close to the walls as she made her way through the torchlit alleys. The gold cloaks were out in force, and she was not the only one to take note of them. She saw cutpurses and beggars and whores slipping off into the darkness at their approach.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 58,
|
||||
chapterName: 'Arya V',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-ch65-1',
|
||||
content: '"I am Eddard Stark, Lord of Winterfell and Hand of the King," he said, and his voice echoed off the walls. "I come before you to confess my treason in the sight of gods and men." "No," someone shouted from the crowd. Others took up the cry. If the gods were good, they might spare Sansa the sight of her father\'s death. "I betrayed the faith of my king and the trust of my friend, Robert," Ned said. "I swore to protect and defend his children, yet before his blood was cold, I plotted to murder his son and seize the throne for myself."',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 65,
|
||||
chapterName: 'Arya V',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-ch65-2',
|
||||
content: 'The headsman moved forward. "Bring me his head." The knight hesitated. Ser Ilyn, Payne, the King\'s Justice, had taken the blade out of its scabbard. The blade, the greatsword Ice, Eddard Stark\'s greatsword, the same blade Ned himself had used to take off the head of the deserter that had started it all.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 65,
|
||||
chapterName: 'Arya V',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-jon-1',
|
||||
content: 'Jon Snow was the only brother never to stand inside the sept. He had been born bastard. Whoever his mother had been, she had left nothing of herself but Jon\'s dark hair. The Stark face was there for all to see, the long face, the grey eyes, the brown hair. But the rest... he was a bastard, and this he could not change.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 5,
|
||||
chapterName: 'Jon I',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-wall-1',
|
||||
content: 'The Wall was like that. As much as seven hundred feet high at some places, and so thick at the base that it would take a man half the morning to walk from one side to the other. It was made of ice, solid ice, pale blue and sometimes white, sometimes dark as stone. Ancient ice, with the weight of centuries pressing it down.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 19,
|
||||
chapterName: 'Jon III',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-wall-2',
|
||||
content: 'The Night\'s Watch had once manned seventeen of the nineteen great castles built at intervals along the Wall, but that was in the old days, when the realm was young and had few enemies. Now only three were still occupied: Castle Black in the center, the Shadow Tower hard by the mountains, and Eastwatch-by-the-Sea.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 19,
|
||||
chapterName: 'Jon III',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-dany-1',
|
||||
content: 'Daenerys Targaryen had never been to Vaes Dothrak, but she knew of it. It was said to be the only city in all the grass sea, and as large as all the Dothraki khalasars put together. Her brother Viserys had told her that the Dothraki did not build, they only conquered. But Vaes Dothrak was different. The horselords came here to trade, and the crones of the dosh khaleen dwelt here in peace.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 46,
|
||||
chapterName: 'Daenerys V',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-dany-2',
|
||||
content: 'She was khaleesi, she had her bloodriders and her handmaids, and such wealth as she had never dreamed of, all a gift from Drogo. And yet it was not enough. She wanted more. She wanted to go home. She wanted her brother to be the man he should have been, to love her and protect her. She wanted to see Viserys crowned king, to see him take his rightful throne and rule the Seven Kingdoms.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 46,
|
||||
chapterName: 'Daenerys V',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-tyrion-1',
|
||||
content: 'Tyrion Lannister had claimed that most men would rather deny a hard truth than face it, but Jon had never seen the truth of that until now. They wanted to believe that the Others were dead and gone, that the Wall would protect them, and they would not look at the things that lived beyond it.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 21,
|
||||
chapterName: 'Tyrion III',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-tyrion-2',
|
||||
content: 'It all goes back and back, Tyrion thought, to our mothers and fathers and theirs before them. We are puppets dancing on the strings of those who came before us, and one day our own children will take up our strings and dance on in our steads.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 9,
|
||||
chapterName: 'Tyrion I',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cok-redwedding-1',
|
||||
content: 'The musicians in the gallery had finally gotten both king and hall alike to quiet when Joffrey rose to address them. "The realm has been through dark days," he began. "But now the dawn has broken at last!" A great roar of approval filled the throne room.',
|
||||
metadata: {
|
||||
bookTitle: 'A Clash of Kings',
|
||||
chapterNumber: 2,
|
||||
chapterName: 'Sansa I',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sos-redwedding-1',
|
||||
content: 'The Freys had brought their musicians, but they might as well have stayed at the Twins. There was no room for them on the dais, and the benches were already crowded with Freys, Boltons, Ryswells, and other northmen loyal to the Dreadfort.',
|
||||
metadata: {
|
||||
bookTitle: 'A Storm of Swords',
|
||||
chapterNumber: 51,
|
||||
chapterName: 'Catelyn VII',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sos-redwedding-2',
|
||||
content: '"Robb." She clutched at his arm. "Listen to me. Listen. For once listen. This music... it\'s... wrong. These musicians are not..." But before she could finish, the music changed to something martial and threatening. Robb turned toward the sound. "What—" Then the musicians threw down their woodharps and viols and drew forth crossbows.',
|
||||
metadata: {
|
||||
bookTitle: 'A Storm of Swords',
|
||||
chapterNumber: 51,
|
||||
chapterName: 'Catelyn VII',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sos-redwedding-3',
|
||||
content: 'The Freys at the benches stood and at the door the portcullis came crashing down with a tremendous clang, sealing them inside. Smalljon Umber swept the table off the dais and flung it at the Freys, but it fell short. Catelyn saw the musicians putting crossbows to their shoulders. "RUN!" she screamed at Robb.',
|
||||
metadata: {
|
||||
bookTitle: 'A Storm of Swords',
|
||||
chapterNumber: 51,
|
||||
chapterName: 'Catelyn VII',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-robert-1',
|
||||
content: 'Robert Baratheon had always been a man of huge appetites, but the years and his indulgences had taken their toll. The last time Ned had seen him, the king had been a great powerful brute of a man, sweating in his armor. Now he was fat. All his muscle had turned to fat.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 4,
|
||||
chapterName: 'Eddard I',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-robert-2',
|
||||
content: '"You never knew Lyanna as I did, Robert," Ned told him. "You saw her beauty, but not the iron underneath. She would have told you that you have no business in the melee." "I was getting bored sitting about," Robert said. "The joust is finished. I wanted a bit of fun."',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 30,
|
||||
chapterName: 'Eddard VII',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-throne-1',
|
||||
content: 'The Iron Throne was a seat of twisted blades and jagged edges, a throne built from a thousand swords surrendered to Aegon the Conqueror. It was a monstrous thing of spikes and jagged edges and metal ribbons, all of it cold iron. The throne was high enough that Joffrey had to climb steps to reach it, and the steps were as sharp as knives.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 57,
|
||||
chapterName: 'Sansa V',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-winterfell-1',
|
||||
content: 'Winterfell was everything the south was not: an ancient castle, dark and cold, built by the First Men ten thousand years ago. Its walls were thick, its towers high, and its crypts ran deep into the earth. The hot springs gave it heat, and the glass gardens gave it food.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 1,
|
||||
chapterName: 'Bran I',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-stark-words',
|
||||
content: '"Winter is coming," Ned said. It was the Stark words, and they had never been more apt. The lone wolf dies, but the pack survives. Summer was the time for squabbles. Winter was coming, and winter was the time for family.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 1,
|
||||
chapterName: 'Bran I',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cok-blackwater-1',
|
||||
content: 'Tyrion saw the first of Stannis\'s fleet emerging from the morning mists. "They\'re here," he said calmly. The sight was enough to make a man\'s blood run cold. The ships came on, more and more of them, galleys and great cogs, carracks and dromonds, and somewhere out in that forest of masts was Stannis Baratheon himself.',
|
||||
metadata: {
|
||||
bookTitle: 'A Clash of Kings',
|
||||
chapterNumber: 58,
|
||||
chapterName: 'Tyrion XIII',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cok-blackwater-2',
|
||||
content: 'The first ship to reach the chain was a hulking three-decker with green sails, the Swordfish. When the boom chain caught her, she heeled over drunkenly, oars snapping like twigs. The next ship rammed her and both began to burn. Then a third struck, and the fourth and fifth collided trying to avoid the others. The whole river was a tangle of burning ships.',
|
||||
metadata: {
|
||||
bookTitle: 'A Clash of Kings',
|
||||
chapterNumber: 58,
|
||||
chapterName: 'Tyrion XIII',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cok-wildfire-1',
|
||||
content: 'The single pot of wildfire would be enough to turn that whole stretch of river into an inferno. He imagined the ships burning, the men screaming as the flesh melted off their bones. Tyrion was no stranger to the screams of dying men. He had heard them aplenty at the Green Fork. Yet somehow this was different.',
|
||||
metadata: {
|
||||
bookTitle: 'A Clash of Kings',
|
||||
chapterNumber: 58,
|
||||
chapterName: 'Tyrion XIII',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sos-joffrey-death-1',
|
||||
content: 'Joffrey lurched to his feet. "I\'m choking," he said, clawing at his throat. His face was turning red. Then he began to cough, a terrible wet coughing. The king\'s chalice slipped from his hand and bounced across the dais, the dark red wine spreading across the floor like blood.',
|
||||
metadata: {
|
||||
bookTitle: 'A Storm of Swords',
|
||||
chapterNumber: 60,
|
||||
chapterName: 'Tyrion VIII',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sos-joffrey-death-2',
|
||||
content: 'The boy\'s only thirteen, Tyrion realized. He was going to laugh, then, but instead he felt a strange sadness. Joffrey was a monster, true, but he was also a boy, and now he was dying. His face had gone from red to purple.',
|
||||
metadata: {
|
||||
bookTitle: 'A Storm of Swords',
|
||||
chapterNumber: 60,
|
||||
chapterName: 'Tyrion VIII',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-kings-landing-1',
|
||||
content: 'King\'s Landing was a pestilent city at the best of times. The sept stood on Visenya\'s Hill, and between the hill and the castle, the streets were a snarl of wynds and alleys. The city reeked of offal, of nightsoil, of the smell of people packed too close together.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 18,
|
||||
chapterName: 'Catelyn IV',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-direwolves-1',
|
||||
content: 'A freak of nature, Jon told himself, as rare as any dwarf. He had never seen a direwolf before, but he knew them from tales. They were larger than normal wolves, and fiercer. The last direwolf in the Seven Kingdoms had been killed two hundred years ago. Until now.',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 1,
|
||||
chapterName: 'Bran I',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'got-direwolves-2',
|
||||
content: '"There are five pups," Jon said. "Three male, two female." "What of it?" Theon Greyjoy said. "An omen," Jon Snow said quietly. "The direwolf is the sigil of your house. You have five trueborn children. Three sons, two daughters. The direwolf is the sigil of your house."',
|
||||
metadata: {
|
||||
bookTitle: 'A Game of Thrones',
|
||||
chapterNumber: 1,
|
||||
chapterName: 'Bran I',
|
||||
},
|
||||
},
|
||||
];
|
||||
219
use-cases/game-of-throne-demo/backend/src/routes/chat.ts
Normal file
219
use-cases/game-of-throne-demo/backend/src/routes/chat.ts
Normal file
@ -0,0 +1,219 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { IMemoryService } from '../services/IMemoryService.js';
|
||||
import { OpenAIService, Message } from '../services/OpenAIService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
interface ChatRequest {
|
||||
message: string;
|
||||
conversationHistory?: Message[];
|
||||
}
|
||||
|
||||
export function createChatRouter(
|
||||
memoryService: IMemoryService,
|
||||
openaiService: OpenAIService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/chat', async (req: Request, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const { message, conversationHistory = [] } = req.body as ChatRequest;
|
||||
|
||||
if (!message || typeof message !== 'string') {
|
||||
res.status(400).json({ error: 'Invalid request: message is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('ChatRoute', `User query received: "${message}"`);
|
||||
|
||||
// Set up SSE
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Retrieve memories
|
||||
const memoryStartTime = Date.now();
|
||||
const memories = await memoryService.retrieveMemories(message, 5);
|
||||
const memoryTime = Date.now() - memoryStartTime;
|
||||
|
||||
logger.info('MemoryService', `Retrieved ${memories.length} memories in ${memoryTime}ms`);
|
||||
|
||||
// Send memories to client
|
||||
res.write(`data: ${JSON.stringify({ type: 'memories', memories })}\n\n`);
|
||||
|
||||
// Stream LLM response
|
||||
logger.info('OpenAIService', 'Streaming response started');
|
||||
let tokenCount = 0;
|
||||
|
||||
try {
|
||||
let fullResponse = '';
|
||||
for await (const token of openaiService.streamChatCompletion(
|
||||
message,
|
||||
memories,
|
||||
conversationHistory
|
||||
)) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'token', token })}\n\n`);
|
||||
fullResponse += token;
|
||||
tokenCount++;
|
||||
}
|
||||
|
||||
// Send done event
|
||||
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
logger.info('OpenAIService', `Response complete: ${tokenCount} tokens in ${totalTime}ms`);
|
||||
|
||||
// Generate follow-up questions asynchronously
|
||||
try {
|
||||
const followUps = await openaiService.generateFollowUps(message, fullResponse, memories);
|
||||
if (followUps.length > 0) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'followups', followUps })}\n\n`);
|
||||
logger.info('OpenAIService', `Generated ${followUps.length} follow-up questions`);
|
||||
}
|
||||
} catch (followUpError) {
|
||||
logger.error('OpenAIService', 'Error generating follow-ups:', followUpError);
|
||||
// Don't fail the response if follow-ups fail
|
||||
}
|
||||
|
||||
res.end();
|
||||
} catch (streamError) {
|
||||
logger.error('OpenAIService', 'Streaming error:', streamError);
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Unable to generate response. Please try again.'
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ChatRoute', 'Error processing chat request:', error);
|
||||
|
||||
// Try to send error if headers not sent
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
} else {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'An error occurred processing your request.'
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Comparison endpoint - runs two parallel streams (with and without memory)
|
||||
router.post('/chat/compare', async (req: Request, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const { message, conversationHistory = [] } = req.body as ChatRequest;
|
||||
|
||||
if (!message || typeof message !== 'string') {
|
||||
res.status(400).json({ error: 'Invalid request: message is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('ChatRoute', `Comparison query received: "${message}"`);
|
||||
|
||||
// Set up SSE
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// 1. Retrieve memories once
|
||||
const memoryStartTime = Date.now();
|
||||
const memories = await memoryService.retrieveMemories(message, 5);
|
||||
const memoryTime = Date.now() - memoryStartTime;
|
||||
|
||||
logger.info('MemoryService', `Retrieved ${memories.length} memories in ${memoryTime}ms`);
|
||||
|
||||
// Send memories to client
|
||||
res.write(`data: ${JSON.stringify({ type: 'memories', memories })}\n\n`);
|
||||
|
||||
// 2. Run both streams in parallel
|
||||
let withMemoryResponse = '';
|
||||
|
||||
const processStream = async (
|
||||
stream: AsyncIterable<string>,
|
||||
streamName: 'withMemory' | 'withoutMemory'
|
||||
): Promise<string> => {
|
||||
let fullResponse = '';
|
||||
let tokenCount = 0;
|
||||
|
||||
for await (const token of stream) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'token', token, stream: streamName })}\n\n`);
|
||||
fullResponse += token;
|
||||
tokenCount++;
|
||||
}
|
||||
|
||||
// Send stream-specific done event
|
||||
res.write(`data: ${JSON.stringify({ type: 'done', stream: streamName })}\n\n`);
|
||||
logger.info('OpenAIService', `${streamName} stream complete: ${tokenCount} tokens`);
|
||||
|
||||
return fullResponse;
|
||||
};
|
||||
|
||||
try {
|
||||
// Create both streams
|
||||
const withMemoryStream = openaiService.streamChatCompletion(message, memories, conversationHistory);
|
||||
const withoutMemoryStream = openaiService.streamChatCompletion(message, [], conversationHistory);
|
||||
|
||||
// 3. Process concurrently with Promise.all
|
||||
logger.info('OpenAIService', 'Starting parallel streaming for comparison');
|
||||
const [withMemoryResult] = await Promise.all([
|
||||
processStream(withMemoryStream, 'withMemory'),
|
||||
processStream(withoutMemoryStream, 'withoutMemory')
|
||||
]);
|
||||
|
||||
withMemoryResponse = withMemoryResult;
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
logger.info('OpenAIService', `Comparison complete in ${totalTime}ms`);
|
||||
|
||||
// 4. Generate follow-ups for "with memory" response
|
||||
try {
|
||||
const followUps = await openaiService.generateFollowUps(message, withMemoryResponse, memories);
|
||||
if (followUps.length > 0) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'followups', followUps })}\n\n`);
|
||||
logger.info('OpenAIService', `Generated ${followUps.length} follow-up questions`);
|
||||
}
|
||||
} catch (followUpError) {
|
||||
logger.error('OpenAIService', 'Error generating follow-ups:', followUpError);
|
||||
}
|
||||
|
||||
// 5. Send complete event
|
||||
res.write(`data: ${JSON.stringify({ type: 'complete' })}\n\n`);
|
||||
res.end();
|
||||
} catch (streamError) {
|
||||
logger.error('OpenAIService', 'Comparison streaming error:', streamError);
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Unable to generate comparison response. Please try again.'
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ChatRoute', 'Error processing comparison request:', error);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
} else {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'An error occurred processing your request.'
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
62
use-cases/game-of-throne-demo/backend/src/routes/health.ts
Normal file
62
use-cases/game-of-throne-demo/backend/src/routes/health.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { IMemoryService } from '../services/IMemoryService.js';
|
||||
import { OpenAIService } from '../services/OpenAIService.js';
|
||||
|
||||
interface HealthStatus {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
backend: 'ok';
|
||||
openai: 'ok' | 'error';
|
||||
memory: 'ok' | 'error';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function createHealthRouter(
|
||||
memoryService: IMemoryService,
|
||||
openaiService: OpenAIService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get('/health', async (_req: Request, res: Response) => {
|
||||
const health: HealthStatus = {
|
||||
status: 'healthy',
|
||||
backend: 'ok',
|
||||
openai: 'ok',
|
||||
memory: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Check OpenAI
|
||||
const openaiAvailable = await openaiService.isAvailable();
|
||||
if (!openaiAvailable) {
|
||||
health.openai = 'error';
|
||||
health.status = 'degraded';
|
||||
}
|
||||
} catch {
|
||||
health.openai = 'error';
|
||||
health.status = 'degraded';
|
||||
}
|
||||
|
||||
try {
|
||||
// Check Memory Service
|
||||
const memoryAvailable = await memoryService.isAvailable();
|
||||
if (!memoryAvailable) {
|
||||
health.memory = 'error';
|
||||
health.status = 'degraded';
|
||||
}
|
||||
} catch {
|
||||
health.memory = 'error';
|
||||
health.status = 'degraded';
|
||||
}
|
||||
|
||||
// If both critical services are down, status is unhealthy
|
||||
if (health.openai === 'error' && health.memory === 'error') {
|
||||
health.status = 'unhealthy';
|
||||
}
|
||||
|
||||
const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
|
||||
res.status(statusCode).json(health);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
74
use-cases/game-of-throne-demo/backend/src/server.ts
Normal file
74
use-cases/game-of-throne-demo/backend/src/server.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { MockMemoryService } from './services/MockMemoryService.js';
|
||||
import { EverCoreService } from './services/EverMemOSService.js';
|
||||
import { OpenAIService } from './services/OpenAIService.js';
|
||||
import { createChatRouter } from './routes/chat.js';
|
||||
import { createHealthRouter } from './routes/health.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
|
||||
// Environment variables
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY || '';
|
||||
const OPENAI_MODEL = process.env.OPENAI_MODEL || 'openai/gpt-5.2';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
const USE_EVERMEMOS = process.env.USE_EVERMEMOS === 'true';
|
||||
const EVERMEMOS_URL = process.env.EVERMEMOS_URL || 'http://localhost:1995';
|
||||
const EVERMEMOS_API_KEY = process.env.EVERMEMOS_API_KEY || '';
|
||||
const EVERMEMOS_GROUP_ID = process.env.EVERMEMOS_GROUP_ID || 'asoiaf';
|
||||
|
||||
if (!OPENAI_API_KEY) {
|
||||
logger.error('Server', 'OPENAI_API_KEY environment variable is not set (use OpenRouter API key)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
const memoryService = USE_EVERMEMOS
|
||||
? new EverCoreService({
|
||||
baseUrl: EVERMEMOS_URL,
|
||||
apiKey: EVERMEMOS_API_KEY || undefined,
|
||||
groupId: EVERMEMOS_GROUP_ID,
|
||||
})
|
||||
: new MockMemoryService();
|
||||
const openaiService = new OpenAIService(OPENAI_API_KEY, OPENAI_MODEL);
|
||||
|
||||
const isCloudMode = USE_EVERMEMOS && !!EVERMEMOS_API_KEY;
|
||||
logger.info('Server', `Memory service: ${USE_EVERMEMOS ? (isCloudMode ? 'EverMind Cloud' : 'EverCore (local)') : 'Mock'}`);
|
||||
if (USE_EVERMEMOS) {
|
||||
logger.info('Server', `EverCore URL: ${EVERMEMOS_URL}`);
|
||||
if (isCloudMode) {
|
||||
logger.info('Server', `EverMind Cloud API Key: ${EVERMEMOS_API_KEY.slice(0, 8)}...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: FRONTEND_URL === '*' ? true : FRONTEND_URL,
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, _res, next) => {
|
||||
logger.info('Server', `${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api', createChatRouter(memoryService, openaiService));
|
||||
app.use('/api', createHealthRouter(memoryService, openaiService));
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
logger.error('Server', 'Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
logger.info('Server', `Backend server running on http://localhost:${PORT}`);
|
||||
logger.info('Server', `CORS enabled for: ${FRONTEND_URL}`);
|
||||
logger.info('Server', `Using OpenRouter with model: ${OPENAI_MODEL}`);
|
||||
});
|
||||
@ -0,0 +1,377 @@
|
||||
import type { IMemoryService, Memory } from './IMemoryService';
|
||||
|
||||
interface EverCoreMemoryItem {
|
||||
memory_type: string;
|
||||
summary: string | null;
|
||||
subject?: string; // Concise title/headline
|
||||
episode?: string; // Detailed narrative with timestamps
|
||||
user_id?: string;
|
||||
timestamp?: string;
|
||||
group_id?: string | null;
|
||||
group_name?: string | null;
|
||||
keywords?: string[] | null;
|
||||
linked_entities?: string[] | null;
|
||||
score?: number | null;
|
||||
original_data?: OriginalDataItem[]; // Nested inside each memory item
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface OriginalDataMessage {
|
||||
content: string;
|
||||
extend?: {
|
||||
message_id?: string;
|
||||
speaker_name?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface OriginalDataItem {
|
||||
data_type: string;
|
||||
messages: OriginalDataMessage[];
|
||||
}
|
||||
|
||||
interface ProfileSearchItem {
|
||||
item_type: 'explicit_info' | 'implicit_trait';
|
||||
category?: string;
|
||||
trait_name?: string;
|
||||
description: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface EverCoreSearchResponse {
|
||||
status: string;
|
||||
message?: string;
|
||||
result: {
|
||||
profiles: ProfileSearchItem[];
|
||||
memories: EverCoreMemoryItem[];
|
||||
total_count: number;
|
||||
scores: number[];
|
||||
has_more: boolean;
|
||||
pending_messages?: unknown[];
|
||||
query_metadata?: unknown;
|
||||
metadata?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface EverCoreHealthResponse {
|
||||
status: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Book abbreviation to full title mapping
|
||||
*/
|
||||
const BOOK_TITLES: Record<string, string> = {
|
||||
'got': 'A Game of Thrones',
|
||||
'cok': 'A Clash of Kings',
|
||||
'sos': 'A Storm of Swords',
|
||||
'ffc': 'A Feast for Crows',
|
||||
'dwd': 'A Dance with Dragons',
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for EverCore/EverMind Cloud service
|
||||
*/
|
||||
interface EverCoreConfig {
|
||||
baseUrl: string;
|
||||
apiKey?: string; // Required for cloud API
|
||||
groupId?: string; // Group ID for search (default: 'asoiaf')
|
||||
}
|
||||
|
||||
/**
|
||||
* EverCore service implementation for memory retrieval
|
||||
* Supports both local EverCore and EverMind Cloud API
|
||||
*/
|
||||
export class EverCoreService implements IMemoryService {
|
||||
private baseUrl: string;
|
||||
private apiKey?: string;
|
||||
private groupId: string;
|
||||
private isCloudMode: boolean;
|
||||
|
||||
constructor(config: string | EverCoreConfig) {
|
||||
if (typeof config === 'string') {
|
||||
// Legacy: just a URL string (local mode)
|
||||
this.baseUrl = config.replace(/\/$/, '');
|
||||
this.apiKey = undefined;
|
||||
this.groupId = 'asoiaf';
|
||||
this.isCloudMode = false;
|
||||
} else {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = config.apiKey;
|
||||
this.groupId = config.groupId || 'asoiaf';
|
||||
this.isCloudMode = !!config.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve relevant memories for a query using EverCore search
|
||||
*/
|
||||
async retrieveMemories(query: string, limit: number = 5): Promise<Memory[]> {
|
||||
try {
|
||||
const searchUrl = `${this.baseUrl}/api/v0/memories/search`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
retrieve_method: 'hybrid',
|
||||
top_k: limit.toString(),
|
||||
include_metadata: 'true',
|
||||
});
|
||||
|
||||
// Add group_ids for cloud mode
|
||||
if (this.isCloudMode) {
|
||||
params.set('group_ids', this.groupId);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Add auth header for cloud mode
|
||||
if (this.apiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${searchUrl}?${params}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(this.isCloudMode ? 15000 : 10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`EverCore search failed: HTTP ${response.status}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json() as EverCoreSearchResponse;
|
||||
return this.mapSearchResultsToMemories(data);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving memories from EverCore:', error);
|
||||
return []; // Graceful degradation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if EverCore service is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.apiKey) {
|
||||
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json() as EverCoreHealthResponse;
|
||||
// Cloud API returns "ok" status, local returns "healthy"
|
||||
return data.status === 'healthy' || data.status === 'ok';
|
||||
} catch (error) {
|
||||
console.warn('EverCore health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map EverCore search results to our Memory interface
|
||||
*/
|
||||
private mapSearchResultsToMemories(data: EverCoreSearchResponse): Memory[] {
|
||||
const memories: Memory[] = [];
|
||||
|
||||
const result = data.result;
|
||||
if (!result || !result.memories || result.memories.length === 0) {
|
||||
return memories;
|
||||
}
|
||||
|
||||
const memoryItems = result.memories;
|
||||
const scores = result.scores || [];
|
||||
|
||||
for (let i = 0; i < memoryItems.length; i++) {
|
||||
const item = memoryItems[i];
|
||||
const score = item.score ?? scores[i] ?? 0;
|
||||
const originalContents = item.original_data || [];
|
||||
|
||||
const memory = this.mapMemoryItem(item, score, originalContents);
|
||||
if (memory) {
|
||||
memories.push(memory);
|
||||
}
|
||||
}
|
||||
|
||||
return memories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a single EverCore memory item to our Memory interface
|
||||
*/
|
||||
private mapMemoryItem(
|
||||
item: EverCoreMemoryItem,
|
||||
score: number,
|
||||
originalContents: OriginalDataItem[] = []
|
||||
): Memory | null {
|
||||
try {
|
||||
// Extract original book content and metadata from original_data
|
||||
const originalTexts: string[] = [];
|
||||
const cleanedTexts: string[] = [];
|
||||
let firstMessageId: string | undefined;
|
||||
let metadata: Memory['metadata'] = {
|
||||
bookTitle: 'Unknown Book',
|
||||
chapterNumber: undefined,
|
||||
chapterName: undefined,
|
||||
};
|
||||
|
||||
for (const orig of originalContents) {
|
||||
for (const msg of orig.messages || []) {
|
||||
if (msg.content) {
|
||||
// Store raw content for original display
|
||||
originalTexts.push(msg.content);
|
||||
|
||||
// Parse metadata from the first message's content prefix
|
||||
if (cleanedTexts.length === 0) {
|
||||
const parsed = this.parseContent(msg.content, msg.extend?.message_id || '');
|
||||
metadata = parsed.metadata;
|
||||
cleanedTexts.push(parsed.content);
|
||||
firstMessageId = msg.extend?.message_id;
|
||||
} else {
|
||||
const parsed = this.parseContent(msg.content, '');
|
||||
cleanedTexts.push(parsed.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join original texts (with prefix) for "Show original" feature
|
||||
const originalContent = cleanedTexts.join('\n\n');
|
||||
|
||||
// Generate a unique ID
|
||||
const memoryId = firstMessageId || `memory-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Clean summary by removing date artifacts
|
||||
const cleanedSummary = this.cleanDateArtifacts(item.summary || '');
|
||||
|
||||
// Use summary as the main content for display
|
||||
return {
|
||||
id: memoryId,
|
||||
content: cleanedSummary,
|
||||
metadata,
|
||||
relevanceScore: score,
|
||||
// New rich fields
|
||||
subject: this.cleanDateArtifacts(item.subject || ''),
|
||||
summary: cleanedSummary,
|
||||
episode: item.episode,
|
||||
originalContent: originalContent || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error mapping memory item:', error, item);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content to extract metadata and clean paragraph text
|
||||
* Expected format: "[Book Title - ChX: POV Name]\n\nParagraph text..."
|
||||
*/
|
||||
private parseContent(content: string, messageId: string): {
|
||||
content: string;
|
||||
metadata: Memory['metadata'];
|
||||
} {
|
||||
// Try to extract metadata from content prefix
|
||||
const prefixMatch = content.match(/^\[(.+?)\s+-\s+Ch(\d+):\s+(.+?)\]\n\n/);
|
||||
|
||||
if (prefixMatch) {
|
||||
const [fullMatch, bookTitle, chapterNum, chapterName] = prefixMatch;
|
||||
const cleanContent = content.slice(fullMatch.length); // Remove prefix
|
||||
|
||||
return {
|
||||
content: cleanContent,
|
||||
metadata: {
|
||||
bookTitle: bookTitle.trim(),
|
||||
chapterNumber: parseInt(chapterNum, 10),
|
||||
chapterName: chapterName.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: try to parse from message_id and use full content
|
||||
const fallbackMetadata = this.parseMessageId(messageId);
|
||||
|
||||
return {
|
||||
content,
|
||||
metadata: {
|
||||
bookTitle: fallbackMetadata.bookTitle,
|
||||
chapterNumber: fallbackMetadata.chapterNumber,
|
||||
chapterName: fallbackMetadata.chapterName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse message ID to extract book and chapter info as fallback
|
||||
* Format: "asoiaf-{book}-ch{num}-p{paragraph}"
|
||||
* Example: "asoiaf-got-ch01-p001"
|
||||
*/
|
||||
private parseMessageId(messageId: string): {
|
||||
bookTitle: string;
|
||||
chapterNumber?: number;
|
||||
chapterName?: string;
|
||||
} {
|
||||
const match = messageId.match(/asoiaf-(\w+)-ch(\d+)-p(\d+)/);
|
||||
|
||||
if (match) {
|
||||
const [, bookAbbrev, chapterNum] = match;
|
||||
const bookTitle = BOOK_TITLES[bookAbbrev] || `Unknown Book (${bookAbbrev})`;
|
||||
|
||||
return {
|
||||
bookTitle,
|
||||
chapterNumber: parseInt(chapterNum, 10),
|
||||
chapterName: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Complete fallback
|
||||
return {
|
||||
bookTitle: 'Unknown Book',
|
||||
chapterNumber: undefined,
|
||||
chapterName: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove date artifacts from text generated by EverMind Cloud
|
||||
* Examples:
|
||||
* "On January 18, 2026, a Night's Watch..." -> "A Night's Watch..."
|
||||
* "Bran Witnesses His Father Execute a Deserted Night's Watchman - January 18, 2026" -> "Bran Witnesses..."
|
||||
*/
|
||||
private cleanDateArtifacts(text: string): string {
|
||||
if (!text) return text;
|
||||
|
||||
// Remove date prefixes like "On January 18, 2026, " or "On Sunday, January 18, 2026, "
|
||||
let cleaned = text.replace(
|
||||
/^On\s+(?:Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday)?,?\s*(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s*\d{4},?\s*/i,
|
||||
''
|
||||
);
|
||||
|
||||
// Remove date suffixes like " - January 18, 2026", ", January 18, 2026", or " on January 18, 2026"
|
||||
cleaned = cleaned.replace(
|
||||
/\s*[-–—,]\s*(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s*\d{4}\.?$/i,
|
||||
''
|
||||
);
|
||||
|
||||
// Remove inline date references like "(January 18, 2026)" or "on January 18, 2026"
|
||||
cleaned = cleaned.replace(
|
||||
/\s+on\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s*\d{4}/gi,
|
||||
''
|
||||
);
|
||||
|
||||
// Capitalize first letter if it was lowercased after removing prefix
|
||||
if (cleaned.length > 0 && cleaned[0] !== cleaned[0].toUpperCase()) {
|
||||
cleaned = cleaned[0].toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
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 IMemoryService {
|
||||
/**
|
||||
* Retrieve relevant memories for a query
|
||||
*/
|
||||
retrieveMemories(query: string, limit?: number): Promise<Memory[]>;
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
isAvailable(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Clear all memories (Stage 2)
|
||||
*/
|
||||
clearMemories?(): Promise<void>;
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import { IMemoryService, Memory } from './IMemoryService.js';
|
||||
import { mockMemories } from '../data/mockMemories.js';
|
||||
|
||||
export class MockMemoryService implements IMemoryService {
|
||||
constructor() {
|
||||
// No API needed for keyword-based retrieval
|
||||
}
|
||||
|
||||
async retrieveMemories(query: string, limit: number = 5): Promise<Memory[]> {
|
||||
// Fast keyword-based retrieval for PoC
|
||||
// Calculate relevance score for each memory based on keyword matching
|
||||
const queryLower = query.toLowerCase();
|
||||
const queryWords = queryLower.split(/\s+/).filter(word => word.length > 3);
|
||||
|
||||
const scoredMemories = mockMemories.map((memory, index) => {
|
||||
const contentLower = memory.content.toLowerCase();
|
||||
const chapterLower = (memory.metadata.chapterName || '').toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Score based on query word matches
|
||||
queryWords.forEach(word => {
|
||||
const contentMatches = (contentLower.match(new RegExp(word, 'g')) || []).length;
|
||||
const chapterMatches = (chapterLower.match(new RegExp(word, 'g')) || []).length;
|
||||
score += contentMatches * 10 + chapterMatches * 5;
|
||||
});
|
||||
|
||||
// Bonus for exact phrase match
|
||||
if (contentLower.includes(queryLower)) {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
return { memory, score, index };
|
||||
});
|
||||
|
||||
// Sort by score (highest first) and return top N
|
||||
const topMemories = scoredMemories
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(item => item.memory);
|
||||
|
||||
// If no matches found (all scores are 0), return random selection
|
||||
if (topMemories.every((_, i) => scoredMemories[i].score === 0)) {
|
||||
return mockMemories.slice(0, limit);
|
||||
}
|
||||
|
||||
return topMemories;
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,210 @@
|
||||
import OpenAI from 'openai';
|
||||
import { Memory } from './IMemoryService.js';
|
||||
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class OpenAIService {
|
||||
private openai: OpenAI;
|
||||
private model: string;
|
||||
private systemPromptWithMemory: string;
|
||||
private systemPromptWithoutMemory: string;
|
||||
|
||||
constructor(apiKey: string, model: string = 'anthropic/claude-3-haiku') {
|
||||
this.openai = new OpenAI({
|
||||
apiKey,
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
defaultHeaders: {
|
||||
'HTTP-Referer': 'https://github.com/your-repo', // Optional, for OpenRouter rankings
|
||||
'X-Title': 'EverMem Story Memory Demo', // Optional, for OpenRouter rankings
|
||||
}
|
||||
});
|
||||
this.model = model;
|
||||
|
||||
// System prompt when memories are provided
|
||||
this.systemPromptWithMemory = `You are an expert on "A Game of Thrones" (Book 1) by George R.R. Martin.
|
||||
You have access to numbered excerpts from the book to answer user questions accurately.
|
||||
|
||||
Guidelines:
|
||||
- ONLY use the provided memory excerpts to answer questions. Do NOT add information from general knowledge.
|
||||
- When your answer is based on a specific memory, cite it using [1], [2], etc. at the end of the relevant sentence or paragraph.
|
||||
- You can cite multiple sources for the same statement, e.g., [1][2].
|
||||
- If the provided memories don't contain enough information to fully answer the question, just answer with what's available in the memories.
|
||||
- Be concise and accurate. Stick strictly to what's in the excerpts.
|
||||
|
||||
Example format:
|
||||
"Ned Stark executed the deserter before the family discovered the direwolves [1]. The pups were found near their dead mother [2]."
|
||||
`;
|
||||
|
||||
// System prompt when no memories are provided (general knowledge only)
|
||||
this.systemPromptWithoutMemory = `You are a helpful assistant answering questions about "A Game of Thrones" (Book 1) by George R.R. Martin.
|
||||
|
||||
IMPORTANT CONSTRAINTS:
|
||||
- You must ONLY use knowledge from your training data. Do NOT search the internet, use tools, or access any external sources.
|
||||
- Answer based solely on what you remember from your training about the book.
|
||||
- If you don't remember specific details (exact quotes, chapter numbers, minor character names, specific scenes), be honest and say you're not certain rather than guessing.
|
||||
- Do NOT make up specific details like page numbers, exact quotes, or precise plot points if you're unsure.
|
||||
|
||||
Guidelines:
|
||||
- Provide a helpful answer using your general knowledge of the story, characters, and plot.
|
||||
- Be concise and conversational.
|
||||
- It's okay to give a general answer if you don't recall specifics.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream chat completion with memories as context
|
||||
*/
|
||||
async *streamChatCompletion(
|
||||
query: string,
|
||||
memories: Memory[],
|
||||
conversationHistory: Message[] = []
|
||||
): AsyncGenerator<string, void, unknown> {
|
||||
// Use appropriate system prompt based on whether memories are provided
|
||||
// Same model is used for both to show that the difference comes from memory, not model
|
||||
const systemPrompt = memories.length > 0
|
||||
? this.systemPromptWithMemory
|
||||
: this.systemPromptWithoutMemory;
|
||||
|
||||
// Build the messages array with sliding window
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
];
|
||||
|
||||
// Add memories as context with citation numbers
|
||||
if (memories.length > 0) {
|
||||
const memoriesText = memories
|
||||
.map((m, index) => {
|
||||
const chapterInfo = m.metadata.chapterNumber
|
||||
? `Chapter ${m.metadata.chapterNumber}${m.metadata.chapterName ? `: ${m.metadata.chapterName}` : ''}`
|
||||
: m.metadata.chapterName || '';
|
||||
const source = chapterInfo
|
||||
? `${m.metadata.bookTitle} - ${chapterInfo}`
|
||||
: m.metadata.bookTitle;
|
||||
|
||||
// Include both summary and original text for better context
|
||||
let content = `Summary: ${m.content}`;
|
||||
if (m.originalContent) {
|
||||
content += `\n\nOriginal Text:\n${m.originalContent}`;
|
||||
}
|
||||
|
||||
return `[${index + 1}] ${source}\n${content}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
messages.push({
|
||||
role: 'system',
|
||||
content: `Retrieved Memories (cite using [1], [2], etc.):\n\n${memoriesText}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add conversation history (sliding window - last messages that fit in ~4000 tokens)
|
||||
// Rough estimate: 1 token ≈ 4 characters
|
||||
const maxHistoryTokens = 4000;
|
||||
const maxHistoryChars = maxHistoryTokens * 4;
|
||||
|
||||
let historyChars = 0;
|
||||
const recentHistory: Message[] = [];
|
||||
|
||||
for (let i = conversationHistory.length - 1; i >= 0; i--) {
|
||||
const msg = conversationHistory[i];
|
||||
const msgChars = msg.content.length;
|
||||
|
||||
if (historyChars + msgChars > maxHistoryChars) {
|
||||
break;
|
||||
}
|
||||
|
||||
recentHistory.unshift(msg);
|
||||
historyChars += msgChars;
|
||||
}
|
||||
|
||||
messages.push(...recentHistory);
|
||||
|
||||
// Add current query
|
||||
messages.push({ role: 'user', content: query });
|
||||
|
||||
// Stream the response
|
||||
const stream = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
messages,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.choices[0]?.delta?.content;
|
||||
if (content) {
|
||||
yield content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate follow-up questions based on the conversation
|
||||
*/
|
||||
async generateFollowUps(
|
||||
query: string,
|
||||
assistantResponse: string,
|
||||
memories: Memory[]
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const memoryContext = memories
|
||||
.slice(0, 3)
|
||||
.map(m => m.subject || m.content.slice(0, 100))
|
||||
.join('; ');
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You generate follow-up questions for a Q&A about "A Game of Thrones" (Book 1).
|
||||
Given the user's question and the assistant's response, suggest 2-3 natural follow-up questions the user might want to ask.
|
||||
|
||||
Rules:
|
||||
- Questions should be specific and interesting
|
||||
- Questions should relate to the current topic or naturally expand on it
|
||||
- Keep questions concise (under 15 words each)
|
||||
- Return ONLY the questions, one per line, no numbering or bullets`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `User asked: "${query}"
|
||||
|
||||
Assistant responded about: ${assistantResponse.slice(0, 500)}
|
||||
|
||||
Related context: ${memoryContext}
|
||||
|
||||
Generate 2-3 follow-up questions:`
|
||||
}
|
||||
],
|
||||
max_tokens: 150,
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content || '';
|
||||
const questions = content
|
||||
.split('\n')
|
||||
.map(q => q.trim())
|
||||
.filter(q => q.length > 0 && q.endsWith('?'))
|
||||
.slice(0, 3);
|
||||
|
||||
return questions;
|
||||
} catch (error) {
|
||||
console.error('Error generating follow-ups:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenAI API is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await this.openai.models.list();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
use-cases/game-of-throne-demo/backend/src/utils/logger.ts
Normal file
22
use-cases/game-of-throne-demo/backend/src/utils/logger.ts
Normal file
@ -0,0 +1,22 @@
|
||||
type LogLevel = 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
class Logger {
|
||||
private log(level: LogLevel, component: string, message: string, ...args: unknown[]) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] [${level}] [${component}]`, message, ...args);
|
||||
}
|
||||
|
||||
info(component: string, message: string, ...args: unknown[]) {
|
||||
this.log('INFO', component, message, ...args);
|
||||
}
|
||||
|
||||
warn(component: string, message: string, ...args: unknown[]) {
|
||||
this.log('WARN', component, message, ...args);
|
||||
}
|
||||
|
||||
error(component: string, message: string, ...args: unknown[]) {
|
||||
this.log('ERROR', component, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
24
use-cases/game-of-throne-demo/backend/tsconfig.json
Normal file
24
use-cases/game-of-throne-demo/backend/tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
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,
|
||||
},
|
||||
})
|
||||
27
use-cases/game-of-throne-demo/package.json
Normal file
27
use-cases/game-of-throne-demo/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "qa-book-demo",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"frontend",
|
||||
"backend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun run --filter frontend dev & bun run --filter backend dev",
|
||||
"dev:frontend": "bun run --filter frontend dev",
|
||||
"dev:backend": "bun run --filter backend dev",
|
||||
"build": "bun run --filter frontend build && bun run --filter backend build",
|
||||
"type-check": "bun run --filter frontend type-check && bun run --filter backend type-check",
|
||||
"lint": "bun run --filter frontend lint && bun run --filter backend lint",
|
||||
"load-novel": "bun run scripts/load-novel.ts",
|
||||
"load-novel-cloud": "bun run scripts/load-novel-cloud.ts",
|
||||
"clear-memories": "bun run scripts/clear-memories.ts",
|
||||
"clear-memories-cloud": "bun run scripts/clear-memories-cloud.ts",
|
||||
"get-memories-cloud": "bun run scripts/get-memories-cloud.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
93
use-cases/game-of-throne-demo/sample/got-sample.txt
Normal file
93
use-cases/game-of-throne-demo/sample/got-sample.txt
Normal file
@ -0,0 +1,93 @@
|
||||
PROLOGUE
|
||||
|
||||
"We should start back," Gared urged as the woods began to grow dark around them. "The wildlings are dead."
|
||||
|
||||
"Do the dead frighten you?" Ser Waymar Royce asked with just the hint of a smile.
|
||||
|
||||
Gared did not rise to the bait. He was an old man, past fifty, and he had seen the lordlings come and go. "Dead is dead," he said. "We have no business with the dead."
|
||||
|
||||
"Are they dead?" Royce asked softly. "What proof have we?"
|
||||
|
||||
"Will saw them," Gared said. "If he says they are dead, that's proof enough for me."
|
||||
|
||||
Will had known they would drag him into the quarrel sooner or later. He wished it had been later rather than sooner. "My mother told me that dead men sing no songs," he put in.
|
||||
|
||||
"My wet nurse said the same thing, Will," Royce replied. "Never believe anything you hear at a woman's tit. There are things to be learned even from the dead." His voice echoed, too loud in the twilit forest.
|
||||
|
||||
"We have a long ride before us," Gared pointed out. "Eight days, maybe nine. And night is falling."
|
||||
|
||||
Ser Waymar Royce glanced at the sky with disinterest. "It does that every day about this time. Are you unmanned by the dark, Gared?"
|
||||
|
||||
Will could see that the weights hung differently on him now, and he feared what that might mean. "There's something wrong here," Gared muttered.
|
||||
|
||||
The young knight turned back. "Wrong?"
|
||||
|
||||
Gared watched him with his small hard eyes. "Can't you feel it? The trees... there were no trees here before."
|
||||
|
||||
BRAN
|
||||
|
||||
The morning had dawned clear and cold, with a crispness that hinted at the end of summer. They set forth at daybreak to see a man beheaded, twenty in all, and Bran rode among them, nervous with excitement. This was the first time he had been deemed old enough to go with his lord father and his brothers to see the king's justice done. It was the ninth year of summer, and the seventh of Bran's life.
|
||||
|
||||
The man had been taken outside a small holdfast in the hills. Robb thought he was a wildling, his sword sworn to Mance Rayder, the King-beyond-the-Wall. It made Bran's skin prickle to think of it. He remembered the hearth tales Old Nan told them. The wildlings were cruel men, she said, slavers and slayers and thieves. They consorted with giants and ghouls, stole girl children in the dead of night, and drank blood from polished horns. And their women lay with the Others in the Long Night to sire terrible half-human children.
|
||||
|
||||
But the man they found bound hand and foot to the holdfast wall awaiting the king's justice was old and scrawny, not much taller than Robb. He had lost both ears and a finger to frostbite, and he dressed all in black, the same as a brother of the Night's Watch, except that his furs were ragged and greasy.
|
||||
|
||||
The breath of man and horse mingled, steaming, in the cold morning air as his lord father had the man cut down from the wall and dragged before them. Robb and Jon sat tall and still on their horses, with Bran between them on his pony, trying to seem older than seven, trying to pretend that he'd seen all this before. A faint wind blew through the holdfast gate. Over their heads flapped the banner of the Starks of Winterfell: a grey direwolf racing across an ice-white field.
|
||||
|
||||
Bran's father sat solemnly on his horse, long brown hair stirring in the wind. His closely trimmed beard was shot with white, making him look older than his thirty-five years. He had a grim cast to his grey eyes this day, and he seemed not at all the man who would sit before the fire in the evening and talk softly of the age of heroes and the children of the forest. He had taken off Father's face, Bran thought, and donned the face of Lord Stark of Winterfell.
|
||||
|
||||
CATELYN
|
||||
|
||||
Catelyn had never liked this godswood.
|
||||
|
||||
She had been born a Tully, at Riverrun far to the south, on the Red Fork of the Trident. The godswood there was a garden, bright and airy, where tall redwoods spread dappled shadows across tinkling streams, birds sang from hidden nests, and the air smelled of flowers.
|
||||
|
||||
The gods of Winterfell kept a different sort of wood. It was a dark, primal place, three acres of old forest untouched for ten thousand years as the gloomy castle rose around it. It smelled of moist earth and decay. No redwoods grew here. This was a wood of stubborn sentinel trees armored in grey-green needles, of ## and oaks as old as the realm itself. Here thick black trunks crowded close together while twisted branches wove a dense canopy overhead and misshapen roots wrestled beneath the soil. This was a place of deep silence and brooding shadows, and the gods who lived here had no names.
|
||||
|
||||
But she knew she would find her husband here tonight. Whenever he took a man's life, afterward he would seek the quiet of the godswood.
|
||||
|
||||
Catelyn had been anointed with the seven oils and named in the rainbow of light that filled the sept of Riverrun. She was of the Faith, like her father and grandfather and his father before him. Her gods had names, and their faces were as familiar as the faces of her parents. Worship was a septon with a censer, the smell of incense, a seven-sided crystal alive with light, voices raised in song. The Tullys kept a godswood, as all the great houses did, but it was only a place to walk or read or lie in the sun. Worship was for the sept.
|
||||
|
||||
For her sake, Ned had built a small sept where she might sing to the seven gods, but the blood of the First Men still flowed in the veins of the Starks, and his own gods were the old ones, the nameless, faceless gods of the greenwood they shared with the vanished children of the forest.
|
||||
|
||||
JON
|
||||
|
||||
Jon climbed the steps slowly, trying not to think that this might be the last time ever. Ghost padded silently beside him. Outside, snow swirled through the castle gates, and the yard was all noise and chaos, but inside the tower was dark and still.
|
||||
|
||||
Tyrion Lannister was curled up in a window seat when Jon entered his bedchamber, reading a book by the light of a stub of candle beside him.
|
||||
|
||||
"I see you have found a wolf," the little man said, closing the book.
|
||||
|
||||
"He doesn't like strangers," Jon said.
|
||||
|
||||
"A wise beast," Tyrion said with a twisted smile. He climbed down from the window seat. "I think not. Your wolf is smarter than you. He doesn't like me, so I'd best keep my distance."
|
||||
|
||||
"I wanted to thank you," Jon said. "For speaking to my father."
|
||||
|
||||
"Thank me?" Tyrion grinned. "For what? For speaking the truth? A man grown fears no truth."
|
||||
|
||||
"Some men do," Jon said quietly.
|
||||
|
||||
The dwarf let out a bark of laughter. "Yes, I have noticed. But I would be a poor brother if I did not give you a bit of counsel on your way. You will find that the Watch is not as romantic as the songs may have led you to believe. Most of the black brothers are sullen and unlettered, rough men at best. You would do well to make what friends you can among them."
|
||||
|
||||
"I will," Jon promised.
|
||||
|
||||
"See that you do. And one last thing. You must remember always that you are a bastard."
|
||||
|
||||
The words stabbed at Jon like a knife.
|
||||
|
||||
DAENERYS
|
||||
|
||||
Her brother held the gown up for her inspection. "This is beauty. Touch it. Go on. Caress the fabric."
|
||||
|
||||
Dany touched it. The cloth was so smooth that it seemed to run through her fingers like water. She could not remember ever wearing anything so soft. It frightened her. She pulled her hand away. "Is it really mine?"
|
||||
|
||||
"A gift from the Magister Illyrio," Viserys said, smiling. Her brother was in a high mood tonight. "The color will bring out the violet in your eyes. And you shall have gold as well, and jewels of all sorts. Illyrio has promised. Tonight you must look like a princess."
|
||||
|
||||
A princess, Dany thought. She had forgotten what that was like. Perhaps she had never known.
|
||||
|
||||
"Why does he give us so much?" she asked. "What does he want from us?" For nigh on half a year, they had lived in the magister's house, eating his food, pampered by his servants.
|
||||
|
||||
"Illyrio is no fool," Viserys said. He was a gaunt young man with nervous hands and a feverish look in his pale lilac eyes. "The magister knows that I will not forget my friends when I come into my throne."
|
||||
|
||||
Dany said nothing. Magister Illyrio was a dealer in spices, gemstones, dragonbone, and other, less savory things. He had friends in all of the Nine Free Cities, it was said, and even beyond, in Vaes Dothrak and the fabled lands beside the Jade Sea. It was also said that he'd never had a friend he wouldn't sell for the right price. Dany listened to the talk in the streets, and she heard these things, but she knew better than to question her brother when he wove his webs of dream.
|
||||
325
use-cases/game-of-throne-demo/scripts/clear-memories-cloud.ts
Normal file
325
use-cases/game-of-throne-demo/scripts/clear-memories-cloud.ts
Normal file
@ -0,0 +1,325 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Clear Memories Script for EverMind Cloud API
|
||||
*
|
||||
* Deletes all memories from EverMind Cloud and cleans up progress files.
|
||||
*
|
||||
* Usage:
|
||||
* bun run clear-memories-cloud --api-key <key>
|
||||
* bun run clear-memories-cloud --api-key <key> --dry-run
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'util';
|
||||
import { existsSync, readdirSync, unlinkSync } from 'fs';
|
||||
import { resolve, basename } from 'path';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface CliArgs {
|
||||
apiKey: string;
|
||||
apiUrl: string;
|
||||
groupId: string;
|
||||
deleteAll: boolean;
|
||||
dryRun: boolean;
|
||||
keepProgress: boolean;
|
||||
}
|
||||
|
||||
interface DeleteResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
result?: {
|
||||
filters: string[];
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
count: number;
|
||||
notFound: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI Argument Parsing
|
||||
// ============================================================================
|
||||
|
||||
function parseCliArgs(): CliArgs | null {
|
||||
try {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
'api-key': { type: 'string' },
|
||||
'api-url': { type: 'string', default: 'https://api.evermind.ai' },
|
||||
'group-id': { type: 'string', default: 'asoiaf' },
|
||||
'delete-all': { type: 'boolean', default: false },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
'keep-progress': { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
return null;
|
||||
}
|
||||
|
||||
// API key from argument or environment variable
|
||||
const apiKey = values['api-key'] as string || process.env.EVERMIND_API_KEY || '';
|
||||
if (!apiKey) {
|
||||
console.error('❌ Error: API key required. Use --api-key or set EVERMIND_API_KEY environment variable\n');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const deleteAll = values['delete-all'] as boolean;
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
apiUrl: values['api-url'] as string,
|
||||
groupId: deleteAll ? '__all__' : values['group-id'] as string,
|
||||
deleteAll,
|
||||
dryRun: values['dry-run'] as boolean,
|
||||
keepProgress: values['keep-progress'] as boolean,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error parsing arguments:', error instanceof Error ? error.message : String(error));
|
||||
console.error('');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
Clear Memories Script for EverMind Cloud API
|
||||
|
||||
Deletes all memories from EverMind Cloud and cleans up progress files.
|
||||
|
||||
Usage:
|
||||
bun run clear-memories-cloud --api-key <key> [options]
|
||||
|
||||
Required:
|
||||
--api-key <key> EverMind API key (or set EVERMIND_API_KEY env var)
|
||||
|
||||
Options:
|
||||
--api-url <url> EverMind API URL (default: https://api.evermind.ai)
|
||||
--group-id <id> Group ID to delete memories for (default: asoiaf)
|
||||
--delete-all Delete ALL memories (sets group_id to "__all__")
|
||||
--dry-run Show what would be deleted without actually deleting
|
||||
--keep-progress Keep progress files, only delete memories from cloud
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
bun run clear-memories-cloud --api-key YOUR_KEY
|
||||
bun run clear-memories-cloud --api-key YOUR_KEY --dry-run
|
||||
bun run clear-memories-cloud --api-key YOUR_KEY --delete-all
|
||||
EVERMIND_API_KEY=your_key bun run clear-memories-cloud
|
||||
`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Progress File Cleanup
|
||||
// ============================================================================
|
||||
|
||||
function findProgressFiles(): string[] {
|
||||
const cwd = process.cwd();
|
||||
const files: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(cwd);
|
||||
for (const entry of entries) {
|
||||
// Match both local and cloud progress files
|
||||
if ((entry.startsWith('.novel-progress-') || entry.startsWith('.novel-progress-cloud-')) && entry.endsWith('.json')) {
|
||||
files.push(resolve(cwd, entry));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading directory:', error);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function deleteProgressFiles(files: string[], dryRun: boolean): number {
|
||||
let deleted = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (dryRun) {
|
||||
console.log(` Would delete: ${basename(file)}`);
|
||||
deleted++;
|
||||
} else {
|
||||
try {
|
||||
unlinkSync(file);
|
||||
console.log(` Deleted: ${basename(file)}`);
|
||||
deleted++;
|
||||
} catch (error) {
|
||||
console.error(` Failed to delete ${basename(file)}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EverMind Cloud API
|
||||
// ============================================================================
|
||||
|
||||
async function deleteMemories(apiUrl: string, apiKey: string, groupId: string): Promise<DeleteResult> {
|
||||
// API expects all three fields: event_id, user_id, group_id
|
||||
// Use "__all__" magic value to match all records for that field
|
||||
const requestBody = {
|
||||
event_id: '__all__',
|
||||
user_id: '__all__',
|
||||
group_id: groupId, // Specific group to delete, or "__all__" for everything
|
||||
};
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/v0/memories`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
const data = await response.json() as DeleteResponse;
|
||||
|
||||
// Handle 404 as success (no memories to delete)
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'No memories found (already clean)',
|
||||
count: 0,
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.ok && data.status === 'ok') {
|
||||
return {
|
||||
success: true,
|
||||
message: data.message || 'Memories deleted',
|
||||
count: data.result?.count || 0,
|
||||
notFound: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: data.message || `HTTP ${response.status}`,
|
||||
count: 0,
|
||||
notFound: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkHealth(apiUrl: string, apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const args = parseCliArgs();
|
||||
|
||||
if (!args) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('═'.repeat(60));
|
||||
console.log('🧹 Clear Memories - EverMind Cloud');
|
||||
console.log('═'.repeat(60));
|
||||
console.log(`API: ${args.apiUrl}`);
|
||||
console.log(`Key: ${args.apiKey.slice(0, 8)}...${args.apiKey.slice(-4)}`);
|
||||
console.log(`Target: ${args.deleteAll ? 'ALL MEMORIES' : `group "${args.groupId}"`}`);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('\n⚠️ DRY RUN MODE - No changes will be made\n');
|
||||
}
|
||||
|
||||
// Step 1: Check EverMind Cloud health
|
||||
console.log('\n📡 Checking EverMind Cloud connection...');
|
||||
const isHealthy = await checkHealth(args.apiUrl, args.apiKey);
|
||||
|
||||
if (!isHealthy) {
|
||||
console.log(' ⚠️ EverMind Cloud is not available at', args.apiUrl);
|
||||
console.log(' Skipping memory deletion from cloud.\n');
|
||||
} else {
|
||||
console.log(' ✓ EverMind Cloud is healthy\n');
|
||||
|
||||
// Step 2: Delete memories from cloud
|
||||
console.log(`📦 Deleting memories for group_id: "${args.groupId}"...`);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log(` Would send DELETE request to ${args.apiUrl}/api/v0/memories`);
|
||||
console.log(` With body: {"event_id": "__all__", "user_id": "__all__", "group_id": "${args.groupId}"}`);
|
||||
} else {
|
||||
try {
|
||||
const result = await deleteMemories(args.apiUrl, args.apiKey, args.groupId);
|
||||
|
||||
if (result.success) {
|
||||
if (result.notFound) {
|
||||
console.log(` ✓ ${result.message}`);
|
||||
} else {
|
||||
console.log(` ✓ ${result.message}`);
|
||||
console.log(` Memories deleted: ${result.count}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ✗ ${result.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(' ✗ Failed to delete memories:', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Clean up progress files
|
||||
if (!args.keepProgress) {
|
||||
console.log('\n📁 Cleaning up progress files...');
|
||||
const progressFiles = findProgressFiles();
|
||||
|
||||
if (progressFiles.length === 0) {
|
||||
console.log(' No progress files found.');
|
||||
} else {
|
||||
console.log(` Found ${progressFiles.length} progress file(s):`);
|
||||
const deleted = deleteProgressFiles(progressFiles, args.dryRun);
|
||||
console.log(` ${args.dryRun ? 'Would delete' : 'Deleted'}: ${deleted} file(s)`);
|
||||
}
|
||||
} else {
|
||||
console.log('\n📁 Keeping progress files (--keep-progress flag set)');
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '═'.repeat(60));
|
||||
if (args.dryRun) {
|
||||
console.log('✅ Dry run complete. Run without --dry-run to apply changes.');
|
||||
} else {
|
||||
console.log('✅ Cleanup complete!');
|
||||
}
|
||||
console.log('═'.repeat(60) + '\n');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('\nUnexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
323
use-cases/game-of-throne-demo/scripts/get-memories-cloud.ts
Normal file
323
use-cases/game-of-throne-demo/scripts/get-memories-cloud.ts
Normal file
@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Get Memories Script for EverMind Cloud API
|
||||
*
|
||||
* Lists memories stored in EverMind Cloud with pagination support.
|
||||
*
|
||||
* Usage:
|
||||
* bun run get-memories-cloud --api-key <key>
|
||||
* bun run get-memories-cloud --api-key <key> --group-id asoiaf --page 1 --page-size 10
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'util';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface CliArgs {
|
||||
apiKey: string;
|
||||
apiUrl: string;
|
||||
groupId: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
memoryType: string | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
allPages: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface MemoryItem {
|
||||
memory_type: string;
|
||||
summary?: string | null;
|
||||
subject?: string | null;
|
||||
episode?: string | null;
|
||||
user_id?: string;
|
||||
timestamp?: string;
|
||||
group_id?: string | null;
|
||||
group_name?: string | null;
|
||||
keywords?: string[] | null;
|
||||
linked_entities?: string[] | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface GetMemoriesResponse {
|
||||
status: string;
|
||||
message?: string;
|
||||
result: {
|
||||
memories: MemoryItem[];
|
||||
total_count: number;
|
||||
count: number;
|
||||
metadata?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI Argument Parsing
|
||||
// ============================================================================
|
||||
|
||||
function parseCliArgs(): CliArgs | null {
|
||||
try {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
'api-key': { type: 'string' },
|
||||
'api-url': { type: 'string', default: 'https://api.evermind.ai' },
|
||||
'group-id': { type: 'string', default: 'asoiaf' },
|
||||
'page': { type: 'string', default: '1' },
|
||||
'page-size': { type: 'string', default: '20' },
|
||||
'memory-type': { type: 'string' },
|
||||
'start-time': { type: 'string' },
|
||||
'end-time': { type: 'string' },
|
||||
'all': { type: 'boolean', default: false },
|
||||
'json': { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
return null;
|
||||
}
|
||||
|
||||
const apiKey = values['api-key'] as string || process.env.EVERMIND_API_KEY || '';
|
||||
if (!apiKey) {
|
||||
console.error('Error: API key required. Use --api-key or set EVERMIND_API_KEY environment variable\n');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
apiUrl: values['api-url'] as string,
|
||||
groupId: values['group-id'] as string,
|
||||
page: parseInt(values['page'] as string, 10),
|
||||
pageSize: parseInt(values['page-size'] as string, 10),
|
||||
memoryType: (values['memory-type'] as string) || null,
|
||||
startTime: (values['start-time'] as string) || null,
|
||||
endTime: (values['end-time'] as string) || null,
|
||||
allPages: values['all'] as boolean,
|
||||
json: values['json'] as boolean,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error parsing arguments:', error instanceof Error ? error.message : String(error));
|
||||
console.error('');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
Get Memories Script for EverMind Cloud API
|
||||
|
||||
Lists memories stored in EverMind Cloud with pagination support.
|
||||
|
||||
Usage:
|
||||
bun run get-memories-cloud --api-key <key> [options]
|
||||
|
||||
Required:
|
||||
--api-key <key> EverMind API key (or set EVERMIND_API_KEY env var)
|
||||
|
||||
Options:
|
||||
--api-url <url> EverMind API URL (default: https://api.evermind.ai)
|
||||
--group-id <id> Group ID to query (default: asoiaf)
|
||||
--page <num> Page number (default: 1)
|
||||
--page-size <num> Results per page, 1-100 (default: 20)
|
||||
--memory-type <type> Filter by type: profile, episodic_memory, foresight, event_log
|
||||
--start-time <iso> Filter start time (ISO 8601 with timezone)
|
||||
--end-time <iso> Filter end time (ISO 8601 with timezone)
|
||||
--all Fetch all pages (overrides --page)
|
||||
--json Output raw JSON response
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
bun run get-memories-cloud --api-key YOUR_KEY
|
||||
bun run get-memories-cloud --api-key YOUR_KEY --page-size 5 --all
|
||||
bun run get-memories-cloud --api-key YOUR_KEY --memory-type profile
|
||||
bun run get-memories-cloud --api-key YOUR_KEY --json
|
||||
`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EverMind Cloud API
|
||||
// ============================================================================
|
||||
|
||||
async function getMemories(
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
params: {
|
||||
groupId: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
memoryType: string | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
}
|
||||
): Promise<GetMemoriesResponse> {
|
||||
const queryParams = new URLSearchParams({
|
||||
group_ids: params.groupId,
|
||||
page: params.page.toString(),
|
||||
page_size: params.pageSize.toString(),
|
||||
});
|
||||
|
||||
if (params.memoryType) {
|
||||
queryParams.set('memory_type', params.memoryType);
|
||||
}
|
||||
if (params.startTime) {
|
||||
queryParams.set('start_time', params.startTime);
|
||||
}
|
||||
if (params.endTime) {
|
||||
queryParams.set('end_time', params.endTime);
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/v0/memories?${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
return await response.json() as GetMemoriesResponse;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Display
|
||||
// ============================================================================
|
||||
|
||||
function displayMemory(memory: MemoryItem, index: number): void {
|
||||
const type = memory.memory_type || 'unknown';
|
||||
const subject = memory.subject || memory.summary || '(no subject)';
|
||||
const timestamp = memory.timestamp ? new Date(memory.timestamp).toLocaleString() : 'N/A';
|
||||
|
||||
console.log(` ${index}. [${type}] ${subject}`);
|
||||
|
||||
if (memory.summary && memory.summary !== memory.subject) {
|
||||
const summary = memory.summary.length > 120
|
||||
? memory.summary.slice(0, 120) + '...'
|
||||
: memory.summary;
|
||||
console.log(` Summary: ${summary}`);
|
||||
}
|
||||
|
||||
if (memory.keywords && memory.keywords.length > 0) {
|
||||
console.log(` Keywords: ${memory.keywords.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(` Time: ${timestamp} | Group: ${memory.group_id || 'N/A'}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const args = parseCliArgs();
|
||||
|
||||
if (!args) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.json) {
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log('EverMind Cloud - Get Memories');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`API: ${args.apiUrl}`);
|
||||
console.log(`Key: ${args.apiKey.slice(0, 8)}...${args.apiKey.slice(-4)}`);
|
||||
console.log(`Group: ${args.groupId}`);
|
||||
if (args.memoryType) {
|
||||
console.log(`Type filter: ${args.memoryType}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
let totalFetched = 0;
|
||||
let currentPage = args.page;
|
||||
let totalCount = 0;
|
||||
|
||||
do {
|
||||
try {
|
||||
const data = await getMemories(args.apiUrl, args.apiKey, {
|
||||
groupId: args.groupId,
|
||||
page: currentPage,
|
||||
pageSize: args.pageSize,
|
||||
memoryType: args.memoryType,
|
||||
startTime: args.startTime,
|
||||
endTime: args.endTime,
|
||||
});
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
if (!args.allPages) break;
|
||||
}
|
||||
|
||||
if (data.status !== 'ok') {
|
||||
console.error(`API error: ${data.message || 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
totalCount = data.result.total_count;
|
||||
const memories = data.result.memories;
|
||||
|
||||
if (!args.json) {
|
||||
if (currentPage === args.page) {
|
||||
console.log(`Total memories: ${totalCount}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
if (currentPage === args.page) {
|
||||
console.log('No memories found.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`--- Page ${currentPage} (${memories.length} results) ---\n`);
|
||||
|
||||
for (let i = 0; i < memories.length; i++) {
|
||||
const globalIndex = (currentPage - 1) * args.pageSize + i + 1;
|
||||
displayMemory(memories[i], globalIndex);
|
||||
}
|
||||
}
|
||||
|
||||
totalFetched += memories.length;
|
||||
|
||||
if (!args.allPages || totalFetched >= totalCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentPage++;
|
||||
} catch (error) {
|
||||
console.error(`\nError fetching memories:`, error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
} while (args.allPages);
|
||||
|
||||
if (!args.json) {
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Fetched ${totalFetched} of ${totalCount} memories`);
|
||||
|
||||
if (!args.allPages && totalFetched < totalCount) {
|
||||
const totalPages = Math.ceil(totalCount / args.pageSize);
|
||||
console.log(`Page ${args.page} of ${totalPages}. Use --all to fetch all pages.`);
|
||||
}
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('\nUnexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
786
use-cases/game-of-throne-demo/scripts/load-novel-cloud.ts
Normal file
786
use-cases/game-of-throne-demo/scripts/load-novel-cloud.ts
Normal file
@ -0,0 +1,786 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Novel Loading Script for EverMind Cloud API
|
||||
*
|
||||
* Processes plain text novel files, detects chapters, splits into paragraphs,
|
||||
* and stores them in EverMind Cloud with progress tracking and resumption support.
|
||||
*
|
||||
* Usage:
|
||||
* bun run load-novel-cloud --file <path> --book-title <title> --book-abbrev <abbrev> --api-key <key>
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'util';
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve, basename } from 'path';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface CliArgs {
|
||||
file: string;
|
||||
bookTitle: string;
|
||||
bookAbbrev: string;
|
||||
apiKey: string;
|
||||
apiUrl: string;
|
||||
paragraphLimit: number;
|
||||
minParagraphSize: number;
|
||||
checkHealth: boolean;
|
||||
dryRun: boolean;
|
||||
freshStart: boolean;
|
||||
progressFile?: string;
|
||||
}
|
||||
|
||||
interface Chapter {
|
||||
number: number;
|
||||
name: string;
|
||||
text: string;
|
||||
startPos: number;
|
||||
}
|
||||
|
||||
interface Paragraph {
|
||||
messageId: string;
|
||||
chapterNumber: number;
|
||||
chapterName: string;
|
||||
paragraphNumber: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ProgressFile {
|
||||
book_title: string;
|
||||
book_abbrev: string;
|
||||
started_at: string;
|
||||
last_updated: string;
|
||||
total_chapters: number;
|
||||
total_paragraphs: number;
|
||||
paragraphs: Record<string, 'success' | 'failed'>;
|
||||
}
|
||||
|
||||
// EverMind Cloud API request format
|
||||
interface CloudMemorizeRequest {
|
||||
message_id: string;
|
||||
group_id: string;
|
||||
group_name: string;
|
||||
create_time: string;
|
||||
role: string;
|
||||
sender: string;
|
||||
sender_name: string;
|
||||
content: string;
|
||||
refer_list: string[];
|
||||
}
|
||||
|
||||
interface SaveResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface LoadingSummary {
|
||||
chaptersProcessed: number;
|
||||
totalParagraphs: number;
|
||||
alreadyLoaded: number;
|
||||
newlyLoaded: number;
|
||||
failed: number;
|
||||
failedParagraphs: Array<{ messageId: string; error: string }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI Argument Parsing
|
||||
// ============================================================================
|
||||
|
||||
function parseCliArgs(): CliArgs | null {
|
||||
try {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
file: { type: 'string' },
|
||||
'book-title': { type: 'string' },
|
||||
'book-abbrev': { type: 'string' },
|
||||
'api-key': { type: 'string' },
|
||||
'api-url': { type: 'string', default: 'https://api.evermind.ai' },
|
||||
'paragraph-limit': { type: 'string', default: '10' },
|
||||
'min-paragraph-size': { type: 'string', default: '200' },
|
||||
'check-health': { type: 'boolean', default: false },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
'fresh-start': { type: 'boolean', default: false },
|
||||
'progress-file': { type: 'string' },
|
||||
help: { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate required arguments
|
||||
if (!values.file || !values['book-title'] || !values['book-abbrev']) {
|
||||
console.error('❌ Error: Missing required arguments\n');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// API key from argument or environment variable
|
||||
const apiKey = values['api-key'] as string || process.env.EVERMIND_API_KEY || '';
|
||||
if (!apiKey) {
|
||||
console.error('❌ Error: API key required. Use --api-key or set EVERMIND_API_KEY environment variable\n');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
file: values.file as string,
|
||||
bookTitle: values['book-title'] as string,
|
||||
bookAbbrev: values['book-abbrev'] as string,
|
||||
apiKey,
|
||||
apiUrl: values['api-url'] as string,
|
||||
paragraphLimit: parseInt(values['paragraph-limit'] as string, 10),
|
||||
minParagraphSize: parseInt(values['min-paragraph-size'] as string, 10),
|
||||
checkHealth: values['check-health'] as boolean,
|
||||
dryRun: values['dry-run'] as boolean,
|
||||
freshStart: values['fresh-start'] as boolean,
|
||||
progressFile: values['progress-file'] as string | undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Error parsing arguments:', error instanceof Error ? error.message : String(error));
|
||||
console.error('');
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
Novel Loading Script for EverMind Cloud API
|
||||
|
||||
Usage:
|
||||
bun run load-novel-cloud --file <path> --book-title <title> --book-abbrev <abbrev> --api-key <key> [options]
|
||||
|
||||
Required Arguments:
|
||||
--file <path> Path to novel text file
|
||||
--book-title <title> Full book title (e.g., "A Game of Thrones")
|
||||
--book-abbrev <abbrev> Book abbreviation for message IDs (e.g., "got")
|
||||
--api-key <key> EverMind API key (or set EVERMIND_API_KEY env var)
|
||||
|
||||
Optional Arguments:
|
||||
--api-url <url> EverMind API URL (default: https://api.evermind.ai)
|
||||
--paragraph-limit <num> Maximum number of paragraphs to load (default: 10, use 0 for unlimited)
|
||||
--min-paragraph-size <n> Minimum characters per paragraph, groups short ones (default: 200, use 0 to disable)
|
||||
--check-health Check API health before loading
|
||||
--dry-run Parse and show what would be loaded without actually loading
|
||||
--fresh-start Ignore existing progress file and start from beginning
|
||||
--progress-file <path> Custom progress file path (default: .novel-progress-cloud-{abbrev}.json)
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
bun run load-novel-cloud --file got.txt --book-title "A Game of Thrones" --book-abbrev "got" --api-key YOUR_KEY
|
||||
bun run load-novel-cloud --file got.txt --book-title "A Game of Thrones" --book-abbrev "got" --paragraph-limit 50
|
||||
EVERMIND_API_KEY=your_key bun run load-novel-cloud --file got.txt --book-title "A Game of Thrones" --book-abbrev "got"
|
||||
`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Chapter Detection
|
||||
// ============================================================================
|
||||
|
||||
const CHAPTER_PATTERNS = [
|
||||
/^PROLOGUE\s*$/m,
|
||||
/^EPILOGUE\s*$/m,
|
||||
/^([A-Z][A-Z\s]{2,})\s*$/m, // POV character names (EDDARD, JON, ARYA, etc.)
|
||||
/^CHAPTER\s+(\d+)/im,
|
||||
];
|
||||
|
||||
interface ChapterBoundary {
|
||||
position: number;
|
||||
name: string;
|
||||
isPrologue: boolean;
|
||||
isEpilogue: boolean;
|
||||
}
|
||||
|
||||
function detectChapters(text: string): Chapter[] {
|
||||
const boundaries: ChapterBoundary[] = [];
|
||||
|
||||
// Find all chapter boundaries
|
||||
for (const pattern of CHAPTER_PATTERNS) {
|
||||
const matches = text.matchAll(new RegExp(pattern, 'gm'));
|
||||
|
||||
for (const match of matches) {
|
||||
const position = match.index!;
|
||||
const matchedText = match[0].trim();
|
||||
|
||||
// Determine chapter name
|
||||
let name = matchedText;
|
||||
let isPrologue = false;
|
||||
let isEpilogue = false;
|
||||
|
||||
if (matchedText === 'PROLOGUE') {
|
||||
isPrologue = true;
|
||||
name = 'Prologue';
|
||||
} else if (matchedText === 'EPILOGUE') {
|
||||
isEpilogue = true;
|
||||
name = 'Epilogue';
|
||||
} else if (match[1]) {
|
||||
// Captured group from POV pattern or chapter number
|
||||
name = toTitleCase(match[1].trim());
|
||||
}
|
||||
|
||||
boundaries.push({ position, name, isPrologue, isEpilogue });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by position and remove duplicates
|
||||
boundaries.sort((a, b) => a.position - b.position);
|
||||
const uniqueBoundaries = boundaries.filter(
|
||||
(boundary, index, arr) =>
|
||||
index === 0 || boundary.position !== arr[index - 1].position
|
||||
);
|
||||
|
||||
// Extract chapters
|
||||
const chapters: Chapter[] = [];
|
||||
let chapterNumber = 0;
|
||||
|
||||
for (let i = 0; i < uniqueBoundaries.length; i++) {
|
||||
const boundary = uniqueBoundaries[i];
|
||||
const nextBoundary = uniqueBoundaries[i + 1];
|
||||
|
||||
// Assign chapter number - always increment to ensure unique IDs
|
||||
// (The file may contain multiple books, each with their own PROLOGUE/EPILOGUE)
|
||||
chapterNumber++;
|
||||
|
||||
// Extract chapter text
|
||||
const startPos = boundary.position;
|
||||
const endPos = nextBoundary ? nextBoundary.position : text.length;
|
||||
const chapterText = text.slice(startPos, endPos);
|
||||
|
||||
// Skip the chapter heading line itself
|
||||
const firstNewline = chapterText.indexOf('\n');
|
||||
const contentText = firstNewline !== -1 ? chapterText.slice(firstNewline + 1) : chapterText;
|
||||
|
||||
chapters.push({
|
||||
number: chapterNumber,
|
||||
name: boundary.name,
|
||||
text: contentText.trim(),
|
||||
startPos,
|
||||
});
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
function toTitleCase(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Paragraph Splitting
|
||||
// ============================================================================
|
||||
|
||||
function splitIntoParagraphs(
|
||||
chapter: Chapter,
|
||||
bookTitle: string,
|
||||
bookAbbrev: string,
|
||||
minParagraphSize: number = 0
|
||||
): Paragraph[] {
|
||||
// Split by double newlines (paragraph breaks)
|
||||
const rawParagraphs = chapter.text.split(/\n\s*\n/);
|
||||
|
||||
const paragraphs: Paragraph[] = [];
|
||||
let paragraphNumber = 1;
|
||||
|
||||
for (const rawText of rawParagraphs) {
|
||||
const cleanText = rawText.trim();
|
||||
|
||||
// Skip empty paragraphs
|
||||
if (!cleanText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const messageId = generateMessageId(bookAbbrev, chapter.number, paragraphNumber);
|
||||
|
||||
paragraphs.push({
|
||||
messageId,
|
||||
chapterNumber: chapter.number,
|
||||
chapterName: chapter.name,
|
||||
paragraphNumber,
|
||||
text: cleanText,
|
||||
});
|
||||
|
||||
paragraphNumber++;
|
||||
}
|
||||
|
||||
// Group short paragraphs if minParagraphSize is set
|
||||
if (minParagraphSize > 0) {
|
||||
return groupShortParagraphs(paragraphs, minParagraphSize, bookAbbrev, chapter.number);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group consecutive short paragraphs together until they reach minimum size
|
||||
* This helps create more coherent memory chunks with better context
|
||||
*/
|
||||
function groupShortParagraphs(
|
||||
paragraphs: Paragraph[],
|
||||
minSize: number,
|
||||
bookAbbrev: string,
|
||||
chapterNum: number
|
||||
): Paragraph[] {
|
||||
if (paragraphs.length === 0) {
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
const grouped: Paragraph[] = [];
|
||||
let currentGroup: Paragraph[] = [];
|
||||
let currentSize = 0;
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
currentGroup.push(paragraph);
|
||||
currentSize += paragraph.text.length;
|
||||
|
||||
// Check if we've reached the minimum size or this is the last paragraph
|
||||
const isLastParagraph = paragraph === paragraphs[paragraphs.length - 1];
|
||||
const reachedMinSize = currentSize >= minSize;
|
||||
|
||||
if (reachedMinSize || isLastParagraph) {
|
||||
// Merge the current group into a single paragraph
|
||||
if (currentGroup.length === 1) {
|
||||
// No grouping needed
|
||||
grouped.push(currentGroup[0]);
|
||||
} else {
|
||||
// Merge multiple paragraphs
|
||||
const mergedText = currentGroup.map(p => p.text).join('\n\n');
|
||||
const firstParagraphNum = currentGroup[0].paragraphNumber;
|
||||
|
||||
grouped.push({
|
||||
messageId: generateMessageId(bookAbbrev, chapterNum, firstParagraphNum),
|
||||
chapterNumber: currentGroup[0].chapterNumber,
|
||||
chapterName: currentGroup[0].chapterName,
|
||||
paragraphNumber: firstParagraphNum,
|
||||
text: mergedText,
|
||||
});
|
||||
}
|
||||
|
||||
// Reset for next group
|
||||
currentGroup = [];
|
||||
currentSize = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function generateMessageId(bookAbbrev: string, chapterNum: number, paragraphNum: number): string {
|
||||
const chStr = chapterNum.toString().padStart(2, '0');
|
||||
const pStr = paragraphNum.toString().padStart(3, '0');
|
||||
return `asoiaf-${bookAbbrev}-ch${chStr}-p${pStr}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Progress File Management
|
||||
// ============================================================================
|
||||
|
||||
function getProgressFilePath(args: CliArgs): string {
|
||||
if (args.progressFile) {
|
||||
return resolve(args.progressFile);
|
||||
}
|
||||
return resolve(`.novel-progress-cloud-${args.bookAbbrev}.json`);
|
||||
}
|
||||
|
||||
async function readProgressFile(filePath: string): Promise<ProgressFile | null> {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(filePath).text();
|
||||
return JSON.parse(content) as ProgressFile;
|
||||
} catch (error) {
|
||||
console.error(`⚠ Warning: Failed to read progress file: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeProgressFile(filePath: string, progress: ProgressFile): Promise<void> {
|
||||
try {
|
||||
await Bun.write(filePath, JSON.stringify(progress, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`⚠ Warning: Failed to write progress file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProgressFile(
|
||||
filePath: string,
|
||||
messageId: string,
|
||||
status: 'success' | 'failed',
|
||||
progress: ProgressFile
|
||||
): Promise<void> {
|
||||
progress.paragraphs[messageId] = status;
|
||||
progress.last_updated = new Date().toISOString();
|
||||
await writeProgressFile(filePath, progress);
|
||||
}
|
||||
|
||||
function initializeProgressFile(args: CliArgs, totalChapters: number, totalParagraphs: number): ProgressFile {
|
||||
return {
|
||||
book_title: args.bookTitle,
|
||||
book_abbrev: args.bookAbbrev,
|
||||
started_at: new Date().toISOString(),
|
||||
last_updated: new Date().toISOString(),
|
||||
total_chapters: totalChapters,
|
||||
total_paragraphs: totalParagraphs,
|
||||
paragraphs: {},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EverMind Cloud API Interaction
|
||||
// ============================================================================
|
||||
|
||||
async function checkHealth(apiUrl: string, apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error(`❌ Health check failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveParagraphWithRetry(
|
||||
paragraph: Paragraph,
|
||||
bookTitle: string,
|
||||
apiUrl: string,
|
||||
apiKey: string,
|
||||
maxRetries: number = 3
|
||||
): Promise<SaveResult> {
|
||||
// Create chapter metadata prefix
|
||||
const chapterMetadata = `[${bookTitle} - Ch${paragraph.chapterNumber}: ${paragraph.chapterName}]`;
|
||||
const content = `${chapterMetadata}\n\n${paragraph.text}`;
|
||||
|
||||
// EverMind Cloud API request format
|
||||
const request: CloudMemorizeRequest = {
|
||||
message_id: paragraph.messageId,
|
||||
group_id: 'asoiaf',
|
||||
group_name: 'A Song of Ice and Fire',
|
||||
create_time: new Date().toISOString(),
|
||||
role: 'assistant', // Using 'assistant' for narrator content
|
||||
sender: 'asoiaf_narrator',
|
||||
sender_name: 'Narrator',
|
||||
content,
|
||||
refer_list: [],
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/v0/memories`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout for cloud API
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
await response.json(); // Parse response to ensure it's valid
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const isLastAttempt = attempt === maxRetries;
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Determine error type for better logging
|
||||
const isTimeout = errorMsg.includes('timeout') || errorMsg.includes('abort');
|
||||
const errorType = isTimeout ? 'timeout' : 'error';
|
||||
|
||||
if (isLastAttempt) {
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
const delayMs = Math.pow(2, attempt - 1) * 1000;
|
||||
console.log(` ⚠ Retry ${attempt}/${maxRetries} (${errorType}) after ${delayMs}ms...`);
|
||||
console.log(` Error: ${errorMsg}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: 'Max retries exceeded' };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Loading Logic
|
||||
// ============================================================================
|
||||
|
||||
async function loadNovel(args: CliArgs): Promise<void> {
|
||||
const filePath = resolve(args.file);
|
||||
|
||||
// Check if file exists
|
||||
if (!existsSync(filePath)) {
|
||||
console.error(`❌ Error: File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('═'.repeat(60));
|
||||
console.log('📚 EverMind Cloud - Novel Loading Script');
|
||||
console.log('═'.repeat(60));
|
||||
console.log(`API: ${args.apiUrl}`);
|
||||
console.log(`Key: ${args.apiKey.slice(0, 8)}...${args.apiKey.slice(-4)}`);
|
||||
console.log('');
|
||||
|
||||
// Health check if requested
|
||||
if (args.checkHealth) {
|
||||
console.log('🔍 Checking EverMind Cloud API...');
|
||||
const isHealthy = await checkHealth(args.apiUrl, args.apiKey);
|
||||
|
||||
if (isHealthy) {
|
||||
console.log('✓ EverMind Cloud API: OK\n');
|
||||
} else {
|
||||
console.error('❌ EverMind Cloud API is not available or API key is invalid.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Read novel file
|
||||
console.log(`📖 Reading novel file: ${basename(filePath)}`);
|
||||
const text = await Bun.file(filePath).text();
|
||||
|
||||
// Detect chapters
|
||||
console.log('🔍 Detecting chapters...');
|
||||
const chapters = detectChapters(text);
|
||||
|
||||
if (chapters.length === 0) {
|
||||
console.error('❌ Error: No chapters detected in the file.');
|
||||
console.error('Make sure the file contains chapter markers like:');
|
||||
console.error(' - PROLOGUE');
|
||||
console.error(' - Character names in ALL CAPS (e.g., EDDARD, JON)');
|
||||
console.error(' - CHAPTER X');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✓ Found ${chapters.length} chapters\n`);
|
||||
|
||||
// Split into paragraphs
|
||||
const allParagraphs: Paragraph[] = [];
|
||||
for (const chapter of chapters) {
|
||||
const paragraphs = splitIntoParagraphs(chapter, args.bookTitle, args.bookAbbrev, args.minParagraphSize);
|
||||
allParagraphs.push(...paragraphs);
|
||||
}
|
||||
|
||||
console.log(`✓ Total paragraphs in novel: ${allParagraphs.length}`);
|
||||
if (args.minParagraphSize > 0) {
|
||||
console.log(`✓ Grouped short paragraphs (min size: ${args.minParagraphSize} chars)`);
|
||||
}
|
||||
|
||||
// Apply paragraph limit
|
||||
const paragraphsToLoad = args.paragraphLimit > 0
|
||||
? allParagraphs.slice(0, args.paragraphLimit)
|
||||
: allParagraphs;
|
||||
|
||||
if (args.paragraphLimit > 0 && allParagraphs.length > args.paragraphLimit) {
|
||||
console.log(`⚠ Paragraph limit applied: loading first ${args.paragraphLimit} paragraphs\n`);
|
||||
} else {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Dry run mode
|
||||
if (args.dryRun) {
|
||||
console.log('🔎 DRY RUN MODE - Showing exact memories that would be saved:\n');
|
||||
console.log('═'.repeat(80));
|
||||
console.log(`Total paragraphs to load: ${paragraphsToLoad.length}\n`);
|
||||
|
||||
for (let i = 0; i < paragraphsToLoad.length; i++) {
|
||||
const paragraph = paragraphsToLoad[i];
|
||||
|
||||
// Create the exact memory object that would be saved
|
||||
const chapterMetadata = `[${args.bookTitle} - Ch${paragraph.chapterNumber}: ${paragraph.chapterName}]`;
|
||||
const content = `${chapterMetadata}\n\n${paragraph.text}`;
|
||||
|
||||
const memoryObject: CloudMemorizeRequest = {
|
||||
message_id: paragraph.messageId,
|
||||
group_id: 'asoiaf',
|
||||
group_name: 'A Song of Ice and Fire',
|
||||
create_time: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
sender: 'asoiaf_narrator',
|
||||
sender_name: 'Narrator',
|
||||
content,
|
||||
refer_list: [],
|
||||
};
|
||||
|
||||
console.log(`\n[${i + 1}/${paragraphsToLoad.length}] Memory Object:`);
|
||||
console.log('─'.repeat(80));
|
||||
console.log(JSON.stringify(memoryObject, null, 2));
|
||||
console.log('─'.repeat(80));
|
||||
|
||||
// Show a preview of the content for readability
|
||||
const contentPreview = paragraph.text.slice(0, 150);
|
||||
console.log(`Content preview: ${contentPreview}${paragraph.text.length > 150 ? '...' : ''}`);
|
||||
console.log(`Content length: ${content.length} characters`);
|
||||
}
|
||||
|
||||
console.log('\n' + '═'.repeat(80));
|
||||
console.log(`\nSummary:`);
|
||||
console.log(` Total chapters detected: ${chapters.length}`);
|
||||
console.log(` Total paragraphs in novel: ${allParagraphs.length}`);
|
||||
console.log(` Paragraphs to load: ${paragraphsToLoad.length}`);
|
||||
console.log('\nRun without --dry-run to actually save these memories to EverMind Cloud.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize or load progress file
|
||||
const progressFilePath = getProgressFilePath(args);
|
||||
let progress: ProgressFile;
|
||||
|
||||
if (args.freshStart || !existsSync(progressFilePath)) {
|
||||
if (args.freshStart && existsSync(progressFilePath)) {
|
||||
console.log(`🗑️ Fresh start: Ignoring existing progress file\n`);
|
||||
}
|
||||
progress = initializeProgressFile(args, chapters.length, paragraphsToLoad.length);
|
||||
await writeProgressFile(progressFilePath, progress);
|
||||
console.log(`✓ Created progress file: ${basename(progressFilePath)}\n`);
|
||||
} else {
|
||||
const existingProgress = await readProgressFile(progressFilePath);
|
||||
if (existingProgress) {
|
||||
progress = existingProgress;
|
||||
console.log(`✓ Resuming from existing progress file: ${basename(progressFilePath)}`);
|
||||
const successCount = Object.values(progress.paragraphs).filter(s => s === 'success').length;
|
||||
console.log(` Already loaded: ${successCount} paragraphs\n`);
|
||||
} else {
|
||||
progress = initializeProgressFile(args, chapters.length, paragraphsToLoad.length);
|
||||
await writeProgressFile(progressFilePath, progress);
|
||||
}
|
||||
}
|
||||
|
||||
// Load paragraphs
|
||||
const summary: LoadingSummary = {
|
||||
chaptersProcessed: 0,
|
||||
totalParagraphs: paragraphsToLoad.length,
|
||||
alreadyLoaded: 0,
|
||||
newlyLoaded: 0,
|
||||
failed: 0,
|
||||
failedParagraphs: [],
|
||||
};
|
||||
|
||||
console.log('📚 Loading novel into EverMind Cloud...\n');
|
||||
|
||||
// Create a Set of message IDs to load for quick lookup
|
||||
const messageIdsToLoad = new Set(paragraphsToLoad.map(p => p.messageId));
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const paragraphs = splitIntoParagraphs(chapter, args.bookTitle, args.bookAbbrev, args.minParagraphSize);
|
||||
|
||||
// Filter paragraphs to only those in our load list
|
||||
const paragraphsInChapterToLoad = paragraphs.filter(p => messageIdsToLoad.has(p.messageId));
|
||||
|
||||
// Skip chapter if no paragraphs to load
|
||||
if (paragraphsInChapterToLoad.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Loading Chapter ${chapter.number}: ${chapter.name}`);
|
||||
|
||||
for (const paragraph of paragraphsInChapterToLoad) {
|
||||
const existingStatus = progress.paragraphs[paragraph.messageId];
|
||||
|
||||
// Skip already loaded paragraphs
|
||||
if (existingStatus === 'success') {
|
||||
console.log(` ⊘ Skipping ${paragraph.messageId} (already loaded)`);
|
||||
summary.alreadyLoaded++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to save
|
||||
const result = await saveParagraphWithRetry(paragraph, args.bookTitle, args.apiUrl, args.apiKey);
|
||||
|
||||
// Update progress file immediately
|
||||
await updateProgressFile(
|
||||
progressFilePath,
|
||||
paragraph.messageId,
|
||||
result.success ? 'success' : 'failed',
|
||||
progress
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(` ✓ Saved ${paragraph.messageId}`);
|
||||
summary.newlyLoaded++;
|
||||
} else {
|
||||
console.log(` ✗ Failed ${paragraph.messageId}: ${result.error}`);
|
||||
summary.failed++;
|
||||
summary.failedParagraphs.push({
|
||||
messageId: paragraph.messageId,
|
||||
error: result.error || 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
summary.chaptersProcessed++;
|
||||
console.log(''); // Empty line between chapters
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log('═'.repeat(60));
|
||||
console.log('📊 Loading Summary');
|
||||
console.log('═'.repeat(60));
|
||||
console.log(`Chapters processed: ${summary.chaptersProcessed}`);
|
||||
console.log(`Total paragraphs: ${summary.totalParagraphs}`);
|
||||
console.log(`Already loaded: ${summary.alreadyLoaded}`);
|
||||
console.log(`Newly loaded: ${summary.newlyLoaded}`);
|
||||
console.log(`Failed: ${summary.failed}`);
|
||||
console.log('');
|
||||
|
||||
if (summary.failedParagraphs.length > 0) {
|
||||
console.log('❌ Failed paragraphs (can retry by running script again):');
|
||||
for (const failed of summary.failedParagraphs) {
|
||||
console.log(` - ${failed.messageId}: ${failed.error}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(`Progress saved to: ${basename(progressFilePath)}`);
|
||||
|
||||
if (summary.failed > 0) {
|
||||
console.log('\n⚠ Some paragraphs failed to load. Run the script again to retry.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ Novel loaded successfully to EverMind Cloud!');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entry Point
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const args = parseCliArgs();
|
||||
|
||||
if (!args) {
|
||||
return; // Help was shown or args were invalid
|
||||
}
|
||||
|
||||
try {
|
||||
await loadNovel(args);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Unexpected error:', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user