diff --git a/docs/reference/API-routes.md b/docs/reference/API-routes.md index af99925..7c59e50 100644 --- a/docs/reference/API-routes.md +++ b/docs/reference/API-routes.md @@ -116,11 +116,9 @@ all projects use isolated memory. Returns `201` with the created project object. | `icon` | string | Icon identifier | | `isolated` | integer | Memory isolation flag (always 1) | | `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 -enables safe partial updates (e.g. saving just `notes` without affecting -`name` or `colour`). Both orchestration and memory service implement dynamic -field patching. +Only provided fields are updated — omitted fields are not touched. ### Models @@ -161,7 +159,8 @@ Returns `503` if llama-server is unreachable. "temperature": 0.65, "repeatPenalty": 1.3, "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 | | `topP` | float | 0–1 | Nucleus sampling probability mass | | `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 — changes take effect immediately without a service restart. diff --git a/docs/services/chat-client.md b/docs/services/chat-client.md index ac81a57..55075ff 100644 --- a/docs/services/chat-client.md +++ b/docs/services/chat-client.md @@ -98,14 +98,15 @@ src/ │ ├── MessageBubble.jsx # Individual message bubble — renders markdown via react-markdown │ ├── InfoPanel.jsx # Right panel — model selector and session metadata (slide-in) │ ├── 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 │ ├── AllProjectsView.jsx # Project tile grid with create/edit/delete; tile click navigates to ProjectView │ ├── ProjectView.jsx # Individual project — conversations, new chat input, memory │ │ # placeholder, user notes, ⋮ edit/delete menu │ ├── MemoryView.jsx # Paginated, searchable, expandable, deletable episode viewer -│ └── SettingsView.jsx # Settings — Memory limits, Models (inference params, active -│ # model, context window), Service Health, Appearance placeholder +│ └── SettingsView.jsx # Settings — Memory, Models, Behaviour (system prompt), +│ # About, Appearance ├── index.css # Global reset, CSS variables, utility classes └── main.jsx # React entry point ``` @@ -151,7 +152,7 @@ in the `ChatWindow` header. | 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 | | `'all-chats'` | `AllChatsView` | "All Chats →" or ☰ icon in collapsed rail | | `'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 - Quick action pills that populate the input without auto-sending -Sending from HomeView uses `handleHomeSend` in `App.jsx`, which calls -`createSession()` (returns the new session object), then immediately calls -`sendMessage` with the session passed directly as a parameter — avoiding the -React state settling race condition that would cause the message to be dropped. +`handleHomeSend` in `App.jsx` calls `createSession()` (which returns the new +session object), then immediately calls `sendMessage` with the session passed +directly — avoiding the React state settling race condition. ## CSS Architecture @@ -283,6 +283,7 @@ mode, and delete confirmation in `confirm-delete` mode. - `useContextMenu` dismisses on a `window` click listener - 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) +- `Sidebar` groups sessions by project — `key` must be passed directly to ``, not included in the props spread object ## 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 exposes `refreshProjects` for keeping the sidebar in sync after mutations. -`ProjectModal` handles create, edit, and delete confirmation. Fields: name -(required), description (optional), colour picker. +### ProjectModal Fields -Clicking a project tile in `AllProjectsView` calls `onSelectProject` then -navigates to `'project'`. +- **Name** (required) +- **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 @@ -322,9 +339,8 @@ navigates to `'project'`. saved value (`savedNotes` state tracks the baseline, not `initialNotes`) `updateProject` in `orchestration.js` uses a passthrough pattern — spreads -all fields directly into the request body, only transforming `isolated` if -present. This allows partial updates like `{ notes }` without clobbering -other fields. +all fields directly into the request body. This allows partial updates like +`{ notes }` or `{ system_prompt }` without clobbering other fields. 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 `useSettings()` call at the top level and passes them as props to -`ModelsSection` and `ModelsFolderSetting` — avoiding triple fetch on mount. -`modelProps` (context window, loaded model) is fetched once in `App.jsx` and -passed down as a prop, eliminating a duplicate fetch on every settings open. +`ModelsSection`, `ModelsFolderSetting`, and `SystemPromptSetting` — avoiding +triple fetch on mount. `modelProps` (context window, loaded model) is fetched +once in `App.jsx` and passed down as a prop. `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, active model dropdown, read-only model info panel (file, size, context window, 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 - **Appearance** — theme (coming soon) diff --git a/docs/services/memory-service.md b/docs/services/memory-service.md index 1ac3518..7134f7c 100644 --- a/docs/services/memory-service.md +++ b/docs/services/memory-service.md @@ -58,7 +58,7 @@ Six core tables: - **entities** — named things the system learns about (people, places, concepts) - **relationships** — directional labeled links between entities - **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 @@ -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(`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 system_prompt TEXT`); } catch {} ``` 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 from only the fields passed — prevents partial updates from overwriting fields -that weren't touched: +that weren't touched. +`updateProject` allowlist: ```js -// updateProject example -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); -} +const allowed = ['name', 'description', 'colour', 'icon', 'isolated', 'notes', 'system_prompt']; ``` -This means saving just `{ notes: "..." }` won't touch `name`, `colour`, or -any other field. +This means saving just `{ notes: "..." }` or `{ system_prompt: "..." }` won't +touch any other field. ## Qdrant / Semantic Layer @@ -183,8 +170,10 @@ After extraction, each entity is: Qdrant collection with `{ name, type, notes, projectId }` as payload — `projectId` scopes entities to their project for isolated retrieval -The Qdrant payload stores enough information to reconstruct entity context -at retrieval time without a SQLite roundtrip. +`extractAndStoreEntities` receives `projectId` from `createEpisode`, which +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 diff --git a/docs/services/orchestration-service.md b/docs/services/orchestration-service.md index e6ae466..a04d0d6 100644 --- a/docs/services/orchestration-service.md +++ b/docs/services/orchestration-service.md @@ -48,7 +48,7 @@ src/ ├── routes/ │ ├── chat.js # POST /chat and POST /chat/stream │ ├── 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 │ ├── settings.js # GET /settings and PATCH /settings │ ├── 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 | | `topP` | 0.9 | Nucleus sampling probability mass | | `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 `@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. 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 - `memory-isolation.md` for full behaviour. + the project and all its session IDs. Used to scope semantic search. The + 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). -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 against recent episodes. Non-critical — if it fails, pipeline continues with recency-only context. -5. **Entity search** — reuse the embedded user message vector to query the - `entities` Qdrant collection (score threshold 0.6, limit 5). Returns - entity payloads (`name`, `type`, `notes`) directly — no SQLite roundtrip - needed. Non-critical — if it fails, pipeline continues without entity context. +6. **Entity search** — query the `entities` Qdrant collection filtered by + `projectId`. Non-project sessions receive no entity context. Non-critical. -6. **Prompt assembly** — combine system prompt, entity context, semantic - episodes, recent episodes, and user message. +7. **Prompt assembly** — combine resolved system prompt, entity context, + 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; `/chat/stream` pipes SSE chunks to the client. -8. **Episode write** — write the exchange back to memory. Fire-and-forget - for `/chat`; awaited for `/chat/stream` to ensure the full text is - accumulated before saving. +9. **Episode write** — write the exchange back to memory with `projectId`. + Fire-and-forget for `/chat`; awaited for `/chat/stream`. -9. **Auto-naming** — on `isFirstMessage && !session.name`, fire a secondary - inference call with a naming prompt (max 20 tokens, temperature 0.3) and - write the result back as `session.name`. Fully fire-and-forget. +10. **Auto-naming** — on `isFirstMessage && !session.name`, fire a secondary + inference call with a naming prompt (max 20 tokens, temperature 0.3) and + write the result back as `session.name`. Fully fire-and-forget. ### Prompt Structure ``` -[System prompt] +[Resolved system prompt] Here is what you know about entities relevant to this conversation: - {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. `GET /models/props` fetches directly from llama-server via `LLAMA_SERVER_URL`. -Returns `{ contextWindow, modelAlias }`. Used by the client to display -read-only context window size and the currently loaded model in the settings -panel. Returns `503` if llama-server is unreachable. +Returns `{ contextWindow, modelAlias }`. `n_ctx` is at +`data.default_generation_settings.n_ctx` in the llama-server response. +Returns `503` if llama-server is unreachable. ## Sessions Route Behaviour diff --git a/packages/chat-client/src/components/MessageBubble.jsx b/packages/chat-client/src/components/MessageBubble.jsx index 618f93c..1e3ddb0 100644 --- a/packages/chat-client/src/components/MessageBubble.jsx +++ b/packages/chat-client/src/components/MessageBubble.jsx @@ -25,13 +25,13 @@ export default function MessageBubble({ message }) {
)} {message.error && ( -
+
⚠ Failed to complete response
)} diff --git a/packages/chat-client/src/components/Sidebar.jsx b/packages/chat-client/src/components/Sidebar.jsx index bb7e6e7..cde7a8c 100644 --- a/packages/chat-client/src/components/Sidebar.jsx +++ b/packages/chat-client/src/components/Sidebar.jsx @@ -126,7 +126,7 @@ export default function Sidebar({ {/* Header */}
- NexusAI + NexusAI
@@ -166,8 +166,8 @@ export default function Sidebar({ padding: '10px', borderRadius: 'var(--radius-md)', border: '1px dashed var(--border)', - color: 'var(--text-muted)', - fontSize: '12px', + color: 'var(--text-sb-hdr)', + fontSize: '13px', textAlign: 'center', }}> No projects yet @@ -226,11 +226,17 @@ export default function Sidebar({ display: 'flex', alignItems: 'center', gap: '6px', padding: '6px 16px 2px', }}> -
- + {project?.name ?? 'Project'}
@@ -246,7 +252,7 @@ export default function Sidebar({ <> {Object.keys(grouped).length > 0 && (
- Other + Other
)} {unassigned.map(session => ( @@ -334,14 +340,14 @@ function SectionHeader({ label, isOpen, onToggle }) { className="btn-reset label-upper" style={{ width: '100%', padding: '8px 16px', - display: 'flex', alignItems: 'center', justifyContent: 'space-between', - color: 'var(--text-muted)', + display: 'flex', alignItems: 'center', justifyContent: 'center', + color: 'var(--text-sb-hdr)', + }} - onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} - onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'} + > {label} - {isOpen ? '▾' : '▸'} + {isOpen ? '▾' : '▸'} ); } @@ -357,6 +363,9 @@ function SessionRow({ session, isActive, isHovered, onHover, onSelect, onRename, background: isActive || isHovered ? 'var(--bg-elevated)' : 'transparent', borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent', transition: 'background 0.1s', + overflow: 'hidden', + width: '100%', + boxSizing: 'border-box', }} > - -
- )} +
+ + +
); } diff --git a/packages/chat-client/src/index.css b/packages/chat-client/src/index.css index 6a9bfb7..f0e77fa 100644 --- a/packages/chat-client/src/index.css +++ b/packages/chat-client/src/index.css @@ -1,17 +1,19 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { - --bg-base: #0f1117; - --bg-surface: #0e0d0d; - --bg-elevated: #222536; - --border: #2e3150; - --accent: #3d3a79; + --bg-base: #9c9a9a; + --bg-surface: #000000; + --bg-elevated: #111111; + --border: #989899; + --accent: #333335; --accent-hover: #574fd6; --text-primary: #e8e8f0; --text-secondary: #8b8fa8; - --text-muted: #555870; - --bubble-user: #4742a8; - --bubble-ai: #20264d; + --text-muted: #ababaf; + --text-sb-hdr: #ffffff; + --bubble-user: #020202; + --bubble-ai: #303033; + --warning: #ec5353; --sidebar-width: 180px; --panel-width: 200px; --header-height: 40px; @@ -64,7 +66,9 @@ html, body, #root { cursor: pointer; display: flex; align-items: center; - justify-content: center; + justify-content: flex-start; + min-width: 0; + overflow: hidden; } .btn-icon { @@ -105,5 +109,5 @@ html, body, #root { .text-muted { color: var(--text-muted); } .text-secondary { color: var(--text-secondary); } .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; } \ No newline at end of file diff --git a/packages/memory-service/src/db/index.js b/packages/memory-service/src/db/index.js index c370ec9..bb62724 100644 --- a/packages/memory-service/src/db/index.js +++ b/packages/memory-service/src/db/index.js @@ -38,6 +38,18 @@ function getDB() { db.exec(`ALTER TABLE projects ADD COLUMN system_prompt TEXT`); } 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 db.exec(`INSERT OR REPLACE INTO episodes_fts(rowid, user_message, ai_response) SELECT id, user_message, ai_response FROM episodes`); diff --git a/packages/memory-service/src/db/schema.js b/packages/memory-service/src/db/schema.js index e9c66b3..9c39a1a 100644 --- a/packages/memory-service/src/db/schema.js +++ b/packages/memory-service/src/db/schema.js @@ -41,12 +41,17 @@ const schema = ` CREATE TABLE IF NOT EXISTS summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE, + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, content TEXT NOT NULL, + token_count INTEGER, episode_range TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), 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 ON episodes(session_id); CREATE INDEX IF NOT EXISTS idx_episodes_created diff --git a/packages/memory-service/src/db/summaries.js b/packages/memory-service/src/db/summaries.js new file mode 100644 index 0000000..5a4bb1f --- /dev/null +++ b/packages/memory-service/src/db/summaries.js @@ -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 }; \ No newline at end of file