summary system backend implementation
This commit is contained in:
@@ -116,11 +116,9 @@ all projects use isolated memory. Returns `201` with the created project object.
|
|||||||
| `icon` | string | Icon identifier |
|
| `icon` | string | Icon identifier |
|
||||||
| `isolated` | integer | Memory isolation flag (always 1) |
|
| `isolated` | integer | Memory isolation flag (always 1) |
|
||||||
| `notes` | string | User-authored project notes |
|
| `notes` | string | User-authored project notes |
|
||||||
|
| `system_prompt` | string | Per-project system prompt override (null = use global) |
|
||||||
|
|
||||||
Only provided fields are updated — omitted fields are not touched. This
|
Only provided fields are updated — omitted fields are not touched.
|
||||||
enables safe partial updates (e.g. saving just `notes` without affecting
|
|
||||||
`name` or `colour`). Both orchestration and memory service implement dynamic
|
|
||||||
field patching.
|
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
@@ -161,7 +159,8 @@ Returns `503` if llama-server is unreachable.
|
|||||||
"temperature": 0.65,
|
"temperature": 0.65,
|
||||||
"repeatPenalty": 1.3,
|
"repeatPenalty": 1.3,
|
||||||
"topP": 0.9,
|
"topP": 0.9,
|
||||||
"topK": 41
|
"topK": 41,
|
||||||
|
"systemPrompt": "You are a helpful assistant..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -177,6 +176,7 @@ Returns `503` if llama-server is unreachable.
|
|||||||
| `repeatPenalty` | float | 1–2 | Repeat token penalty |
|
| `repeatPenalty` | float | 1–2 | Repeat token penalty |
|
||||||
| `topP` | float | 0–1 | Nucleus sampling probability mass |
|
| `topP` | float | 0–1 | Nucleus sampling probability mass |
|
||||||
| `topK` | integer | 1–100 | Top-K token candidates per step |
|
| `topK` | integer | 1–100 | Top-K token candidates per step |
|
||||||
|
| `systemPrompt` | string | — | Global system prompt (null reverts to hardcoded default) |
|
||||||
|
|
||||||
Settings are persisted to `data/settings.json` and read on every request —
|
Settings are persisted to `data/settings.json` and read on every request —
|
||||||
changes take effect immediately without a service restart.
|
changes take effect immediately without a service restart.
|
||||||
|
|||||||
@@ -98,14 +98,15 @@ src/
|
|||||||
│ ├── MessageBubble.jsx # Individual message bubble — renders markdown via react-markdown
|
│ ├── MessageBubble.jsx # Individual message bubble — renders markdown via react-markdown
|
||||||
│ ├── InfoPanel.jsx # Right panel — model selector and session metadata (slide-in)
|
│ ├── InfoPanel.jsx # Right panel — model selector and session metadata (slide-in)
|
||||||
│ ├── SessionModal.jsx # Modal for session rename, project assignment, delete
|
│ ├── SessionModal.jsx # Modal for session rename, project assignment, delete
|
||||||
│ ├── ProjectModal.jsx # Modal for project create, edit, delete
|
│ ├── ProjectModal.jsx # Modal for project create/edit — name, description, colour,
|
||||||
|
│ │ # system prompt override; delete confirmation
|
||||||
│ ├── AllChatsView.jsx # Paginated session list with project indicator column
|
│ ├── AllChatsView.jsx # Paginated session list with project indicator column
|
||||||
│ ├── AllProjectsView.jsx # Project tile grid with create/edit/delete; tile click navigates to ProjectView
|
│ ├── AllProjectsView.jsx # Project tile grid with create/edit/delete; tile click navigates to ProjectView
|
||||||
│ ├── ProjectView.jsx # Individual project — conversations, new chat input, memory
|
│ ├── ProjectView.jsx # Individual project — conversations, new chat input, memory
|
||||||
│ │ # placeholder, user notes, ⋮ edit/delete menu
|
│ │ # placeholder, user notes, ⋮ edit/delete menu
|
||||||
│ ├── MemoryView.jsx # Paginated, searchable, expandable, deletable episode viewer
|
│ ├── MemoryView.jsx # Paginated, searchable, expandable, deletable episode viewer
|
||||||
│ └── SettingsView.jsx # Settings — Memory limits, Models (inference params, active
|
│ └── SettingsView.jsx # Settings — Memory, Models, Behaviour (system prompt),
|
||||||
│ # model, context window), Service Health, Appearance placeholder
|
│ # About, Appearance
|
||||||
├── index.css # Global reset, CSS variables, utility classes
|
├── index.css # Global reset, CSS variables, utility classes
|
||||||
└── main.jsx # React entry point
|
└── main.jsx # React entry point
|
||||||
```
|
```
|
||||||
@@ -151,7 +152,7 @@ in the `ChatWindow` header.
|
|||||||
|
|
||||||
| View | Component | Trigger |
|
| View | Component | Trigger |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `'home'` | `HomeView` | Initial load; going back from chat with no history |
|
| `'home'` | `HomeView` | Initial load |
|
||||||
| `'chat'` | `ChatWindow` | Selecting a session; new chat; sending from HomeView |
|
| `'chat'` | `ChatWindow` | Selecting a session; new chat; sending from HomeView |
|
||||||
| `'all-chats'` | `AllChatsView` | "All Chats →" or ☰ icon in collapsed rail |
|
| `'all-chats'` | `AllChatsView` | "All Chats →" or ☰ icon in collapsed rail |
|
||||||
| `'all-projects'` | `AllProjectsView` | "View Projects" button or ⊞ icon |
|
| `'all-projects'` | `AllProjectsView` | "View Projects" button or ⊞ icon |
|
||||||
@@ -178,10 +179,9 @@ leaving `'home'` expands it.
|
|||||||
- Centred textarea input — sending creates a new session and navigates to chat
|
- Centred textarea input — sending creates a new session and navigates to chat
|
||||||
- Quick action pills that populate the input without auto-sending
|
- Quick action pills that populate the input without auto-sending
|
||||||
|
|
||||||
Sending from HomeView uses `handleHomeSend` in `App.jsx`, which calls
|
`handleHomeSend` in `App.jsx` calls `createSession()` (which returns the new
|
||||||
`createSession()` (returns the new session object), then immediately calls
|
session object), then immediately calls `sendMessage` with the session passed
|
||||||
`sendMessage` with the session passed directly as a parameter — avoiding the
|
directly — avoiding the React state settling race condition.
|
||||||
React state settling race condition that would cause the message to be dropped.
|
|
||||||
|
|
||||||
## CSS Architecture
|
## CSS Architecture
|
||||||
|
|
||||||
@@ -283,6 +283,7 @@ mode, and delete confirmation in `confirm-delete` mode.
|
|||||||
- `useContextMenu` dismisses on a `window` click listener
|
- `useContextMenu` dismisses on a `window` click listener
|
||||||
- Dynamic `updateSession` SQL builds `SET` clause from only the fields passed — prevents accidental overwrites
|
- Dynamic `updateSession` SQL builds `SET` clause from only the fields passed — prevents accidental overwrites
|
||||||
- `AllChatsView` pagination uses `CLIENT_DEFAULTS.PAGE_SIZE` (not `API_DEFAULTS.PAGE_SIZE` which doesn't exist)
|
- `AllChatsView` pagination uses `CLIENT_DEFAULTS.PAGE_SIZE` (not `API_DEFAULTS.PAGE_SIZE` which doesn't exist)
|
||||||
|
- `Sidebar` groups sessions by project — `key` must be passed directly to `<SessionRow key={...}>`, not included in the props spread object
|
||||||
|
|
||||||
## Sidebar — Session Grouping
|
## Sidebar — Session Grouping
|
||||||
|
|
||||||
@@ -302,11 +303,27 @@ The isolated toggle has been removed from `ProjectModal`.
|
|||||||
`useProjects` fetches the project list from `GET /projects` on mount and
|
`useProjects` fetches the project list from `GET /projects` on mount and
|
||||||
exposes `refreshProjects` for keeping the sidebar in sync after mutations.
|
exposes `refreshProjects` for keeping the sidebar in sync after mutations.
|
||||||
|
|
||||||
`ProjectModal` handles create, edit, and delete confirmation. Fields: name
|
### ProjectModal Fields
|
||||||
(required), description (optional), colour picker.
|
|
||||||
|
|
||||||
Clicking a project tile in `AllProjectsView` calls `onSelectProject` then
|
- **Name** (required)
|
||||||
navigates to `'project'`.
|
- **Description** (optional)
|
||||||
|
- **Colour** — picker from six preset hex values
|
||||||
|
- **System Prompt** (optional) — overrides the global system prompt for all
|
||||||
|
conversations in this project. Leave blank to use the global default.
|
||||||
|
Stored as `system_prompt` (snake_case) matching the SQLite column.
|
||||||
|
`Enter` key does not submit — textarea fields make it ambiguous. Save button only.
|
||||||
|
|
||||||
|
`handleSave` in `ProjectView` destructures `system_prompt` (snake_case) to
|
||||||
|
match what `ProjectModal` sends. `updateProject` in `orchestration.js` uses
|
||||||
|
a passthrough pattern — spreads all fields into the request body.
|
||||||
|
|
||||||
|
### System Prompt Hierarchy
|
||||||
|
|
||||||
|
System prompt resolution in `chat/index.js` (orchestration):
|
||||||
|
|
||||||
|
1. `project.system_prompt` — if set on the project (highest priority)
|
||||||
|
2. `settings.systemPrompt` — global setting from `settings.json`
|
||||||
|
3. `ORCHESTRATION.SYSTEM_PROMPT` — hardcoded constant in `@nexusai/shared` (last resort)
|
||||||
|
|
||||||
### ProjectView
|
### ProjectView
|
||||||
|
|
||||||
@@ -322,9 +339,8 @@ navigates to `'project'`.
|
|||||||
saved value (`savedNotes` state tracks the baseline, not `initialNotes`)
|
saved value (`savedNotes` state tracks the baseline, not `initialNotes`)
|
||||||
|
|
||||||
`updateProject` in `orchestration.js` uses a passthrough pattern — spreads
|
`updateProject` in `orchestration.js` uses a passthrough pattern — spreads
|
||||||
all fields directly into the request body, only transforming `isolated` if
|
all fields directly into the request body. This allows partial updates like
|
||||||
present. This allows partial updates like `{ notes }` without clobbering
|
`{ notes }` or `{ system_prompt }` without clobbering other fields.
|
||||||
other fields.
|
|
||||||
|
|
||||||
For memory isolation behaviour, see `memory-isolation.md`.
|
For memory isolation behaviour, see `memory-isolation.md`.
|
||||||
|
|
||||||
@@ -337,9 +353,9 @@ buttons during in-flight requests.
|
|||||||
|
|
||||||
`SettingsView` receives `settings`/`saveSetting`/`saving` from a single
|
`SettingsView` receives `settings`/`saveSetting`/`saving` from a single
|
||||||
`useSettings()` call at the top level and passes them as props to
|
`useSettings()` call at the top level and passes them as props to
|
||||||
`ModelsSection` and `ModelsFolderSetting` — avoiding triple fetch on mount.
|
`ModelsSection`, `ModelsFolderSetting`, and `SystemPromptSetting` — avoiding
|
||||||
`modelProps` (context window, loaded model) is fetched once in `App.jsx` and
|
triple fetch on mount. `modelProps` (context window, loaded model) is fetched
|
||||||
passed down as a prop, eliminating a duplicate fetch on every settings open.
|
once in `App.jsx` and passed down as a prop.
|
||||||
|
|
||||||
`SettingsView` is organised into sections:
|
`SettingsView` is organised into sections:
|
||||||
|
|
||||||
@@ -347,6 +363,9 @@ passed down as a prop, eliminating a duplicate fetch on every settings open.
|
|||||||
- **Models** — models folder path, temperature, repeat penalty, Top-P, Top-K,
|
- **Models** — models folder path, temperature, repeat penalty, Top-P, Top-K,
|
||||||
active model dropdown, read-only model info panel (file, size, context window,
|
active model dropdown, read-only model info panel (file, size, context window,
|
||||||
loaded model from llama-server)
|
loaded model from llama-server)
|
||||||
|
- **Behaviour** — global system prompt textarea (`SystemPromptSetting`). Save
|
||||||
|
button appears only when content differs from `savedPrompt` state. Saving an
|
||||||
|
empty string sends `null` which reverts to the hardcoded default.
|
||||||
- **About** — service health check panel, version
|
- **About** — service health check panel, version
|
||||||
- **Appearance** — theme (coming soon)
|
- **Appearance** — theme (coming soon)
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Six core tables:
|
|||||||
- **entities** — named things the system learns about (people, places, concepts)
|
- **entities** — named things the system learns about (people, places, concepts)
|
||||||
- **relationships** — directional labeled links between entities
|
- **relationships** — directional labeled links between entities
|
||||||
- **summaries** — condensed episode groups for efficient context retrieval
|
- **summaries** — condensed episode groups for efficient context retrieval
|
||||||
- **projects** — named groupings of sessions with `name`, `description`, `colour`, `icon`, `isolated`, `notes`
|
- **projects** — named groupings of sessions with `name`, `description`, `colour`, `icon`, `isolated`, `notes`, `system_prompt`
|
||||||
|
|
||||||
### Migrations
|
### Migrations
|
||||||
|
|
||||||
@@ -71,6 +71,7 @@ try { db.exec(`ALTER TABLE sessions ADD COLUMN project_id INTEGER REFERENCES pro
|
|||||||
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id)`); } catch {}
|
try { db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id)`); } catch {}
|
||||||
try { db.exec(`ALTER TABLE projects ADD COLUMN isolated INTEGER NOT NULL DEFAULT 0`); } catch {}
|
try { db.exec(`ALTER TABLE projects ADD COLUMN isolated INTEGER NOT NULL DEFAULT 0`); } catch {}
|
||||||
try { db.exec(`ALTER TABLE projects ADD COLUMN notes TEXT`); } catch {}
|
try { db.exec(`ALTER TABLE projects ADD COLUMN notes TEXT`); } catch {}
|
||||||
|
try { db.exec(`ALTER TABLE projects ADD COLUMN system_prompt TEXT`); } catch {}
|
||||||
```
|
```
|
||||||
|
|
||||||
New migrations are always appended here — never modify the schema file for
|
New migrations are always appended here — never modify the schema file for
|
||||||
@@ -92,29 +93,15 @@ keep the FTS index automatically in sync with the episodes table.
|
|||||||
|
|
||||||
Both `updateSession` and `updateProject` build their `SET` clause dynamically
|
Both `updateSession` and `updateProject` build their `SET` clause dynamically
|
||||||
from only the fields passed — prevents partial updates from overwriting fields
|
from only the fields passed — prevents partial updates from overwriting fields
|
||||||
that weren't touched:
|
that weren't touched.
|
||||||
|
|
||||||
|
`updateProject` allowlist:
|
||||||
```js
|
```js
|
||||||
// updateProject example
|
const allowed = ['name', 'description', 'colour', 'icon', 'isolated', 'notes', 'system_prompt'];
|
||||||
function updateProject(id, fields = {}) {
|
|
||||||
const allowed = ['name', 'description', 'colour', 'icon', 'isolated', 'notes'];
|
|
||||||
const updates = [];
|
|
||||||
const values = [];
|
|
||||||
for (const key of allowed) {
|
|
||||||
if (fields[key] !== undefined) {
|
|
||||||
updates.push(`${key} = ?`);
|
|
||||||
values.push(fields[key] ?? null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updates.length === 0) return getProject(id);
|
|
||||||
values.push(id);
|
|
||||||
db.prepare(`UPDATE projects SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
||||||
return getProject(id);
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This means saving just `{ notes: "..." }` won't touch `name`, `colour`, or
|
This means saving just `{ notes: "..." }` or `{ system_prompt: "..." }` won't
|
||||||
any other field.
|
touch any other field.
|
||||||
|
|
||||||
## Qdrant / Semantic Layer
|
## Qdrant / Semantic Layer
|
||||||
|
|
||||||
@@ -183,8 +170,10 @@ After extraction, each entity is:
|
|||||||
Qdrant collection with `{ name, type, notes, projectId }` as payload —
|
Qdrant collection with `{ name, type, notes, projectId }` as payload —
|
||||||
`projectId` scopes entities to their project for isolated retrieval
|
`projectId` scopes entities to their project for isolated retrieval
|
||||||
|
|
||||||
The Qdrant payload stores enough information to reconstruct entity context
|
`extractAndStoreEntities` receives `projectId` from `createEpisode`, which
|
||||||
at retrieval time without a SQLite roundtrip.
|
receives it from the episode route, which receives it from orchestration's
|
||||||
|
`createEpisode` call. This ensures entities are tagged with the correct
|
||||||
|
project scope at extraction time.
|
||||||
|
|
||||||
## Project Delete Behaviour
|
## Project Delete Behaviour
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ src/
|
|||||||
├── routes/
|
├── routes/
|
||||||
│ ├── chat.js # POST /chat and POST /chat/stream
|
│ ├── chat.js # POST /chat and POST /chat/stream
|
||||||
│ ├── sessions.js # Session CRUD proxy
|
│ ├── sessions.js # Session CRUD proxy
|
||||||
│ ├── projects.js # Project CRUD proxy
|
│ ├── projects.js # Project CRUD proxy — passes req.body straight through
|
||||||
│ ├── episodes.js # Episode list and delete proxy
|
│ ├── episodes.js # Episode list and delete proxy
|
||||||
│ ├── settings.js # GET /settings and PATCH /settings
|
│ ├── settings.js # GET /settings and PATCH /settings
|
||||||
│ ├── health.js # GET /health — pings all four services
|
│ ├── health.js # GET /health — pings all four services
|
||||||
@@ -75,6 +75,7 @@ via `appSettings.load()` — changes apply immediately without a service restart
|
|||||||
| `repeatPenalty` | 1.1 | Repeat token penalty |
|
| `repeatPenalty` | 1.1 | Repeat token penalty |
|
||||||
| `topP` | 0.9 | Nucleus sampling probability mass |
|
| `topP` | 0.9 | Nucleus sampling probability mass |
|
||||||
| `topK` | 40 | Top-K token candidates per step |
|
| `topK` | 40 | Top-K token candidates per step |
|
||||||
|
| `systemPrompt` | *(ORCHESTRATION.SYSTEM_PROMPT)* | Global system prompt. `null` reverts to hardcoded constant. |
|
||||||
|
|
||||||
Defaults are defined in `config/settings.js` and fall back to constants in
|
Defaults are defined in `config/settings.js` and fall back to constants in
|
||||||
`@nexusai/shared`. Values saved in `settings.json` take precedence.
|
`@nexusai/shared`. Values saved in `settings.json` take precedence.
|
||||||
@@ -91,41 +92,43 @@ difference is how the inference response is delivered to the client.
|
|||||||
step needed.
|
step needed.
|
||||||
|
|
||||||
2. **Project context resolution** — if the session has a `project_id`, fetch
|
2. **Project context resolution** — if the session has a `project_id`, fetch
|
||||||
the project and all its session IDs. Used to scope semantic search. See
|
the project and all its session IDs. Used to scope semantic search. The
|
||||||
`memory-isolation.md` for full behaviour.
|
project's `system_prompt` is also read at this step if set.
|
||||||
|
|
||||||
3. **Recent episode retrieval** — fetch the most recent episodes for the
|
3. **System prompt resolution** — three-tier hierarchy:
|
||||||
|
- `project.system_prompt` — if the session is in a project and it's set (highest priority)
|
||||||
|
- `settings.systemPrompt` — global setting from `settings.json`
|
||||||
|
- `ORCHESTRATION.SYSTEM_PROMPT` — hardcoded constant in `@nexusai/shared` (last resort)
|
||||||
|
|
||||||
|
4. **Recent episode retrieval** — fetch the most recent episodes for the
|
||||||
session (`recentEpisodeLimit`, default 5).
|
session (`recentEpisodeLimit`, default 5).
|
||||||
|
|
||||||
4. **Semantic search** — embed the user message, query Qdrant for the top
|
5. **Semantic search** — embed the user message, query Qdrant for the top
|
||||||
most similar past episodes (`semanticLimit`, `scoreThreshold`). Deduplicated
|
most similar past episodes (`semanticLimit`, `scoreThreshold`). Deduplicated
|
||||||
against recent episodes. Non-critical — if it fails, pipeline continues with
|
against recent episodes. Non-critical — if it fails, pipeline continues with
|
||||||
recency-only context.
|
recency-only context.
|
||||||
|
|
||||||
5. **Entity search** — reuse the embedded user message vector to query the
|
6. **Entity search** — query the `entities` Qdrant collection filtered by
|
||||||
`entities` Qdrant collection (score threshold 0.6, limit 5). Returns
|
`projectId`. Non-project sessions receive no entity context. Non-critical.
|
||||||
entity payloads (`name`, `type`, `notes`) directly — no SQLite roundtrip
|
|
||||||
needed. Non-critical — if it fails, pipeline continues without entity context.
|
|
||||||
|
|
||||||
6. **Prompt assembly** — combine system prompt, entity context, semantic
|
7. **Prompt assembly** — combine resolved system prompt, entity context,
|
||||||
episodes, recent episodes, and user message.
|
semantic episodes, recent episodes, and user message.
|
||||||
|
|
||||||
7. **Inference** — send to inference service with settings-derived parameters
|
8. **Inference** — send to inference service with settings-derived parameters
|
||||||
(temperature, topP, topK, repeatPenalty). `/chat` awaits full response;
|
(temperature, topP, topK, repeatPenalty). `/chat` awaits full response;
|
||||||
`/chat/stream` pipes SSE chunks to the client.
|
`/chat/stream` pipes SSE chunks to the client.
|
||||||
|
|
||||||
8. **Episode write** — write the exchange back to memory. Fire-and-forget
|
9. **Episode write** — write the exchange back to memory with `projectId`.
|
||||||
for `/chat`; awaited for `/chat/stream` to ensure the full text is
|
Fire-and-forget for `/chat`; awaited for `/chat/stream`.
|
||||||
accumulated before saving.
|
|
||||||
|
|
||||||
9. **Auto-naming** — on `isFirstMessage && !session.name`, fire a secondary
|
10. **Auto-naming** — on `isFirstMessage && !session.name`, fire a secondary
|
||||||
inference call with a naming prompt (max 20 tokens, temperature 0.3) and
|
inference call with a naming prompt (max 20 tokens, temperature 0.3) and
|
||||||
write the result back as `session.name`. Fully fire-and-forget.
|
write the result back as `session.name`. Fully fire-and-forget.
|
||||||
|
|
||||||
### Prompt Structure
|
### Prompt Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
[System prompt]
|
[Resolved system prompt]
|
||||||
|
|
||||||
Here is what you know about entities relevant to this conversation:
|
Here is what you know about entities relevant to this conversation:
|
||||||
- {name} ({type}): {notes}
|
- {name} ({type}): {notes}
|
||||||
@@ -175,9 +178,9 @@ is terminated by `res.end()` after the done event.
|
|||||||
folder for richer metadata (label, description). Returns file size in GB.
|
folder for richer metadata (label, description). Returns file size in GB.
|
||||||
|
|
||||||
`GET /models/props` fetches directly from llama-server via `LLAMA_SERVER_URL`.
|
`GET /models/props` fetches directly from llama-server via `LLAMA_SERVER_URL`.
|
||||||
Returns `{ contextWindow, modelAlias }`. Used by the client to display
|
Returns `{ contextWindow, modelAlias }`. `n_ctx` is at
|
||||||
read-only context window size and the currently loaded model in the settings
|
`data.default_generation_settings.n_ctx` in the llama-server response.
|
||||||
panel. Returns `503` if llama-server is unreachable.
|
Returns `503` if llama-server is unreachable.
|
||||||
|
|
||||||
## Sessions Route Behaviour
|
## Sessions Route Behaviour
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ export default function MessageBubble({ message }) {
|
|||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
maxWidth: '70%',
|
maxWidth: '70%',
|
||||||
padding: '10px 14px',
|
padding: '14px 14px',
|
||||||
borderRadius: isUser ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
borderRadius: isUser ? '18px 4px 4px 18px' : '4px 18px 18px 4px',
|
||||||
background: isUser ? 'var(--bubble-user)' : 'var(--bubble-ai)',
|
background: isUser ? 'var(--bubble-user)' : 'var(--bubble-ai)',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
fontSize: '14px',
|
fontSize: '18px',
|
||||||
lineHeight: '1.6',
|
lineHeight: '1.8',
|
||||||
border: isUser ? 'none' : '1px solid var(--border)',
|
border: isUser ? 'none' : '2px solid var(--border)',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}}>
|
}}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
@@ -60,7 +60,7 @@ export default function MessageBubble({ message }) {
|
|||||||
}} />
|
}} />
|
||||||
)}
|
)}
|
||||||
{message.error && (
|
{message.error && (
|
||||||
<div className="text-xs" style={{ marginTop: '6px', color: '#ff6b6b' }}>
|
<div className="text-xs" style={{ marginTop: '6px', color: 'var(--warning)' }}>
|
||||||
⚠ Failed to complete response
|
⚠ Failed to complete response
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export default function Sidebar({
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="panel-header" style={{ justifyContent: 'space-between', padding: '0 12px 0 16px' }}>
|
<div className="panel-header" style={{ justifyContent: 'space-between', padding: '0 12px 0 16px' }}>
|
||||||
<span className="text-base" style={{ fontWeight: 500, color: 'var(--text-secondary)' }}>NexusAI</span>
|
<span className="text-base" style={{ fontWeight: 1000, color: 'var(--text-secondary)' }}>NexusAI</span>
|
||||||
<button className="btn-icon" onClick={onToggle}>◀</button>
|
<button className="btn-icon" onClick={onToggle}>◀</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,8 +166,8 @@ export default function Sidebar({
|
|||||||
padding: '10px',
|
padding: '10px',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
border: '1px dashed var(--border)',
|
border: '1px dashed var(--border)',
|
||||||
color: 'var(--text-muted)',
|
color: 'var(--text-sb-hdr)',
|
||||||
fontSize: '12px',
|
fontSize: '13px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}>
|
}}>
|
||||||
No projects yet
|
No projects yet
|
||||||
@@ -226,11 +226,17 @@ export default function Sidebar({
|
|||||||
display: 'flex', alignItems: 'center', gap: '6px',
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
padding: '6px 16px 2px',
|
padding: '6px 16px 2px',
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<span className=" text-muted truncate"
|
||||||
width: '6px', height: '6px', borderRadius: '50%', flexShrink: 0,
|
style={{
|
||||||
background: project?.colour ?? 'var(--accent)',
|
fontSize: '12px',
|
||||||
}} />
|
textTransform: 'uppercase',
|
||||||
<span className="text-xs text-muted truncate">
|
fontWeight: '500',
|
||||||
|
textAlign: 'center',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: `1px solid ${project.colour ?? 'var(--border)'}`,
|
||||||
|
padding: '2px 2px',
|
||||||
|
width: '100%'
|
||||||
|
}}>
|
||||||
{project?.name ?? 'Project'}
|
{project?.name ?? 'Project'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +252,7 @@ export default function Sidebar({
|
|||||||
<>
|
<>
|
||||||
{Object.keys(grouped).length > 0 && (
|
{Object.keys(grouped).length > 0 && (
|
||||||
<div style={{ padding: '6px 16px 2px' }}>
|
<div style={{ padding: '6px 16px 2px' }}>
|
||||||
<span className="text-xs text-muted">Other</span>
|
<span className=" text-muted " style={{fontSize: '12px', textTransform: 'uppercase', fontWeight: '500', textAlign: 'center',}}>Other</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{unassigned.map(session => (
|
{unassigned.map(session => (
|
||||||
@@ -334,14 +340,14 @@ function SectionHeader({ label, isOpen, onToggle }) {
|
|||||||
className="btn-reset label-upper"
|
className="btn-reset label-upper"
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '8px 16px',
|
width: '100%', padding: '8px 16px',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
color: 'var(--text-muted)',
|
color: 'var(--text-sb-hdr)',
|
||||||
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
|
|
||||||
>
|
>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<span style={{ fontSize: '10px' }}>{isOpen ? '▾' : '▸'}</span>
|
<span style={{ fontSize: '13px' }}>{isOpen ? '▾' : '▸'}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -357,6 +363,9 @@ function SessionRow({ session, isActive, isHovered, onHover, onSelect, onRename,
|
|||||||
background: isActive || isHovered ? 'var(--bg-elevated)' : 'transparent',
|
background: isActive || isHovered ? 'var(--bg-elevated)' : 'transparent',
|
||||||
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
|
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
transition: 'background 0.1s',
|
transition: 'background 0.1s',
|
||||||
|
overflow: 'hidden',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -365,7 +374,9 @@ function SessionRow({ session, isActive, isHovered, onHover, onSelect, onRename,
|
|||||||
style={{
|
style={{
|
||||||
flex: 1, padding: '8px 16px',
|
flex: 1, padding: '8px 16px',
|
||||||
paddingRight: isHovered && !session.isNew ? '4px' : '16px',
|
paddingRight: isHovered && !session.isNew ? '4px' : '16px',
|
||||||
textAlign: 'left', minWidth: 0,
|
textAlign: 'left',
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-base truncate" style={{
|
<span className="text-base truncate" style={{
|
||||||
@@ -380,14 +391,22 @@ function SessionRow({ session, isActive, isHovered, onHover, onSelect, onRename,
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isHovered && !session.isNew && (
|
<div
|
||||||
<div className="flex items-center flex-shrink" style={{ gap: '2px', paddingRight: '8px' }}>
|
style={{
|
||||||
<button className="btn-icon" title="Rename" onClick={onRename}
|
display: 'flex', alignItems: 'center',
|
||||||
style={{ padding: '2px 4px', fontSize: '12px' }}>✎</button>
|
gap: '2px',
|
||||||
<button className="btn-icon" title="Delete" onClick={onDelete}
|
paddingRight: isHovered && !session.isNew ? '8px' : '0px',
|
||||||
style={{ padding: '2px 4px', fontSize: '12px', color: '#ff6b6b' }}>✕</button>
|
flexShrink: 0,
|
||||||
</div>
|
width: isHovered && !session.isNew ? '44px' : '0px',
|
||||||
)}
|
overflow: 'hidden',
|
||||||
|
transition: 'width 0.1s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="btn-icon" title="Rename" onClick={onRename}
|
||||||
|
style={{ padding: '2px 4px', fontSize: '12px' }}>✎</button>
|
||||||
|
<button className="btn-icon" title="Delete" onClick={onDelete}
|
||||||
|
style={{ padding: '2px 4px', fontSize: '12px', color: '#ff6b6b' }}>✕</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-base: #0f1117;
|
--bg-base: #9c9a9a;
|
||||||
--bg-surface: #0e0d0d;
|
--bg-surface: #000000;
|
||||||
--bg-elevated: #222536;
|
--bg-elevated: #111111;
|
||||||
--border: #2e3150;
|
--border: #989899;
|
||||||
--accent: #3d3a79;
|
--accent: #333335;
|
||||||
--accent-hover: #574fd6;
|
--accent-hover: #574fd6;
|
||||||
--text-primary: #e8e8f0;
|
--text-primary: #e8e8f0;
|
||||||
--text-secondary: #8b8fa8;
|
--text-secondary: #8b8fa8;
|
||||||
--text-muted: #555870;
|
--text-muted: #ababaf;
|
||||||
--bubble-user: #4742a8;
|
--text-sb-hdr: #ffffff;
|
||||||
--bubble-ai: #20264d;
|
--bubble-user: #020202;
|
||||||
|
--bubble-ai: #303033;
|
||||||
|
--warning: #ec5353;
|
||||||
--sidebar-width: 180px;
|
--sidebar-width: 180px;
|
||||||
--panel-width: 200px;
|
--panel-width: 200px;
|
||||||
--header-height: 40px;
|
--header-height: 40px;
|
||||||
@@ -64,7 +66,9 @@ html, body, #root {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
@@ -105,5 +109,5 @@ html, body, #root {
|
|||||||
.text-muted { color: var(--text-muted); }
|
.text-muted { color: var(--text-muted); }
|
||||||
.text-secondary { color: var(--text-secondary); }
|
.text-secondary { color: var(--text-secondary); }
|
||||||
.text-accent { color: var(--accent); }
|
.text-accent { color: var(--accent); }
|
||||||
.label-upper { font-size: 11px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
.label-upper { font-size: 13px; font-weight: 750; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
||||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
@@ -38,6 +38,18 @@ function getDB() {
|
|||||||
db.exec(`ALTER TABLE projects ADD COLUMN system_prompt TEXT`);
|
db.exec(`ALTER TABLE projects ADD COLUMN system_prompt TEXT`);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE summaries ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE`);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE summaries ADD COLUMN token_count INTEGER`);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_summaries_project ON summaries(project_id)`);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
// Sync FTS index with any existing episodes data
|
// Sync FTS index with any existing episodes data
|
||||||
db.exec(`INSERT OR REPLACE INTO episodes_fts(rowid, user_message, ai_response)
|
db.exec(`INSERT OR REPLACE INTO episodes_fts(rowid, user_message, ai_response)
|
||||||
SELECT id, user_message, ai_response FROM episodes`);
|
SELECT id, user_message, ai_response FROM episodes`);
|
||||||
|
|||||||
@@ -41,12 +41,17 @@ const schema = `
|
|||||||
CREATE TABLE IF NOT EXISTS summaries (
|
CREATE TABLE IF NOT EXISTS summaries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
|
token_count INTEGER,
|
||||||
episode_range TEXT,
|
episode_range TEXT,
|
||||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
metadata TEXT
|
metadata TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_summaries_project ON summaries(project_id);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_episodes_session
|
CREATE INDEX IF NOT EXISTS idx_episodes_session
|
||||||
ON episodes(session_id);
|
ON episodes(session_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_episodes_created
|
CREATE INDEX IF NOT EXISTS idx_episodes_created
|
||||||
|
|||||||
53
packages/memory-service/src/db/summaries.js
Normal file
53
packages/memory-service/src/db/summaries.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const { getDB } = require('./index');
|
||||||
|
const { parseRow } = require('@nexusai/shared');
|
||||||
|
|
||||||
|
function createSummary({ sessionId = null, projectId = null, content, tokenCount = null, episodeRange = null, metadata = null }) {
|
||||||
|
const db = getDB();
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO summaries (session_id, project_id, content, token_count, episode_range, metadata)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(sessionId, projectId, content, tokenCount, episodeRange, metadata ? JSON.stringify(metadata) : null);
|
||||||
|
|
||||||
|
return getSummary(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummary(id) {
|
||||||
|
const db = getDB();
|
||||||
|
const row = db.prepare(`SELECT * FROM summaries WHERE id = ?`).get(id);
|
||||||
|
return row ? parseRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummariesBySession(sessionId) {
|
||||||
|
const db = getDB();
|
||||||
|
return db.prepare(`SELECT * FROM summaries WHERE session_id = ? ORDER BY created_at ASC`)
|
||||||
|
.all(sessionId).map(parseRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummariesByProject(projectId) {
|
||||||
|
const db = getDB();
|
||||||
|
return db.prepare(`SELECT * FROM summaries WHERE project_id = ? ORDER BY created_at ASC`)
|
||||||
|
.all(projectId).map(parseRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummary(id, { content, tokenCount, episodeRange, metadata }) {
|
||||||
|
const db = getDB();
|
||||||
|
const fields = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
if (content !== undefined) { fields.push('content = ?'); values.push(content); }
|
||||||
|
if (tokenCount !== undefined) { fields.push('token_count = ?'); values.push(tokenCount); }
|
||||||
|
if (episodeRange !== undefined){ fields.push('episode_range = ?'); values.push(episodeRange); }
|
||||||
|
if (metadata !== undefined) { fields.push('metadata = ?'); values.push(JSON.stringify(metadata)); }
|
||||||
|
|
||||||
|
if (!fields.length) return getSummary(id);
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
db.prepare(`UPDATE summaries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||||
|
return getSummary(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSummary(id) {
|
||||||
|
getDB().prepare(`DELETE FROM summaries WHERE id = ?`).run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createSummary, getSummary, getSummariesBySession, getSummariesByProject, updateSummary, deleteSummary };
|
||||||
Reference in New Issue
Block a user