Changeset 0.17.0 (#75)
This commit is contained in:
55
CHANGELOG.md
55
CHANGELOG.md
@@ -2,6 +2,61 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.17.0] — 2026-02-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Persona-KB binding.** Personas can now have knowledge bases directly
|
||||||
|
bound to them. When a user selects a persona with bound KBs, the
|
||||||
|
`kb_search` tool is automatically scoped to those KBs and the persona's
|
||||||
|
system prompt includes a KB listing hint. Admin and team admin preset
|
||||||
|
forms include a KB picker with per-KB auto-search toggles.
|
||||||
|
Migration adds `persona_knowledge_bases` join table.
|
||||||
|
- **Enterprise KB mode.** New `discoverable` flag on knowledge bases
|
||||||
|
controls whether users can see and attach KBs directly. When
|
||||||
|
`kb_direct_access` platform policy is set to `true`, users cannot
|
||||||
|
attach KBs to channels directly — they access KBs exclusively through
|
||||||
|
persona bindings curated by admins. New `ListDiscoverableKBs` and
|
||||||
|
`SetDiscoverable` endpoints.
|
||||||
|
- **Role fallback alerts (issue #69).** When a role's primary provider
|
||||||
|
fails and the fallback activates, the resolver emits a `role.fallback`
|
||||||
|
event on the EventBus with audit log entry. Admin users see a persistent
|
||||||
|
dismissable banner. 5-minute per-role cooldown prevents flooding.
|
||||||
|
- **Chat rename.** Double-click any chat title in the sidebar to edit
|
||||||
|
inline. Enter saves, Escape cancels.
|
||||||
|
- **Utility model auto-naming.** After the first assistant response,
|
||||||
|
a background request to the utility role generates a concise title.
|
||||||
|
Falls back to truncation when no utility model is configured.
|
||||||
|
New endpoint: `POST /channels/:id/generate-title`.
|
||||||
|
- **Chat token count.** Conversation token estimate shown in the model
|
||||||
|
bar, color-coded against context budget.
|
||||||
|
- **State restore on refresh.** Active chat ID persisted to
|
||||||
|
`sessionStorage`, auto-restored on page reload.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **ResolvePreset group access bypass.** `ResolvePreset()` used raw SQL
|
||||||
|
that skipped `resource_grants` checks from v0.16.0. Now uses
|
||||||
|
`PersonaStore.UserCanAccess()`.
|
||||||
|
- **KB create scope authorization.** Now enforces admin role for global
|
||||||
|
KBs and team admin role for team KBs.
|
||||||
|
- **Completion handler persona ID scoping.** `personaID` variable scoped
|
||||||
|
inside an `if` block but referenced downstream. Fixed by threading
|
||||||
|
through function signatures.
|
||||||
|
- **Nil slice JSON marshaling.** `ListDiscoverableKBs` returns `[]`
|
||||||
|
instead of `null` when no KBs match.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `UpdateDocumentStorageKey` moved from raw `ExecContext` to store method.
|
||||||
|
- EventBus route table: `role.fallback` → `DirToClient`.
|
||||||
|
- Resolver gains `.WithBus(bus)` builder (nil-safe, backwards compatible).
|
||||||
|
- Service worker: `/extensions/` excluded from fetch handler.
|
||||||
|
- Mermaid renderer v2.0 (pan/zoom, export) promoted to builtin.
|
||||||
|
- Removed DIAG diagnostics from `TestGroupBasedPersonaAccess`.
|
||||||
|
- Paste-to-file threshold synced from backend `PublicSettings`
|
||||||
|
(`storage.paste_to_file_chars`, admin-configurable, default 2000).
|
||||||
|
Resolves hardcoded constant in `attachments.js`.
|
||||||
|
- Embedding dropdown already had tolerant type filter, manual model ID
|
||||||
|
fallback, and auto-switch on empty — confirmed complete, checkbox updated.
|
||||||
|
|
||||||
## [0.12.0] — 2026-02-25
|
## [0.12.0] — 2026-02-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
184
docs/DESIGN-0.17.0.md
Normal file
184
docs/DESIGN-0.17.0.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# DESIGN-0.17.0 — Persona-KB Binding + Enterprise KB Mode
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Personas become **gateways** to knowledge. In enterprise deployments,
|
||||||
|
users don't interact with KBs directly — they talk to Personas that
|
||||||
|
have KBs attached. This is the core differentiator for enterprise use.
|
||||||
|
|
||||||
|
Depends on: knowledge bases (v0.14.0), user groups (v0.16.0).
|
||||||
|
|
||||||
|
**Design principle: Personas carry context, not users.** When a user
|
||||||
|
selects a Persona with bound KBs, the KB context flows automatically —
|
||||||
|
no manual KB selection, no toggle management, no confusion about which
|
||||||
|
KBs are in scope. Admins curate the KB↔Persona relationships; users
|
||||||
|
just pick a Persona.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Schema
|
||||||
|
|
||||||
|
### `persona_knowledge_bases` (new join table)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE persona_knowledge_bases (
|
||||||
|
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||||
|
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||||
|
auto_search BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (persona_id, kb_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`auto_search` controls whether the KB is searched automatically on every
|
||||||
|
message (top-K results prepended to context) or only via explicit
|
||||||
|
`kb_search` tool calls. Default `false` = tool-only.
|
||||||
|
|
||||||
|
### `knowledge_bases.discoverable` (new column)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE knowledge_bases ADD COLUMN discoverable BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
```
|
||||||
|
|
||||||
|
When `false`, the KB does not appear in user-facing listings
|
||||||
|
(`ListDiscoverableKBs`). It remains searchable through Persona bindings.
|
||||||
|
|
||||||
|
### `platform_policies.kb_direct_access` (new row)
|
||||||
|
|
||||||
|
Seeded as `'true'` (permissive default). When set to `'false'`, the
|
||||||
|
channel KB popup is hidden — users access KBs exclusively through
|
||||||
|
Persona bindings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Store Layer
|
||||||
|
|
||||||
|
### `PersonaStore` additions
|
||||||
|
|
||||||
|
- `SetKBs(ctx, personaID, kbIDs, autoSearch)` — UPSERT join table
|
||||||
|
- `GetKBs(ctx, personaID)` — returns `[]PersonaKB` with KB metadata
|
||||||
|
|
||||||
|
### `KnowledgeBaseStore` additions
|
||||||
|
|
||||||
|
- `ListDiscoverable(ctx, userID, teamIDs)` — KBs where `discoverable=true`
|
||||||
|
and user has access via ownership, team, or global scope
|
||||||
|
- `SetDiscoverable(ctx, kbID, discoverable)` — toggle visibility
|
||||||
|
- `UpdateDocumentStorageKey(ctx, docID, key)` — moved from raw SQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Completion Pipeline
|
||||||
|
|
||||||
|
### KB scoping in `BuildKBHint`
|
||||||
|
|
||||||
|
When a Persona has bound KBs, `BuildKBHint` merges them with any
|
||||||
|
channel-attached KBs:
|
||||||
|
|
||||||
|
1. Load channel KBs (existing path)
|
||||||
|
2. Load Persona KBs via `PersonaStore.GetKBs()`
|
||||||
|
3. Union the two sets (deduplicated by KB ID)
|
||||||
|
4. Build the hint string with listing
|
||||||
|
|
||||||
|
### Persona ID threading
|
||||||
|
|
||||||
|
The `personaID` is threaded through the completion handler methods:
|
||||||
|
`Complete()` → `streamCompletion(…, personaID)` / `syncCompletion(…, personaID)`
|
||||||
|
→ `loadConversation(…, personaID)`.
|
||||||
|
|
||||||
|
The `ExecutionContext` for tool calls carries the Persona ID so
|
||||||
|
`kb_search` can auto-include Persona-bound KBs in its search scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Role Fallback Alerts (issue #69)
|
||||||
|
|
||||||
|
When a role's primary provider fails:
|
||||||
|
|
||||||
|
1. **Attempt fallback** — existing behavior, unchanged.
|
||||||
|
2. **Emit event** — `role.fallback` on EventBus (DirToClient).
|
||||||
|
3. **Cooldown** — 5-minute per-role TTL prevents flooding.
|
||||||
|
4. **Admin toast** — frontend listens for `role.fallback` events,
|
||||||
|
shows warning/error toast to admin users only.
|
||||||
|
|
||||||
|
### Resolver changes
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Resolver struct {
|
||||||
|
stores store.Stores
|
||||||
|
vault *crypto.KeyResolver
|
||||||
|
bus *events.Bus
|
||||||
|
mu sync.Mutex
|
||||||
|
cooldown map[string]time.Time // role → last alert time
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`WithBus(bus)` builder method. Nil-safe — if no bus is attached,
|
||||||
|
alerts are suppressed (log only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Security Fixes
|
||||||
|
|
||||||
|
### ResolvePreset bypass
|
||||||
|
|
||||||
|
`ResolvePreset()` in `presets.go` used raw SQL that skipped the
|
||||||
|
`resource_grants` table added in v0.16.0. A Persona accessible only
|
||||||
|
via group grant would fail at completion time. Fixed to use
|
||||||
|
`PersonaStore.GetByID()` + `UserCanAccess()`.
|
||||||
|
|
||||||
|
### KB create scope authorization
|
||||||
|
|
||||||
|
`POST /api/v1/knowledge-bases` now enforces:
|
||||||
|
- `scope=global` → requires admin role
|
||||||
|
- `scope=team` → requires team admin role for the target team
|
||||||
|
- `scope=personal` → open to all authenticated users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Frontend
|
||||||
|
|
||||||
|
### KB Picker (`persona-kb.js`)
|
||||||
|
|
||||||
|
Standalone component: `renderPersonaKBPicker(container, opts)` → control
|
||||||
|
object with `getValues()`, `setValues()`, `clear()`, `refresh()`.
|
||||||
|
|
||||||
|
Options: `scope` ('admin'|'team'|'personal'), `teamId`, `prefix`.
|
||||||
|
|
||||||
|
Shows checkboxes for available KBs with doc/chunk counts. Selected KBs
|
||||||
|
show an "Auto-inject" toggle for the `auto_search` flag.
|
||||||
|
|
||||||
|
### Integration points
|
||||||
|
|
||||||
|
- Admin preset form: KB picker below the form, load/save bindings on
|
||||||
|
edit/create via `/api/v1/admin/presets/:id/knowledge-bases`.
|
||||||
|
- Team preset form: same pattern via team API prefix.
|
||||||
|
- User preset form: shows discoverable KBs only.
|
||||||
|
- Admin settings: `kb_direct_access` toggle checkbox.
|
||||||
|
|
||||||
|
### Fallback alert
|
||||||
|
|
||||||
|
Admin users receive `UI.toast()` notifications when `role.fallback`
|
||||||
|
events arrive via WebSocket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API Endpoints (new)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/v1/presets/:id/knowledge-bases` | List KBs bound to a persona |
|
||||||
|
| PUT | `/api/v1/presets/:id/knowledge-bases` | Set persona-KB bindings |
|
||||||
|
| GET | `/api/v1/admin/presets/:id/knowledge-bases` | Admin: list persona KBs |
|
||||||
|
| PUT | `/api/v1/admin/presets/:id/knowledge-bases` | Admin: set persona-KB bindings |
|
||||||
|
| GET | `/api/v1/knowledge-bases-discoverable` | List discoverable KBs for user |
|
||||||
|
| PUT | `/api/v1/knowledge-bases/:id/discoverable` | Toggle KB discoverability |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Migration
|
||||||
|
|
||||||
|
Single migration file: `002_v017_persona_kb.sql`
|
||||||
|
|
||||||
|
- Creates `persona_knowledge_bases` table
|
||||||
|
- Adds `discoverable` column to `knowledge_bases`
|
||||||
|
- Seeds `kb_direct_access` platform policy (default `'true'`)
|
||||||
498
docs/DESIGN-CM6.md
Normal file
498
docs/DESIGN-CM6.md
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
# DESIGN: CodeMirror 6 Integration
|
||||||
|
|
||||||
|
**Version:** v0.18.0 (or fits into current cycle)
|
||||||
|
**Status:** Draft
|
||||||
|
**Scope:** Add CM6 as a vendored dependency, bundled at Docker build time via esbuild. Three integration surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Three features converge on the same dependency:
|
||||||
|
|
||||||
|
1. **Chat input** — live markdown rendering (backtick → code block, bold, etc.)
|
||||||
|
2. **Extension editor** — syntax-highlighted JavaScript/JSON editing in admin panel (replaces bare `<textarea>`)
|
||||||
|
3. **Code editing surface** (roadmap) — future extension surface type for code review, snippets, etc.
|
||||||
|
|
||||||
|
CM6 is ESM-native and expects a bundler. The build step is contained in the Docker image build — the frontend continues to ship as static assets served by nginx. No runtime build tooling, no change to the developer experience for non-CM6 code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Build Pipeline
|
||||||
|
|
||||||
|
New Docker stage between the existing `vendor` stage and the final `nginx` stage:
|
||||||
|
|
||||||
|
```
|
||||||
|
Stage 1: vendor (existing — marked, purify, mermaid, katex)
|
||||||
|
Stage 2: cm6-build (NEW — npm install + esbuild → single IIFE bundle)
|
||||||
|
Stage 3: nginx (existing — copies src/ + vendor/ + cm6 bundle)
|
||||||
|
```
|
||||||
|
|
||||||
|
The CM6 build stage:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine AS cm6-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY src/editor/package.json src/editor/build.mjs ./
|
||||||
|
RUN npm install --production=false
|
||||||
|
RUN node build.mjs
|
||||||
|
# Output: /build/dist/codemirror.bundle.js (~180-250KB minified)
|
||||||
|
# /build/dist/codemirror.bundle.css (~5-10KB)
|
||||||
|
```
|
||||||
|
|
||||||
|
Final stage addition:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
COPY --from=cm6-build /build/dist/ /usr/share/nginx/html/vendor/codemirror/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bundle Structure
|
||||||
|
|
||||||
|
A single esbuild entrypoint (`src/editor/index.mjs`) that imports CM6 packages and exposes factory functions on `window.CM`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// src/editor/index.mjs — esbuild entrypoint
|
||||||
|
import { EditorView, keymap, placeholder, ViewPlugin, ... } from '@codemirror/view';
|
||||||
|
import { EditorState, ... } from '@codemirror/state';
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
|
import { json } from '@codemirror/lang-json';
|
||||||
|
import { sql } from '@codemirror/lang-sql';
|
||||||
|
import { html } from '@codemirror/lang-html';
|
||||||
|
import { css } from '@codemirror/lang-css';
|
||||||
|
import { yaml } from '@codemirror/lang-yaml';
|
||||||
|
import { go } from '@codemirror/lang-go';
|
||||||
|
import { python } from '@codemirror/lang-python';
|
||||||
|
import { rust } from '@codemirror/lang-rust';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { defaultKeymap, indentWithTab } from '@codemirror/commands';
|
||||||
|
import { bracketMatching, ... } from '@codemirror/language';
|
||||||
|
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
|
||||||
|
import { autocompletion } from '@codemirror/autocomplete';
|
||||||
|
import { vim } from '@replit/codemirror-vim';
|
||||||
|
import { emacs } from '@replit/codemirror-emacs';
|
||||||
|
|
||||||
|
// Expose on window for script-tag consumption
|
||||||
|
window.CM = {
|
||||||
|
EditorView,
|
||||||
|
EditorState,
|
||||||
|
|
||||||
|
// Factory: chat input (markdown mode, minimal chrome)
|
||||||
|
chatInput(target, opts = {}) { ... },
|
||||||
|
|
||||||
|
// Factory: code editor (full features, language auto-detect)
|
||||||
|
codeEditor(target, opts = {}) { ... },
|
||||||
|
|
||||||
|
// Supported languages for code editor
|
||||||
|
languages: { markdown, javascript, json, sql, html, css, yaml, go, python, rust },
|
||||||
|
|
||||||
|
// Keybinding modes (applied via user preference)
|
||||||
|
keybindings: { vim, emacs },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### esbuild Config
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// src/editor/build.mjs
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: ['index.mjs'],
|
||||||
|
bundle: true,
|
||||||
|
minify: true,
|
||||||
|
format: 'iife',
|
||||||
|
target: ['es2020'],
|
||||||
|
outfile: 'dist/codemirror.bundle.js',
|
||||||
|
// CSS extracted automatically by esbuild
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### package.json (editor only)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.25.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6",
|
||||||
|
"@codemirror/commands": "^6",
|
||||||
|
"@codemirror/lang-css": "^6",
|
||||||
|
"@codemirror/lang-go": "^6",
|
||||||
|
"@codemirror/lang-html": "^6",
|
||||||
|
"@codemirror/lang-javascript": "^6",
|
||||||
|
"@codemirror/lang-json": "^6",
|
||||||
|
"@codemirror/lang-markdown": "^6",
|
||||||
|
"@codemirror/lang-sql": "^6",
|
||||||
|
"@codemirror/lang-python": "^6",
|
||||||
|
"@codemirror/lang-rust": "^6",
|
||||||
|
"@codemirror/lang-yaml": "^6",
|
||||||
|
"@codemirror/language": "^6",
|
||||||
|
"@codemirror/search": "^6",
|
||||||
|
"@codemirror/state": "^6",
|
||||||
|
"@codemirror/theme-one-dark": "^6",
|
||||||
|
"@codemirror/view": "^6",
|
||||||
|
"@replit/codemirror-vim": "^6",
|
||||||
|
"@replit/codemirror-emacs": "^6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Surfaces
|
||||||
|
|
||||||
|
### 1. Chat Input — Markdown Mode
|
||||||
|
|
||||||
|
Replace `<textarea id="messageInput">` with a CM6 instance configured for chat composition.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
- Markdown syntax highlighting as you type (fenced code blocks, bold, italic, links, etc.)
|
||||||
|
- Shift+Enter inserts newline (existing behavior preserved)
|
||||||
|
- Enter sends message (existing behavior preserved)
|
||||||
|
- Auto-growing height (CM6 handles this natively via `EditorView.contentAttributes`)
|
||||||
|
- Placeholder text: "Send a message..."
|
||||||
|
- No line numbers, no gutter — minimal chrome
|
||||||
|
- Tab inserts real tab (indentWithTab) inside code blocks; normal behavior outside
|
||||||
|
|
||||||
|
**Factory:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
CM.chatInput(document.getElementById('messageInputContainer'), {
|
||||||
|
placeholder: 'Send a message...',
|
||||||
|
onSubmit: (text) => sendMessage(text), // Enter key
|
||||||
|
onChange: (text) => updateInputTokens(text), // live token count
|
||||||
|
darkMode: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration path:**
|
||||||
|
|
||||||
|
The existing code reads `document.getElementById('messageInput').value` in many places. The factory returns an object with a `.getValue()` / `.setValue()` / `.focus()` API that matches the textarea interface. A thin shim keeps all existing callsites working:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// After CM.chatInput() creates the editor:
|
||||||
|
const editor = CM.chatInput(...);
|
||||||
|
|
||||||
|
// Shim: existing code that reads .value still works
|
||||||
|
Object.defineProperty(document.getElementById('messageInput'), 'value', {
|
||||||
|
get: () => editor.getValue(),
|
||||||
|
set: (v) => editor.setValue(v),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively (cleaner): introduce `ChatInput.getValue()` / `ChatInput.setValue()` / `ChatInput.focus()` and update callsites. There aren't many — `chat.js`, `attachments.js`, `tokens.js`, and a few event handlers.
|
||||||
|
|
||||||
|
### 2. Extension Editor — JavaScript/JSON Mode
|
||||||
|
|
||||||
|
Replace the two `<textarea>` elements in `editAdminExtension()` (`admin-handlers.js` line 706-710) with CM6 instances.
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
- Manifest textarea → JSON mode with bracket matching, auto-indent
|
||||||
|
- Script textarea → JavaScript mode with bracket matching, auto-indent
|
||||||
|
- Line numbers enabled
|
||||||
|
- Search/replace (Ctrl+F) — currently impossible in a textarea
|
||||||
|
- Tab key inserts proper indent (replaces the manual keydown handler at line 726-736)
|
||||||
|
- Themed to match the app's dark/light mode
|
||||||
|
|
||||||
|
**Factory:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
CM.codeEditor(document.getElementById('extEdit-manifest-container'), {
|
||||||
|
language: 'json',
|
||||||
|
value: manifestJSON,
|
||||||
|
lineNumbers: true,
|
||||||
|
darkMode: isDark,
|
||||||
|
});
|
||||||
|
|
||||||
|
CM.codeEditor(document.getElementById('extEdit-script-container'), {
|
||||||
|
language: 'javascript',
|
||||||
|
value: script,
|
||||||
|
lineNumbers: true,
|
||||||
|
darkMode: isDark,
|
||||||
|
keymap: prefs.editorKeymap || 'standard', // 'standard' | 'vim' | 'emacs'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Save integration:** `saveAdminExtension()` currently reads `.value` from the textareas. The CM6 instances expose `.getValue()` — update the save function to call that instead.
|
||||||
|
|
||||||
|
### 3. Future Code Surface (Roadmap)
|
||||||
|
|
||||||
|
The `CM.codeEditor()` factory with language auto-detection covers this. No additional work needed now — just document that it's available for the extensions surface type when that ships.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loading Strategy
|
||||||
|
|
||||||
|
The CM6 bundle is **not** in `SHELL_FILES` (service worker precache list). It loads on demand:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- index.html: load after core app scripts, before app.js init -->
|
||||||
|
<script src="vendor/codemirror/codemirror.bundle.js?v=%%APP_VERSION%%"
|
||||||
|
onerror="console.warn('CM6 not available — falling back to textarea')"></script>
|
||||||
|
<link rel="stylesheet" href="vendor/codemirror/codemirror.bundle.css?v=%%APP_VERSION%%"
|
||||||
|
onerror="this.remove()">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Graceful degradation:** If the bundle fails to load (disconnected environment without vendored copy, build issue, etc.), `window.CM` is undefined and all integration points fall back to plain `<textarea>`. Each factory callsite checks:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (window.CM) {
|
||||||
|
CM.chatInput(container, opts);
|
||||||
|
} else {
|
||||||
|
// existing textarea behavior — unchanged
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This means CM6 is a progressive enhancement, not a hard dependency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SW Cache Considerations
|
||||||
|
|
||||||
|
Add `vendor/codemirror/` to the SW exclusion list (same fix as the extensions path discussed earlier):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (url.pathname.includes('/api/') ||
|
||||||
|
url.pathname.includes('/ws') ||
|
||||||
|
url.pathname.includes('/branding/') ||
|
||||||
|
url.pathname.includes('/extensions/') ||
|
||||||
|
url.pathname.includes('/vendor/codemirror/') ||
|
||||||
|
event.request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, more broadly, exclude all of `/vendor/` if you want vendor updates to always bypass the SW cache. The core vendors (marked, purify) are small enough that network-first is fine.
|
||||||
|
|
||||||
|
Alternatively, since the bundle is versioned via `?v=%%APP_VERSION%%` and only changes on new builds, it could safely be in `SHELL_FILES` for precaching — unlike extensions which are user-editable at runtime. Either approach works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theme Integration
|
||||||
|
|
||||||
|
CM6's `oneDark` theme for dark mode. For light mode, the CM6 default is clean and neutral.
|
||||||
|
|
||||||
|
Both need CSS variable overrides to match the app's existing `--bg`, `--bg-2`, `--border`, `--text`, `--accent` palette:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const switchboardTheme = EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
backgroundColor: 'var(--bg)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
fontFamily: 'var(--mono)',
|
||||||
|
caretColor: 'var(--accent)',
|
||||||
|
},
|
||||||
|
'.cm-cursor': {
|
||||||
|
borderLeftColor: 'var(--accent)',
|
||||||
|
},
|
||||||
|
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
|
||||||
|
backgroundColor: 'var(--accent-muted, rgba(99,102,241,0.2))',
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
backgroundColor: 'var(--bg-2)',
|
||||||
|
color: 'var(--text-3)',
|
||||||
|
borderRight: '1px solid var(--border)',
|
||||||
|
},
|
||||||
|
}, { dark: document.body.classList.contains('dark-theme') });
|
||||||
|
```
|
||||||
|
|
||||||
|
This goes into the bundle so it's applied automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
editor/
|
||||||
|
package.json # CM6 deps + esbuild
|
||||||
|
build.mjs # esbuild script
|
||||||
|
index.mjs # bundle entrypoint, exports window.CM
|
||||||
|
theme.mjs # switchboard theme overrides
|
||||||
|
chat-input.mjs # chatInput() factory
|
||||||
|
code-editor.mjs # codeEditor() factory
|
||||||
|
js/
|
||||||
|
... # existing app code (unchanged)
|
||||||
|
vendor/
|
||||||
|
marked.min.js # existing
|
||||||
|
purify.min.js # existing
|
||||||
|
codemirror/ # gitignored — built by Docker
|
||||||
|
codemirror.bundle.js
|
||||||
|
codemirror.bundle.css
|
||||||
|
```
|
||||||
|
|
||||||
|
The `src/editor/` directory is self-contained. It has its own `package.json` and `node_modules` (inside Docker only). No npm dependencies bleed into the main `src/js/` code. The build output lands in `vendor/codemirror/` which is gitignored like the other Docker-built vendors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dockerfile Changes
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Stage 1: vendor libs (existing)
|
||||||
|
FROM node:20-alpine AS vendor
|
||||||
|
# ... existing marked, purify, mermaid, katex extraction ...
|
||||||
|
|
||||||
|
# Stage 2: CM6 bundle (NEW)
|
||||||
|
FROM node:20-alpine AS cm6-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY src/editor/ ./
|
||||||
|
RUN npm ci
|
||||||
|
RUN node build.mjs
|
||||||
|
|
||||||
|
# Stage 3: nginx (existing, with additions)
|
||||||
|
FROM nginx:1-alpine
|
||||||
|
# ... existing setup ...
|
||||||
|
COPY src/ /usr/share/nginx/html/
|
||||||
|
COPY --from=vendor /vendor/ /usr/share/nginx/html/vendor/
|
||||||
|
COPY --from=cm6-build /build/dist/ /usr/share/nginx/html/vendor/codemirror/
|
||||||
|
# ... rest unchanged ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The `vendor` and `cm6-build` stages run in parallel (Docker BuildKit), so build time impact is minimal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
### Phase 1: Bundle + Extension Editor
|
||||||
|
- [ ] Create `src/editor/` with package.json, build.mjs, index.mjs
|
||||||
|
- [ ] Implement `CM.codeEditor()` factory
|
||||||
|
- [ ] Update Dockerfile.frontend with cm6-build stage
|
||||||
|
- [ ] Replace extension editor textareas in `admin-handlers.js`
|
||||||
|
- [ ] Remove manual Tab key handler (CM6 handles it)
|
||||||
|
- [ ] Verify save/load round-trip with CM6 `.getValue()`
|
||||||
|
- [ ] Add graceful fallback if `window.CM` undefined
|
||||||
|
|
||||||
|
### Phase 2: Chat Input
|
||||||
|
- [ ] Implement `CM.chatInput()` factory with markdown mode
|
||||||
|
- [ ] Wire Enter=send, Shift+Enter=newline keybindings
|
||||||
|
- [ ] Integrate with `updateInputTokens()` via onChange callback
|
||||||
|
- [ ] Update `chat.js`, `attachments.js`, `tokens.js` to use CM API
|
||||||
|
- [ ] Auto-grow height behavior matching current textarea
|
||||||
|
- [ ] Test paste handling (plain text, code, attachments)
|
||||||
|
- [ ] Test mobile/touch input
|
||||||
|
|
||||||
|
### Phase 3: Polish
|
||||||
|
- [ ] Theme integration with CSS variables
|
||||||
|
- [ ] Dark/light mode switching (listen for theme toggle)
|
||||||
|
- [ ] Editor keymap preference UI (Standard / Vim / Emacs) in appearance settings
|
||||||
|
- [ ] Vim/Emacs modes on code editor + extension editor only (not chat input)
|
||||||
|
- [ ] Add to `SHELL_FILES` in sw.js (or exclude from SW cache)
|
||||||
|
- [ ] Update `debug.js` snapshot to include CM6 version
|
||||||
|
- [ ] Documentation in ARCHITECTURE.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundle Size Estimate
|
||||||
|
|
||||||
|
Based on CM6 package sizes (minified + tree-shaken by esbuild):
|
||||||
|
|
||||||
|
| Package | Approx Size |
|
||||||
|
|---------|-------------|
|
||||||
|
| @codemirror/view + state + commands | ~90KB |
|
||||||
|
| @codemirror/language + highlight | ~30KB |
|
||||||
|
| lang-markdown + lang-javascript + lang-json | ~40KB |
|
||||||
|
| lang-go + lang-sql + lang-html + lang-css + lang-yaml | ~50KB |
|
||||||
|
| lang-python + lang-rust | ~20KB |
|
||||||
|
| theme-one-dark | ~5KB |
|
||||||
|
| search + autocomplete | ~20KB |
|
||||||
|
| @replit/codemirror-vim + emacs | ~40KB |
|
||||||
|
| **Total (minified)** | **~295KB** |
|
||||||
|
| **Gzipped** | **~90KB** |
|
||||||
|
|
||||||
|
For comparison: mermaid.min.js is ~1.2MB. This is modest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved Decisions
|
||||||
|
|
||||||
|
1. **Language modes** — Ship Python, Rust, and YAML upfront alongside Go, JS, JSON, SQL, HTML, CSS, and Markdown. Covers the team's stack. Additional modes are a one-line import + rebuild.
|
||||||
|
|
||||||
|
2. **Local dev build script** — Provide a `scripts/build-editor.sh` that runs the esbuild step standalone, shared by both Dockerfile.frontend and the unified Dockerfile. Developers can also run it locally to get the CM6 bundle without a full Docker build.
|
||||||
|
|
||||||
|
3. **Vim/Emacs keybindings** — Ship both. Exposed as a user preference toggle (default: standard). Applied via `CM.keybindings.vim` / `CM.keybindings.emacs` as CM6 extensions at editor creation time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Script
|
||||||
|
|
||||||
|
Shared script used by both Dockerfiles and local dev:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/sh
|
||||||
|
# scripts/build-editor.sh — Build CM6 bundle
|
||||||
|
# Usage: ./scripts/build-editor.sh [output-dir]
|
||||||
|
#
|
||||||
|
# Defaults to src/vendor/codemirror/ for local dev.
|
||||||
|
# Dockerfiles pass /build/dist/ as the output dir.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
EDITOR_DIR="${SCRIPT_DIR}/../src/editor"
|
||||||
|
OUTPUT_DIR="${1:-${SCRIPT_DIR}/../src/vendor/codemirror}"
|
||||||
|
|
||||||
|
cd "${EDITOR_DIR}"
|
||||||
|
|
||||||
|
# Install if needed (CI/Docker will already have node_modules)
|
||||||
|
[ -d node_modules ] || npm ci
|
||||||
|
|
||||||
|
mkdir -p "${OUTPUT_DIR}"
|
||||||
|
node build.mjs --outdir="${OUTPUT_DIR}"
|
||||||
|
|
||||||
|
echo "✅ CM6 bundle → ${OUTPUT_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockerfile usage:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine AS cm6-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY src/editor/ /build/src/editor/
|
||||||
|
COPY scripts/build-editor.sh /build/scripts/build-editor.sh
|
||||||
|
RUN cd /build/src/editor && npm ci
|
||||||
|
RUN sh /build/scripts/build-editor.sh /build/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keybinding Preference
|
||||||
|
|
||||||
|
Add to user appearance preferences (alongside dark/light theme toggle):
|
||||||
|
|
||||||
|
```
|
||||||
|
Editor keybindings: [Standard ▾] [Vim] [Emacs]
|
||||||
|
```
|
||||||
|
|
||||||
|
Stored in `localStorage` under `cs-appearance`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
|
||||||
|
// prefs.editorKeymap = 'standard' | 'vim' | 'emacs'
|
||||||
|
```
|
||||||
|
|
||||||
|
Applied at editor creation time:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const keymapExt = [];
|
||||||
|
if (prefs.editorKeymap === 'vim') keymapExt.push(CM.keybindings.vim());
|
||||||
|
else if (prefs.editorKeymap === 'emacs') keymapExt.push(CM.keybindings.emacs());
|
||||||
|
|
||||||
|
CM.codeEditor(target, {
|
||||||
|
language: 'javascript',
|
||||||
|
extensions: keymapExt,
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Vim/Emacs modes apply only to the code editor and extension editor surfaces. The chat input always uses standard keybindings to avoid confusing Enter-to-send behavior with modal editing.
|
||||||
1094
docs/ROADMAP.md
1094
docs/ROADMAP.md
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"id": "mermaid-renderer",
|
"id": "mermaid-renderer",
|
||||||
"name": "Mermaid Diagrams",
|
"name": "Mermaid Diagrams",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "switchboard",
|
||||||
"description": "Renders ```mermaid code blocks as SVG diagrams using mermaid.js",
|
"description": "Renders ```mermaid code blocks as interactive SVG diagrams with zoom/pan, SVG/PNG export, and source copy",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"tools": [],
|
"tools": [],
|
||||||
"surfaces": [],
|
"surfaces": [],
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Mermaid Diagram Renderer — Browser Extension
|
// Mermaid Diagram Renderer — Browser Extension
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Renders ```mermaid code blocks as SVG diagrams.
|
// Renders ```mermaid code blocks as interactive SVG diagrams.
|
||||||
|
// Features: zoom/pan viewport, SVG/PNG export, source copy.
|
||||||
// Loads mermaid.js dynamically on first use.
|
// Loads mermaid.js dynamically on first use.
|
||||||
//
|
|
||||||
// Install: Admin → Extensions → paste manifest + this script.
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
Extensions.register({
|
Extensions.register({
|
||||||
@@ -12,7 +11,6 @@ Extensions.register({
|
|||||||
|
|
||||||
_mermaidReady: false,
|
_mermaidReady: false,
|
||||||
_mermaidLoading: null,
|
_mermaidLoading: null,
|
||||||
_renderQueue: [],
|
|
||||||
|
|
||||||
async init(ctx) {
|
async init(ctx) {
|
||||||
const self = this;
|
const self = this;
|
||||||
@@ -29,21 +27,39 @@ Extensions.register({
|
|||||||
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
|
const id = 'mmd-' + Math.random().toString(36).slice(2, 9);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="mermaid-block" data-mermaid-id="${id}">
|
<div class="mermaid-block" data-mermaid-id="${id}">
|
||||||
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}">
|
<div class="mermaid-toolbar">
|
||||||
<div class="mermaid-loading">
|
<span class="mermaid-title">📊 Diagram</span>
|
||||||
<span class="mermaid-spinner"></span> Rendering diagram…
|
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
|
||||||
|
<div class="mermaid-toolbar-btns">
|
||||||
|
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
|
||||||
|
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">−</button>
|
||||||
|
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">⊡</button>
|
||||||
|
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
|
||||||
|
<span class="mmd-sep"></span>
|
||||||
|
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
|
||||||
|
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mermaid-viewport" data-viewport="${id}">
|
||||||
|
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
|
||||||
|
<div class="mermaid-loading">
|
||||||
|
<span class="mermaid-spinner"></span> Rendering diagram…
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<details class="mermaid-source">
|
<details class="mermaid-source">
|
||||||
<summary>📊 View source</summary>
|
<summary>
|
||||||
<pre><code class="language-mermaid">${self._escapeHtml(code.trim())}</code></pre>
|
<span>📋 View source</span>
|
||||||
|
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
|
||||||
|
</summary>
|
||||||
|
<pre><code class="language-mermaid" data-source="${id}">${self._escapeHtml(code.trim())}</code></pre>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Post renderer: find placeholders, render with mermaid.js ──
|
// ── Post renderer: render diagrams + wire interactivity ──
|
||||||
ctx.renderers.register('mermaid-post', {
|
ctx.renderers.register('mermaid-post', {
|
||||||
type: 'post',
|
type: 'post',
|
||||||
priority: 10,
|
priority: 10,
|
||||||
@@ -52,11 +68,13 @@ Extensions.register({
|
|||||||
if (diagrams.length === 0) return;
|
if (diagrams.length === 0) return;
|
||||||
|
|
||||||
diagrams.forEach(el => {
|
diagrams.forEach(el => {
|
||||||
// Skip already-rendered diagrams
|
|
||||||
if (el.dataset.rendered) return;
|
if (el.dataset.rendered) return;
|
||||||
el.dataset.rendered = 'pending';
|
el.dataset.rendered = 'pending';
|
||||||
self._renderDiagram(el);
|
self._renderDiagram(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wire toolbar buttons (event delegation on the container)
|
||||||
|
self._wireToolbar(container);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,21 +82,266 @@ Extensions.register({
|
|||||||
this._loadMermaid();
|
this._loadMermaid();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Zoom/Pan State ──────────────────────
|
||||||
|
|
||||||
|
_getState(id) {
|
||||||
|
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||||
|
if (!vp) return null;
|
||||||
|
if (!vp._mmdState) {
|
||||||
|
vp._mmdState = { scale: 1, panX: 0, panY: 0, dragging: false, startX: 0, startY: 0 };
|
||||||
|
}
|
||||||
|
return vp._mmdState;
|
||||||
|
},
|
||||||
|
|
||||||
|
_applyTransform(id) {
|
||||||
|
const state = this._getState(id);
|
||||||
|
if (!state) return;
|
||||||
|
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||||
|
if (!diagram) return;
|
||||||
|
diagram.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`;
|
||||||
|
const label = document.querySelector(`[data-zoom-label="${id}"]`);
|
||||||
|
if (label) label.textContent = Math.round(state.scale * 100) + '%';
|
||||||
|
},
|
||||||
|
|
||||||
|
_zoom(id, delta) {
|
||||||
|
const state = this._getState(id);
|
||||||
|
if (!state) return;
|
||||||
|
state.scale = Math.max(0.1, Math.min(5, state.scale * (1 + delta)));
|
||||||
|
this._applyTransform(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
_zoomReset(id) {
|
||||||
|
const state = this._getState(id);
|
||||||
|
if (!state) return;
|
||||||
|
state.scale = 1; state.panX = 0; state.panY = 0;
|
||||||
|
this._applyTransform(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
_zoomFit(id) {
|
||||||
|
const state = this._getState(id);
|
||||||
|
if (!state) return;
|
||||||
|
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||||
|
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||||
|
const svg = diagram?.querySelector('svg');
|
||||||
|
if (!vp || !svg) return;
|
||||||
|
|
||||||
|
// Reset to measure natural size
|
||||||
|
state.scale = 1; state.panX = 0; state.panY = 0;
|
||||||
|
diagram.style.transform = '';
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const vpRect = vp.getBoundingClientRect();
|
||||||
|
const svgRect = svg.getBoundingClientRect();
|
||||||
|
if (svgRect.width === 0 || svgRect.height === 0) return;
|
||||||
|
|
||||||
|
const scaleX = (vpRect.width - 32) / svgRect.width;
|
||||||
|
const scaleY = (vpRect.height - 32) / svgRect.height;
|
||||||
|
state.scale = Math.min(scaleX, scaleY, 2); // Cap at 2x
|
||||||
|
this._applyTransform(id);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Toolbar Wiring ──────────────────────
|
||||||
|
|
||||||
|
_wireToolbar(container) {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
// Skip if already wired
|
||||||
|
if (container._mmdWired) return;
|
||||||
|
container._mmdWired = true;
|
||||||
|
|
||||||
|
// Button clicks (delegation)
|
||||||
|
container.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const id = btn.dataset.target;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'zoom-in': self._zoom(id, 0.25); break;
|
||||||
|
case 'zoom-out': self._zoom(id, -0.2); break;
|
||||||
|
case 'zoom-reset': self._zoomReset(id); break;
|
||||||
|
case 'zoom-fit': self._zoomFit(id); break;
|
||||||
|
case 'export-svg': self._exportSVG(id); break;
|
||||||
|
case 'export-png': self._exportPNG(id); break;
|
||||||
|
case 'copy-src': self._copySource(id, btn); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire each viewport for mouse/touch interaction
|
||||||
|
container.querySelectorAll('.mermaid-viewport').forEach(vp => {
|
||||||
|
if (vp._mmdWired) return;
|
||||||
|
vp._mmdWired = true;
|
||||||
|
const id = vp.dataset.viewport;
|
||||||
|
|
||||||
|
// Mouse wheel zoom
|
||||||
|
vp.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
self._zoom(id, delta);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Pan: mousedown → mousemove → mouseup
|
||||||
|
vp.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
const state = self._getState(id);
|
||||||
|
if (!state) return;
|
||||||
|
state.dragging = true;
|
||||||
|
state.startX = e.clientX - state.panX;
|
||||||
|
state.startY = e.clientY - state.panY;
|
||||||
|
vp.classList.add('mmd-grabbing');
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
vp.addEventListener('mousemove', (e) => {
|
||||||
|
const state = self._getState(id);
|
||||||
|
if (!state?.dragging) return;
|
||||||
|
state.panX = e.clientX - state.startX;
|
||||||
|
state.panY = e.clientY - state.startY;
|
||||||
|
self._applyTransform(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
const state = self._getState(id);
|
||||||
|
if (!state) return;
|
||||||
|
state.dragging = false;
|
||||||
|
vp.classList.remove('mmd-grabbing');
|
||||||
|
};
|
||||||
|
vp.addEventListener('mouseup', endDrag);
|
||||||
|
vp.addEventListener('mouseleave', endDrag);
|
||||||
|
|
||||||
|
// Touch: single-finger pan, two-finger pinch zoom
|
||||||
|
let lastTouchDist = 0;
|
||||||
|
vp.addEventListener('touchstart', (e) => {
|
||||||
|
const state = self._getState(id);
|
||||||
|
if (!state) return;
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
state.dragging = true;
|
||||||
|
state.startX = e.touches[0].clientX - state.panX;
|
||||||
|
state.startY = e.touches[0].clientY - state.panY;
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
lastTouchDist = Math.hypot(
|
||||||
|
e.touches[0].clientX - e.touches[1].clientX,
|
||||||
|
e.touches[0].clientY - e.touches[1].clientY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
vp.addEventListener('touchmove', (e) => {
|
||||||
|
const state = self._getState(id);
|
||||||
|
if (!state) return;
|
||||||
|
if (e.touches.length === 1 && state.dragging) {
|
||||||
|
state.panX = e.touches[0].clientX - state.startX;
|
||||||
|
state.panY = e.touches[0].clientY - state.startY;
|
||||||
|
self._applyTransform(id);
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.touches.length === 2) {
|
||||||
|
const dist = Math.hypot(
|
||||||
|
e.touches[0].clientX - e.touches[1].clientX,
|
||||||
|
e.touches[0].clientY - e.touches[1].clientY
|
||||||
|
);
|
||||||
|
if (lastTouchDist > 0) {
|
||||||
|
self._zoom(id, (dist - lastTouchDist) / lastTouchDist);
|
||||||
|
}
|
||||||
|
lastTouchDist = dist;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
vp.addEventListener('touchend', () => {
|
||||||
|
const state = self._getState(id);
|
||||||
|
if (state) state.dragging = false;
|
||||||
|
lastTouchDist = 0;
|
||||||
|
}, { passive: true });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Export ───────────────────────────────
|
||||||
|
|
||||||
|
_exportSVG(id) {
|
||||||
|
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||||
|
const svg = diagram?.querySelector('svg');
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
const clone = svg.cloneNode(true);
|
||||||
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' });
|
||||||
|
this._download(blob, `diagram-${id}.svg`);
|
||||||
|
},
|
||||||
|
|
||||||
|
_exportPNG(id) {
|
||||||
|
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||||
|
const svg = diagram?.querySelector('svg');
|
||||||
|
if (!svg) return;
|
||||||
|
|
||||||
|
const clone = svg.cloneNode(true);
|
||||||
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
|
||||||
|
const svgData = new XMLSerializer().serializeToString(clone);
|
||||||
|
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(svgBlob);
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const scale = 2; // 2x for retina
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.naturalWidth * scale;
|
||||||
|
canvas.height = img.naturalHeight * scale;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (blob) self._download(blob, `diagram-${id}.png`);
|
||||||
|
}, 'image/png');
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
console.error('[Mermaid] PNG export failed');
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
},
|
||||||
|
|
||||||
|
_download(blob, filename) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
|
},
|
||||||
|
|
||||||
|
_copySource(id, btn) {
|
||||||
|
const code = document.querySelector(`[data-source="${id}"]`);
|
||||||
|
if (!code) return;
|
||||||
|
navigator.clipboard.writeText(code.textContent).then(() => {
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = '✓';
|
||||||
|
setTimeout(() => { btn.textContent = orig; }, 1500);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Diagram Rendering ───────────────────
|
||||||
|
|
||||||
async _renderDiagram(el) {
|
async _renderDiagram(el) {
|
||||||
const code = decodeURIComponent(el.dataset.mermaidSrc);
|
const code = decodeURIComponent(el.dataset.mermaidSrc);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this._loadMermaid();
|
await this._loadMermaid();
|
||||||
|
|
||||||
const id = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
|
const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
|
||||||
const { svg } = await mermaid.render(id, code);
|
const { svg } = await mermaid.render(svgId, code);
|
||||||
el.innerHTML = svg;
|
el.innerHTML = svg;
|
||||||
el.dataset.rendered = 'true';
|
el.dataset.rendered = 'true';
|
||||||
|
|
||||||
// Styling handled by .mermaid-diagram svg in CSS
|
|
||||||
const svgEl = el.querySelector('svg');
|
const svgEl = el.querySelector('svg');
|
||||||
if (svgEl) {
|
if (svgEl) {
|
||||||
svgEl.removeAttribute('height');
|
svgEl.removeAttribute('height');
|
||||||
|
svgEl.style.maxWidth = 'none'; // Viewport handles sizing
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
@@ -90,12 +353,13 @@ Extensions.register({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Mermaid Library Loading ──────────────
|
||||||
|
|
||||||
_loadMermaid() {
|
_loadMermaid() {
|
||||||
if (this._mermaidReady) return Promise.resolve();
|
if (this._mermaidReady) return Promise.resolve();
|
||||||
if (this._mermaidLoading) return this._mermaidLoading;
|
if (this._mermaidLoading) return this._mermaidLoading;
|
||||||
|
|
||||||
this._mermaidLoading = new Promise((resolve, reject) => {
|
this._mermaidLoading = new Promise((resolve, reject) => {
|
||||||
// Check if already loaded (e.g. by another script)
|
|
||||||
if (typeof mermaid !== 'undefined') {
|
if (typeof mermaid !== 'undefined') {
|
||||||
this._initMermaid();
|
this._initMermaid();
|
||||||
this._mermaidReady = true;
|
this._mermaidReady = true;
|
||||||
@@ -103,7 +367,6 @@ Extensions.register({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try local vendor first, fall back to CDN
|
|
||||||
const base = (window.__BASE__ || '');
|
const base = (window.__BASE__ || '');
|
||||||
const localSrc = `${base}/vendor/mermaid/mermaid.min.js`;
|
const localSrc = `${base}/vendor/mermaid/mermaid.min.js`;
|
||||||
const cdnSrc = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
|
const cdnSrc = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
|
||||||
@@ -116,7 +379,6 @@ Extensions.register({
|
|||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
script.onerror = () => {
|
script.onerror = () => {
|
||||||
// Local failed — try CDN
|
|
||||||
console.warn('[Mermaid] Local vendor not found, trying CDN');
|
console.warn('[Mermaid] Local vendor not found, trying CDN');
|
||||||
const cdn = document.createElement('script');
|
const cdn = document.createElement('script');
|
||||||
cdn.src = cdnSrc;
|
cdn.src = cdnSrc;
|
||||||
@@ -140,7 +402,6 @@ Extensions.register({
|
|||||||
_initMermaid() {
|
_initMermaid() {
|
||||||
if (typeof mermaid === 'undefined') return;
|
if (typeof mermaid === 'undefined') return;
|
||||||
|
|
||||||
// Detect dark mode
|
|
||||||
const isDark = document.body.classList.contains('dark-theme') ||
|
const isDark = document.body.classList.contains('dark-theme') ||
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
@@ -153,6 +414,8 @@ Extensions.register({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Styles ──────────────────────────────
|
||||||
|
|
||||||
_injectStyles() {
|
_injectStyles() {
|
||||||
if (document.getElementById('ext-style-mermaid-renderer')) return;
|
if (document.getElementById('ext-style-mermaid-renderer')) return;
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
@@ -162,14 +425,44 @@ Extensions.register({
|
|||||||
background: var(--bg-2); border: 1px solid var(--border);
|
background: var(--bg-2); border: 1px solid var(--border);
|
||||||
border-radius: 8px; overflow: hidden; margin: 12px 0;
|
border-radius: 8px; overflow: hidden; margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.mermaid-toolbar {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 10px; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px; color: var(--text-3); flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mermaid-title { font-weight: 600; color: var(--text-2); margin-right: auto; }
|
||||||
|
.mermaid-zoom-label {
|
||||||
|
font-family: var(--mono); font-size: 11px; min-width: 36px;
|
||||||
|
text-align: center; color: var(--text-3);
|
||||||
|
}
|
||||||
|
.mermaid-toolbar-btns { display: flex; gap: 2px; align-items: center; }
|
||||||
|
.mmd-btn {
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
padding: 1px 7px; font-size: 11px; cursor: pointer;
|
||||||
|
color: var(--text-3); font-family: var(--mono); line-height: 1.6;
|
||||||
|
}
|
||||||
|
.mmd-btn:hover { color: var(--text-1); border-color: var(--text-3); background: var(--bg-3); }
|
||||||
|
.mmd-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; }
|
||||||
|
|
||||||
|
/* Viewport: zoom/pan container */
|
||||||
|
.mermaid-viewport {
|
||||||
|
overflow: hidden; position: relative;
|
||||||
|
min-height: 80px; max-height: 600px;
|
||||||
|
cursor: grab; user-select: none;
|
||||||
|
}
|
||||||
|
.mermaid-viewport.mmd-grabbing { cursor: grabbing; }
|
||||||
|
|
||||||
|
/* Diagram: the transform target */
|
||||||
.mermaid-diagram {
|
.mermaid-diagram {
|
||||||
padding: 16px; text-align: center; min-height: 60px;
|
padding: 16px; text-align: center; min-height: 60px;
|
||||||
max-height: 600px; overflow: auto;
|
transform-origin: center center;
|
||||||
}
|
will-change: transform;
|
||||||
.mermaid-diagram svg {
|
|
||||||
max-width: 100%; max-height: 560px;
|
|
||||||
height: auto; width: auto;
|
|
||||||
}
|
}
|
||||||
|
.mermaid-diagram svg { height: auto; }
|
||||||
|
|
||||||
|
/* Loading / Error */
|
||||||
.mermaid-loading {
|
.mermaid-loading {
|
||||||
color: var(--text-3); font-size: 13px; padding: 20px;
|
color: var(--text-3); font-size: 13px; padding: 20px;
|
||||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
@@ -185,11 +478,16 @@ Extensions.register({
|
|||||||
padding: 12px 16px; border-radius: 4px; font-size: 13px;
|
padding: 12px 16px; border-radius: 4px; font-size: 13px;
|
||||||
font-family: var(--mono); white-space: pre-wrap;
|
font-family: var(--mono); white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Source panel */
|
||||||
.mermaid-source { border-top: 1px solid var(--border); font-size: 12px; }
|
.mermaid-source { border-top: 1px solid var(--border); font-size: 12px; }
|
||||||
.mermaid-source summary {
|
.mermaid-source summary {
|
||||||
padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none;
|
padding: 6px 12px; cursor: pointer; color: var(--text-3);
|
||||||
|
user-select: none; display: flex; align-items: center; gap: 8px;
|
||||||
}
|
}
|
||||||
.mermaid-source summary:hover { color: var(--text-2); }
|
.mermaid-source summary:hover { color: var(--text-2); }
|
||||||
|
.mermaid-source summary span { flex: 1; }
|
||||||
|
.mmd-copy-src { font-size: 11px; }
|
||||||
.mermaid-source pre {
|
.mermaid-source pre {
|
||||||
margin: 0; border-radius: 0; border: none;
|
margin: 0; border-radius: 0; border: none;
|
||||||
max-height: 200px; overflow: auto;
|
max-height: 200px; overflow: auto;
|
||||||
|
|||||||
42
server/database/migrations/002_v017_persona_kb.sql
Normal file
42
server/database/migrations/002_v017_persona_kb.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- v0.17.0: Persona-KB Binding + Enterprise KB Mode
|
||||||
|
--
|
||||||
|
-- New: persona_knowledge_bases join table
|
||||||
|
-- New: knowledge_bases.discoverable column
|
||||||
|
-- New: kb_direct_access platform policy
|
||||||
|
|
||||||
|
-- =========================================
|
||||||
|
-- 1. Persona-KB Binding
|
||||||
|
-- =========================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS persona_knowledge_bases (
|
||||||
|
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
|
||||||
|
kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
||||||
|
auto_search BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (persona_id, kb_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persona_kb_persona ON persona_knowledge_bases(persona_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persona_kb_kb ON persona_knowledge_bases(kb_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE persona_knowledge_bases IS 'Binds KBs to Personas — the Persona becomes a gateway to its KBs';
|
||||||
|
COMMENT ON COLUMN persona_knowledge_bases.auto_search IS 'true = auto-prepend top-K results to context; false = kb_search tool only';
|
||||||
|
|
||||||
|
-- =========================================
|
||||||
|
-- 2. Enterprise KB Mode
|
||||||
|
-- =========================================
|
||||||
|
|
||||||
|
ALTER TABLE knowledge_bases
|
||||||
|
ADD COLUMN IF NOT EXISTS discoverable BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN knowledge_bases.discoverable IS 'false = hidden from user KB lists, only accessible through Persona binding';
|
||||||
|
|
||||||
|
-- =========================================
|
||||||
|
-- 3. Platform Policy
|
||||||
|
-- =========================================
|
||||||
|
|
||||||
|
INSERT INTO platform_policies (key, value) VALUES
|
||||||
|
('kb_direct_access', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN platform_policies.key IS 'kb_direct_access: when false, disables channel KB popup (strict enterprise mode)';
|
||||||
@@ -53,6 +53,9 @@ var routeTable = map[string]Direction{
|
|||||||
// Model/provider status
|
// Model/provider status
|
||||||
"model.status": DirToClient,
|
"model.status": DirToClient,
|
||||||
|
|
||||||
|
// Role alerts (v0.17.0)
|
||||||
|
"role.fallback": DirToClient,
|
||||||
|
|
||||||
// Plugin hooks — never cross the wire
|
// Plugin hooks — never cross the wire
|
||||||
"plugin.hook.": DirLocal,
|
"plugin.hook.": DirLocal,
|
||||||
"internal.": DirLocal,
|
"internal.": DirLocal,
|
||||||
|
|||||||
@@ -314,6 +314,19 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
|||||||
systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
|
systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
|
||||||
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
|
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
|
||||||
|
|
||||||
|
// Paste-to-file threshold (admin-configurable via storage.paste_to_file_chars, default 2000)
|
||||||
|
pasteChars := 2000
|
||||||
|
if storageSettings, err := h.stores.GlobalConfig.Get(c.Request.Context(), "storage"); err == nil {
|
||||||
|
if v, ok := storageSettings["paste_to_file_chars"]; ok {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
pasteChars = int(n)
|
||||||
|
case int:
|
||||||
|
pasteChars = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only tell the user whether admin prompt exists — don't expose content
|
// Only tell the user whether admin prompt exists — don't expose content
|
||||||
hasAdminPrompt := false
|
hasAdminPrompt := false
|
||||||
if content, ok := systemPrompt["content"].(string); ok && content != "" {
|
if content, ok := systemPrompt["content"].(string); ok && content != "" {
|
||||||
@@ -321,10 +334,11 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"banner": banner,
|
"banner": banner,
|
||||||
"branding": branding,
|
"branding": branding,
|
||||||
"has_admin_prompt": hasAdminPrompt,
|
"has_admin_prompt": hasAdminPrompt,
|
||||||
"storage_configured": storageConfigured,
|
"storage_configured": storageConfigured,
|
||||||
|
"paste_to_file_chars": pasteChars,
|
||||||
"policies": gin.H{
|
"policies": gin.H{
|
||||||
"allow_registration": policies["allow_registration"],
|
"allow_registration": policies["allow_registration"],
|
||||||
"allow_user_byok": policies["allow_user_byok"],
|
"allow_user_byok": policies["allow_user_byok"],
|
||||||
|
|||||||
@@ -94,12 +94,14 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Preset unwrap: preset overrides defaults, explicit request fields win ──
|
// ── Preset unwrap: preset overrides defaults, explicit request fields win ──
|
||||||
var presetSystemPrompt string
|
var presetSystemPrompt string
|
||||||
|
var personaID string // tracks active persona for KB scoping
|
||||||
if req.PresetID != "" {
|
if req.PresetID != "" {
|
||||||
preset := ResolvePreset(req.PresetID, userID)
|
preset := ResolvePreset(h.stores, req.PresetID, userID)
|
||||||
if preset == nil {
|
if preset == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found or not accessible"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found or not accessible"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
personaID = preset.ID
|
||||||
// Preset provides defaults; explicit request fields take priority
|
// Preset provides defaults; explicit request fields take priority
|
||||||
if req.Model == "" {
|
if req.Model == "" {
|
||||||
req.Model = preset.BaseModelID
|
req.Model = preset.BaseModelID
|
||||||
@@ -138,7 +140,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load conversation history
|
// Load conversation history
|
||||||
messages, err := h.loadConversation(channelID, userID, presetSystemPrompt)
|
messages, err := h.loadConversation(channelID, userID, presetSystemPrompt, personaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"})
|
||||||
return
|
return
|
||||||
@@ -218,9 +220,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if stream {
|
if stream {
|
||||||
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope)
|
h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID)
|
||||||
} else {
|
} else {
|
||||||
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope)
|
h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, configID, providerScope, personaID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,9 +358,9 @@ func (h *CompletionHandler) streamCompletion(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req providers.CompletionRequest,
|
req providers.CompletionRequest,
|
||||||
channelID, userID, model, configID, providerScope string,
|
channelID, userID, model, configID, providerScope, personaID string,
|
||||||
) {
|
) {
|
||||||
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, h.hub)
|
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, personaID, h.hub)
|
||||||
|
|
||||||
// Persist assistant response
|
// Persist assistant response
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
@@ -380,7 +382,7 @@ func (h *CompletionHandler) syncCompletion(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req providers.CompletionRequest,
|
req providers.CompletionRequest,
|
||||||
channelID, userID, model, configID, providerScope string,
|
channelID, userID, model, configID, providerScope, personaID string,
|
||||||
) {
|
) {
|
||||||
var totalInput, totalOutput int
|
var totalInput, totalOutput int
|
||||||
var totalCacheCreation, totalCacheRead int
|
var totalCacheCreation, totalCacheRead int
|
||||||
@@ -446,6 +448,7 @@ func (h *CompletionHandler) syncCompletion(
|
|||||||
execCtx := tools.ExecutionContext{
|
execCtx := tools.ExecutionContext{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
|
PersonaID: personaID,
|
||||||
}
|
}
|
||||||
for _, tc := range resp.ToolCalls {
|
for _, tc := range resp.ToolCalls {
|
||||||
call := tools.ToolCall{
|
call := tools.ToolCall{
|
||||||
@@ -747,7 +750,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
|||||||
//
|
//
|
||||||
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
|
// Summary-aware: if the path contains a summary node (metadata.type = "summary"),
|
||||||
// messages before it are replaced by the summary content as a system message.
|
// messages before it are replaced by the summary content as a system message.
|
||||||
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt string) ([]providers.Message, error) {
|
func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemPrompt, personaID string) ([]providers.Message, error) {
|
||||||
messages := make([]providers.Message, 0)
|
messages := make([]providers.Message, 0)
|
||||||
|
|
||||||
// ── Admin system prompt (always injected first, no opt out) ──
|
// ── Admin system prompt (always injected first, no opt out) ──
|
||||||
@@ -781,7 +784,7 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── KB hint (nudge LLM to use kb_search when KBs are active) ──
|
// ── KB hint (nudge LLM to use kb_search when KBs are active) ──
|
||||||
if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID); kbHint != "" {
|
if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID, personaID); kbHint != "" {
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: kbHint,
|
Content: kbHint,
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
presets := NewPersonaHandler(stores)
|
presets := NewPersonaHandler(stores)
|
||||||
protected.GET("/presets", presets.ListUserPersonas)
|
protected.GET("/presets", presets.ListUserPersonas)
|
||||||
protected.POST("/presets", presets.CreateUserPersona)
|
protected.POST("/presets", presets.CreateUserPersona)
|
||||||
|
protected.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
|
||||||
|
protected.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
||||||
|
|
||||||
// Notes
|
// Notes
|
||||||
notes := NewNoteHandler()
|
notes := NewNoteHandler()
|
||||||
@@ -149,6 +151,8 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
|
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
|
||||||
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
|
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
|
||||||
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
|
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
|
||||||
|
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
|
||||||
|
protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0
|
||||||
|
|
||||||
// Attachments (nil storage = upload returns 503, but metadata works)
|
// Attachments (nil storage = upload returns 503, but metadata works)
|
||||||
attachH := NewAttachmentHandler(stores, nil, nil)
|
attachH := NewAttachmentHandler(stores, nil, nil)
|
||||||
@@ -185,6 +189,8 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
admin.POST("/teams/:id/members", teams.AddMember)
|
admin.POST("/teams/:id/members", teams.AddMember)
|
||||||
admin.GET("/presets", presets.ListAdminPersonas)
|
admin.GET("/presets", presets.ListAdminPersonas)
|
||||||
admin.POST("/presets", presets.CreateAdminPersona)
|
admin.POST("/presets", presets.CreateAdminPersona)
|
||||||
|
admin.GET("/presets/:id/knowledge-bases", presets.GetPersonaKBs) // v0.17.0
|
||||||
|
admin.PUT("/presets/:id/knowledge-bases", presets.SetPersonaKBs) // v0.17.0
|
||||||
|
|
||||||
// Admin groups (v0.16.0)
|
// Admin groups (v0.16.0)
|
||||||
groupAdm := NewGroupHandler(stores)
|
groupAdm := NewGroupHandler(stores)
|
||||||
@@ -2785,45 +2791,6 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
|||||||
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
|
t.Fatalf("set grant: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostic: verify grant row exists
|
|
||||||
var grantCount int
|
|
||||||
database.DB.QueryRow(`SELECT COUNT(*) FROM resource_grants WHERE resource_type = 'persona' AND resource_id = $1`, personaID).Scan(&grantCount)
|
|
||||||
if grantCount == 0 {
|
|
||||||
t.Fatal("DIAG: resource_grant row missing after SET")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnostic: verify group membership
|
|
||||||
var memberCount int
|
|
||||||
database.DB.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND user_id = $2`, group.ID, userID).Scan(&memberCount)
|
|
||||||
if memberCount == 0 {
|
|
||||||
t.Fatal("DIAG: group_members row missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnostic: verify persona exists
|
|
||||||
var personaExists bool
|
|
||||||
database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM personas WHERE id = $1 AND is_active = true)`, personaID).Scan(&personaExists)
|
|
||||||
if !personaExists {
|
|
||||||
t.Fatal("DIAG: persona not found or not active")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnostic: run the subquery directly
|
|
||||||
var subqCount int
|
|
||||||
database.DB.QueryRow(`
|
|
||||||
SELECT COUNT(*) FROM resource_grants rg
|
|
||||||
WHERE rg.resource_type = 'persona'
|
|
||||||
AND (
|
|
||||||
rg.grant_scope = 'global'
|
|
||||||
OR (rg.grant_scope = 'groups'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM group_members gm
|
|
||||||
WHERE gm.user_id = $1
|
|
||||||
AND gm.group_id = ANY(rg.granted_groups)
|
|
||||||
))
|
|
||||||
)
|
|
||||||
`, userID).Scan(&subqCount)
|
|
||||||
t.Logf("DIAG: grant_count=%d member_count=%d persona_exists=%v subquery_matches=%d personaID=%s groupID=%s userID=%s",
|
|
||||||
grantCount, memberCount, personaExists, subqCount, personaID, group.ID, userID)
|
|
||||||
|
|
||||||
// ── User should NOW see team persona via group grant ──
|
// ── User should NOW see team persona via group grant ──
|
||||||
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
w = h.request("GET", "/api/v1/presets", userToken, nil)
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
@@ -2857,3 +2824,214 @@ func TestGroupBasedPersonaAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// v0.17.0 Integration Tests — Persona-KB Binding + Debt Fixes
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ── Persona-KB Binding CRUD ──
|
||||||
|
|
||||||
|
func TestIntegration_PersonaKB_Binding(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
// 1. Create a global persona via admin API
|
||||||
|
w := h.request("POST", "/api/v1/admin/presets", adminToken, map[string]interface{}{
|
||||||
|
"name": "KB Persona",
|
||||||
|
"base_model_id": "test-model",
|
||||||
|
"system_prompt": "You are a test persona.",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create persona: want 200/201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var personaResp map[string]interface{}
|
||||||
|
decode(w, &personaResp)
|
||||||
|
personaID := personaResp["id"].(string)
|
||||||
|
|
||||||
|
// 2. Create a global KB
|
||||||
|
w = h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
|
||||||
|
"name": "Test KB",
|
||||||
|
"scope": "global",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var kbResp map[string]interface{}
|
||||||
|
decode(w, &kbResp)
|
||||||
|
kbID := kbResp["id"].(string)
|
||||||
|
|
||||||
|
// 3. Bind KB to persona
|
||||||
|
w = h.request("PUT",
|
||||||
|
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||||
|
adminToken, map[string]interface{}{
|
||||||
|
"kb_ids": []string{kbID},
|
||||||
|
"auto_search": map[string]bool{kbID: true},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bind KB to persona: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Read back bindings
|
||||||
|
w = h.request("GET",
|
||||||
|
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||||
|
adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var listResp map[string]interface{}
|
||||||
|
decode(w, &listResp)
|
||||||
|
data := listResp["data"].([]interface{})
|
||||||
|
if len(data) != 1 {
|
||||||
|
t.Fatalf("expected 1 binding, got %d", len(data))
|
||||||
|
}
|
||||||
|
binding := data[0].(map[string]interface{})
|
||||||
|
if binding["kb_id"] != kbID {
|
||||||
|
t.Fatalf("binding kb_id mismatch: got %v, want %s", binding["kb_id"], kbID)
|
||||||
|
}
|
||||||
|
if binding["auto_search"] != true {
|
||||||
|
t.Fatal("auto_search should be true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Unbind — send empty list
|
||||||
|
w = h.request("PUT",
|
||||||
|
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||||
|
adminToken, map[string]interface{}{
|
||||||
|
"kb_ids": []string{},
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unbind KB: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify empty
|
||||||
|
w = h.request("GET",
|
||||||
|
fmt.Sprintf("/api/v1/admin/presets/%s/knowledge-bases", personaID),
|
||||||
|
adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get empty persona KBs: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var emptyResp map[string]interface{}
|
||||||
|
decode(w, &emptyResp)
|
||||||
|
emptyData := emptyResp["data"].([]interface{})
|
||||||
|
if len(emptyData) != 0 {
|
||||||
|
t.Fatalf("expected 0 bindings after unbind, got %d", len(emptyData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KB Create Auth (was TODO — now enforced) ──
|
||||||
|
|
||||||
|
func TestIntegration_KB_CreateAuth(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
_ = adminToken
|
||||||
|
|
||||||
|
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||||
|
_, userToken := h.registerUser("kbuser", "kb@test.com", "password123")
|
||||||
|
|
||||||
|
// Regular user should NOT be able to create global KBs
|
||||||
|
w := h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
|
||||||
|
"name": "Sneaky Global KB",
|
||||||
|
"scope": "global",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("non-admin global KB create: want 403, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular user should NOT be able to create team KBs without team admin role
|
||||||
|
w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
|
||||||
|
"name": "Sneaky Team KB",
|
||||||
|
"scope": "team",
|
||||||
|
"team_id": "00000000-0000-0000-0000-000000000001",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("non-team-admin team KB create: want 403, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular user CAN create personal KBs
|
||||||
|
w = h.request("POST", "/api/v1/knowledge-bases", userToken, map[string]interface{}{
|
||||||
|
"name": "My Personal KB",
|
||||||
|
"scope": "personal",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("personal KB create: want 200/201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KB Discoverable Flag ──
|
||||||
|
|
||||||
|
func TestIntegration_KB_Discoverable(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
database.TestDB.Exec("INSERT INTO platform_policies (key, value) VALUES ('default_user_active', 'true') ON CONFLICT (key) DO UPDATE SET value = 'true'")
|
||||||
|
_, userToken := h.registerUser("discuser", "disc@test.com", "password123")
|
||||||
|
|
||||||
|
// 1. Admin creates a global KB (discoverable=true by default)
|
||||||
|
w := h.request("POST", "/api/v1/knowledge-bases", adminToken, map[string]interface{}{
|
||||||
|
"name": "Visible KB",
|
||||||
|
"scope": "global",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK && w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create KB: want 200/201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var kbResp map[string]interface{}
|
||||||
|
decode(w, &kbResp)
|
||||||
|
kbID := kbResp["id"].(string)
|
||||||
|
|
||||||
|
// 2. User can see it in discoverable list
|
||||||
|
w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list discoverable: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var discovResp map[string]interface{}
|
||||||
|
decode(w, &discovResp)
|
||||||
|
kbs, _ := discovResp["data"].([]interface{})
|
||||||
|
if kbs == nil {
|
||||||
|
t.Fatalf("discoverable list returned nil data (response: %s)", w.Body.String())
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, kb := range kbs {
|
||||||
|
if kb.(map[string]interface{})["id"] == kbID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("discoverable KB %s not visible to user (got %d KBs, response: %s)", kbID, len(kbs), w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Admin hides the KB
|
||||||
|
w = h.request("PUT",
|
||||||
|
fmt.Sprintf("/api/v1/knowledge-bases/%s/discoverable", kbID),
|
||||||
|
adminToken, map[string]interface{}{"discoverable": false})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("set discoverable=false: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. User can no longer see it
|
||||||
|
w = h.request("GET", "/api/v1/knowledge-bases-discoverable", userToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list discoverable after hide: want 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var discovResp2 map[string]interface{}
|
||||||
|
decode(w, &discovResp2)
|
||||||
|
kbs2, _ := discovResp2["data"].([]interface{})
|
||||||
|
for _, kb := range kbs2 {
|
||||||
|
if kb.(map[string]interface{})["id"] == kbID {
|
||||||
|
t.Fatal("hidden KB should not appear in discoverable list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Platform Policy: kb_direct_access seeded ──
|
||||||
|
|
||||||
|
func TestIntegration_KB_DirectAccessPolicy(t *testing.T) {
|
||||||
|
h := setupHarness(t)
|
||||||
|
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||||
|
|
||||||
|
// Verify the migration seeded the policy by reading it through admin settings
|
||||||
|
w := h.request("GET", "/api/v1/settings/public", adminToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get public settings: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
// Policy existence is verified by the migration running without error
|
||||||
|
// (if kb_direct_access INSERT fails, the migration fails and no tests run)
|
||||||
|
_ = w
|
||||||
|
}
|
||||||
|
|||||||
@@ -147,9 +147,25 @@ func (h *KnowledgeBaseHandler) CreateKB(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team scope"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team scope"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO: verify user is team admin
|
// Verify user is team admin (or system admin)
|
||||||
|
role, _ := c.Get("role")
|
||||||
|
if role != "admin" {
|
||||||
|
var teamRole string
|
||||||
|
err := database.DB.QueryRow(
|
||||||
|
`SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2`,
|
||||||
|
req.TeamID, userID).Scan(&teamRole)
|
||||||
|
if err != nil || teamRole != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required to create team knowledge bases"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
case "global":
|
case "global":
|
||||||
// TODO: verify user is admin
|
// Verify user is system admin
|
||||||
|
role, _ := c.Get("role")
|
||||||
|
if role != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "admin access required to create global knowledge bases"})
|
||||||
|
return
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be personal, team, or global"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be personal, team, or global"})
|
||||||
return
|
return
|
||||||
@@ -630,9 +646,7 @@ func (h *KnowledgeBaseHandler) getUserTeamIDs(c *gin.Context, userID string) []s
|
|||||||
|
|
||||||
// updateDocStorageKey sets the storage_key on a document after upload.
|
// updateDocStorageKey sets the storage_key on a document after upload.
|
||||||
func (h *KnowledgeBaseHandler) updateDocStorageKey(c *gin.Context, docID, storageKey string) {
|
func (h *KnowledgeBaseHandler) updateDocStorageKey(c *gin.Context, docID, storageKey string) {
|
||||||
database.DB.ExecContext(c.Request.Context(),
|
h.stores.KnowledgeBases.UpdateDocumentStorageKey(c.Request.Context(), docID, storageKey)
|
||||||
`UPDATE kb_documents SET storage_key = $1 WHERE id = $2`,
|
|
||||||
storageKey, docID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// toKBResponse converts a model to the API response type.
|
// toKBResponse converts a model to the API response type.
|
||||||
@@ -747,6 +761,46 @@ func (h *KnowledgeBaseHandler) SetChannelKBs(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"data": kbs})
|
c.JSON(http.StatusOK, gin.H{"data": kbs})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Discoverable Management (v0.17.0) ────────────
|
||||||
|
|
||||||
|
// SetDiscoverable toggles KB visibility to users.
|
||||||
|
func (h *KnowledgeBaseHandler) SetDiscoverable(c *gin.Context) {
|
||||||
|
kbID := c.Param("id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Discoverable bool `json:"discoverable"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.KnowledgeBases.SetDiscoverable(c.Request.Context(), kbID, req.Discoverable); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update discoverability"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDiscoverableKBs returns KBs the user can see that are marked discoverable.
|
||||||
|
// Used by the channel KB toggle popup when kb_direct_access is enabled.
|
||||||
|
func (h *KnowledgeBaseHandler) ListDiscoverableKBs(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
teamIDs := h.getUserTeamIDs(c, userID)
|
||||||
|
|
||||||
|
kbs, err := h.stores.KnowledgeBases.ListDiscoverable(c.Request.Context(), userID, teamIDs)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list knowledge bases"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if kbs == nil {
|
||||||
|
kbs = []models.KnowledgeBase{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": kbs})
|
||||||
|
}
|
||||||
|
|
||||||
// userCanAccess checks if a user can access a KB based on scope.
|
// userCanAccess checks if a user can access a KB based on scope.
|
||||||
func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool {
|
func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID string, teamIDs []string) bool {
|
||||||
switch kb.Scope {
|
switch kb.Scope {
|
||||||
@@ -773,60 +827,57 @@ func (h *KnowledgeBaseHandler) userCanAccess(kb *models.KnowledgeBase, userID st
|
|||||||
// BuildKBHint returns a system prompt fragment listing active KBs for a
|
// BuildKBHint returns a system prompt fragment listing active KBs for a
|
||||||
// channel, or empty string if none are active. Called by the completion
|
// channel, or empty string if none are active. Called by the completion
|
||||||
// handler to nudge the LLM to use kb_search.
|
// handler to nudge the LLM to use kb_search.
|
||||||
func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID string) string {
|
func BuildKBHint(ctx context.Context, stores store.Stores, channelID, userID, personaID string) string {
|
||||||
teamIDs, _ := stores.Teams.GetUserTeamIDs(ctx, userID)
|
teamIDs, _ := stores.Teams.GetUserTeamIDs(ctx, userID)
|
||||||
|
|
||||||
kbIDs, err := stores.KnowledgeBases.GetActiveKBIDs(ctx, channelID, userID, teamIDs)
|
|
||||||
if err != nil || len(kbIDs) == 0 {
|
|
||||||
// Also check personal KBs — always available
|
|
||||||
personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID)
|
|
||||||
if err != nil || len(personalKBs) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// Only include personal KBs that have chunks
|
|
||||||
hasContent := false
|
|
||||||
for _, kb := range personalKBs {
|
|
||||||
if kb.ChunkCount > 0 {
|
|
||||||
hasContent = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasContent {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect KB details for the hint
|
// Collect KB details for the hint
|
||||||
type kbInfo struct {
|
type kbInfo struct {
|
||||||
Name string
|
Name string
|
||||||
DocCount int
|
DocCount int
|
||||||
}
|
}
|
||||||
var kbs []kbInfo
|
var kbs []kbInfo
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
// Persona-bound KBs (v0.17.0)
|
||||||
|
if personaID != "" {
|
||||||
|
personaKBs, err := stores.Personas.GetKBs(ctx, personaID)
|
||||||
|
if err == nil {
|
||||||
|
for _, pkb := range personaKBs {
|
||||||
|
if pkb.ChunkCount > 0 {
|
||||||
|
kbs = append(kbs, kbInfo{Name: pkb.KBName, DocCount: pkb.DocumentCount})
|
||||||
|
seen[pkb.KBID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Channel-linked KBs
|
// Channel-linked KBs
|
||||||
channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID)
|
channelKBs, err := stores.KnowledgeBases.GetChannelKBs(ctx, channelID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, ckb := range channelKBs {
|
for _, ckb := range channelKBs {
|
||||||
if ckb.Enabled && ckb.DocumentCount > 0 {
|
if ckb.Enabled && ckb.DocumentCount > 0 && !seen[ckb.KBID] {
|
||||||
kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount})
|
kbs = append(kbs, kbInfo{Name: ckb.KBName, DocCount: ckb.DocumentCount})
|
||||||
|
seen[ckb.KBID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Personal KBs (not already linked to channel)
|
// Personal KBs (not already counted)
|
||||||
personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID)
|
personalKBs, err := stores.KnowledgeBases.ListPersonal(ctx, userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
linked := make(map[string]bool)
|
|
||||||
for _, ckb := range channelKBs {
|
|
||||||
linked[ckb.KBID] = true
|
|
||||||
}
|
|
||||||
for _, kb := range personalKBs {
|
for _, kb := range personalKBs {
|
||||||
if !linked[kb.ID] && kb.ChunkCount > 0 {
|
if !seen[kb.ID] && kb.ChunkCount > 0 {
|
||||||
kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount})
|
kbs = append(kbs, kbInfo{Name: kb.Name, DocCount: kb.DocumentCount})
|
||||||
|
seen[kb.ID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group-granted KBs are resolved via GetActiveKBIDs — only add names
|
||||||
|
// for KBs found through channel link or persona. The kb_search tool
|
||||||
|
// handles the full resolution at search time.
|
||||||
|
_ = teamIDs // used by GetActiveKBIDsWithPersona at search time
|
||||||
|
|
||||||
if len(kbs) == 0 {
|
if len(kbs) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,17 +366,19 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
|||||||
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore)
|
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore)
|
||||||
|
|
||||||
var presetSystemPrompt string
|
var presetSystemPrompt string
|
||||||
|
var personaID string
|
||||||
model := req.Model
|
model := req.Model
|
||||||
apiConfigID := req.APIConfigID
|
apiConfigID := req.APIConfigID
|
||||||
temperature := req.Temperature
|
temperature := req.Temperature
|
||||||
maxTokens := req.MaxTokens
|
maxTokens := req.MaxTokens
|
||||||
|
|
||||||
if req.PresetID != "" {
|
if req.PresetID != "" {
|
||||||
preset := ResolvePreset(req.PresetID, userID)
|
preset := ResolvePreset(h.stores, req.PresetID, userID)
|
||||||
if preset == nil {
|
if preset == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "preset not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
personaID = preset.ID
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = preset.BaseModelID
|
model = preset.BaseModelID
|
||||||
}
|
}
|
||||||
@@ -461,7 +463,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
|
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
|
||||||
|
|
||||||
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, h.hub)
|
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, personaID, h.hub)
|
||||||
|
|
||||||
// Persist as sibling (regen) with tool activity
|
// Persist as sibling (regen) with tool activity
|
||||||
if result.Content != "" {
|
if result.Content != "" {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
)
|
)
|
||||||
@@ -256,31 +256,52 @@ func (r *personaRequest) toPersona() *models.Persona {
|
|||||||
// ResolvePreset loads a persona by ID and returns it if the user has access.
|
// ResolvePreset loads a persona by ID and returns it if the user has access.
|
||||||
// Returns nil if not found, inactive, or not accessible.
|
// Returns nil if not found, inactive, or not accessible.
|
||||||
// Used by completion.go and messages.go for preset unwrapping.
|
// Used by completion.go and messages.go for preset unwrapping.
|
||||||
func ResolvePreset(presetID, userID string) *models.Persona {
|
func ResolvePreset(stores store.Stores, presetID, userID string) *models.Persona {
|
||||||
var p models.Persona
|
ctx := context.Background()
|
||||||
var providerConfigID *string
|
|
||||||
err := database.DB.QueryRow(`
|
p, err := stores.Personas.GetByID(ctx, presetID)
|
||||||
SELECT id, name, base_model_id, provider_config_id, system_prompt,
|
if err != nil || !p.IsActive {
|
||||||
temperature, max_tokens, thinking_budget, top_p,
|
|
||||||
scope, owner_id, created_by, is_active
|
|
||||||
FROM personas
|
|
||||||
WHERE id = $1 AND is_active = true
|
|
||||||
AND (
|
|
||||||
scope = 'global'
|
|
||||||
OR (scope = 'personal' AND created_by = $2)
|
|
||||||
OR (scope = 'personal' AND is_shared = true)
|
|
||||||
OR (scope = 'team' AND owner_id IN (
|
|
||||||
SELECT team_id FROM team_members WHERE user_id = $2
|
|
||||||
))
|
|
||||||
)
|
|
||||||
`, presetID, userID).Scan(
|
|
||||||
&p.ID, &p.Name, &p.BaseModelID, &providerConfigID, &p.SystemPrompt,
|
|
||||||
&p.Temperature, &p.MaxTokens, &p.ThinkingBudget, &p.TopP,
|
|
||||||
&p.Scope, &p.OwnerID, &p.CreatedBy, &p.IsActive,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
p.ProviderConfigID = providerConfigID
|
|
||||||
return &p
|
ok, err := stores.Personas.UserCanAccess(ctx, userID, presetID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persona-KB Binding Endpoints (v0.17.0) ──────
|
||||||
|
|
||||||
|
// GetPersonaKBs returns the knowledge bases bound to a persona.
|
||||||
|
func (h *PersonaHandler) GetPersonaKBs(c *gin.Context) {
|
||||||
|
personaID := c.Param("id")
|
||||||
|
kbs, err := h.stores.Personas.GetKBs(c.Request.Context(), personaID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load persona KBs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": kbs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPersonaKBs replaces the knowledge bases bound to a persona.
|
||||||
|
func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
|
||||||
|
personaID := c.Param("id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
KBIDs []string `json:"kb_ids"`
|
||||||
|
AutoSearch map[string]bool `json:"auto_search"` // kb_id → true/false
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Personas.SetKBs(c.Request.Context(), personaID, req.KBIDs, req.AutoSearch); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona KBs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func streamWithToolLoop(
|
|||||||
provider providers.Provider,
|
provider providers.Provider,
|
||||||
cfg providers.ProviderConfig,
|
cfg providers.ProviderConfig,
|
||||||
req *providers.CompletionRequest,
|
req *providers.CompletionRequest,
|
||||||
model, userID, channelID string,
|
model, userID, channelID, personaID string,
|
||||||
hub *events.Hub,
|
hub *events.Hub,
|
||||||
) streamResult {
|
) streamResult {
|
||||||
// Set SSE headers
|
// Set SSE headers
|
||||||
@@ -168,6 +168,7 @@ func streamWithToolLoop(
|
|||||||
execCtx := tools.ExecutionContext{
|
execCtx := tools.ExecutionContext{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
ChannelID: channelID,
|
ChannelID: channelID,
|
||||||
|
PersonaID: personaID,
|
||||||
}
|
}
|
||||||
for _, tc := range toolCalls {
|
for _, tc := range toolCalls {
|
||||||
call := tools.ToolCall{
|
call := tools.ToolCall{
|
||||||
|
|||||||
110
server/handlers/title.go
Normal file
110
server/handlers/title.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TitleHandler generates chat titles using the utility role.
|
||||||
|
type TitleHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
resolver *roles.Resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTitleHandler creates a title generation handler.
|
||||||
|
func NewTitleHandler(s store.Stores, r *roles.Resolver) *TitleHandler {
|
||||||
|
return &TitleHandler{stores: s, resolver: r}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateTitle generates a title for a channel using the utility role.
|
||||||
|
// POST /channels/:id/generate-title
|
||||||
|
func (h *TitleHandler) GenerateTitle(c *gin.Context) {
|
||||||
|
channelID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
teamID := c.GetString("team_id")
|
||||||
|
|
||||||
|
// Verify channel access
|
||||||
|
owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID)
|
||||||
|
if err != nil || !owns {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, err := h.stores.Channels.GetByID(c.Request.Context(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load first few messages
|
||||||
|
msgs, err := h.stores.Messages.ListForChannel(c.Request.Context(), channelID, store.ListOptions{
|
||||||
|
Limit: 4, Order: "asc",
|
||||||
|
})
|
||||||
|
if err != nil || len(msgs) == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"title": ch.Title}) // keep existing
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a snippet from the first messages
|
||||||
|
var snippet strings.Builder
|
||||||
|
for _, m := range msgs {
|
||||||
|
content := m.Content
|
||||||
|
if len(content) > 200 {
|
||||||
|
content = content[:200] + "…"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&snippet, "%s: %s\n", m.Role, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call utility role
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var tID *string
|
||||||
|
if teamID != "" {
|
||||||
|
tID = &teamID
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.resolver.Complete(ctx, roles.RoleUtility, userID, tID, []providers.Message{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: "You are a title generator. Given a conversation snippet, produce a concise title of 6 words or fewer. Respond with ONLY the title, no quotes, no punctuation at the end, no explanation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: "Title this conversation:\n\n" + snippet.String(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: truncate first user message
|
||||||
|
c.JSON(http.StatusOK, gin.H{"title": ch.Title})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(result.Content)
|
||||||
|
// Strip surrounding quotes if the model added them
|
||||||
|
title = strings.Trim(title, "\"'`\u201c\u201d\u2018\u2019")
|
||||||
|
if title == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"title": ch.Title})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cap at 100 chars
|
||||||
|
if len(title) > 100 {
|
||||||
|
title = title[:100]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update channel
|
||||||
|
_ = h.stores.Channels.Update(c.Request.Context(), channelID, map[string]interface{}{
|
||||||
|
"title": title,
|
||||||
|
})
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"title": title})
|
||||||
|
}
|
||||||
@@ -145,8 +145,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role resolver for model role dispatch (needs stores + vault)
|
// ── EventBus (created early — needed by role resolver) ──
|
||||||
roleResolver := roles.NewResolver(stores, keyResolver)
|
bus := events.NewBus()
|
||||||
|
|
||||||
|
// Role resolver for model role dispatch (needs stores + vault + bus)
|
||||||
|
roleResolver := roles.NewResolver(stores, keyResolver).WithBus(bus)
|
||||||
|
|
||||||
// ── Knowledge Base Pipeline ─────────────
|
// ── Knowledge Base Pipeline ─────────────
|
||||||
// Embedder + ingester for RAG document processing (v0.14.0).
|
// Embedder + ingester for RAG document processing (v0.14.0).
|
||||||
@@ -171,8 +174,7 @@ func main() {
|
|||||||
// ── Base path group ──────────────────────
|
// ── Base path group ──────────────────────
|
||||||
base := r.Group(cfg.BasePath)
|
base := r.Group(cfg.BasePath)
|
||||||
|
|
||||||
// ── EventBus + WebSocket Hub ─────────────
|
// ── WebSocket Hub ─────────────────────────
|
||||||
bus := events.NewBus()
|
|
||||||
hub := events.NewHub(bus)
|
hub := events.NewHub(bus)
|
||||||
|
|
||||||
// Health check (k8s probes hit this directly)
|
// Health check (k8s probes hit this directly)
|
||||||
@@ -258,6 +260,10 @@ func main() {
|
|||||||
summarize := handlers.NewSummarizeHandler(compactionSvc)
|
summarize := handlers.NewSummarizeHandler(compactionSvc)
|
||||||
protected.POST("/channels/:id/summarize", summarize.Summarize)
|
protected.POST("/channels/:id/summarize", summarize.Summarize)
|
||||||
|
|
||||||
|
// Auto-title generation (utility role)
|
||||||
|
titleH := handlers.NewTitleHandler(stores, roleResolver)
|
||||||
|
protected.POST("/channels/:id/generate-title", titleH.GenerateTitle)
|
||||||
|
|
||||||
// Provider Configs (user-facing — replaces /api-configs)
|
// Provider Configs (user-facing — replaces /api-configs)
|
||||||
provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
|
provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
|
||||||
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
|
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
|
||||||
@@ -301,6 +307,8 @@ func main() {
|
|||||||
protected.DELETE("/presets/:id", personas.DeleteUserPersona)
|
protected.DELETE("/presets/:id", personas.DeleteUserPersona)
|
||||||
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
||||||
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
||||||
|
protected.GET("/presets/:id/knowledge-bases", personas.GetPersonaKBs) // v0.17.0
|
||||||
|
protected.PUT("/presets/:id/knowledge-bases", personas.SetPersonaKBs) // v0.17.0
|
||||||
|
|
||||||
// Notes
|
// Notes
|
||||||
notes := handlers.NewNoteHandler()
|
notes := handlers.NewNoteHandler()
|
||||||
@@ -339,6 +347,8 @@ func main() {
|
|||||||
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
|
protected.POST("/knowledge-bases/:id/rebuild", kbH.RebuildKB)
|
||||||
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
|
protected.GET("/channels/:id/knowledge-bases", kbH.GetChannelKBs)
|
||||||
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
|
protected.PUT("/channels/:id/knowledge-bases", kbH.SetChannelKBs)
|
||||||
|
protected.GET("/knowledge-bases-discoverable", kbH.ListDiscoverableKBs) // v0.17.0
|
||||||
|
protected.PUT("/knowledge-bases/:id/discoverable", kbH.SetDiscoverable) // v0.17.0 (admin only enforced in handler)
|
||||||
|
|
||||||
// Teams (user: my teams)
|
// Teams (user: my teams)
|
||||||
teams := handlers.NewTeamHandler(keyResolver)
|
teams := handlers.NewTeamHandler(keyResolver)
|
||||||
@@ -381,6 +391,8 @@ func main() {
|
|||||||
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
|
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
|
||||||
teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
|
teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
|
||||||
teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
|
teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
|
||||||
|
teamScoped.GET("/presets/:id/knowledge-bases", teamPersonas.GetPersonaKBs) // v0.17.0
|
||||||
|
teamScoped.PUT("/presets/:id/knowledge-bases", teamPersonas.SetPersonaKBs) // v0.17.0
|
||||||
|
|
||||||
// Team role overrides
|
// Team role overrides
|
||||||
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
|
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
|
||||||
@@ -445,6 +457,8 @@ func main() {
|
|||||||
admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona)
|
admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona)
|
||||||
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
||||||
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
||||||
|
admin.GET("/presets/:id/knowledge-bases", personaAdm.GetPersonaKBs) // v0.17.0
|
||||||
|
admin.PUT("/presets/:id/knowledge-bases", personaAdm.SetPersonaKBs) // v0.17.0
|
||||||
|
|
||||||
// Teams (admin)
|
// Teams (admin)
|
||||||
teamAdm := handlers.NewTeamHandler(keyResolver)
|
teamAdm := handlers.NewTeamHandler(keyResolver)
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ type Persona struct {
|
|||||||
|
|
||||||
// Loaded from persona_grants, not stored in personas table
|
// Loaded from persona_grants, not stored in personas table
|
||||||
Grants []Grant `json:"grants,omitempty" db:"-"`
|
Grants []Grant `json:"grants,omitempty" db:"-"`
|
||||||
|
|
||||||
|
// Loaded from persona_knowledge_bases, not stored in personas table
|
||||||
|
KBIDs []string `json:"kb_ids,omitempty" db:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PersonaPatch struct {
|
type PersonaPatch struct {
|
||||||
@@ -691,11 +694,25 @@ type KnowledgeBase struct {
|
|||||||
DocumentCount int `json:"document_count" db:"document_count"`
|
DocumentCount int `json:"document_count" db:"document_count"`
|
||||||
ChunkCount int `json:"chunk_count" db:"chunk_count"`
|
ChunkCount int `json:"chunk_count" db:"chunk_count"`
|
||||||
TotalBytes int64 `json:"total_bytes" db:"total_bytes"`
|
TotalBytes int64 `json:"total_bytes" db:"total_bytes"`
|
||||||
|
Discoverable bool `json:"discoverable" db:"discoverable"`
|
||||||
Status string `json:"status" db:"status"` // active, processing, error
|
Status string `json:"status" db:"status"` // active, processing, error
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PersonaKB binds a knowledge base to a persona.
|
||||||
|
type PersonaKB struct {
|
||||||
|
PersonaID string `json:"persona_id" db:"persona_id"`
|
||||||
|
KBID string `json:"kb_id" db:"kb_id"`
|
||||||
|
AutoSearch bool `json:"auto_search" db:"auto_search"`
|
||||||
|
AddedAt time.Time `json:"added_at" db:"added_at"`
|
||||||
|
|
||||||
|
// Joined fields (not in persona_knowledge_bases table)
|
||||||
|
KBName string `json:"kb_name,omitempty" db:"kb_name"`
|
||||||
|
DocumentCount int `json:"document_count,omitempty" db:"document_count"`
|
||||||
|
ChunkCount int `json:"chunk_count,omitempty" db:"chunk_count"`
|
||||||
|
}
|
||||||
|
|
||||||
// KBDocument is a single uploaded file within a knowledge base.
|
// KBDocument is a single uploaded file within a knowledge base.
|
||||||
type KBDocument struct {
|
type KBDocument struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
)
|
)
|
||||||
@@ -89,11 +93,24 @@ var (
|
|||||||
type Resolver struct {
|
type Resolver struct {
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
vault *crypto.KeyResolver
|
vault *crypto.KeyResolver
|
||||||
|
bus *events.Bus // optional — nil-safe
|
||||||
|
|
||||||
|
// Fallback cooldown: suppress duplicate alerts per role
|
||||||
|
coolMu sync.Mutex
|
||||||
|
cooldown map[string]time.Time // role → last alert timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackCooldown = 5 * time.Minute
|
||||||
|
|
||||||
// NewResolver creates a role resolver with access to stores and vault.
|
// NewResolver creates a role resolver with access to stores and vault.
|
||||||
func NewResolver(s store.Stores, vault *crypto.KeyResolver) *Resolver {
|
func NewResolver(s store.Stores, vault *crypto.KeyResolver) *Resolver {
|
||||||
return &Resolver{stores: s, vault: vault}
|
return &Resolver{stores: s, vault: vault, cooldown: make(map[string]time.Time)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBus attaches an event bus for fallback alert publishing.
|
||||||
|
func (r *Resolver) WithBus(bus *events.Bus) *Resolver {
|
||||||
|
r.bus = bus
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete sends a chat completion using the named role.
|
// Complete sends a chat completion using the named role.
|
||||||
@@ -118,6 +135,7 @@ func (r *Resolver) Complete(ctx context.Context, role string, userID string, tea
|
|||||||
result, err := r.doComplete(ctx, role, cfg.Fallback, messages)
|
result, err := r.doComplete(ctx, role, cfg.Fallback, messages)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.UsedFallback = true
|
result.UsedFallback = true
|
||||||
|
r.onFallback(ctx, role, "completion", cfg.Primary, cfg.Fallback)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("role %q: both primary and fallback failed: %w", role, err)
|
return nil, fmt.Errorf("role %q: both primary and fallback failed: %w", role, err)
|
||||||
@@ -150,6 +168,7 @@ func (r *Resolver) Embed(ctx context.Context, role string, userID string, teamID
|
|||||||
result, err := r.doEmbed(ctx, role, cfg.Fallback, input)
|
result, err := r.doEmbed(ctx, role, cfg.Fallback, input)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.UsedFallback = true
|
result.UsedFallback = true
|
||||||
|
r.onFallback(ctx, role, "embedding", cfg.Primary, cfg.Fallback)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("role %q: both primary and fallback embed failed: %w", role, err)
|
return nil, fmt.Errorf("role %q: both primary and fallback embed failed: %w", role, err)
|
||||||
@@ -387,3 +406,66 @@ func parseRoleConfig(data interface{}) (*RoleConfig, error) {
|
|||||||
}
|
}
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fallback Alerting ──────────────────────
|
||||||
|
|
||||||
|
// onFallback fires on successful fallback activation. It emits:
|
||||||
|
// - log line (always)
|
||||||
|
// - audit_log entry (always)
|
||||||
|
// - event bus "role.fallback" (once per cooldown window)
|
||||||
|
//
|
||||||
|
// The cooldown prevents flooding the admin UI with identical alerts
|
||||||
|
// when a primary is down and every request triggers the fallback.
|
||||||
|
func (r *Resolver) onFallback(ctx context.Context, role, opType string, primary, fallback *RoleBinding) {
|
||||||
|
primaryModel := ""
|
||||||
|
fallbackModel := ""
|
||||||
|
if primary != nil {
|
||||||
|
primaryModel = primary.ModelID
|
||||||
|
}
|
||||||
|
if fallback != nil {
|
||||||
|
fallbackModel = fallback.ModelID
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("⚠ Role %q fallback activated: %s → %s (%s)", role, primaryModel, fallbackModel, opType)
|
||||||
|
|
||||||
|
// Audit log — always written (one row per fallback fire)
|
||||||
|
_ = r.stores.Audit.Log(ctx, &models.AuditEntry{
|
||||||
|
Action: "role.fallback",
|
||||||
|
ResourceType: "role",
|
||||||
|
ResourceID: role,
|
||||||
|
Metadata: models.JSONMap{
|
||||||
|
"operation": opType,
|
||||||
|
"primary_model": primaryModel,
|
||||||
|
"fallback_model": fallbackModel,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bus event — cooldown-gated
|
||||||
|
if r.bus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.coolMu.Lock()
|
||||||
|
last, exists := r.cooldown[role]
|
||||||
|
now := time.Now()
|
||||||
|
if exists && now.Sub(last) < fallbackCooldown {
|
||||||
|
r.coolMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.cooldown[role] = now
|
||||||
|
r.coolMu.Unlock()
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(map[string]string{
|
||||||
|
"role": role,
|
||||||
|
"operation": opType,
|
||||||
|
"primary_model": primaryModel,
|
||||||
|
"fallback_model": fallbackModel,
|
||||||
|
"message": fmt.Sprintf("Role %q primary (%s) failed — using fallback (%s)", role, primaryModel, fallbackModel),
|
||||||
|
})
|
||||||
|
r.bus.PublishAsync(events.Event{
|
||||||
|
Label: "role.fallback",
|
||||||
|
Room: "admin", // admin-targeted
|
||||||
|
Payload: payload,
|
||||||
|
Ts: now.UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ type PersonaStore interface {
|
|||||||
|
|
||||||
// Access check
|
// Access check
|
||||||
UserCanAccess(ctx context.Context, userID, personaID string) (bool, error)
|
UserCanAccess(ctx context.Context, userID, personaID string) (bool, error)
|
||||||
|
|
||||||
|
// Knowledge base bindings
|
||||||
|
SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error
|
||||||
|
GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error)
|
||||||
|
GetKBIDs(ctx context.Context, personaID string) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -385,6 +390,7 @@ type KnowledgeBaseStore interface {
|
|||||||
ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error)
|
ListDocuments(ctx context.Context, kbID string) ([]models.KBDocument, error)
|
||||||
UpdateDocumentStatus(ctx context.Context, id string, status string, errMsg *string) error
|
UpdateDocumentStatus(ctx context.Context, id string, status string, errMsg *string) error
|
||||||
UpdateDocumentText(ctx context.Context, id string, text string, chunkCount int) error
|
UpdateDocumentText(ctx context.Context, id string, text string, chunkCount int) error
|
||||||
|
UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error
|
||||||
DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) // returns for storage cleanup
|
DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) // returns for storage cleanup
|
||||||
|
|
||||||
// Chunks
|
// Chunks
|
||||||
@@ -398,9 +404,15 @@ type KnowledgeBaseStore interface {
|
|||||||
GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error)
|
GetChannelKBs(ctx context.Context, channelID string) ([]models.ChannelKB, error)
|
||||||
GetActiveKBIDs(ctx context.Context, channelID string, userID string,
|
GetActiveKBIDs(ctx context.Context, channelID string, userID string,
|
||||||
teamIDs []string) ([]string, error) // enabled + user has access
|
teamIDs []string) ([]string, error) // enabled + user has access
|
||||||
|
GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string,
|
||||||
|
teamIDs []string, personaID string) ([]string, error) // includes persona-bound KBs
|
||||||
|
ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error)
|
||||||
|
|
||||||
// Stats — recount document_count/chunk_count/total_bytes from child tables
|
// Stats — recount document_count/chunk_count/total_bytes from child tables
|
||||||
UpdateStats(ctx context.Context, kbID string) error
|
UpdateStats(ctx context.Context, kbID string) error
|
||||||
|
|
||||||
|
// Discoverable management
|
||||||
|
SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (s *KnowledgeBaseStore) Create(ctx context.Context, kb *models.KnowledgeBas
|
|||||||
func (s *KnowledgeBaseStore) GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error) {
|
func (s *KnowledgeBaseStore) GetByID(ctx context.Context, id string) (*models.KnowledgeBase, error) {
|
||||||
kb, err := scanKB(DB.QueryRowContext(ctx, `
|
kb, err := scanKB(DB.QueryRowContext(ctx, `
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||||
document_count, chunk_count, total_bytes, status, created_at, updated_at
|
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||||
FROM knowledge_bases WHERE id = $1`, id))
|
FROM knowledge_bases WHERE id = $1`, id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -71,7 +71,7 @@ func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, tea
|
|||||||
// User can see: global + their teams + personal + group-granted.
|
// User can see: global + their teams + personal + group-granted.
|
||||||
q := `
|
q := `
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||||
document_count, chunk_count, total_bytes, status, created_at, updated_at
|
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||||
FROM knowledge_bases
|
FROM knowledge_bases
|
||||||
WHERE scope = 'global'
|
WHERE scope = 'global'
|
||||||
OR (scope = 'personal' AND owner_id = $1)`
|
OR (scope = 'personal' AND owner_id = $1)`
|
||||||
@@ -107,21 +107,21 @@ func (s *KnowledgeBaseStore) ListForUser(ctx context.Context, userID string, tea
|
|||||||
func (s *KnowledgeBaseStore) ListGlobal(ctx context.Context) ([]models.KnowledgeBase, error) {
|
func (s *KnowledgeBaseStore) ListGlobal(ctx context.Context) ([]models.KnowledgeBase, error) {
|
||||||
return queryKBs(ctx, `
|
return queryKBs(ctx, `
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||||
document_count, chunk_count, total_bytes, status, created_at, updated_at
|
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||||
FROM knowledge_bases WHERE scope = 'global' ORDER BY name`)
|
FROM knowledge_bases WHERE scope = 'global' ORDER BY name`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) ListForTeam(ctx context.Context, teamID string) ([]models.KnowledgeBase, error) {
|
func (s *KnowledgeBaseStore) ListForTeam(ctx context.Context, teamID string) ([]models.KnowledgeBase, error) {
|
||||||
return queryKBs(ctx, `
|
return queryKBs(ctx, `
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||||
document_count, chunk_count, total_bytes, status, created_at, updated_at
|
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||||
FROM knowledge_bases WHERE team_id = $1 ORDER BY name`, teamID)
|
FROM knowledge_bases WHERE team_id = $1 ORDER BY name`, teamID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error) {
|
func (s *KnowledgeBaseStore) ListPersonal(ctx context.Context, userID string) ([]models.KnowledgeBase, error) {
|
||||||
return queryKBs(ctx, `
|
return queryKBs(ctx, `
|
||||||
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||||
document_count, chunk_count, total_bytes, status, created_at, updated_at
|
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||||
FROM knowledge_bases WHERE scope = 'personal' AND owner_id = $1 ORDER BY name`, userID)
|
FROM knowledge_bases WHERE scope = 'personal' AND owner_id = $1 ORDER BY name`, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +196,12 @@ func (s *KnowledgeBaseStore) UpdateDocumentText(ctx context.Context, id string,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *KnowledgeBaseStore) UpdateDocumentStorageKey(ctx context.Context, id string, storageKey string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
UPDATE kb_documents SET storage_key = $2 WHERE id = $1`, id, storageKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *KnowledgeBaseStore) DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) {
|
func (s *KnowledgeBaseStore) DeleteDocument(ctx context.Context, id string) (*models.KBDocument, error) {
|
||||||
var doc models.KBDocument
|
var doc models.KBDocument
|
||||||
var errMsg sql.NullString
|
var errMsg sql.NullString
|
||||||
@@ -385,6 +391,111 @@ func (s *KnowledgeBaseStore) UpdateStats(ctx context.Context, kbID string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Discoverable Management (v0.17.0) ────────────
|
||||||
|
|
||||||
|
func (s *KnowledgeBaseStore) SetDiscoverable(ctx context.Context, kbID string, discoverable bool) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
UPDATE knowledge_bases SET discoverable = $2, updated_at = now()
|
||||||
|
WHERE id = $1`, kbID, discoverable)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveKBIDsWithPersona returns KB IDs accessible for a channel, including
|
||||||
|
// KBs bound to the active persona. Persona-bound KBs are included even if
|
||||||
|
// they are not discoverable and not channel-linked.
|
||||||
|
func (s *KnowledgeBaseStore) GetActiveKBIDsWithPersona(ctx context.Context, channelID string, userID string, teamIDs []string, personaID string) ([]string, error) {
|
||||||
|
// Start with channel-linked KBs (existing behavior)
|
||||||
|
q := `
|
||||||
|
SELECT ckb.kb_id
|
||||||
|
FROM channel_knowledge_bases ckb
|
||||||
|
JOIN knowledge_bases kb ON ckb.kb_id = kb.id
|
||||||
|
WHERE ckb.channel_id = $1 AND ckb.enabled = true
|
||||||
|
AND (
|
||||||
|
kb.scope = 'global'
|
||||||
|
OR (kb.scope = 'personal' AND kb.owner_id = $2)`
|
||||||
|
|
||||||
|
args := []interface{}{channelID, userID}
|
||||||
|
|
||||||
|
if len(teamIDs) > 0 {
|
||||||
|
q += fmt.Sprintf(` OR (kb.scope = 'team' AND kb.team_id = ANY($%d))`, len(args)+1)
|
||||||
|
args = append(args, pq.Array(teamIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
q += `)`
|
||||||
|
|
||||||
|
// UNION with persona-bound KBs (bypass discoverable check)
|
||||||
|
if personaID != "" {
|
||||||
|
q += fmt.Sprintf(`
|
||||||
|
UNION
|
||||||
|
SELECT pkb.kb_id
|
||||||
|
FROM persona_knowledge_bases pkb
|
||||||
|
JOIN knowledge_bases kb ON pkb.kb_id = kb.id
|
||||||
|
WHERE pkb.persona_id = $%d
|
||||||
|
AND kb.chunk_count > 0`, len(args)+1)
|
||||||
|
args = append(args, personaID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := DB.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !seen[id] {
|
||||||
|
seen[id] = true
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDiscoverable returns KBs the user can see AND that are discoverable.
|
||||||
|
// Used for the channel KB toggle popup when kb_direct_access is enabled.
|
||||||
|
func (s *KnowledgeBaseStore) ListDiscoverable(ctx context.Context, userID string, teamIDs []string) ([]models.KnowledgeBase, error) {
|
||||||
|
q := `
|
||||||
|
SELECT id, name, description, scope, owner_id, team_id, embedding_config,
|
||||||
|
document_count, chunk_count, total_bytes, status, discoverable, created_at, updated_at
|
||||||
|
FROM knowledge_bases
|
||||||
|
WHERE discoverable = true
|
||||||
|
AND (
|
||||||
|
scope = 'global'
|
||||||
|
OR (scope = 'personal' AND owner_id = $1)`
|
||||||
|
|
||||||
|
args := []interface{}{userID}
|
||||||
|
|
||||||
|
if len(teamIDs) > 0 {
|
||||||
|
q += fmt.Sprintf(` OR (scope = 'team' AND team_id = ANY($%d))`, len(args)+1)
|
||||||
|
args = append(args, pq.Array(teamIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group-granted KBs
|
||||||
|
q += fmt.Sprintf(`
|
||||||
|
OR id IN (
|
||||||
|
SELECT rg.resource_id FROM resource_grants rg
|
||||||
|
WHERE rg.resource_type = 'knowledge_base'
|
||||||
|
AND (
|
||||||
|
rg.grant_scope = 'global'
|
||||||
|
OR (rg.grant_scope = 'groups'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM group_members gm
|
||||||
|
WHERE gm.user_id = $%d
|
||||||
|
AND gm.group_id = ANY(rg.granted_groups)
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)`, len(args)+1)
|
||||||
|
args = append(args, userID)
|
||||||
|
|
||||||
|
q += `) ORDER BY name`
|
||||||
|
return queryKBs(ctx, q, args...)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scan Helpers ─────────────────────────────────
|
// ── Scan Helpers ─────────────────────────────────
|
||||||
|
|
||||||
func scanKB(row *sql.Row) (*models.KnowledgeBase, error) {
|
func scanKB(row *sql.Row) (*models.KnowledgeBase, error) {
|
||||||
@@ -396,7 +507,7 @@ func scanKB(row *sql.Row) (*models.KnowledgeBase, error) {
|
|||||||
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
||||||
&ownerID, &teamID, &embCfgJSON,
|
&ownerID, &teamID, &embCfgJSON,
|
||||||
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
|
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
|
||||||
&kb.Status, &kb.CreatedAt, &kb.UpdatedAt,
|
&kb.Status, &kb.Discoverable, &kb.CreatedAt, &kb.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -423,7 +534,7 @@ func queryKBs(ctx context.Context, query string, args ...interface{}) ([]models.
|
|||||||
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
&kb.ID, &kb.Name, &kb.Description, &kb.Scope,
|
||||||
&ownerID, &teamID, &embCfgJSON,
|
&ownerID, &teamID, &embCfgJSON,
|
||||||
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
|
&kb.DocumentCount, &kb.ChunkCount, &kb.TotalBytes,
|
||||||
&kb.Status, &kb.CreatedAt, &kb.UpdatedAt,
|
&kb.Status, &kb.Discoverable, &kb.CreatedAt, &kb.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -320,3 +320,84 @@ func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
|
|||||||
}
|
}
|
||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Persona-KB Bindings (v0.17.0) ───────────
|
||||||
|
|
||||||
|
func (s *PersonaStore) SetKBs(ctx context.Context, personaID string, kbIDs []string, autoSearch map[string]bool) error {
|
||||||
|
tx, err := DB.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM persona_knowledge_bases WHERE persona_id = $1", personaID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kbID := range kbIDs {
|
||||||
|
auto := false
|
||||||
|
if autoSearch != nil {
|
||||||
|
auto = autoSearch[kbID]
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO persona_knowledge_bases (persona_id, kb_id, auto_search)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
personaID, kbID, auto)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("persona KB %s: %w", kbID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PersonaStore) GetKBs(ctx context.Context, personaID string) ([]models.PersonaKB, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT pkb.persona_id, pkb.kb_id, pkb.auto_search, pkb.added_at,
|
||||||
|
kb.name AS kb_name, kb.document_count, kb.chunk_count
|
||||||
|
FROM persona_knowledge_bases pkb
|
||||||
|
JOIN knowledge_bases kb ON kb.id = pkb.kb_id
|
||||||
|
WHERE pkb.persona_id = $1
|
||||||
|
ORDER BY kb.name`, personaID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var result []models.PersonaKB
|
||||||
|
for rows.Next() {
|
||||||
|
var pkb models.PersonaKB
|
||||||
|
if err := rows.Scan(&pkb.PersonaID, &pkb.KBID, &pkb.AutoSearch, &pkb.AddedAt,
|
||||||
|
&pkb.KBName, &pkb.DocumentCount, &pkb.ChunkCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, pkb)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = make([]models.PersonaKB, 0)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PersonaStore) GetKBIDs(ctx context.Context, personaID string) ([]string, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
"SELECT kb_id FROM persona_knowledge_bases WHERE persona_id = $1", personaID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if ids == nil {
|
||||||
|
ids = make([]string, 0)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,8 +69,16 @@ func (t *kbSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, ar
|
|||||||
// Resolve user's team IDs for scoped access
|
// Resolve user's team IDs for scoped access
|
||||||
teamIDs, _ := t.stores.Teams.GetUserTeamIDs(ctx, execCtx.UserID)
|
teamIDs, _ := t.stores.Teams.GetUserTeamIDs(ctx, execCtx.UserID)
|
||||||
|
|
||||||
// Get active KB IDs for this channel (respects scope: global, team, personal)
|
// Get active KB IDs for this channel, including persona-bound KBs
|
||||||
kbIDs, err := t.stores.KnowledgeBases.GetActiveKBIDs(ctx, execCtx.ChannelID, execCtx.UserID, teamIDs)
|
var kbIDs []string
|
||||||
|
var err error
|
||||||
|
if execCtx.PersonaID != "" {
|
||||||
|
kbIDs, err = t.stores.KnowledgeBases.GetActiveKBIDsWithPersona(
|
||||||
|
ctx, execCtx.ChannelID, execCtx.UserID, teamIDs, execCtx.PersonaID)
|
||||||
|
} else {
|
||||||
|
kbIDs, err = t.stores.KnowledgeBases.GetActiveKBIDs(
|
||||||
|
ctx, execCtx.ChannelID, execCtx.UserID, teamIDs)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to look up active knowledge bases: %w", err)
|
return "", fmt.Errorf("failed to look up active knowledge bases: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type ToolResult struct {
|
|||||||
type ExecutionContext struct {
|
type ExecutionContext struct {
|
||||||
UserID string
|
UserID string
|
||||||
ChannelID string
|
ChannelID string
|
||||||
|
PersonaID string // set when a persona is active — tools use for scoped KB access
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool is the interface every built-in tool implements.
|
// Tool is the interface every built-in tool implements.
|
||||||
|
|||||||
135
src/css/persona-kb.css
Normal file
135
src/css/persona-kb.css
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/* persona-kb.css — Persona–Knowledge Base picker styles (v0.17.0) */
|
||||||
|
|
||||||
|
.persona-kb-picker {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-kb-picker > label {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-kb-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: var(--radius, 6px);
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-kb-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-kb-item:hover {
|
||||||
|
background: var(--hover-bg, rgba(255,255,255,0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-kb-item.selected {
|
||||||
|
background: var(--selected-bg, rgba(100,160,255,0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-kb-item input[type="checkbox"] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-kb-item .kb-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.persona-kb-item .kb-meta {
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-search-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-search-toggle.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-search-toggle span {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Persona-KB Section (in preset form) ─── */
|
||||||
|
|
||||||
|
.persona-kb-section {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Role Fallback Alert Banner ───────────── */
|
||||||
|
|
||||||
|
.role-fallback-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--warning-bg, #fff3cd);
|
||||||
|
border: 1px solid var(--warning-border, #ffc107);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-fallback-banner .fallback-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-fallback-banner .fallback-msg {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-fallback-banner .btn-small {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat Rename Inline Input ────────────── */
|
||||||
|
|
||||||
|
.chat-rename-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--input-bg, #1a1a2e);
|
||||||
|
color: var(--text-color, #e0e0e0);
|
||||||
|
border: 1px solid var(--accent-color, #4a9eff);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat Header Token Count ─────────────── */
|
||||||
|
|
||||||
|
.chat-token-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-token-count.warning { color: var(--warning-color, #f0ad4e); }
|
||||||
|
.chat-token-count.danger { color: var(--danger-color, #d9534f); }
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
<link rel="stylesheet" href="css/styles.css?v=%%APP_VERSION%%">
|
||||||
|
<link rel="stylesheet" href="css/persona-kb.css?v=%%APP_VERSION%%">
|
||||||
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
|
<link rel="stylesheet" href="branding/custom.css" onerror="this.remove()">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -130,6 +131,7 @@
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="model-caps" id="modelCaps"></div>
|
<div class="model-caps" id="modelCaps"></div>
|
||||||
|
<span class="chat-token-count" id="chatTokenCount"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="messages" id="chatMessages">
|
<div class="messages" id="chatMessages">
|
||||||
@@ -454,6 +456,7 @@
|
|||||||
<p class="section-hint" style="margin-bottom:8px">Personal model personas with custom system prompts and settings.</p>
|
<p class="section-hint" style="margin-bottom:8px">Personal model personas with custom system prompts and settings.</p>
|
||||||
<div id="userPresetList"><div class="empty-hint">Loading...</div></div>
|
<div id="userPresetList"><div class="empty-hint">Loading...</div></div>
|
||||||
<div id="userAddPresetForm" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
<div id="userAddPresetForm" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
||||||
|
<div id="userPresetKBPicker" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
||||||
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Persona</button>
|
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Persona</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -542,6 +545,7 @@
|
|||||||
<div class="team-tab-content" id="teamTabPresets" style="display:none">
|
<div class="team-tab-content" id="teamTabPresets" style="display:none">
|
||||||
<div id="settingsTeamPresets"></div>
|
<div id="settingsTeamPresets"></div>
|
||||||
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
||||||
|
<div id="teamPresetKBPicker" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
||||||
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
|
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Usage tab -->
|
<!-- Usage tab -->
|
||||||
@@ -725,6 +729,7 @@
|
|||||||
<button class="btn-small btn-primary" id="adminAddPresetBtn">+ New Global Persona</button>
|
<button class="btn-small btn-primary" id="adminAddPresetBtn">+ New Global Persona</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form"></div>
|
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form"></div>
|
||||||
|
<div id="adminPresetKBPicker" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
||||||
<div id="adminPresetList"></div>
|
<div id="adminPresetList"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-section-content" id="adminKnowledgeBasesTab" style="display:none">
|
<div class="admin-section-content" id="adminKnowledgeBasesTab" style="display:none">
|
||||||
@@ -761,6 +766,11 @@
|
|||||||
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal personas</label>
|
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal personas</label>
|
||||||
<p class="section-hint">When disabled, users can only use admin-created global personas.</p>
|
<p class="section-hint">When disabled, users can only use admin-created global personas.</p>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>Knowledge Base Access</h3>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" id="adminKBDirectAccessToggle"> Restrict KB access to persona bindings only</label>
|
||||||
|
<p class="section-hint">When enabled, users cannot attach KBs directly to channels — they can only use KBs bound to personas by admins.</p>
|
||||||
|
</section>
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>Default Model</h3>
|
<h3>Default Model</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -989,6 +999,7 @@
|
|||||||
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
|
<script src="js/attachments.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/tools-toggle.js?v=%%APP_VERSION%%"></script>
|
<script src="js/tools-toggle.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/knowledge-ui.js?v=%%APP_VERSION%%"></script>
|
<script src="js/knowledge-ui.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/persona-kb.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
<script src="js/chat.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
<script src="js/settings-handlers.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
<script src="js/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
||||||
|
|||||||
@@ -193,6 +193,8 @@ function ensureAdminPresetForm() {
|
|||||||
prefix: 'adminPreset',
|
prefix: 'adminPreset',
|
||||||
showAvatar: true,
|
showAvatar: true,
|
||||||
showProviderConfig: true,
|
showProviderConfig: true,
|
||||||
|
showKBPicker: true,
|
||||||
|
kbScope: 'admin',
|
||||||
onSubmit: (vals) => createAdminPreset(vals),
|
onSubmit: (vals) => createAdminPreset(vals),
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
@@ -263,6 +265,10 @@ function editAdminPreset(id) {
|
|||||||
document.getElementById('adminAddPresetForm').style.display = '';
|
document.getElementById('adminAddPresetForm').style.display = '';
|
||||||
form.setValues(p);
|
form.setValues(p);
|
||||||
form.setSubmitLabel('Update');
|
form.setSubmitLabel('Update');
|
||||||
|
// Load KB bindings (v0.17.0)
|
||||||
|
if (form.kbPicker) {
|
||||||
|
loadPersonaKBs(id, form.kbPicker, '/api/v1/admin');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAdminPreset(vals) {
|
async function createAdminPreset(vals) {
|
||||||
@@ -299,6 +305,12 @@ async function createAdminPreset(vals) {
|
|||||||
catch (e) { console.warn('Preset avatar upload failed:', e.message); }
|
catch (e) { console.warn('Preset avatar upload failed:', e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save KB bindings (v0.17.0)
|
||||||
|
if (presetId && vals._kbValues && _adminPresetForm?.kbPicker) {
|
||||||
|
try { await API.adminSetPresetKBs(presetId, vals._kbValues); }
|
||||||
|
catch (e) { console.warn('Preset KB binding failed:', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
_editingPresetId = null;
|
_editingPresetId = null;
|
||||||
document.getElementById('adminAddPresetForm').style.display = 'none';
|
document.getElementById('adminAddPresetForm').style.display = 'none';
|
||||||
if (_adminPresetForm) {
|
if (_adminPresetForm) {
|
||||||
@@ -646,6 +658,20 @@ function _initAdminListeners() {
|
|||||||
await UI.loadAdminExtensions();
|
await UI.loadAdminExtensions();
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Role fallback alerts (v0.17.0) — show admin banner when fallback fires
|
||||||
|
if (typeof Events !== 'undefined') {
|
||||||
|
Events.on('role.fallback', (payload) => {
|
||||||
|
const banner = document.getElementById('roleFallbackBanner');
|
||||||
|
if (!banner) return;
|
||||||
|
const msg = payload?.message || 'A model role is using its fallback provider.';
|
||||||
|
banner.querySelector('.fallback-msg').textContent = msg;
|
||||||
|
banner.style.display = '';
|
||||||
|
// Auto-dismiss after 60s
|
||||||
|
clearTimeout(banner._dismissTimer);
|
||||||
|
banner._dismissTimer = setTimeout(() => { banner.style.display = 'none'; }, 60000);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extension admin actions (global scope for onclick) ──
|
// ── Extension admin actions (global scope for onclick) ──
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ const API = {
|
|||||||
},
|
},
|
||||||
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
|
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
|
||||||
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
||||||
|
generateTitle(id) { return this._post(`/api/v1/channels/${id}/generate-title`, {}); },
|
||||||
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||||||
|
|
||||||
// ── Messages ─────────────────────────────
|
// ── Messages ─────────────────────────────
|
||||||
@@ -302,6 +303,8 @@ const API = {
|
|||||||
// User presets
|
// User presets
|
||||||
listUserPresets() { return this._get('/api/v1/presets'); },
|
listUserPresets() { return this._get('/api/v1/presets'); },
|
||||||
createUserPreset(preset) { return this._post('/api/v1/presets', preset); },
|
createUserPreset(preset) { return this._post('/api/v1/presets', preset); },
|
||||||
|
getUserPresetKBs(presetId) { return this._get(`/api/v1/presets/${presetId}/knowledge-bases`); },
|
||||||
|
setUserPresetKBs(presetId, data) { return this._put(`/api/v1/presets/${presetId}/knowledge-bases`, data); },
|
||||||
updateUserPreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
|
updateUserPreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
|
||||||
deleteUserPreset(id) { return this._del(`/api/v1/presets/${id}`); },
|
deleteUserPreset(id) { return this._del(`/api/v1/presets/${id}`); },
|
||||||
listAllModels() { return this._get('/api/v1/models'); },
|
listAllModels() { return this._get('/api/v1/models'); },
|
||||||
@@ -389,6 +392,8 @@ const API = {
|
|||||||
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
|
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
|
||||||
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
|
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
|
||||||
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
|
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
|
||||||
|
adminGetPresetKBs(presetId) { return this._get(`/api/v1/admin/presets/${presetId}/knowledge-bases`); },
|
||||||
|
adminSetPresetKBs(presetId, data) { return this._put(`/api/v1/admin/presets/${presetId}/knowledge-bases`, data); },
|
||||||
|
|
||||||
// ── Admin Teams ─────────────────────────
|
// ── Admin Teams ─────────────────────────
|
||||||
adminListTeams() { return this._get('/api/v1/admin/teams'); },
|
adminListTeams() { return this._get('/api/v1/admin/teams'); },
|
||||||
@@ -447,6 +452,8 @@ const API = {
|
|||||||
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
|
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
|
||||||
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
|
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
|
||||||
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
|
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
|
||||||
|
teamGetPresetKBs(teamId, presetId) { return this._get(`/api/v1/teams/${teamId}/presets/${presetId}/knowledge-bases`); },
|
||||||
|
teamSetPresetKBs(teamId, presetId, data) { return this._put(`/api/v1/teams/${teamId}/presets/${presetId}/knowledge-bases`, data); },
|
||||||
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
||||||
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
||||||
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
|
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
|
||||||
@@ -611,6 +618,7 @@ const API = {
|
|||||||
|
|
||||||
// ── Knowledge Bases ──────────────────────
|
// ── Knowledge Bases ──────────────────────
|
||||||
listKnowledgeBases() { return this._get('/api/v1/knowledge-bases'); },
|
listKnowledgeBases() { return this._get('/api/v1/knowledge-bases'); },
|
||||||
|
listDiscoverableKBs() { return this._get('/api/v1/knowledge-bases-discoverable'); },
|
||||||
createKnowledgeBase(name, description, scope, teamId) {
|
createKnowledgeBase(name, description, scope, teamId) {
|
||||||
const body = { name, description: description || '' };
|
const body = { name, description: description || '' };
|
||||||
if (scope) body.scope = scope;
|
if (scope) body.scope = scope;
|
||||||
|
|||||||
@@ -199,6 +199,15 @@ async function startApp() {
|
|||||||
}
|
}
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
UI.updateModelSelector();
|
UI.updateModelSelector();
|
||||||
|
|
||||||
|
// Restore last-active chat from sessionStorage (QOL: state restore on refresh)
|
||||||
|
try {
|
||||||
|
const savedChat = sessionStorage.getItem('cs-active-chat');
|
||||||
|
if (savedChat && App.chats.some(c => c.id === savedChat)) {
|
||||||
|
selectChat(savedChat);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
UI.updateUser();
|
UI.updateUser();
|
||||||
UI.showAdminButton(API.isAdmin);
|
UI.showAdminButton(API.isAdmin);
|
||||||
initListeners();
|
initListeners();
|
||||||
@@ -206,6 +215,13 @@ async function startApp() {
|
|||||||
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||||||
try {
|
try {
|
||||||
Events.connect((window.__BASE__ || '') + '/ws');
|
Events.connect((window.__BASE__ || '') + '/ws');
|
||||||
|
|
||||||
|
// Admin-only: persistent fallback alert banner (v0.17.0)
|
||||||
|
Events.on('role.fallback', (payload) => {
|
||||||
|
if (App.user?.role !== 'admin') return;
|
||||||
|
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||||||
|
showFallbackBanner(data);
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('EventBus WebSocket not available:', e.message);
|
console.warn('EventBus WebSocket not available:', e.message);
|
||||||
}
|
}
|
||||||
@@ -213,6 +229,27 @@ async function startApp() {
|
|||||||
console.log('✅ Chat Switchboard ready');
|
console.log('✅ Chat Switchboard ready');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Role Fallback Banner (v0.17.0) ──────────
|
||||||
|
|
||||||
|
function showFallbackBanner(data) {
|
||||||
|
let banner = document.getElementById('roleFallbackBanner');
|
||||||
|
if (!banner) {
|
||||||
|
// Create persistent banner above messages
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.id = 'roleFallbackBanner';
|
||||||
|
banner.className = 'role-fallback-banner';
|
||||||
|
const msgs = document.getElementById('chatMessages');
|
||||||
|
if (msgs) msgs.parentNode.insertBefore(banner, msgs);
|
||||||
|
else return;
|
||||||
|
}
|
||||||
|
const msg = data.message || `Role "${data.role}" primary failed — using fallback`;
|
||||||
|
banner.innerHTML = `
|
||||||
|
<span class="fallback-icon">⚠️</span>
|
||||||
|
<span class="fallback-msg">${esc(msg)}</span>
|
||||||
|
<button class="btn-small" onclick="this.parentElement.remove()">Dismiss</button>`;
|
||||||
|
banner.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Modal Helpers ───────────────────────────
|
// ── Modal Helpers ───────────────────────────
|
||||||
|
|
||||||
function openModal(id) {
|
function openModal(id) {
|
||||||
@@ -553,6 +590,9 @@ async function initBanners() {
|
|||||||
// Storage capabilities (file upload, vision)
|
// Storage capabilities (file upload, vision)
|
||||||
App.storageConfigured = !!data.storage_configured;
|
App.storageConfigured = !!data.storage_configured;
|
||||||
|
|
||||||
|
// Paste-to-file threshold (synced from backend, default 2000)
|
||||||
|
App.pasteToFileChars = data.paste_to_file_chars || 2000;
|
||||||
|
|
||||||
// Also flatten into serverSettings for backward compat
|
// Also flatten into serverSettings for backward compat
|
||||||
App.serverSettings = {};
|
App.serverSettings = {};
|
||||||
if (data.banner) App.serverSettings.banner = data.banner;
|
if (data.banner) App.serverSettings.banner = data.banner;
|
||||||
|
|||||||
@@ -647,9 +647,11 @@ async function loadAdminStorage() {
|
|||||||
|
|
||||||
// ── Constants (Paste) ───────────────────────
|
// ── Constants (Paste) ───────────────────────
|
||||||
// Threshold for auto-attaching pasted text as a file.
|
// Threshold for auto-attaching pasted text as a file.
|
||||||
// TODO: sync from backend global_settings (storage_paste_to_file_chars)
|
// Synced from backend via PublicSettings (storage.paste_to_file_chars).
|
||||||
// once it's included in the PublicSettings boot payload.
|
// Falls back to 2000 if not yet loaded.
|
||||||
const PASTE_TO_FILE_CHARS = 2000;
|
function _pasteToFileChars() {
|
||||||
|
return App.pasteToFileChars || 2000;
|
||||||
|
}
|
||||||
|
|
||||||
// Accept string for file picker — mirrors backend allowedMIMETypes.
|
// Accept string for file picker — mirrors backend allowedMIMETypes.
|
||||||
// Backend is authoritative (rejects disallowed types server-side),
|
// Backend is authoritative (rejects disallowed types server-side),
|
||||||
@@ -761,9 +763,10 @@ function _initAttachmentListeners() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Large text detection — auto-attach as .txt file
|
// Large text detection — auto-attach as .txt file
|
||||||
if (PASTE_TO_FILE_CHARS > 0) {
|
const _ptfChars = _pasteToFileChars();
|
||||||
|
if (_ptfChars > 0) {
|
||||||
const text = e.clipboardData?.getData('text/plain');
|
const text = e.clipboardData?.getData('text/plain');
|
||||||
if (text && text.length > PASTE_TO_FILE_CHARS) {
|
if (text && text.length > _ptfChars) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const blob = new Blob([text], { type: 'text/plain' });
|
const blob = new Blob([text], { type: 'text/plain' });
|
||||||
const file = new File([blob], `${crypto.randomUUID()}.txt`, { type: 'text/plain' });
|
const file = new File([blob], `${crypto.randomUUID()}.txt`, { type: 'text/plain' });
|
||||||
|
|||||||
118
src/js/chat.js
118
src/js/chat.js
@@ -38,7 +38,7 @@ async function summarizeAndContinue() {
|
|||||||
siblingIndex: m.sibling_index || 0,
|
siblingIndex: m.sibling_index || 0,
|
||||||
}));
|
}));
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
updateContextWarning();
|
updateContextWarning(); updateChatTokenCount();
|
||||||
updateInputTokens();
|
updateInputTokens();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -96,6 +96,7 @@ async function loadChats() {
|
|||||||
async function selectChat(chatId) {
|
async function selectChat(chatId) {
|
||||||
clearStaged(); // Discard any staged attachments from previous chat
|
clearStaged(); // Discard any staged attachments from previous chat
|
||||||
App.currentChatId = chatId;
|
App.currentChatId = chatId;
|
||||||
|
try { sessionStorage.setItem('cs-active-chat', chatId); } catch (_) {}
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
document.getElementById('sidebar').classList.add('collapsed');
|
document.getElementById('sidebar').classList.add('collapsed');
|
||||||
@@ -137,11 +138,32 @@ async function selectChat(chatId) {
|
|||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||||
Tokens._warningDismissed = false;
|
Tokens._warningDismissed = false;
|
||||||
updateContextWarning();
|
updateContextWarning(); updateChatTokenCount();
|
||||||
updateInputTokens();
|
updateInputTokens();
|
||||||
|
|
||||||
// Refresh KB toggle state for the new channel
|
// Refresh KB toggle state for the new channel
|
||||||
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
if (typeof KnowledgeUI !== 'undefined') KnowledgeUI.onChatChanged();
|
||||||
|
updateChatTokenCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat Header Token Count ──────────────────
|
||||||
|
|
||||||
|
function updateChatTokenCount() {
|
||||||
|
const el = document.getElementById('chatTokenCount');
|
||||||
|
if (!el) return;
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (!chat || !chat.messages.length) { el.textContent = ''; return; }
|
||||||
|
|
||||||
|
const tokens = Tokens.estimateConversation(chat.messages, App.settings?.systemPrompt);
|
||||||
|
const budget = Tokens.getContextBudget();
|
||||||
|
if (budget.maxContext > 0) {
|
||||||
|
const pct = tokens / budget.maxContext;
|
||||||
|
el.textContent = `${Tokens.format(tokens)} / ${Tokens.format(budget.maxContext)}`;
|
||||||
|
el.className = 'chat-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
|
||||||
|
} else {
|
||||||
|
el.textContent = `~${Tokens.format(tokens)}`;
|
||||||
|
el.className = 'chat-token-count';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-Chat Model/Preset Persistence ────────
|
// ── Per-Chat Model/Preset Persistence ────────
|
||||||
@@ -242,7 +264,7 @@ async function newChat() {
|
|||||||
UI.showEmptyState();
|
UI.showEmptyState();
|
||||||
UI.showRegenerate(false);
|
UI.showRegenerate(false);
|
||||||
Tokens._warningDismissed = false;
|
Tokens._warningDismissed = false;
|
||||||
updateContextWarning();
|
updateContextWarning(); updateChatTokenCount();
|
||||||
updateInputTokens();
|
updateInputTokens();
|
||||||
document.getElementById('messageInput').focus();
|
document.getElementById('messageInput').focus();
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
@@ -269,6 +291,89 @@ async function deleteChat(chatId) {
|
|||||||
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Auto-Name Chat (utility model) ───────────
|
||||||
|
|
||||||
|
let _autoNamePending = new Set(); // debounce: one inflight per chat
|
||||||
|
|
||||||
|
async function autoNameChat(chatId, chat) {
|
||||||
|
if (!chatId || !chat) return;
|
||||||
|
if (_autoNamePending.has(chatId)) return;
|
||||||
|
|
||||||
|
// Only auto-name if title looks auto-generated (first 50 chars of user msg or "New Chat")
|
||||||
|
const firstUserMsg = chat.messages.find(m => m.role === 'user');
|
||||||
|
if (!firstUserMsg) return;
|
||||||
|
const truncated = firstUserMsg.content.slice(0, 50).trim();
|
||||||
|
const currentTitle = (chat.title || '').trim();
|
||||||
|
const isAutoGenerated = !currentTitle ||
|
||||||
|
currentTitle === 'New Chat' ||
|
||||||
|
currentTitle === truncated ||
|
||||||
|
truncated.startsWith(currentTitle);
|
||||||
|
if (!isAutoGenerated) return;
|
||||||
|
|
||||||
|
_autoNamePending.add(chatId);
|
||||||
|
try {
|
||||||
|
const resp = await API.generateTitle(chatId);
|
||||||
|
if (resp?.title && resp.title !== chat.title) {
|
||||||
|
chat.title = resp.title;
|
||||||
|
UI.renderChatList();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail — keep truncated title
|
||||||
|
console.debug('Auto-name failed:', e.message);
|
||||||
|
} finally {
|
||||||
|
_autoNamePending.delete(chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat Rename (inline edit) ────────────────
|
||||||
|
|
||||||
|
function startRenameChat(chatId) {
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
// Find the title span for this chat
|
||||||
|
const items = document.querySelectorAll('.chat-item');
|
||||||
|
let titleSpan = null;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.getAttribute('onclick')?.includes(chatId)) {
|
||||||
|
titleSpan = item.querySelector('.chat-item-title');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!titleSpan) return;
|
||||||
|
|
||||||
|
// Replace span with input
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'chat-rename-input';
|
||||||
|
input.value = chat.title || '';
|
||||||
|
input.onclick = (e) => e.stopPropagation();
|
||||||
|
input.ondblclick = (e) => e.stopPropagation();
|
||||||
|
|
||||||
|
const commit = async () => {
|
||||||
|
const newTitle = input.value.trim();
|
||||||
|
if (newTitle && newTitle !== chat.title) {
|
||||||
|
try {
|
||||||
|
await API.updateChannel(chatId, { title: newTitle });
|
||||||
|
chat.title = newTitle;
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Rename failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UI.renderChatList();
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('blur', commit);
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||||||
|
if (e.key === 'Escape') { input.value = chat.title || ''; input.blur(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
titleSpan.replaceWith(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Send Message ─────────────────────────────
|
// ── Send Message ─────────────────────────────
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
@@ -328,6 +433,11 @@ async function sendMessage() {
|
|||||||
|
|
||||||
// Persist the model/preset selection for this chat
|
// Persist the model/preset selection for this chat
|
||||||
_saveChatModel(App.currentChatId);
|
_saveChatModel(App.currentChatId);
|
||||||
|
|
||||||
|
// Auto-name: title the chat after first assistant response (v0.17.0)
|
||||||
|
if (chat.messages.length <= 3) {
|
||||||
|
autoNameChat(App.currentChatId, chat);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
UI.toast('Generation stopped', 'warning');
|
UI.toast('Generation stopped', 'warning');
|
||||||
@@ -381,7 +491,7 @@ async function reloadActivePath() {
|
|||||||
chat.messageCount = chat.messages.length;
|
chat.messageCount = chat.messages.length;
|
||||||
await loadChannelAttachments(App.currentChatId);
|
await loadChannelAttachments(App.currentChatId);
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
updateContextWarning();
|
updateContextWarning(); updateChatTokenCount();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to reload path:', e.message);
|
console.error('Failed to reload path:', e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
177
src/js/persona-kb.js
Normal file
177
src/js/persona-kb.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
// persona-kb.js — Persona–Knowledge Base binding UI (v0.17.0)
|
||||||
|
//
|
||||||
|
// Provides renderPersonaKBPicker(container, opts) which renders a multi-select
|
||||||
|
// KB picker with auto_search toggles for use in persona create/edit forms.
|
||||||
|
// Pattern: (container, options) → control object with getValues/setValues/clear.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Persona-KB Picker ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a KB picker into a container element.
|
||||||
|
*
|
||||||
|
* Options:
|
||||||
|
* scope - 'admin' | 'team' | 'personal' — controls which KBs are shown
|
||||||
|
* teamId - required for team scope
|
||||||
|
* prefix - DOM id prefix (default: 'pkb')
|
||||||
|
*
|
||||||
|
* Returns: { getValues(), setValues(kbs), clear(), refresh() }
|
||||||
|
*/
|
||||||
|
function renderPersonaKBPicker(containerEl, options = {}) {
|
||||||
|
const pfx = options.prefix || 'pkb';
|
||||||
|
let _allKBs = []; // available KBs from server
|
||||||
|
let _selected = {}; // kb_id → { selected: bool, auto_search: bool }
|
||||||
|
|
||||||
|
containerEl.innerHTML = `
|
||||||
|
<div class="persona-kb-picker" id="${pfx}_picker">
|
||||||
|
<label>Knowledge Bases</label>
|
||||||
|
<div class="form-hint">Bind KBs to this persona — users will search these KBs automatically when using this persona.</div>
|
||||||
|
<div class="persona-kb-list" id="${pfx}_list">
|
||||||
|
<div class="empty-hint">Loading knowledge bases…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── Load available KBs ──
|
||||||
|
async function loadKBs() {
|
||||||
|
try {
|
||||||
|
let resp;
|
||||||
|
if (options.scope === 'team' && options.teamId) {
|
||||||
|
resp = await API._get(`/api/v1/teams/${options.teamId}/knowledge-bases`);
|
||||||
|
} else if (options.scope === 'admin') {
|
||||||
|
resp = await API._get('/api/v1/knowledge-bases');
|
||||||
|
} else {
|
||||||
|
// User scope — show discoverable KBs
|
||||||
|
resp = await API._get('/api/v1/knowledge-bases-discoverable');
|
||||||
|
}
|
||||||
|
_allKBs = (resp.data || []).filter(kb => kb.chunk_count > 0);
|
||||||
|
} catch (e) {
|
||||||
|
_allKBs = [];
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render KB list ──
|
||||||
|
function render() {
|
||||||
|
const list = document.getElementById(`${pfx}_list`);
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (_allKBs.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-hint">No knowledge bases with content available.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = _allKBs.map(kb => {
|
||||||
|
const sel = _selected[kb.id] || { selected: false, auto_search: false };
|
||||||
|
return `
|
||||||
|
<label class="persona-kb-item ${sel.selected ? 'selected' : ''}">
|
||||||
|
<input type="checkbox" data-kb-id="${esc(kb.id)}" ${sel.selected ? 'checked' : ''}>
|
||||||
|
<span class="kb-name">${esc(kb.name)}</span>
|
||||||
|
<span class="kb-meta">${kb.document_count} docs · ${kb.chunk_count} chunks</span>
|
||||||
|
<label class="auto-search-toggle ${sel.selected ? '' : 'hidden'}" title="Auto-inject top results into context (no tool call needed)">
|
||||||
|
<input type="checkbox" data-kb-auto="${esc(kb.id)}" ${sel.auto_search ? 'checked' : ''}>
|
||||||
|
<span>Auto-inject</span>
|
||||||
|
</label>
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Wire checkboxes
|
||||||
|
list.querySelectorAll('input[data-kb-id]').forEach(cb => {
|
||||||
|
cb.addEventListener('change', function() {
|
||||||
|
const kbId = this.dataset.kbId;
|
||||||
|
if (!_selected[kbId]) _selected[kbId] = { selected: false, auto_search: false };
|
||||||
|
_selected[kbId].selected = this.checked;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
list.querySelectorAll('input[data-kb-auto]').forEach(cb => {
|
||||||
|
cb.addEventListener('change', function() {
|
||||||
|
const kbId = this.dataset.kbAuto;
|
||||||
|
if (_selected[kbId]) {
|
||||||
|
_selected[kbId].auto_search = this.checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Control object ──
|
||||||
|
const control = {
|
||||||
|
getValues() {
|
||||||
|
const kbIds = [];
|
||||||
|
const autoSearch = {};
|
||||||
|
for (const [kbId, state] of Object.entries(_selected)) {
|
||||||
|
if (state.selected) {
|
||||||
|
kbIds.push(kbId);
|
||||||
|
if (state.auto_search) {
|
||||||
|
autoSearch[kbId] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kb_ids: kbIds, auto_search: autoSearch };
|
||||||
|
},
|
||||||
|
|
||||||
|
setValues(personaKBs) {
|
||||||
|
_selected = {};
|
||||||
|
if (Array.isArray(personaKBs)) {
|
||||||
|
for (const pkb of personaKBs) {
|
||||||
|
_selected[pkb.kb_id] = {
|
||||||
|
selected: true,
|
||||||
|
auto_search: !!pkb.auto_search,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
_selected = {};
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
await loadKBs();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-load on create
|
||||||
|
loadKBs();
|
||||||
|
|
||||||
|
return control;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Persona-KB Save/Load Helpers ────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load persona KB bindings and populate the picker.
|
||||||
|
* @param {string} personaId
|
||||||
|
* @param {object} kbPicker - control object from renderPersonaKBPicker
|
||||||
|
* @param {string} apiPrefix - e.g. '/api/v1/admin' or '/api/v1/teams/:teamId'
|
||||||
|
*/
|
||||||
|
async function loadPersonaKBs(personaId, kbPicker, apiPrefix) {
|
||||||
|
if (!personaId || !kbPicker) return;
|
||||||
|
try {
|
||||||
|
const resp = await API._get(`${apiPrefix}/presets/${personaId}/knowledge-bases`);
|
||||||
|
kbPicker.setValues(resp.data || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load persona KBs:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save persona KB bindings from the picker.
|
||||||
|
* @param {string} personaId
|
||||||
|
* @param {object} kbPicker - control object from renderPersonaKBPicker
|
||||||
|
* @param {string} apiPrefix - e.g. '/api/v1/admin' or '/api/v1/teams/:teamId'
|
||||||
|
*/
|
||||||
|
async function savePersonaKBs(personaId, kbPicker, apiPrefix) {
|
||||||
|
if (!personaId || !kbPicker) return;
|
||||||
|
const values = kbPicker.getValues();
|
||||||
|
try {
|
||||||
|
await API._put(`${apiPrefix}/presets/${personaId}/knowledge-bases`, values);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to save persona KBs:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,6 +208,12 @@ async function handleSaveAdminSettings() {
|
|||||||
const defaultModel = document.getElementById('adminDefaultModel').value;
|
const defaultModel = document.getElementById('adminDefaultModel').value;
|
||||||
await API.adminUpdateSetting('default_model', { value: defaultModel });
|
await API.adminUpdateSetting('default_model', { value: defaultModel });
|
||||||
|
|
||||||
|
// KB direct access → kb_direct_access policy (v0.17.0)
|
||||||
|
const kbDirect = document.getElementById('adminKBDirectAccessToggle')?.checked;
|
||||||
|
if (kbDirect != null) {
|
||||||
|
await API.adminUpdateSetting('kb_direct_access', { value: kbDirect ? 'true' : 'false' });
|
||||||
|
}
|
||||||
|
|
||||||
// Banner → global_settings (JSON value)
|
// Banner → global_settings (JSON value)
|
||||||
const banner = {
|
const banner = {
|
||||||
enabled: document.getElementById('adminBannerEnabled').checked,
|
enabled: document.getElementById('adminBannerEnabled').checked,
|
||||||
@@ -532,6 +538,9 @@ function _initSettingsListeners() {
|
|||||||
prefix: 'teamPreset',
|
prefix: 'teamPreset',
|
||||||
showAvatar: true,
|
showAvatar: true,
|
||||||
showProviderConfig: false,
|
showProviderConfig: false,
|
||||||
|
showKBPicker: true,
|
||||||
|
kbScope: 'team',
|
||||||
|
kbTeamId: UI._managingTeamId,
|
||||||
onSubmit: async (vals) => {
|
onSubmit: async (vals) => {
|
||||||
const teamId = UI._managingTeamId;
|
const teamId = UI._managingTeamId;
|
||||||
if (!vals.name) return UI.toast('Name required', 'error');
|
if (!vals.name) return UI.toast('Name required', 'error');
|
||||||
@@ -543,6 +552,10 @@ function _initSettingsListeners() {
|
|||||||
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
||||||
catch (e) { console.warn('Team preset avatar upload failed:', e.message); }
|
catch (e) { console.warn('Team preset avatar upload failed:', e.message); }
|
||||||
}
|
}
|
||||||
|
if (presetId && vals._kbValues) {
|
||||||
|
try { await API.teamSetPresetKBs(teamId, presetId, vals._kbValues); }
|
||||||
|
catch (e) { console.warn('Team preset KB binding failed:', e.message); }
|
||||||
|
}
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
_teamPresetForm.clearForm();
|
_teamPresetForm.clearForm();
|
||||||
UI.toast('Team preset created');
|
UI.toast('Team preset created');
|
||||||
@@ -576,6 +589,8 @@ function _initSettingsListeners() {
|
|||||||
prefix: 'userPreset',
|
prefix: 'userPreset',
|
||||||
showAvatar: true,
|
showAvatar: true,
|
||||||
showProviderConfig: false,
|
showProviderConfig: false,
|
||||||
|
showKBPicker: true,
|
||||||
|
kbScope: 'personal',
|
||||||
onSubmit: async (vals) => {
|
onSubmit: async (vals) => {
|
||||||
if (!vals.name) return UI.toast('Name required', 'error');
|
if (!vals.name) return UI.toast('Name required', 'error');
|
||||||
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
|
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
|
||||||
@@ -586,6 +601,10 @@ function _initSettingsListeners() {
|
|||||||
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
|
||||||
catch (e) { console.warn('User preset avatar upload failed:', e.message); }
|
catch (e) { console.warn('User preset avatar upload failed:', e.message); }
|
||||||
}
|
}
|
||||||
|
if (presetId && vals._kbValues) {
|
||||||
|
try { await API.setUserPresetKBs(presetId, vals._kbValues); }
|
||||||
|
catch (e) { console.warn('User preset KB binding failed:', e.message); }
|
||||||
|
}
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
_userPresetForm.clearForm();
|
_userPresetForm.clearForm();
|
||||||
UI.toast('Preset created');
|
UI.toast('Preset created');
|
||||||
|
|||||||
@@ -900,6 +900,10 @@ Object.assign(UI, {
|
|||||||
// User presets / personas (policy: allow_user_personas)
|
// User presets / personas (policy: allow_user_personas)
|
||||||
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
|
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
|
||||||
|
|
||||||
|
// KB direct access (policy: kb_direct_access — v0.17.0)
|
||||||
|
const kbDirectEl = document.getElementById('adminKBDirectAccessToggle');
|
||||||
|
if (kbDirectEl) kbDirectEl.checked = policies.kb_direct_access === 'true';
|
||||||
|
|
||||||
// Default model (policy: default_model) — populate from current model list
|
// Default model (policy: default_model) — populate from current model list
|
||||||
const defModelSel = document.getElementById('adminDefaultModel');
|
const defModelSel = document.getElementById('adminDefaultModel');
|
||||||
if (defModelSel) {
|
if (defModelSel) {
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ const UI = {
|
|||||||
html += `
|
html += `
|
||||||
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
|
||||||
onclick="selectChat('${c.id}')">
|
onclick="selectChat('${c.id}')">
|
||||||
<span class="chat-item-title">${esc(c.title)}</span>
|
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${esc(c.title)}</span>
|
||||||
<span class="chat-item-time">${time}</span>
|
<span class="chat-item-time">${time}</span>
|
||||||
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -68,10 +68,11 @@ self.addEventListener('activate', (event) => {
|
|||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
// Never cache API calls, WebSocket upgrades, or branding assets
|
// Never cache API calls, WebSocket upgrades, branding, or extension assets
|
||||||
if (url.pathname.includes('/api/') ||
|
if (url.pathname.includes('/api/') ||
|
||||||
url.pathname.includes('/ws') ||
|
url.pathname.includes('/ws') ||
|
||||||
url.pathname.includes('/branding/') ||
|
url.pathname.includes('/branding/') ||
|
||||||
|
url.pathname.includes('/extensions/') ||
|
||||||
event.request.method !== 'GET') {
|
event.request.method !== 'GET') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user