Changeset 0.12.0 (#63)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# .gitea/workflows/ci.yaml
|
# .gitea/workflows/ci.yaml
|
||||||
# ============================================
|
# ============================================
|
||||||
# Chat Switchboard - CI/CD Pipeline (v0.9.4)
|
# Chat Switchboard - CI/CD Pipeline (v0.12.0)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Cluster deployments use SEPARATE FE + BE images.
|
# Cluster deployments use SEPARATE FE + BE images.
|
||||||
# Unified image is for Docker Hub only (docker-compose use).
|
# Unified image is for Docker Hub only (docker-compose use).
|
||||||
@@ -31,6 +31,9 @@
|
|||||||
# Required Gitea Variables:
|
# Required Gitea Variables:
|
||||||
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
|
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
|
||||||
# SEED_USERS (optional) — CSV: "user:pass:role,..." for dev/test seed accounts
|
# SEED_USERS (optional) — CSV: "user:pass:role,..." for dev/test seed accounts
|
||||||
|
# STORAGE_CLASS — StorageClass for PVC (e.g. "cephfs", required for file storage)
|
||||||
|
# STORAGE_SIZE — PVC capacity (e.g. "10Gi", default: "10Gi")
|
||||||
|
# STORAGE_BACKEND — "pvc" or "s3" (default: "pvc")
|
||||||
#
|
#
|
||||||
# Required Gitea Secrets:
|
# Required Gitea Secrets:
|
||||||
# POSTGRES_USER, POSTGRES_PASSWORD
|
# POSTGRES_USER, POSTGRES_PASSWORD
|
||||||
@@ -39,6 +42,7 @@
|
|||||||
# ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32)
|
# ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32)
|
||||||
# VENICE_API_KEY (live provider integration tests — optional, tests skip if missing)
|
# VENICE_API_KEY (live provider integration tests — optional, tests skip if missing)
|
||||||
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
|
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
|
||||||
|
# S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY (only when STORAGE_BACKEND=s3)
|
||||||
#
|
#
|
||||||
# Global Variables (Gitea org-level):
|
# Global Variables (Gitea org-level):
|
||||||
# CERT_ISSUER_PROD
|
# CERT_ISSUER_PROD
|
||||||
@@ -450,6 +454,26 @@ jobs:
|
|||||||
--from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
|
--from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
- name: Sync S3 secrets
|
||||||
|
if: vars.STORAGE_BACKEND == 's3'
|
||||||
|
env:
|
||||||
|
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||||
|
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||||
|
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
|
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||||
|
S3_REGION: ${{ vars.S3_REGION || 'us-east-1' }}
|
||||||
|
S3_PREFIX: ${{ vars.S3_PREFIX }}
|
||||||
|
run: |
|
||||||
|
kubectl create secret generic switchboard-s3 \
|
||||||
|
--namespace=${NAMESPACE} \
|
||||||
|
--from-literal=S3_ENDPOINT="${S3_ENDPOINT}" \
|
||||||
|
--from-literal=S3_BUCKET="${S3_BUCKET}" \
|
||||||
|
--from-literal=S3_ACCESS_KEY="${S3_ACCESS_KEY}" \
|
||||||
|
--from-literal=S3_SECRET_KEY="${S3_SECRET_KEY}" \
|
||||||
|
--from-literal=S3_REGION="${S3_REGION}" \
|
||||||
|
--from-literal=S3_PREFIX="${S3_PREFIX}" \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
- name: Render and apply manifests
|
- name: Render and apply manifests
|
||||||
env:
|
env:
|
||||||
ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }}
|
ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }}
|
||||||
@@ -468,12 +492,31 @@ jobs:
|
|||||||
BE_MEMORY_LIMIT: ${{ steps.env.outputs.BE_MEMORY_LIMIT }}
|
BE_MEMORY_LIMIT: ${{ steps.env.outputs.BE_MEMORY_LIMIT }}
|
||||||
BE_CPU_REQUEST: ${{ steps.env.outputs.BE_CPU_REQUEST }}
|
BE_CPU_REQUEST: ${{ steps.env.outputs.BE_CPU_REQUEST }}
|
||||||
BE_CPU_LIMIT: ${{ steps.env.outputs.BE_CPU_LIMIT }}
|
BE_CPU_LIMIT: ${{ steps.env.outputs.BE_CPU_LIMIT }}
|
||||||
|
# Storage (v0.12.0+)
|
||||||
|
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
|
||||||
|
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
|
||||||
|
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
|
||||||
|
EXTRACTION_CONCURRENCY: ${{ vars.EXTRACTION_CONCURRENCY || '3' }}
|
||||||
run: |
|
run: |
|
||||||
|
# Render PVC first (must exist before backend references it)
|
||||||
|
if [[ -n "${STORAGE_CLASS}" ]]; then
|
||||||
|
envsubst < k8s/storage-pvc.yaml > /tmp/storage-pvc.yaml
|
||||||
|
fi
|
||||||
|
|
||||||
envsubst < k8s/backend.yaml > /tmp/backend.yaml
|
envsubst < k8s/backend.yaml > /tmp/backend.yaml
|
||||||
envsubst < k8s/frontend.yaml > /tmp/frontend.yaml
|
envsubst < k8s/frontend.yaml > /tmp/frontend.yaml
|
||||||
envsubst < k8s/ingress.yaml > /tmp/ingress.yaml
|
envsubst < k8s/ingress.yaml > /tmp/ingress.yaml
|
||||||
|
|
||||||
echo "━━━ Applying manifests for ${DEPLOY_HOST} ━━━"
|
echo "━━━ Applying manifests for ${DEPLOY_HOST} ━━━"
|
||||||
|
|
||||||
|
# Apply PVC (idempotent — won't recreate if exists)
|
||||||
|
if [[ -n "${STORAGE_CLASS}" ]]; then
|
||||||
|
kubectl apply -f /tmp/storage-pvc.yaml
|
||||||
|
echo " ✓ Storage PVC (class: ${STORAGE_CLASS}, size: ${STORAGE_SIZE})"
|
||||||
|
else
|
||||||
|
echo " ⚠ STORAGE_CLASS not set — file storage disabled"
|
||||||
|
fi
|
||||||
|
|
||||||
kubectl apply -f /tmp/backend.yaml
|
kubectl apply -f /tmp/backend.yaml
|
||||||
kubectl apply -f /tmp/frontend.yaml
|
kubectl apply -f /tmp/frontend.yaml
|
||||||
kubectl apply -f /tmp/ingress.yaml
|
kubectl apply -f /tmp/ingress.yaml
|
||||||
@@ -536,6 +579,15 @@ jobs:
|
|||||||
echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Frontend** | \`${FE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Database** | \`${{ steps.env.outputs.DB_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| **DB Wipe** | ${{ steps.env.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| **DB Wipe** | ${{ steps.env.outputs.DB_WIPE }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [[ -n "${{ vars.STORAGE_CLASS }}" ]]; then
|
||||||
|
if [[ "${{ vars.STORAGE_BACKEND }}" == "s3" ]]; then
|
||||||
|
echo "| **Storage** | S3 (bucket: ${{ vars.S3_BUCKET }}, PVC scratch: ${{ vars.STORAGE_CLASS }}) |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "| **Storage** | PVC (class: ${{ vars.STORAGE_CLASS }}, size: ${{ vars.STORAGE_SIZE || '10Gi' }}) |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "| **Storage** | Disabled (STORAGE_CLASS not set) |" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then
|
if [[ "${{ steps.env.outputs.is_release }}" == "true" ]]; then
|
||||||
echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY
|
echo "| **Docker Hub** | \`${DOCKERHUB_IMAGE}:${{ steps.env.outputs.EXTRA_TAG }}\` (unified) |" >> $GITHUB_STEP_SUMMARY
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -234,13 +234,33 @@ Communication: `app.js` calls `API.*` methods, updates state, then calls `UI.*`
|
|||||||
|
|
||||||
- **Auth**: JWT access tokens (short-lived) + refresh tokens (DB-stored, revocable)
|
- **Auth**: JWT access tokens (short-lived) + refresh tokens (DB-stored, revocable)
|
||||||
- **Admin Bootstrap**: `SWITCHBOARD_ADMIN_USERNAME`/`PASSWORD` env vars create/update admin on every startup (K8s secret pattern)
|
- **Admin Bootstrap**: `SWITCHBOARD_ADMIN_USERNAME`/`PASSWORD` env vars create/update admin on every startup (K8s secret pattern)
|
||||||
- **API Key Storage**: Provider API keys stored in `api_key_enc` column (TODO: at-rest encryption)
|
- **API Key Storage**: Provider API keys encrypted with AES-256-GCM. Two-tier: org keys use `ENCRYPTION_KEY` env var, personal BYOK keys use per-user encryption keys (admins cannot recover)
|
||||||
- **Policies**: Boolean flags in `global_settings` table control registration, BYOK, team providers, etc.
|
- **Policies**: Boolean flags in `global_settings` table control registration, BYOK, team providers, etc.
|
||||||
- **Audit**: All admin operations logged to `audit_log` with actor, action, resource type/ID, and diff
|
- **Audit**: All admin operations logged to `audit_log` with actor, action, resource type/ID, and diff
|
||||||
- **Banner**: Environment classification banner configurable via admin settings (text, color, position)
|
- **Banner**: Environment classification banner configurable via admin settings (text, color, position)
|
||||||
- **CORS**: Configurable allowed origins via env var
|
- **CORS**: Configurable allowed origins via env var
|
||||||
- **No sensitive terminology**: Banner system avoids classification-related terms for security compliance
|
- **No sensitive terminology**: Banner system avoids classification-related terms for security compliance
|
||||||
|
|
||||||
|
## File Storage
|
||||||
|
|
||||||
|
Blob storage for attachments, extracted text, and (future) knowledge base documents.
|
||||||
|
|
||||||
|
**Interface**: `storage.ObjectStore` — `Put`, `Get`, `Delete`, `DeletePrefix`, `Exists`, `Healthy`, `Stats`, `Backend`. All handlers use the interface; backend is selected at startup.
|
||||||
|
|
||||||
|
**Backends**:
|
||||||
|
|
||||||
|
| Backend | Config | Use Case |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| **PVC** | `STORAGE_BACKEND=pvc` + `STORAGE_PATH` | Single-node, dev, docker-compose. Local filesystem with atomic writes (temp+rename). |
|
||||||
|
| **S3** | `STORAGE_BACKEND=s3` + `S3_ENDPOINT`, `S3_BUCKET`, credentials | Multi-node production. MinIO, Ceph RGW, AWS S3. Uses minio-go v7. |
|
||||||
|
| **Auto** | `STORAGE_BACKEND=` (empty) | Tries PVC at `STORAGE_PATH`; disables if not writable. |
|
||||||
|
|
||||||
|
**Key layout**: `attachments/{channel_id}/{attachment_id}_{filename}`. S3 prefix (`S3_PREFIX`) prepended for shared buckets.
|
||||||
|
|
||||||
|
**PVC always mounted**: Even with S3 backend, the PVC mount at `STORAGE_PATH` is needed for the extraction queue's local scratch directory (`processing/{id}/status.json`). With S3, the PVC can be small (1Gi) — only transient coordination state, not blobs.
|
||||||
|
|
||||||
|
**Admin panel**: Settings → Storage tab shows backend type, health status, file count, total size, endpoint/bucket (S3) or path (PVC), orphan detection and cleanup.
|
||||||
|
|
||||||
## Backward Compatibility
|
## Backward Compatibility
|
||||||
|
|
||||||
v0.9 maintains backward-compatible API routes:
|
v0.9 maintains backward-compatible API routes:
|
||||||
|
|||||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,6 +2,42 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.12.0] — 2026-02-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **File handling and vision support.** Upload images, PDFs, and documents
|
||||||
|
into chat via 📎 button, drag-and-drop, or paste. Multimodal message
|
||||||
|
assembly injects base64 images for vision-capable models and extracted
|
||||||
|
text for documents. Staged attachment strip shows upload progress and
|
||||||
|
extraction status. Auth-aware blob rendering with Bearer tokens. Image
|
||||||
|
lightbox viewer. Vision capability hints on image attachments.
|
||||||
|
- **Storage backend abstraction.** `ObjectStore` interface with two
|
||||||
|
implementations: PVC (local filesystem) for single-node/dev and S3
|
||||||
|
(minio-go) for multi-node production. S3 backend works with any
|
||||||
|
S3-compatible API: MinIO, Ceph RGW, AWS S3. Auto-detection when
|
||||||
|
`STORAGE_BACKEND` is not set (tries PVC, disables if not writable).
|
||||||
|
Both backends share identical semantics — all handlers, attachment
|
||||||
|
CRUD, multimodal assembly, and orphan cleanup work regardless of
|
||||||
|
backend.
|
||||||
|
- **S3 configuration.** `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`,
|
||||||
|
`S3_SECRET_KEY`, `S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` env
|
||||||
|
vars. Endpoint auto-detects SSL from scheme. Path-style URLs default
|
||||||
|
on (required for MinIO/Ceph). Optional key prefix for shared buckets.
|
||||||
|
CI pipeline syncs S3 secrets to K8s when `STORAGE_BACKEND=s3`.
|
||||||
|
- **Text extraction pipeline.** PDF, DOCX, XLSX, PPTX, ODT, RTF text
|
||||||
|
extraction via filesystem-based queue. Inline and sidecar modes.
|
||||||
|
Crash recovery for items stuck in processing state.
|
||||||
|
- **Admin storage panel.** Backend health, file count, total size,
|
||||||
|
orphan detection and cleanup. Shows PVC path or S3 endpoint/bucket
|
||||||
|
depending on active backend. Extraction queue status.
|
||||||
|
- **Vault CLI commands.** `switchboard vault rekey` re-encrypts all
|
||||||
|
provider API keys when rotating the encryption key.
|
||||||
|
`switchboard vault status` shows encryption health. Admin UI
|
||||||
|
encryption status indicator in Settings tab.
|
||||||
|
- **Per-chat model persistence.** Server-side `channels.settings`
|
||||||
|
JSONB field stores `last_selector_id`. Roams across devices with
|
||||||
|
localStorage write-through cache as fallback.
|
||||||
|
|
||||||
## [0.11.0] — 2026-02-25
|
## [0.11.0] — 2026-02-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
1304
DESIGN-0.12.0.md
Normal file
1304
DESIGN-0.12.0.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,15 @@ All configuration via environment variables. See `server/.env.example` for the f
|
|||||||
| `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password |
|
| `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password |
|
||||||
| `ENCRYPTION_KEY` | ` ` | AES key for API key encryption (required if encrypted keys exist) |
|
| `ENCRYPTION_KEY` | ` ` | AES key for API key encryption (required if encrypted keys exist) |
|
||||||
| `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` |
|
| `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` |
|
||||||
|
| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. Auto-detects PVC if not set. |
|
||||||
|
| `STORAGE_PATH` | `/data/storage` | PVC mount point (also scratch dir for S3 extraction) |
|
||||||
|
| `S3_ENDPOINT` | ` ` | S3 endpoint (e.g. `http://minio:9000`, required for S3) |
|
||||||
|
| `S3_BUCKET` | ` ` | S3 bucket name (must exist, required for S3) |
|
||||||
|
| `S3_ACCESS_KEY` | ` ` | S3 access key ID |
|
||||||
|
| `S3_SECRET_KEY` | ` ` | S3 secret access key |
|
||||||
|
| `S3_REGION` | `us-east-1` | S3 region |
|
||||||
|
| `S3_PREFIX` | ` ` | Optional key prefix within bucket |
|
||||||
|
| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs (required for MinIO, Ceph) |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
124
ROADMAP.md
124
ROADMAP.md
@@ -43,19 +43,23 @@ v0.14.0 Knowledge Bases (embedding role + file storage + pgvector)
|
|||||||
│
|
│
|
||||||
v0.15.0 Compaction (utility role + background job)
|
v0.15.0 Compaction (utility role + background job)
|
||||||
│
|
│
|
||||||
v0.16.0 @mention Routing + Multi-model
|
v0.15.1 Context Recall Tools (attachment_recall, conversation_search)
|
||||||
│
|
│
|
||||||
v0.17.0 Extension Surfaces + Modes
|
v0.16.0 Conversation Memory (cross-conversation user facts)
|
||||||
│
|
│
|
||||||
v0.18.0 Smart Routing (policy on model roles)
|
v0.17.0 @mention Routing + Multi-model
|
||||||
│
|
│
|
||||||
v0.19.0 Multi-Participant Channels + Presence
|
v0.18.0 Extension Surfaces + Modes
|
||||||
│
|
│
|
||||||
v0.20.0 Auth Strategy (mTLS/OIDC) + Full RBAC
|
v0.19.0 Smart Routing (policy on model roles)
|
||||||
|
│
|
||||||
|
v0.20.0 Multi-Participant Channels + Presence
|
||||||
|
│
|
||||||
|
v0.21.0 Auth Strategy (mTLS/OIDC) + Full RBAC
|
||||||
│
|
│
|
||||||
┌───────┴──────────────┐
|
┌───────┴──────────────┐
|
||||||
│ │
|
│ │
|
||||||
v0.21.0 Workflow v0.22.0 Tasks /
|
v0.22.0 Workflow v0.23.0 Tasks /
|
||||||
Engine Autonomous Agents
|
Engine Autonomous Agents
|
||||||
(team-owned, (service channels,
|
(team-owned, (service channels,
|
||||||
staged processes, scheduler, unattended
|
staged processes, scheduler, unattended
|
||||||
@@ -254,7 +258,7 @@ after backfill. If `ENCRYPTION_KEY` is not set, migration refuses to run
|
|||||||
derivation. No additional UX. On login, derive PDK → unwrap UEK → cache
|
derivation. No additional UX. On login, derive PDK → unwrap UEK → cache
|
||||||
in session. User never knows the vault exists.
|
in session. User never knows the vault exists.
|
||||||
|
|
||||||
**mTLS / OIDC** (v0.20.0) — authentication is external (cert/token).
|
**mTLS / OIDC** (v0.21.0) — authentication is external (cert/token).
|
||||||
Vault passphrase is conditionally required:
|
Vault passphrase is conditionally required:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -336,7 +340,7 @@ func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, err
|
|||||||
- [x] UEK eviction on logout (memory zeroing)
|
- [x] UEK eviction on logout (memory zeroing)
|
||||||
- [ ] UEK re-wrap on password change _(deferred — Settings handler)_
|
- [ ] UEK re-wrap on password change _(deferred — Settings handler)_
|
||||||
- [ ] UEK destruction on admin password reset with confirmation _(deferred)_
|
- [ ] UEK destruction on admin password reset with confirmation _(deferred)_
|
||||||
- [ ] Vault passphrase prompt for mTLS/OIDC _(deferred — v0.20.0 dependency)_
|
- [ ] Vault passphrase prompt for mTLS/OIDC _(deferred — v0.21.0 dependency)_
|
||||||
|
|
||||||
**Provider key lifecycle**
|
**Provider key lifecycle**
|
||||||
- [x] Encrypt on provider create/update (all 6 write paths: admin global, personal, team)
|
- [x] Encrypt on provider create/update (all 6 write paths: admin global, personal, team)
|
||||||
@@ -344,8 +348,9 @@ func (s *Store) DecryptAPIKey(config ApiConfig, uekCache *sync.Map) (string, err
|
|||||||
- [x] `ErrVaultLocked` handling (prompt user to unlock)
|
- [x] `ErrVaultLocked` handling (prompt user to unlock)
|
||||||
|
|
||||||
**Admin tooling**
|
**Admin tooling**
|
||||||
- [ ] `switchboard vault rekey` CLI command _(deferred)_
|
- [x] `switchboard vault rekey` CLI command _(v0.12.0)_
|
||||||
- [ ] Admin UI: encryption status indicator _(deferred)_
|
- [x] `switchboard vault status` CLI command _(v0.12.0)_
|
||||||
|
- [x] Admin UI: encryption status indicator _(v0.12.0 — Settings tab)_
|
||||||
- [x] Admin UI: password reset warning (v0.10.0 — vault destruction dialog)
|
- [x] Admin UI: password reset warning (v0.10.0 — vault destruction dialog)
|
||||||
|
|
||||||
**CI/CD**
|
**CI/CD**
|
||||||
@@ -579,17 +584,21 @@ The platform play. See [EXTENSIONS.md](EXTENSIONS.md) for full spec.
|
|||||||
File input into chat — table stakes for serious use.
|
File input into chat — table stakes for serious use.
|
||||||
|
|
||||||
**Storage Backend**
|
**Storage Backend**
|
||||||
- [ ] S3-compatible API (MinIO, Ceph RGW, AWS — anything with S3 API)
|
- [x] S3-compatible API (MinIO, Ceph RGW, AWS — anything with S3 API)
|
||||||
- [ ] Local PVC fallback (single-node / dev)
|
- [x] Local PVC backend (single-node / dev)
|
||||||
- [ ] Admin config: backend selection, endpoint, credentials, size limits
|
- [x] Admin config: backend health status, orphan cleanup panel
|
||||||
- [ ] Reused by KBs (v0.14.0) and compaction snapshots (v0.15.0)
|
- [x] Reused by KBs (v0.14.0) and compaction snapshots (v0.15.0)
|
||||||
|
|
||||||
**Chat Integration**
|
**Chat Integration**
|
||||||
- [ ] `attachments` table (metadata in PG, blobs in object store)
|
- [x] `attachments` table (metadata in PG, blobs in object store)
|
||||||
- [ ] Image/file upload in chat messages
|
- [x] Image/file upload in chat messages (📎 button, drag-and-drop)
|
||||||
- [ ] Multimodal message assembly for vision-capable models
|
- [x] Multimodal message assembly for vision-capable models
|
||||||
- [ ] Text extraction on upload (PDF, DOCX, TXT → tsvector)
|
- [x] Text extraction pipeline (PDF, DOCX, TXT — inline + sidecar modes)
|
||||||
- [ ] Paste-to-upload (clipboard image support)
|
- [x] Paste-to-upload (clipboard images + large text auto-attach)
|
||||||
|
- [x] Staged attachment strip with extraction status polling
|
||||||
|
- [x] Auth-aware rendering (blob URLs with Bearer token)
|
||||||
|
- [x] Image lightbox viewer
|
||||||
|
- [x] Vision capability hints on image attachments
|
||||||
- [ ] Document generation output storage (extension → attachment → download)
|
- [ ] Document generation output storage (extension → attachment → download)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -633,7 +642,56 @@ Depends on: utility model role (v0.10.0).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.16.0 — @mention Routing + Multi-model
|
## v0.15.1 — Context Recall Tools
|
||||||
|
|
||||||
|
Depends on: file handling (v0.12.0), tool framework (v0.11.0).
|
||||||
|
|
||||||
|
Attachments are injected into context once (at send time) and are not
|
||||||
|
replayed on subsequent turns — the model's own response acts as a natural
|
||||||
|
summary. After compaction (v0.15.0) or in long conversations, the model
|
||||||
|
may need to re-read source material. These tools bridge that gap.
|
||||||
|
|
||||||
|
**`attachment_recall` tool**
|
||||||
|
- [ ] `attachment_recall(attachment_id)` — re-reads attachment content from storage
|
||||||
|
- [ ] Images: re-inject as base64 content part (vision gating still applies)
|
||||||
|
- [ ] Documents: return extracted text
|
||||||
|
- [ ] Security: scoped to current channel's attachments only
|
||||||
|
- [ ] Extend `ExecutionContext` with optional storage backend reference
|
||||||
|
- [ ] Tool definition includes attachment list hint so model knows what's available
|
||||||
|
|
||||||
|
**`conversation_search` tool**
|
||||||
|
- [ ] Keyword search over compacted/archived messages in current channel
|
||||||
|
- [ ] Returns matching message excerpts with timestamps
|
||||||
|
- [ ] Useful post-compaction when summary dropped details the user asks about
|
||||||
|
|
||||||
|
**Token estimator improvements**
|
||||||
|
- [ ] Include attachment token estimates in context counter
|
||||||
|
- [ ] Images: use provider-specific sizing rules (e.g. tile-based for OpenAI)
|
||||||
|
- [ ] Documents: count extracted text tokens
|
||||||
|
- [ ] Staged attachments reflected in input token counter before send
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.16.0 — Conversation Memory
|
||||||
|
|
||||||
|
Depends on: compaction (v0.15.0), knowledge bases (v0.14.0).
|
||||||
|
|
||||||
|
Long-term memory across conversations. Unlike KB (static documents) or
|
||||||
|
compaction (within-conversation), this captures user-level facts and
|
||||||
|
preferences that persist across channels.
|
||||||
|
|
||||||
|
- [ ] `user_memory` table: `user_id`, `key`, `value`, `source_channel_id`, `confidence`, `created_at`, `updated_at`
|
||||||
|
- [ ] `memory_save` tool: LLM can explicitly store a fact ("user prefers Python", "project deadline is March 15")
|
||||||
|
- [ ] `memory_recall` tool: LLM queries stored facts relevant to current context
|
||||||
|
- [ ] Auto-extraction: background job identifies memorable facts from conversations (opt-in)
|
||||||
|
- [ ] Memory injection: relevant facts injected into system prompt at completion time
|
||||||
|
- [ ] User controls: view, edit, delete memories in Settings
|
||||||
|
- [ ] Admin controls: enable/disable, retention policies, per-team scoping
|
||||||
|
- [ ] Privacy: memories never cross team boundaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.17.0 — @mention Routing + Multi-model
|
||||||
|
|
||||||
The channel schema already supports multiple models. This adds routing.
|
The channel schema already supports multiple models. This adds routing.
|
||||||
|
|
||||||
@@ -645,7 +703,7 @@ The channel schema already supports multiple models. This adds routing.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.17.0 — Extension Surfaces + Modes
|
## v0.18.0 — Extension Surfaces + Modes
|
||||||
|
|
||||||
Depends on: extension foundation (v0.11.0).
|
Depends on: extension foundation (v0.11.0).
|
||||||
See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes).
|
See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes).
|
||||||
@@ -659,7 +717,7 @@ See [EXTENSIONS.md §6](EXTENSIONS.md#6-surfaces-modes).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.18.0 — Smart Routing
|
## v0.19.0 — Smart Routing
|
||||||
|
|
||||||
Depends on: model roles (v0.10.0), usage tracking (v0.10.0).
|
Depends on: model roles (v0.10.0), usage tracking (v0.10.0).
|
||||||
Rules-based routing engine — policy, not ML.
|
Rules-based routing engine — policy, not ML.
|
||||||
@@ -672,13 +730,13 @@ Rules-based routing engine — policy, not ML.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.19.0 — Multi-Participant Channels + Presence
|
## v0.20.0 — Multi-Participant Channels + Presence
|
||||||
|
|
||||||
The channel foundation for workflows and real-time collaboration. Today
|
The channel foundation for workflows and real-time collaboration. Today
|
||||||
channels are single-user direct chats. This release makes channels a
|
channels are single-user direct chats. This release makes channels a
|
||||||
shared space that multiple actors can inhabit.
|
shared space that multiple actors can inhabit.
|
||||||
|
|
||||||
Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exists).
|
Depends on: extension surfaces (v0.18.0), WebSocket infrastructure (already exists).
|
||||||
|
|
||||||
**Channel Participants**
|
**Channel Participants**
|
||||||
- [ ] `channel_participants` table: `channel_id`, `participant_type` (user, session, persona), `participant_id`, `role` (owner, member, observer), `joined_at`
|
- [ ] `channel_participants` table: `channel_id`, `participant_type` (user, session, persona), `participant_id`, `role` (owner, member, observer), `joined_at`
|
||||||
@@ -699,7 +757,7 @@ Depends on: extension surfaces (v0.17.0), WebSocket infrastructure (already exis
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.20.0 — Auth Strategy (mTLS/OIDC) + Full RBAC
|
## v0.21.0 — Auth Strategy (mTLS/OIDC) + Full RBAC
|
||||||
|
|
||||||
Enterprise auth modes on top of the teams foundation.
|
Enterprise auth modes on top of the teams foundation.
|
||||||
Depends on: vault passphrase support from v0.9.3 (conditional unlock for
|
Depends on: vault passphrase support from v0.9.3 (conditional unlock for
|
||||||
@@ -723,7 +781,7 @@ personal providers under external auth).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.21.0 — Workflow Engine
|
## v0.22.0 — Workflow Engine
|
||||||
|
|
||||||
Team-owned, stage-based process execution. The channel is the runtime,
|
Team-owned, stage-based process execution. The channel is the runtime,
|
||||||
personas drive each stage, and the existing tool/notes infrastructure
|
personas drive each stage, and the existing tool/notes infrastructure
|
||||||
@@ -731,7 +789,7 @@ handles structured data collection. See [ARCHITECTURE.md — Workflow
|
|||||||
Architecture](ARCHITECTURE.md#workflow-architecture-future--v0210) for
|
Architecture](ARCHITECTURE.md#workflow-architecture-future--v0210) for
|
||||||
the conceptual model.
|
the conceptual model.
|
||||||
|
|
||||||
Depends on: multi-participant channels (v0.19.0), anonymous identity (v0.20.0).
|
Depends on: multi-participant channels (v0.20.0), anonymous identity (v0.21.0).
|
||||||
|
|
||||||
**Workflow Definitions**
|
**Workflow Definitions**
|
||||||
- [ ] `workflows` table: `team_id` (owner), `name`, `description`, `is_active`, `entry_mode` (public_link, team_internal, api)
|
- [ ] `workflows` table: `team_id` (owner), `name`, `description`, `is_active`, `entry_mode` (public_link, team_internal, api)
|
||||||
@@ -771,10 +829,10 @@ Depends on: multi-participant channels (v0.19.0), anonymous identity (v0.20.0).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.22.0 — Tasks / Autonomous Agents
|
## v0.23.0 — Tasks / Autonomous Agents
|
||||||
|
|
||||||
Unattended execution — workflows without a human in the loop.
|
Unattended execution — workflows without a human in the loop.
|
||||||
Depends on: workflow engine (v0.21.0).
|
Depends on: workflow engine (v0.22.0).
|
||||||
|
|
||||||
- [ ] Scheduler + task runner (cron-like triggers for workflow instantiation)
|
- [ ] Scheduler + task runner (cron-like triggers for workflow instantiation)
|
||||||
- [ ] `task_create` tool (AI can spawn sub-workflows)
|
- [ ] `task_create` tool (AI can spawn sub-workflows)
|
||||||
@@ -812,13 +870,7 @@ based on need.
|
|||||||
- Backup/restore CronJob manifests
|
- Backup/restore CronJob manifests
|
||||||
|
|
||||||
**UX / Multi-Seat**
|
**UX / Multi-Seat**
|
||||||
- Per-chat model/preset persistence (server-side). Currently a localStorage
|
- ~~Per-chat model/preset persistence (server-side)~~ ✅ _(v0.12.0 — `channels.settings.last_selector_id` JSONB merge, localStorage write-through cache)_
|
||||||
bandaid (v0.10.2) that doesn't roam across devices. Proper fix: store
|
|
||||||
`last_selector_id` in `channels.settings` JSONB on each send. On chat
|
|
||||||
selection, resolve from `channel.settings.last_selector_id` → match against
|
|
||||||
available models/presets → fall back to `channel.model` → global default.
|
|
||||||
Eliminates device-local state entirely. Needs `updateChannel` PATCH on
|
|
||||||
completion success (single extra column merge, no migration).
|
|
||||||
|
|
||||||
**Platform**
|
**Platform**
|
||||||
- Rate limiting per user/team/tier (token budgets)
|
- Rate limiting per user/team/tier (token budgets)
|
||||||
|
|||||||
668
admin.go
Normal file
668
admin.go
Normal file
@@ -0,0 +1,668 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
vault *crypto.KeyResolver
|
||||||
|
uekCache *crypto.UEKCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache) *AdminHandler {
|
||||||
|
return &AdminHandler{stores: s, vault: vault, uekCache: uekCache}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Management ─────────────────────────
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||||
|
opts := store.DefaultListOptions()
|
||||||
|
|
||||||
|
// Accept both limit/offset and page/per_page conventions
|
||||||
|
if limit, _ := strconv.Atoi(c.Query("limit")); limit > 0 {
|
||||||
|
opts.Limit = limit
|
||||||
|
}
|
||||||
|
if offset, _ := strconv.Atoi(c.Query("offset")); offset > 0 {
|
||||||
|
opts.Offset = offset
|
||||||
|
}
|
||||||
|
if perPage, _ := strconv.Atoi(c.Query("per_page")); perPage > 0 {
|
||||||
|
opts.Limit = perPage
|
||||||
|
}
|
||||||
|
if page, _ := strconv.Atoi(c.Query("page")); page > 1 {
|
||||||
|
opts.Offset = (page - 1) * opts.Limit
|
||||||
|
}
|
||||||
|
|
||||||
|
users, total, err := h.stores.Users.List(c.Request.Context(), opts)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"users": users, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Email string `json:"email" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required,min=8"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
||||||
|
role := models.UserRoleUser
|
||||||
|
if req.Role == models.UserRoleAdmin {
|
||||||
|
role = models.UserRoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Username: strings.ToLower(req.Username),
|
||||||
|
Email: strings.ToLower(req.Email),
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Role: role,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(c, "user.create", "user", user.ID, nil)
|
||||||
|
c.JSON(http.StatusCreated, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var req struct {
|
||||||
|
Role string `json:"role" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"role": req.Role}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "role updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var req struct {
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Users.SetActive(c.Request.Context(), id, req.IsActive); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(c, "user.active_change", "user", id, gin.H{"is_active": req.IsActive})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user status updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ResetPassword(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password" binding:"required,min=8"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
||||||
|
if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"password_hash": string(hash)}); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy vault — old UEK is unrecoverable with new password.
|
||||||
|
// A fresh vault will be initialized on the user's next login.
|
||||||
|
h.destroyVault(c, id)
|
||||||
|
|
||||||
|
h.auditLog(c, "user.password_reset", "user", id, nil)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// destroyVault nullifies a user's encrypted UEK and deletes their personal
|
||||||
|
// provider keys. Called when an admin resets a user's password — the old
|
||||||
|
// UEK cannot be recovered without the old password.
|
||||||
|
func (h *AdminHandler) destroyVault(c *gin.Context, userID string) {
|
||||||
|
if h.uekCache == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Null out vault columns — user gets a fresh vault on next login
|
||||||
|
_, err := database.DB.Exec(`
|
||||||
|
UPDATE users
|
||||||
|
SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = false
|
||||||
|
WHERE id = $1
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠ Failed to clear vault for user %s: %v", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict from session cache (if user is currently logged in)
|
||||||
|
h.uekCache.Evict(userID)
|
||||||
|
|
||||||
|
// Delete personal provider configs — their keys are undecryptable now
|
||||||
|
result, err := database.DB.Exec(`
|
||||||
|
DELETE FROM provider_configs WHERE scope = 'personal' AND owner_id = $1
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠ Failed to delete personal providers for user %s: %v", userID, err)
|
||||||
|
} else if rows, _ := result.RowsAffected(); rows > 0 {
|
||||||
|
log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", rows, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{
|
||||||
|
"reason": "admin_password_reset",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.auditLog(c, "user.delete", "user", id, nil)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Global Settings ─────────────────────────
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListGlobalSettings(c *gin.Context) {
|
||||||
|
settings, err := h.stores.GlobalConfig.GetAll(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Also include policies
|
||||||
|
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
|
||||||
|
c.JSON(http.StatusOK, gin.H{"settings": settings, "policies": policies})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) GetGlobalSetting(c *gin.Context) {
|
||||||
|
key := c.Param("key")
|
||||||
|
val, err := h.stores.GlobalConfig.Get(c.Request.Context(), key)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"key": key, "value": val})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) {
|
||||||
|
key := c.Param("key")
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid := userID.(string)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this is a policy (string value) or a global config (JSON value)
|
||||||
|
var strVal string
|
||||||
|
if err := json.Unmarshal(req.Value, &strVal); err == nil {
|
||||||
|
// String value → try as policy first
|
||||||
|
if _, ok := models.PolicyDefaults[key]; ok {
|
||||||
|
if err := h.stores.Policies.Set(c.Request.Context(), key, strVal, uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.auditLog(c, "policy.update", "policy", key, gin.H{"value": strVal})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "policy updated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON value → global config
|
||||||
|
var jsonVal models.JSONMap
|
||||||
|
json.Unmarshal(req.Value, &jsonVal)
|
||||||
|
if err := h.stores.GlobalConfig.Set(c.Request.Context(), key, jsonVal, uid); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(c, "settings.update", "global_settings", key, nil)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "setting updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
||||||
|
// Banner config, branding, etc. — safe subset for non-admin users
|
||||||
|
banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner")
|
||||||
|
branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding")
|
||||||
|
systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
|
||||||
|
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
|
||||||
|
|
||||||
|
// Only tell the user whether admin prompt exists — don't expose content
|
||||||
|
hasAdminPrompt := false
|
||||||
|
if content, ok := systemPrompt["content"].(string); ok && content != "" {
|
||||||
|
hasAdminPrompt = true
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"banner": banner,
|
||||||
|
"branding": branding,
|
||||||
|
"has_admin_prompt": hasAdminPrompt,
|
||||||
|
"storage_configured": storageConfigured,
|
||||||
|
"policies": gin.H{
|
||||||
|
"allow_registration": policies["allow_registration"],
|
||||||
|
"allow_user_byok": policies["allow_user_byok"],
|
||||||
|
"allow_user_personas": policies["allow_user_personas"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vault Status ────────────────────────────
|
||||||
|
|
||||||
|
func (h *AdminHandler) VaultStatus(c *gin.Context) {
|
||||||
|
hasKey := h.vault != nil && h.vault.HasEnvKey()
|
||||||
|
keyStr := ""
|
||||||
|
if hasKey {
|
||||||
|
keyStr = "set" // non-empty signals to VaultStatus that key is configured
|
||||||
|
}
|
||||||
|
status, err := crypto.VaultStatus(database.DB, keyStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Provider Configs (Global) ───────────────
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
|
||||||
|
cfgs, err := h.stores.Providers.ListGlobal(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redact API keys but expose has_key flag for UI
|
||||||
|
type configWithKey struct {
|
||||||
|
models.ProviderConfig
|
||||||
|
HasKey bool `json:"has_key"`
|
||||||
|
}
|
||||||
|
out := make([]configWithKey, len(cfgs))
|
||||||
|
for i, cfg := range cfgs {
|
||||||
|
out[i] = configWithKey{
|
||||||
|
ProviderConfig: cfg,
|
||||||
|
HasKey: cfg.HasKey(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"configs": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
||||||
|
// Wrapper struct: models.ProviderConfig has APIKeyEnc tagged json:"-"
|
||||||
|
// so ShouldBindJSON would silently drop the api_key field.
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Provider string `json:"provider" binding:"required"`
|
||||||
|
Endpoint string `json:"endpoint" binding:"required"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
ModelDefault string `json:"model_default,omitempty"`
|
||||||
|
Config map[string]interface{} `json:"config,omitempty"`
|
||||||
|
Headers map[string]interface{} `json:"headers,omitempty"`
|
||||||
|
Settings map[string]interface{} `json:"settings,omitempty"`
|
||||||
|
IsPrivate bool `json:"is_private,omitempty"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := providers.Get(req.Provider); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "unsupported provider: " + req.Provider,
|
||||||
|
"supported_providers": providers.List(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &models.ProviderConfig{
|
||||||
|
Name: req.Name,
|
||||||
|
Provider: req.Provider,
|
||||||
|
Endpoint: req.Endpoint,
|
||||||
|
ModelDefault: req.ModelDefault,
|
||||||
|
Config: models.JSONMap(req.Config),
|
||||||
|
Headers: models.JSONMap(req.Headers),
|
||||||
|
Settings: models.JSONMap(req.Settings),
|
||||||
|
Scope: models.ScopeGlobal,
|
||||||
|
KeyScope: models.ScopeGlobal,
|
||||||
|
IsActive: true,
|
||||||
|
IsPrivate: req.IsPrivate,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt the API key
|
||||||
|
if req.APIKey != "" {
|
||||||
|
if h.vault != nil {
|
||||||
|
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.APIKeyEnc = enc
|
||||||
|
cfg.KeyNonce = nonce
|
||||||
|
} else {
|
||||||
|
// No vault configured — store raw bytes (unencrypted fallback)
|
||||||
|
cfg.APIKeyEnc = []byte(req.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider})
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"id": cfg.ID,
|
||||||
|
"scope": cfg.Scope,
|
||||||
|
"name": cfg.Name,
|
||||||
|
"provider": cfg.Provider,
|
||||||
|
"endpoint": cfg.Endpoint,
|
||||||
|
"model_default": cfg.ModelDefault,
|
||||||
|
"config": cfg.Config,
|
||||||
|
"headers": cfg.Headers,
|
||||||
|
"settings": cfg.Settings,
|
||||||
|
"is_active": cfg.IsActive,
|
||||||
|
"is_private": cfg.IsPrivate,
|
||||||
|
"has_key": cfg.HasKey(),
|
||||||
|
"created_at": cfg.CreatedAt,
|
||||||
|
"updated_at": cfg.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
// Wrapper struct: ProviderConfigPatch has APIKeyEnc tagged json:"-"
|
||||||
|
var req struct {
|
||||||
|
models.ProviderConfigPatch
|
||||||
|
APIKey *string `json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := req.ProviderConfigPatch
|
||||||
|
// Transfer api_key → encrypted fields
|
||||||
|
if req.APIKey != nil && *req.APIKey != "" {
|
||||||
|
if h.vault != nil {
|
||||||
|
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patch.APIKeyEnc = enc
|
||||||
|
patch.KeyNonce = nonce
|
||||||
|
} else {
|
||||||
|
patch.APIKeyEnc = []byte(*req.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(c, "provider.update", "provider_config", id, nil)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
// Delete associated catalog entries first
|
||||||
|
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
|
||||||
|
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.auditLog(c, "provider.delete", "provider_config", id, nil)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model Catalog ───────────────────────────
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
|
||||||
|
entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"models": entries})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) FetchModels(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
ProviderConfigID string `json:"provider_config_id"`
|
||||||
|
}
|
||||||
|
c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
// If no specific provider, fetch from ALL global providers
|
||||||
|
if req.ProviderConfigID == "" {
|
||||||
|
configs, err := h.stores.Providers.ListGlobal(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list providers"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(configs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no providers configured — add one first"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalAdded, totalUpdated, totalFetched := 0, 0, 0
|
||||||
|
var errs []string
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if !cfg.IsActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
added, updated, fetched, err := h.fetchModelsForProvider(c, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, cfg.Provider+": "+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalAdded += added
|
||||||
|
totalUpdated += updated
|
||||||
|
totalFetched += fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gin.H{
|
||||||
|
"message": "models synced",
|
||||||
|
"added": totalAdded,
|
||||||
|
"updated": totalUpdated,
|
||||||
|
"total": totalFetched,
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
result["errors"] = errs
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single provider fetch
|
||||||
|
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "provider config not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
added, updated, fetched, err := h.fetchModelsForProvider(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "models synced",
|
||||||
|
"added": added,
|
||||||
|
"updated": updated,
|
||||||
|
"total": fetched,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchModelsForProvider fetches and syncs models for a single provider config.
|
||||||
|
func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) {
|
||||||
|
// Decrypt the API key for the provider API call
|
||||||
|
apiKey := ""
|
||||||
|
if len(cfg.APIKeyEnc) > 0 {
|
||||||
|
if h.vault != nil {
|
||||||
|
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No vault — key stored as raw bytes (unencrypted fallback)
|
||||||
|
apiKey = string(cfg.APIKeyEnc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(c, "models.fetch", "provider_config", cfg.ID, gin.H{
|
||||||
|
"added": result.Added, "updated": result.Updated, "total": result.Total,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.Added, result.Updated, result.Total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var req struct {
|
||||||
|
Visibility *string `json:"visibility"`
|
||||||
|
DisplayName *string `json:"display_name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Visibility != nil {
|
||||||
|
if err := h.stores.Catalog.SetVisibility(c.Request.Context(), id, *req.Visibility); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "model updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) BulkUpdateModels(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
ProviderConfigID string `json:"provider_config_id"`
|
||||||
|
Visibility string `json:"visibility" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if req.ProviderConfigID != "" {
|
||||||
|
err = h.stores.Catalog.BulkSetVisibility(c.Request.Context(), req.ProviderConfigID, req.Visibility)
|
||||||
|
} else {
|
||||||
|
err = h.stores.Catalog.BulkSetVisibilityAll(c.Request.Context(), req.Visibility)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "bulk update complete"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := h.stores.Catalog.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "model deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats ───────────────────────────────────
|
||||||
|
|
||||||
|
func (h *AdminHandler) GetStats(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
stats := gin.H{}
|
||||||
|
|
||||||
|
var userCount, channelCount, messageCount int
|
||||||
|
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount)
|
||||||
|
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&channelCount)
|
||||||
|
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&messageCount)
|
||||||
|
|
||||||
|
stats["users"] = userCount
|
||||||
|
stats["channels"] = channelCount
|
||||||
|
stats["messages"] = messageCount
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────
|
||||||
|
// NOTE: ListAuditLog and ListAuditActions are in audit.go
|
||||||
|
|
||||||
|
func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID string, metadata interface{}) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := userID.(string)
|
||||||
|
|
||||||
|
var meta models.JSONMap
|
||||||
|
if metadata != nil {
|
||||||
|
b, _ := json.Marshal(metadata)
|
||||||
|
json.Unmarshal(b, &meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &models.AuditEntry{
|
||||||
|
ActorID: &uid,
|
||||||
|
Action: action,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
Metadata: meta,
|
||||||
|
IPAddress: c.ClientIP(),
|
||||||
|
UserAgent: c.GetHeader("User-Agent"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Audit.Log(context.Background(), entry); err != nil {
|
||||||
|
log.Printf("audit log error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
636
api.js
Normal file
636
api.js
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – API Client
|
||||||
|
// ==========================================
|
||||||
|
// Backend-only mode. Handles auth tokens and
|
||||||
|
// all HTTP calls. No offline fallback.
|
||||||
|
//
|
||||||
|
// BASE_PATH: injected via index.html <script>
|
||||||
|
// at container startup. All API paths are
|
||||||
|
// prefixed automatically.
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const BASE = window.__BASE__ || '';
|
||||||
|
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
|
||||||
|
|
||||||
|
const API = {
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
user: null,
|
||||||
|
_refreshing: null,
|
||||||
|
|
||||||
|
// ── Bootstrap ────────────────────────────
|
||||||
|
|
||||||
|
loadTokens() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
|
||||||
|
if (saved) {
|
||||||
|
this.accessToken = saved.accessToken || null;
|
||||||
|
this.refreshToken = saved.refreshToken || null;
|
||||||
|
this.user = saved.user || null;
|
||||||
|
// Unknown age — refresh soon to get a fresh token
|
||||||
|
if (this.refreshToken) this._scheduleRefresh(60);
|
||||||
|
}
|
||||||
|
} catch (e) { /* corrupt storage */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
saveTokens() {
|
||||||
|
localStorage.setItem(_storageKey, JSON.stringify({
|
||||||
|
accessToken: this.accessToken,
|
||||||
|
refreshToken: this.refreshToken,
|
||||||
|
user: this.user
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTokens() {
|
||||||
|
this.accessToken = null;
|
||||||
|
this.refreshToken = null;
|
||||||
|
this.user = null;
|
||||||
|
localStorage.removeItem(_storageKey);
|
||||||
|
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
|
||||||
|
},
|
||||||
|
|
||||||
|
get isAuthed() { return !!this.accessToken; },
|
||||||
|
get isAdmin() { return this.user?.role === 'admin'; },
|
||||||
|
|
||||||
|
// ── Auth ─────────────────────────────────
|
||||||
|
|
||||||
|
async health() {
|
||||||
|
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
|
||||||
|
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
|
||||||
|
return this._parseJSON(resp, '/api/v1/health');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Detect proxy interception: HTTP 200 but HTML instead of JSON
|
||||||
|
async _parseJSON(resp, context) {
|
||||||
|
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (ct.includes('text/html')) {
|
||||||
|
// Proxy returned an HTML page (block page, auth wall, etc.)
|
||||||
|
let title = 'unknown';
|
||||||
|
try {
|
||||||
|
const html = await resp.clone().text();
|
||||||
|
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||||
|
if (m) title = m[1].trim();
|
||||||
|
} catch (e) { /* best effort */ }
|
||||||
|
const err = new Error(`Proxy interception detected on ${context}: "${title}"`);
|
||||||
|
err.proxyBlocked = true;
|
||||||
|
err.proxyTitle = title;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(login, password) {
|
||||||
|
const data = await this._post('/api/v1/auth/login', { login, password }, true);
|
||||||
|
this._setAuth(data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(username, email, password) {
|
||||||
|
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
|
||||||
|
if (!data.pending) this._setAuth(data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
try { await this._post('/api/v1/auth/logout', { refresh_token: this.refreshToken }, true); }
|
||||||
|
catch (e) { /* best effort */ }
|
||||||
|
this.clearTokens();
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
if (this._refreshing) return this._refreshing;
|
||||||
|
this._refreshing = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
|
||||||
|
this._setAuth(data);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
this.clearTokens();
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this._refreshing = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return this._refreshing;
|
||||||
|
},
|
||||||
|
|
||||||
|
_setAuth(data) {
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.refreshToken = data.refresh_token;
|
||||||
|
this.user = data.user;
|
||||||
|
this.saveTokens();
|
||||||
|
this._scheduleRefresh(data.expires_in || 900);
|
||||||
|
},
|
||||||
|
|
||||||
|
_refreshTimer: null,
|
||||||
|
|
||||||
|
_scheduleRefresh(expiresIn) {
|
||||||
|
if (this._refreshTimer) clearTimeout(this._refreshTimer);
|
||||||
|
// Refresh at 80% of expiry (e.g., 12min for 15min token)
|
||||||
|
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
|
||||||
|
this._refreshTimer = setTimeout(async () => {
|
||||||
|
if (!this.refreshToken) return;
|
||||||
|
console.debug('🔄 Proactive token refresh');
|
||||||
|
await this.refresh();
|
||||||
|
}, ms);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Channels ──────────────────────────────
|
||||||
|
|
||||||
|
listChannels(page = 1, perPage = 100, type = '') {
|
||||||
|
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
|
||||||
|
if (type) url += `&type=${type}`;
|
||||||
|
return this._get(url);
|
||||||
|
},
|
||||||
|
createChannel(title, model, systemPrompt, type = 'direct') {
|
||||||
|
const body = { title, type };
|
||||||
|
if (model) body.model = model;
|
||||||
|
if (systemPrompt) body.system_prompt = systemPrompt;
|
||||||
|
return this._post('/api/v1/channels', body);
|
||||||
|
},
|
||||||
|
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
|
||||||
|
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
||||||
|
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||||||
|
|
||||||
|
// ── Messages ─────────────────────────────
|
||||||
|
|
||||||
|
listMessages(channelId, page = 1, perPage = 200) {
|
||||||
|
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Message Tree (forking) ───────────────
|
||||||
|
|
||||||
|
getActivePath(channelId) {
|
||||||
|
return this._get(`/api/v1/channels/${channelId}/path`);
|
||||||
|
},
|
||||||
|
|
||||||
|
editMessage(channelId, messageId, content) {
|
||||||
|
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
|
||||||
|
},
|
||||||
|
|
||||||
|
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId) {
|
||||||
|
const body = {};
|
||||||
|
if (model) body.model = model;
|
||||||
|
if (presetId) body.preset_id = presetId;
|
||||||
|
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||||
|
|
||||||
|
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this._authHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401 && this.refreshToken) {
|
||||||
|
if (await this.refresh()) {
|
||||||
|
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this._authHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (ct.includes('text/html')) {
|
||||||
|
const err = new Error('Proxy blocked the regeneration request');
|
||||||
|
err.proxyBlocked = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (ct.includes('text/html')) {
|
||||||
|
const err = new Error('Proxy intercepted the regeneration stream');
|
||||||
|
err.proxyBlocked = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCursor(channelId, activeLeafId) {
|
||||||
|
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
|
||||||
|
},
|
||||||
|
|
||||||
|
listSiblings(channelId, messageId) {
|
||||||
|
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Summarize & Continue ────────────────
|
||||||
|
summarizeChannel(channelId) {
|
||||||
|
return this._post(`/api/v1/channels/${channelId}/summarize`, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Completions ──────────────────────────
|
||||||
|
|
||||||
|
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds) {
|
||||||
|
const body = { channel_id: channelId, content, stream: true };
|
||||||
|
if (presetId) {
|
||||||
|
body.preset_id = presetId;
|
||||||
|
// Preset resolves the model on backend; still pass model for channel metadata
|
||||||
|
if (model) body.model = model;
|
||||||
|
} else {
|
||||||
|
if (model) body.model = model;
|
||||||
|
}
|
||||||
|
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||||
|
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||||
|
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||||
|
if (attachmentIds?.length) body.attachment_ids = attachmentIds;
|
||||||
|
|
||||||
|
let resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this._authHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.status === 401 && this.refreshToken) {
|
||||||
|
if (await this.refresh()) {
|
||||||
|
resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this._authHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (ct.includes('text/html')) {
|
||||||
|
const err = new Error('Proxy blocked the completion request');
|
||||||
|
err.proxyBlocked = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
// Also check 200 OK but HTML (proxy returning block page with 200)
|
||||||
|
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (ct.includes('text/html')) {
|
||||||
|
const err = new Error('Proxy intercepted the completion stream');
|
||||||
|
err.proxyBlocked = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Models ───────────────────────────────
|
||||||
|
|
||||||
|
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||||||
|
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
|
||||||
|
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); },
|
||||||
|
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); },
|
||||||
|
|
||||||
|
// User presets
|
||||||
|
listUserPresets() { return this._get('/api/v1/presets'); },
|
||||||
|
createUserPreset(preset) { return this._post('/api/v1/presets', preset); },
|
||||||
|
updateUserPreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
|
||||||
|
deleteUserPreset(id) { return this._del(`/api/v1/presets/${id}`); },
|
||||||
|
listAllModels() { return this._get('/api/v1/models'); },
|
||||||
|
|
||||||
|
// ── API Configs (user providers) ─────────
|
||||||
|
|
||||||
|
listConfigs() { return this._get('/api/v1/api-configs'); },
|
||||||
|
getConfig(id) { return this._get(`/api/v1/api-configs/${id}`); },
|
||||||
|
createConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||||
|
return this._post('/api/v1/api-configs', {
|
||||||
|
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
|
||||||
|
updateConfig(id, patch) { return this._put(`/api/v1/api-configs/${id}`, patch); },
|
||||||
|
listProviderModels(id) { return this._get(`/api/v1/api-configs/${id}/models`); },
|
||||||
|
fetchProviderModels(id) { return this._post(`/api/v1/api-configs/${id}/models/fetch`); },
|
||||||
|
|
||||||
|
// ── Profile & Settings ───────────────────
|
||||||
|
|
||||||
|
async getProfile() {
|
||||||
|
const data = await this._get('/api/v1/profile');
|
||||||
|
// Sync user object with profile data (including avatar)
|
||||||
|
if (data && this.user) {
|
||||||
|
this.user.avatar = data.avatar || null;
|
||||||
|
this.user.display_name = data.display_name;
|
||||||
|
this.saveTokens();
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
|
||||||
|
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
|
||||||
|
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
|
||||||
|
changePassword(current, newPw) {
|
||||||
|
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
|
||||||
|
},
|
||||||
|
getSettings() { return this._get('/api/v1/settings'); },
|
||||||
|
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
|
||||||
|
|
||||||
|
// ── Admin ────────────────────────────────
|
||||||
|
|
||||||
|
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
|
||||||
|
adminCreateUser(username, email, password, role) {
|
||||||
|
return this._post('/api/v1/admin/users', { username, email, password, role });
|
||||||
|
},
|
||||||
|
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
|
||||||
|
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
|
||||||
|
adminToggleActive(id, active, teamIds, teamRole) {
|
||||||
|
const body = { is_active: active };
|
||||||
|
if (teamIds?.length) body.team_ids = teamIds;
|
||||||
|
if (teamRole) body.team_role = teamRole;
|
||||||
|
return this._put(`/api/v1/admin/users/${id}/active`, body);
|
||||||
|
},
|
||||||
|
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
|
||||||
|
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
|
||||||
|
getPublicSettings() { return this._get('/api/v1/settings/public'); },
|
||||||
|
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
|
||||||
|
adminGetStats() { return this._get('/api/v1/admin/stats'); },
|
||||||
|
|
||||||
|
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
|
||||||
|
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault, isPrivate) {
|
||||||
|
return this._post('/api/v1/admin/configs', {
|
||||||
|
name, provider, endpoint, api_key: apiKey, model_default: modelDefault, is_private: !!isPrivate
|
||||||
|
});
|
||||||
|
},
|
||||||
|
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
|
||||||
|
adminUpdateGlobalConfig(id, updates) { return this._put(`/api/v1/admin/configs/${id}`, updates); },
|
||||||
|
|
||||||
|
adminListModels() { return this._get('/api/v1/admin/models'); },
|
||||||
|
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
|
||||||
|
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
|
||||||
|
adminBulkUpdateModels(visibility) {
|
||||||
|
// Send both for backward compat: old BE reads is_enabled, new BE reads visibility
|
||||||
|
return this._put('/api/v1/admin/models/bulk', {
|
||||||
|
visibility,
|
||||||
|
is_enabled: visibility === 'enabled'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
|
||||||
|
|
||||||
|
// ── Admin Presets ────────────────────────
|
||||||
|
adminListPresets() { return this._get('/api/v1/admin/presets'); },
|
||||||
|
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
|
||||||
|
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
|
||||||
|
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
|
||||||
|
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`); },
|
||||||
|
|
||||||
|
// ── Admin Teams ─────────────────────────
|
||||||
|
adminListTeams() { return this._get('/api/v1/admin/teams'); },
|
||||||
|
|
||||||
|
// ── Admin Audit ─────────────────────────
|
||||||
|
adminListAudit(params) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.page) q.set('page', params.page);
|
||||||
|
if (params?.per_page) q.set('per_page', params.per_page);
|
||||||
|
if (params?.action) q.set('action', params.action);
|
||||||
|
if (params?.actor_id) q.set('actor_id', params.actor_id);
|
||||||
|
if (params?.resource_type) q.set('resource_type', params.resource_type);
|
||||||
|
return this._get(`/api/v1/admin/audit?${q}`);
|
||||||
|
},
|
||||||
|
adminListAuditActions() { return this._get('/api/v1/admin/audit/actions'); },
|
||||||
|
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
|
||||||
|
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
|
||||||
|
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
|
||||||
|
adminDeleteTeam(id) { return this._del(`/api/v1/admin/teams/${id}`); },
|
||||||
|
adminListMembers(teamId) { return this._get(`/api/v1/admin/teams/${teamId}/members`); },
|
||||||
|
adminAddMember(teamId, userId, role) { return this._post(`/api/v1/admin/teams/${teamId}/members`, { user_id: userId, role }); },
|
||||||
|
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
|
||||||
|
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
|
||||||
|
|
||||||
|
// ── User Teams ──────────────────────────
|
||||||
|
listMyTeams() { return this._get('/api/v1/teams/mine'); },
|
||||||
|
|
||||||
|
// ── Team Admin Self-Service ─────────────
|
||||||
|
teamListMembers(teamId) { return this._get(`/api/v1/teams/${teamId}/members`); },
|
||||||
|
teamAddMember(teamId, userId, role) { return this._post(`/api/v1/teams/${teamId}/members`, { user_id: userId, role }); },
|
||||||
|
teamUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/teams/${teamId}/members/${memberId}`, { role }); },
|
||||||
|
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
|
||||||
|
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
|
||||||
|
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
|
||||||
|
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
||||||
|
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
||||||
|
|
||||||
|
// ── Team Providers ──────────────────────
|
||||||
|
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
|
||||||
|
teamCreateProvider(teamId, provider) { return this._post(`/api/v1/teams/${teamId}/providers`, provider); },
|
||||||
|
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
|
||||||
|
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
|
||||||
|
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
|
||||||
|
teamListAudit(teamId, params) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.page) q.set('page', params.page);
|
||||||
|
if (params?.per_page) q.set('per_page', params.per_page);
|
||||||
|
if (params?.action) q.set('action', params.action);
|
||||||
|
if (params?.actor_id) q.set('actor_id', params.actor_id);
|
||||||
|
if (params?.resource_type) q.set('resource_type', params.resource_type);
|
||||||
|
return this._get(`/api/v1/teams/${teamId}/audit?${q}`);
|
||||||
|
},
|
||||||
|
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
|
||||||
|
|
||||||
|
// ── User Presets ─────────────────────────
|
||||||
|
listPresets() { return this._get('/api/v1/presets'); },
|
||||||
|
createPreset(preset) { return this._post('/api/v1/presets', preset); },
|
||||||
|
updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
|
||||||
|
deletePreset(id) { return this._del(`/api/v1/presets/${id}`); },
|
||||||
|
|
||||||
|
// ── Notes ────────────────────────────────
|
||||||
|
listNotes(limit = 50, offset = 0, folder, tag, sort) {
|
||||||
|
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
|
||||||
|
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
|
||||||
|
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
|
||||||
|
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
|
||||||
|
return this._get(url);
|
||||||
|
},
|
||||||
|
createNote(title, content, folderPath, tags) {
|
||||||
|
const body = { title, content };
|
||||||
|
if (folderPath) body.folder_path = folderPath;
|
||||||
|
if (tags?.length) body.tags = tags;
|
||||||
|
return this._post('/api/v1/notes', body);
|
||||||
|
},
|
||||||
|
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
|
||||||
|
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
|
||||||
|
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
|
||||||
|
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
|
||||||
|
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
|
||||||
|
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
|
||||||
|
|
||||||
|
// ── Attachments ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to a channel. Uses multipart/form-data (not JSON).
|
||||||
|
* Returns the created attachment object with id, metadata, etc.
|
||||||
|
*/
|
||||||
|
async uploadAttachment(channelId, file) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file, file.name);
|
||||||
|
|
||||||
|
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/attachments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||||
|
// No Content-Type — browser sets multipart boundary automatically
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = await doFetch();
|
||||||
|
if (resp.status === 401 && this.refreshToken) {
|
||||||
|
if (await this.refresh()) resp = await doFetch();
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
|
||||||
|
err.status = resp.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return this._parseJSON(resp, `/api/v1/channels/${channelId}/attachments`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAttachment(id) { return this._get(`/api/v1/attachments/${id}`); },
|
||||||
|
|
||||||
|
async downloadAttachmentBlob(id) {
|
||||||
|
const doFetch = () => fetch(BASE + `/api/v1/attachments/${id}/download`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = await doFetch();
|
||||||
|
if (resp.status === 401 && this.refreshToken) {
|
||||||
|
if (await this.refresh()) resp = await doFetch();
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.blob();
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAttachment(id) { return this._del(`/api/v1/attachments/${id}`); },
|
||||||
|
listChannelAttachments(channelId) { return this._get(`/api/v1/channels/${channelId}/attachments`); },
|
||||||
|
|
||||||
|
// ── Admin Storage ───────────────────────
|
||||||
|
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
|
||||||
|
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
|
||||||
|
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
|
||||||
|
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
|
||||||
|
|
||||||
|
// Vault
|
||||||
|
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
|
||||||
|
|
||||||
|
// ── Admin Roles ─────────────────────────
|
||||||
|
adminListRoles() { return this._get('/api/v1/admin/roles'); },
|
||||||
|
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
|
||||||
|
adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
|
||||||
|
adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
|
||||||
|
|
||||||
|
// ── Admin Usage & Pricing ───────────────
|
||||||
|
adminGetUsage(params) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.period) q.set('period', params.period);
|
||||||
|
if (params?.since) q.set('since', params.since);
|
||||||
|
if (params?.until) q.set('until', params.until);
|
||||||
|
if (params?.group_by) q.set('group_by', params.group_by);
|
||||||
|
return this._get(`/api/v1/admin/usage?${q}`);
|
||||||
|
},
|
||||||
|
adminGetUserUsage(userId, params) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.period) q.set('period', params.period);
|
||||||
|
if (params?.group_by) q.set('group_by', params.group_by);
|
||||||
|
return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
|
||||||
|
},
|
||||||
|
adminGetTeamUsage(teamId, params) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.period) q.set('period', params.period);
|
||||||
|
if (params?.group_by) q.set('group_by', params.group_by);
|
||||||
|
return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
|
||||||
|
},
|
||||||
|
adminListPricing() { return this._get('/api/v1/admin/pricing'); },
|
||||||
|
adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
|
||||||
|
adminDeletePricing(providerConfigId, modelId) {
|
||||||
|
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── User Usage ──────────────────────────
|
||||||
|
getMyUsage(params) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.period) q.set('period', params.period);
|
||||||
|
if (params?.group_by) q.set('group_by', params.group_by);
|
||||||
|
return this._get(`/api/v1/usage?${q}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Team Roles ──────────────────────────
|
||||||
|
teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
|
||||||
|
teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
|
||||||
|
teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
|
||||||
|
|
||||||
|
// ── Team Usage ──────────────────────────
|
||||||
|
teamGetUsage(teamId, params) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.period) q.set('period', params.period);
|
||||||
|
if (params?.group_by) q.set('group_by', params.group_by);
|
||||||
|
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── HTTP Internals ───────────────────────
|
||||||
|
|
||||||
|
_esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
},
|
||||||
|
|
||||||
|
_authHeaders() {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async _get(path) { return this._authed(path); },
|
||||||
|
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
|
||||||
|
async _put(path, body) { return this._authed(path, 'PUT', body); },
|
||||||
|
async _del(path) { return this._authed(path, 'DELETE'); },
|
||||||
|
|
||||||
|
async _authed(path, method = 'GET', body) {
|
||||||
|
try {
|
||||||
|
return await this._raw(path, method, body, true);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401 && this.refreshToken) {
|
||||||
|
if (await this.refresh()) {
|
||||||
|
return this._raw(path, method, body, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _raw(path, method = 'GET', body, auth = false) {
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||||
|
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
const resp = await fetch(BASE + path, opts);
|
||||||
|
if (!resp.ok) {
|
||||||
|
// Check if the error response is also a proxy page
|
||||||
|
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (ct.includes('text/html')) {
|
||||||
|
let title = 'unknown';
|
||||||
|
try {
|
||||||
|
const html = await resp.text();
|
||||||
|
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||||
|
if (m) title = m[1].trim();
|
||||||
|
} catch (e) { /* */ }
|
||||||
|
const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
|
||||||
|
err.status = resp.status;
|
||||||
|
err.proxyBlocked = true;
|
||||||
|
err.proxyTitle = title;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||||||
|
err.status = resp.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return this._parseJSON(resp, path);
|
||||||
|
}
|
||||||
|
};
|
||||||
458
channels.go
Normal file
458
channels.go
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Request / Response types ────────────────
|
||||||
|
|
||||||
|
type createChannelRequest struct {
|
||||||
|
Title string `json:"title" binding:"required,max=500"`
|
||||||
|
Type string `json:"type,omitempty"` // direct (default), group, channel
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||||
|
APIConfigID *string `json:"provider_config_id,omitempty"`
|
||||||
|
Folder string `json:"folder,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateChannelRequest struct {
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
Model *string `json:"model,omitempty"`
|
||||||
|
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||||
|
APIConfigID *string `json:"provider_config_id,omitempty"`
|
||||||
|
IsArchived *bool `json:"is_archived,omitempty"`
|
||||||
|
IsPinned *bool `json:"is_pinned,omitempty"`
|
||||||
|
Folder *string `json:"folder,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Model *string `json:"model"`
|
||||||
|
APIConfigID *string `json:"provider_config_id"`
|
||||||
|
SystemPrompt *string `json:"system_prompt"`
|
||||||
|
IsArchived bool `json:"is_archived"`
|
||||||
|
IsPinned bool `json:"is_pinned"`
|
||||||
|
Folder *string `json:"folder"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Settings json.RawMessage `json:"settings,omitempty"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type paginatedResponse struct {
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PerPage int `json:"per_page"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelHandler holds dependencies for channel endpoints.
|
||||||
|
type ChannelHandler struct{}
|
||||||
|
|
||||||
|
// NewChannelHandler creates a new channel handler.
|
||||||
|
func NewChannelHandler() *ChannelHandler {
|
||||||
|
return &ChannelHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelDeleteHook is called after a channel is successfully deleted.
|
||||||
|
// Set at startup by SetChannelDeleteHook to clean up storage files.
|
||||||
|
var channelDeleteHook func(channelID string)
|
||||||
|
|
||||||
|
// SetChannelDeleteHook registers a callback invoked after channel deletion.
|
||||||
|
// Used to clean up attachment files on the storage backend.
|
||||||
|
func SetChannelDeleteHook(fn func(channelID string)) {
|
||||||
|
channelDeleteHook = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────
|
||||||
|
|
||||||
|
// getUserID extracts the authenticated user's ID from context.
|
||||||
|
func getUserID(c *gin.Context) string {
|
||||||
|
uid, _ := c.Get("user_id")
|
||||||
|
s, _ := uid.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePagination reads page and per_page from query params with defaults.
|
||||||
|
func parsePagination(c *gin.Context) (page, perPage, offset int) {
|
||||||
|
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
perPage, _ = strconv.Atoi(c.DefaultQuery("per_page", "50"))
|
||||||
|
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 {
|
||||||
|
perPage = 50
|
||||||
|
}
|
||||||
|
if perPage > 100 {
|
||||||
|
perPage = 100
|
||||||
|
}
|
||||||
|
offset = (page - 1) * perPage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List Channels ───────────────────────────
|
||||||
|
|
||||||
|
func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
page, perPage, offset := parsePagination(c)
|
||||||
|
|
||||||
|
// Optional filters
|
||||||
|
archived := c.DefaultQuery("archived", "false")
|
||||||
|
folder := c.Query("folder")
|
||||||
|
channelType := c.DefaultQuery("type", "") // empty = all types
|
||||||
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
countQuery := `SELECT COUNT(*) FROM channels WHERE user_id = $1 AND is_archived = $2`
|
||||||
|
countArgs := []interface{}{userID, archived == "true"}
|
||||||
|
argN := 3
|
||||||
|
|
||||||
|
if channelType != "" {
|
||||||
|
countQuery += ` AND type = $` + strconv.Itoa(argN)
|
||||||
|
countArgs = append(countArgs, channelType)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if folder != "" {
|
||||||
|
countQuery += ` AND folder = $` + strconv.Itoa(argN)
|
||||||
|
countArgs = append(countArgs, folder)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if search != "" {
|
||||||
|
countQuery += ` AND title ILIKE $` + strconv.Itoa(argN)
|
||||||
|
countArgs = append(countArgs, "%"+search+"%")
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count channels"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch channels with message count
|
||||||
|
query := `
|
||||||
|
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||||
|
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
|
||||||
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
|
c.created_at, c.updated_at
|
||||||
|
FROM channels c
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
|
||||||
|
) mc ON mc.channel_id = c.id
|
||||||
|
WHERE c.user_id = $1 AND c.is_archived = $2`
|
||||||
|
|
||||||
|
args := []interface{}{userID, archived == "true"}
|
||||||
|
argN = 3
|
||||||
|
|
||||||
|
if channelType != "" {
|
||||||
|
query += ` AND c.type = $` + strconv.Itoa(argN)
|
||||||
|
args = append(args, channelType)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if folder != "" {
|
||||||
|
query += ` AND c.folder = $` + strconv.Itoa(argN)
|
||||||
|
args = append(args, folder)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if search != "" {
|
||||||
|
query += ` AND c.title ILIKE $` + strconv.Itoa(argN)
|
||||||
|
args = append(args, "%"+search+"%")
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY c.is_pinned DESC, c.updated_at DESC LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
|
||||||
|
args = append(args, perPage, offset)
|
||||||
|
|
||||||
|
rows, err := database.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
channels := make([]channelResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var ch channelResponse
|
||||||
|
var tags []string
|
||||||
|
err := rows.Scan(
|
||||||
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||||
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||||
|
pq.Array(&tags), &ch.Settings,
|
||||||
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan channel"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tags == nil {
|
||||||
|
tags = []string{}
|
||||||
|
}
|
||||||
|
ch.Tags = tags
|
||||||
|
channels = append(channels, ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, paginatedResponse{
|
||||||
|
Data: channels,
|
||||||
|
Page: page,
|
||||||
|
PerPage: perPage,
|
||||||
|
Total: total,
|
||||||
|
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create Channel ──────────────────────────
|
||||||
|
|
||||||
|
func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
|
||||||
|
var req createChannelRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Tags == nil {
|
||||||
|
req.Tags = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default type is "direct" (1:1 AI chat)
|
||||||
|
channelType := req.Type
|
||||||
|
if channelType == "" {
|
||||||
|
channelType = "direct"
|
||||||
|
}
|
||||||
|
|
||||||
|
var ch channelResponse
|
||||||
|
var tags []string
|
||||||
|
err := database.DB.QueryRow(`
|
||||||
|
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||||
|
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
|
||||||
|
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||||
|
req.Folder, pq.Array(req.Tags),
|
||||||
|
).Scan(
|
||||||
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||||
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||||
|
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tags == nil {
|
||||||
|
tags = []string{}
|
||||||
|
}
|
||||||
|
ch.Tags = tags
|
||||||
|
ch.MessageCount = 0
|
||||||
|
|
||||||
|
// Auto-create channel_member for the creator
|
||||||
|
_, _ = database.DB.Exec(`
|
||||||
|
INSERT INTO channel_members (channel_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'owner')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, ch.ID, userID)
|
||||||
|
|
||||||
|
// Auto-create channel_model if model specified
|
||||||
|
if req.Model != "" {
|
||||||
|
_, _ = database.DB.Exec(`
|
||||||
|
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
|
||||||
|
VALUES ($1, $2, $3, true)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, ch.ID, req.Model, req.APIConfigID)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get Channel ─────────────────────────────
|
||||||
|
|
||||||
|
func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
channelID := c.Param("id")
|
||||||
|
|
||||||
|
var ch channelResponse
|
||||||
|
var tags []string
|
||||||
|
err := database.DB.QueryRow(`
|
||||||
|
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||||
|
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
|
||||||
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
|
c.created_at, c.updated_at
|
||||||
|
FROM channels c
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT channel_id, COUNT(*) AS cnt FROM messages GROUP BY channel_id
|
||||||
|
) mc ON mc.channel_id = c.id
|
||||||
|
WHERE c.id = $1 AND c.user_id = $2
|
||||||
|
`, channelID, userID).Scan(
|
||||||
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||||
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||||
|
pq.Array(&tags), &ch.Settings,
|
||||||
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get channel"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tags == nil {
|
||||||
|
tags = []string{}
|
||||||
|
}
|
||||||
|
ch.Tags = tags
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update Channel ──────────────────────────
|
||||||
|
|
||||||
|
func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
channelID := c.Param("id")
|
||||||
|
|
||||||
|
if database.DB == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateChannelRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
var ownerID string
|
||||||
|
err := database.DB.QueryRow(`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ownerID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "not your channel"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic UPDATE
|
||||||
|
setClauses := []string{}
|
||||||
|
args := []interface{}{}
|
||||||
|
argN := 1
|
||||||
|
|
||||||
|
addClause := func(col string, val interface{}) {
|
||||||
|
setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN))
|
||||||
|
args = append(args, val)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Title != nil {
|
||||||
|
addClause("title", *req.Title)
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
addClause("description", *req.Description)
|
||||||
|
}
|
||||||
|
if req.Model != nil {
|
||||||
|
addClause("model", *req.Model)
|
||||||
|
}
|
||||||
|
if req.SystemPrompt != nil {
|
||||||
|
addClause("system_prompt", *req.SystemPrompt)
|
||||||
|
}
|
||||||
|
if req.APIConfigID != nil {
|
||||||
|
addClause("provider_config_id", *req.APIConfigID)
|
||||||
|
}
|
||||||
|
if req.IsArchived != nil {
|
||||||
|
addClause("is_archived", *req.IsArchived)
|
||||||
|
}
|
||||||
|
if req.IsPinned != nil {
|
||||||
|
addClause("is_pinned", *req.IsPinned)
|
||||||
|
}
|
||||||
|
if req.Folder != nil {
|
||||||
|
addClause("folder", *req.Folder)
|
||||||
|
}
|
||||||
|
if req.Tags != nil {
|
||||||
|
addClause("tags", pq.Array(req.Tags))
|
||||||
|
}
|
||||||
|
if req.Settings != nil {
|
||||||
|
// JSONB merge: new settings keys overwrite existing, unmentioned keys preserved
|
||||||
|
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
|
||||||
|
args = append(args, []byte(*req.Settings))
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(setClauses) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "UPDATE channels SET "
|
||||||
|
for i, clause := range setClauses {
|
||||||
|
if i > 0 {
|
||||||
|
query += ", "
|
||||||
|
}
|
||||||
|
query += clause
|
||||||
|
}
|
||||||
|
query += " WHERE id = $" + strconv.Itoa(argN) + " AND user_id = $" + strconv.Itoa(argN+1)
|
||||||
|
args = append(args, channelID, userID)
|
||||||
|
|
||||||
|
_, err = database.DB.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated channel
|
||||||
|
h.GetChannel(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete Channel ──────────────────────────
|
||||||
|
|
||||||
|
func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
channelID := c.Param("id")
|
||||||
|
|
||||||
|
result, err := database.DB.Exec(
|
||||||
|
`DELETE FROM channels WHERE id = $1 AND user_id = $2`,
|
||||||
|
channelID, userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up storage files (CASCADE already removed PG attachment rows)
|
||||||
|
if channelDeleteHook != nil {
|
||||||
|
go channelDeleteHook(channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
|
||||||
|
}
|
||||||
632
chat.js
Normal file
632
chat.js
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – Chat Operations
|
||||||
|
// ==========================================
|
||||||
|
// Chat management, send, stream, regenerate, edit, branch,
|
||||||
|
// summarize, per-chat model persistence.
|
||||||
|
|
||||||
|
// ── Summarize & Continue ──────────────────
|
||||||
|
|
||||||
|
async function summarizeAndContinue() {
|
||||||
|
const channelId = App.currentChatId;
|
||||||
|
if (!channelId) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('summarizeBtn');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Summarizing…';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await API.summarizeChannel(channelId);
|
||||||
|
UI.toast(`Conversation summarized (${result.summarized_count} messages)`, 'success');
|
||||||
|
|
||||||
|
// Reload the active path to get the updated message tree
|
||||||
|
const resp = await API.getActivePath(channelId);
|
||||||
|
const chat = App.chats.find(c => c.id === channelId);
|
||||||
|
if (chat && resp) {
|
||||||
|
chat.messages = (resp.path || []).map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
parent_id: m.parent_id || null,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
model: m.model || '',
|
||||||
|
modelName: '',
|
||||||
|
timestamp: m.created_at,
|
||||||
|
tool_calls: m.tool_calls || null,
|
||||||
|
metadata: m.metadata || null,
|
||||||
|
siblingCount: m.sibling_count || 1,
|
||||||
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
}));
|
||||||
|
UI.renderMessages(chat.messages);
|
||||||
|
updateContextWarning();
|
||||||
|
updateInputTokens();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Summarize failed:', err);
|
||||||
|
UI.toast(err.message || 'Summarization failed', 'error');
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '📝 Summarize & Continue';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat Management ──────────────────────────
|
||||||
|
|
||||||
|
async function loadChats() {
|
||||||
|
try {
|
||||||
|
const resp = await API.listChannels(1, 100, 'direct');
|
||||||
|
App.chats = (resp.data || []).map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
title: c.title,
|
||||||
|
type: c.type || 'direct',
|
||||||
|
model: c.model || '',
|
||||||
|
messageCount: c.message_count || 0,
|
||||||
|
messages: [],
|
||||||
|
updatedAt: c.updated_at,
|
||||||
|
settings: c.settings || {},
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load chats:', e.message);
|
||||||
|
UI.toast('Failed to load chats', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectChat(chatId) {
|
||||||
|
clearStaged(); // Discard any staged attachments from previous chat
|
||||||
|
App.currentChatId = chatId;
|
||||||
|
UI.renderChatList();
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
document.getElementById('sidebar').classList.add('collapsed');
|
||||||
|
const ov = document.getElementById('sidebarOverlay');
|
||||||
|
if (ov) ov.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
if (chat.messages.length === 0 && chat.messageCount > 0) {
|
||||||
|
try {
|
||||||
|
const resp = await API.getActivePath(chatId);
|
||||||
|
chat.messages = (resp.path || []).map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
parent_id: m.parent_id || null,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
model: m.model || '',
|
||||||
|
modelName: '',
|
||||||
|
timestamp: m.created_at,
|
||||||
|
tool_calls: m.tool_calls || null,
|
||||||
|
metadata: m.metadata || null,
|
||||||
|
siblingCount: m.sibling_count || 1,
|
||||||
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load messages:', e.message);
|
||||||
|
UI.toast('Failed to load messages', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the last-used model/preset for this chat
|
||||||
|
_restoreChatModel(chatId, chat);
|
||||||
|
|
||||||
|
// Load attachments for message rendering (non-blocking, best-effort)
|
||||||
|
await loadChannelAttachments(chatId);
|
||||||
|
|
||||||
|
UI.renderMessages(chat.messages);
|
||||||
|
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||||
|
Tokens._warningDismissed = false;
|
||||||
|
updateContextWarning();
|
||||||
|
updateInputTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-Chat Model/Preset Persistence ────────
|
||||||
|
// Server-side: saved in channel.settings.last_selector_id (JSONB merge).
|
||||||
|
// localStorage used as write-through cache for instant restore without
|
||||||
|
// waiting for the channel list API call.
|
||||||
|
|
||||||
|
function _saveChatModel(chatId) {
|
||||||
|
if (!chatId) return;
|
||||||
|
const selectorId = UI.getModelValue();
|
||||||
|
if (!selectorId) return;
|
||||||
|
|
||||||
|
// Write-through: localStorage for instant restore on next visit
|
||||||
|
try {
|
||||||
|
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||||
|
map[chatId] = selectorId;
|
||||||
|
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||||
|
} catch (e) { /* quota exceeded, etc */ }
|
||||||
|
|
||||||
|
// Update in-memory chat object
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (chat) {
|
||||||
|
if (!chat.settings) chat.settings = {};
|
||||||
|
chat.settings.last_selector_id = selectorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to server (fire-and-forget — non-critical)
|
||||||
|
API.updateChannel(chatId, { settings: { last_selector_id: selectorId } })
|
||||||
|
.catch(e => console.debug('Failed to persist model selection:', e.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _restoreChatModel(chatId, chat) {
|
||||||
|
// Priority: server settings → localStorage fallback → channel base model → keep current
|
||||||
|
let targetId = null;
|
||||||
|
|
||||||
|
// 1. Server-side settings (most authoritative, roams across devices)
|
||||||
|
if (chat?.settings?.last_selector_id) {
|
||||||
|
targetId = chat.settings.last_selector_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. localStorage fallback (covers race where settings haven't loaded yet)
|
||||||
|
if (!targetId) {
|
||||||
|
try {
|
||||||
|
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||||
|
if (map[chatId]) targetId = map[chatId];
|
||||||
|
} catch (e) { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify the stored ID still exists in available models
|
||||||
|
if (targetId) {
|
||||||
|
const found = App.models.find(m => m.id === targetId);
|
||||||
|
if (found) {
|
||||||
|
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Stored model removed — clean up stale entries
|
||||||
|
_cleanStaleChatModel(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fall back to channel's base model ID (match by baseModelId)
|
||||||
|
if (chat?.model) {
|
||||||
|
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
|
||||||
|
if (match) {
|
||||||
|
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Stored model gone — fall back to admin default → first visible
|
||||||
|
const visible = App.models.filter(m => !m.hidden);
|
||||||
|
const def = App.defaultModel
|
||||||
|
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
|
||||||
|
: null;
|
||||||
|
const fallback = def || visible[0];
|
||||||
|
if (fallback) {
|
||||||
|
UI.setModelValue(fallback.id, fallback.name + (fallback.provider ? ` (${fallback.provider})` : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cleanStaleChatModel(chatId) {
|
||||||
|
// Remove from localStorage
|
||||||
|
try {
|
||||||
|
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||||
|
delete map[chatId];
|
||||||
|
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||||
|
} catch (e) { /* */ }
|
||||||
|
// Clear from in-memory settings (server cleanup is fire-and-forget)
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (chat?.settings?.last_selector_id) {
|
||||||
|
delete chat.settings.last_selector_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newChat() {
|
||||||
|
clearStaged(); // Discard any staged attachments
|
||||||
|
App.currentChatId = null;
|
||||||
|
UI.renderChatList();
|
||||||
|
UI.showEmptyState();
|
||||||
|
UI.showRegenerate(false);
|
||||||
|
Tokens._warningDismissed = false;
|
||||||
|
updateContextWarning();
|
||||||
|
updateInputTokens();
|
||||||
|
document.getElementById('messageInput').focus();
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
document.getElementById('sidebar').classList.add('collapsed');
|
||||||
|
const ov = document.getElementById('sidebarOverlay');
|
||||||
|
if (ov) ov.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChat(chatId) {
|
||||||
|
if (!await showConfirm('Delete this chat?')) return;
|
||||||
|
try {
|
||||||
|
await API.deleteChannel(chatId);
|
||||||
|
App.chats = App.chats.filter(c => c.id !== chatId);
|
||||||
|
// Clean up stored model preference
|
||||||
|
try {
|
||||||
|
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||||
|
delete map[chatId];
|
||||||
|
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||||
|
} catch (e) { /* */ }
|
||||||
|
if (App.currentChatId === chatId) { clearPreview(); newChat(); }
|
||||||
|
UI.renderChatList();
|
||||||
|
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send Message ─────────────────────────────
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const input = document.getElementById('messageInput');
|
||||||
|
const text = input.value.trim();
|
||||||
|
const hasAttachments = hasStagedAttachments();
|
||||||
|
|
||||||
|
// Need text or attachments, not generating, not blocked by uploads
|
||||||
|
if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return;
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
input.style.height = 'auto';
|
||||||
|
|
||||||
|
// Snapshot attachment IDs before clearing staged state
|
||||||
|
const attachmentIds = getStagedAttachmentIds();
|
||||||
|
|
||||||
|
const selectedId = UI.getModelValue();
|
||||||
|
const modelInfo = App.findModel(selectedId);
|
||||||
|
// For presets, send the base model ID; for regular models, send the model ID
|
||||||
|
const model = modelInfo?.baseModelId || selectedId;
|
||||||
|
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||||
|
|
||||||
|
if (!App.currentChatId) {
|
||||||
|
try {
|
||||||
|
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
|
||||||
|
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||||
|
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
|
||||||
|
App.chats.unshift(chat);
|
||||||
|
App.currentChatId = chat.id;
|
||||||
|
UI.renderChatList();
|
||||||
|
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
// Optimistic: show user message immediately
|
||||||
|
const userContent = text || (hasAttachments ? `[${attachmentIds.length} file${attachmentIds.length > 1 ? 's' : ''} attached]` : '');
|
||||||
|
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
|
||||||
|
UI.renderMessages(chat.messages);
|
||||||
|
|
||||||
|
// Clear staged attachments (they're now part of this message)
|
||||||
|
if (hasAttachments) consumeStaged();
|
||||||
|
|
||||||
|
App.isGenerating = true;
|
||||||
|
App.abortController = new AbortController();
|
||||||
|
UI.setGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds);
|
||||||
|
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||||
|
|
||||||
|
// Reload active path from server to get proper IDs and sibling metadata
|
||||||
|
await reloadActivePath();
|
||||||
|
UI.showRegenerate(true);
|
||||||
|
|
||||||
|
// Persist the model/preset selection for this chat
|
||||||
|
_saveChatModel(App.currentChatId);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
UI.toast('Generation stopped', 'warning');
|
||||||
|
await reloadActivePath();
|
||||||
|
} else {
|
||||||
|
console.error('Completion error:', e);
|
||||||
|
const msg = e.message || '';
|
||||||
|
if (e.proxyBlocked) {
|
||||||
|
UI.toast('Network proxy blocked this request — contact your network admin', 'error');
|
||||||
|
} else if (msg.includes('provider error') && msg.includes('401')) {
|
||||||
|
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
|
||||||
|
} else if (msg.includes('provider error') && msg.includes('429')) {
|
||||||
|
UI.toast('Provider rate limit — wait and retry', 'warning');
|
||||||
|
} else if (msg.includes('no API config') || msg.includes('no model')) {
|
||||||
|
UI.toast('No provider configured — add one in Settings', 'error');
|
||||||
|
} else {
|
||||||
|
UI.toast(msg, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
App.isGenerating = false;
|
||||||
|
App.abortController = null;
|
||||||
|
UI.setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
|
||||||
|
|
||||||
|
// ── Reload Active Path from Server ──────────
|
||||||
|
|
||||||
|
async function reloadActivePath() {
|
||||||
|
if (!App.currentChatId) return;
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.getActivePath(App.currentChatId);
|
||||||
|
chat.messages = (resp.path || []).map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
parent_id: m.parent_id || null,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
model: m.model || '',
|
||||||
|
modelName: '',
|
||||||
|
timestamp: m.created_at,
|
||||||
|
tool_calls: m.tool_calls || null,
|
||||||
|
metadata: m.metadata || null,
|
||||||
|
siblingCount: m.sibling_count || 1,
|
||||||
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
}));
|
||||||
|
chat.messageCount = chat.messages.length;
|
||||||
|
await loadChannelAttachments(App.currentChatId);
|
||||||
|
UI.renderMessages(chat.messages);
|
||||||
|
updateContextWarning();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to reload path:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Regenerate (tree-aware: creates sibling) ─
|
||||||
|
|
||||||
|
async function regenerateMessage(messageId) {
|
||||||
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
|
||||||
|
const selectedId = UI.getModelValue();
|
||||||
|
const modelInfo = App.findModel(selectedId);
|
||||||
|
const model = modelInfo?.baseModelId || selectedId;
|
||||||
|
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||||
|
|
||||||
|
// Truncate display to the parent of the message being regenerated.
|
||||||
|
// This makes the stream appear in the correct position — as a fresh
|
||||||
|
// response to the parent user message, not appended at the bottom.
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (chat) {
|
||||||
|
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
|
||||||
|
if (msgIdx > 0) {
|
||||||
|
const truncated = chat.messages.slice(0, msgIdx);
|
||||||
|
UI.renderMessages(truncated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
App.isGenerating = true;
|
||||||
|
App.abortController = new AbortController();
|
||||||
|
UI.setGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.streamRegenerate(
|
||||||
|
App.currentChatId, messageId, App.abortController.signal,
|
||||||
|
model, presetId, modelInfo?.configId
|
||||||
|
);
|
||||||
|
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||||
|
|
||||||
|
// Reload from server to get proper tree state
|
||||||
|
await reloadActivePath();
|
||||||
|
UI.showRegenerate(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
UI.toast('Generation stopped', 'warning');
|
||||||
|
await reloadActivePath();
|
||||||
|
} else {
|
||||||
|
const msg = e.message || '';
|
||||||
|
if (e.proxyBlocked) {
|
||||||
|
UI.toast('Network proxy blocked this request', 'error');
|
||||||
|
} else if (msg.includes('provider error') && msg.includes('401')) {
|
||||||
|
UI.toast('Provider API key rejected — check Settings', 'error');
|
||||||
|
} else { UI.toast(msg, 'error'); }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
App.isGenerating = false;
|
||||||
|
App.abortController = null;
|
||||||
|
UI.setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: bottom-bar regenerate button targets the last assistant message
|
||||||
|
async function regenerate() {
|
||||||
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (!chat || chat.messages.length === 0) return;
|
||||||
|
|
||||||
|
// Find the last assistant message with an ID
|
||||||
|
for (let i = chat.messages.length - 1; i >= 0; i--) {
|
||||||
|
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
|
||||||
|
return regenerateMessage(chat.messages[i].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UI.toast('No assistant message to regenerate', 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit Message (creates sibling, triggers completion) ──
|
||||||
|
|
||||||
|
async function editMessage(messageId) {
|
||||||
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (!chat) return;
|
||||||
|
|
||||||
|
const msg = chat.messages.find(m => m.id === messageId);
|
||||||
|
if (!msg || msg.role !== 'user') return;
|
||||||
|
|
||||||
|
// Show inline edit UI
|
||||||
|
UI.showEditInline(messageId, msg.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit(messageId, newContent) {
|
||||||
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
if (!newContent.trim()) return;
|
||||||
|
|
||||||
|
const selectedId = UI.getModelValue();
|
||||||
|
const modelInfo = App.findModel(selectedId);
|
||||||
|
const model = modelInfo?.baseModelId || selectedId;
|
||||||
|
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||||
|
|
||||||
|
App.isGenerating = true;
|
||||||
|
App.abortController = new AbortController();
|
||||||
|
UI.setGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
|
||||||
|
// Step 1: Create sibling user message via edit endpoint
|
||||||
|
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
|
||||||
|
|
||||||
|
// Step 2: Reload path to show the new edited message
|
||||||
|
await reloadActivePath();
|
||||||
|
|
||||||
|
// Step 3: Generate assistant response as child of the new edit.
|
||||||
|
// Uses regenerate endpoint (which handles user messages by creating
|
||||||
|
// a child response, not a sibling). This avoids the duplicate user
|
||||||
|
// message that streamCompletion would create.
|
||||||
|
const resp = await API.streamRegenerate(
|
||||||
|
App.currentChatId, editResp.id, App.abortController.signal,
|
||||||
|
model, presetId, modelInfo?.configId
|
||||||
|
);
|
||||||
|
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||||||
|
|
||||||
|
// Step 4: Final reload to get the complete tree state
|
||||||
|
await reloadActivePath();
|
||||||
|
UI.showRegenerate(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
UI.toast('Generation stopped', 'warning');
|
||||||
|
await reloadActivePath();
|
||||||
|
} else {
|
||||||
|
console.error('Edit error:', e);
|
||||||
|
UI.toast(e.message || 'Edit failed', 'error');
|
||||||
|
await reloadActivePath();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
App.isGenerating = false;
|
||||||
|
App.abortController = null;
|
||||||
|
UI.setGenerating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
// Re-render to remove the edit form
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (chat) UI.renderMessages(chat.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Switch Branch (sibling navigation) ──────
|
||||||
|
|
||||||
|
async function switchSibling(messageId, direction) {
|
||||||
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get siblings for this message
|
||||||
|
const data = await API.listSiblings(App.currentChatId, messageId);
|
||||||
|
const siblings = data.siblings || [];
|
||||||
|
const currentIdx = data.current_index ?? 0;
|
||||||
|
|
||||||
|
const targetIdx = currentIdx + direction;
|
||||||
|
if (targetIdx < 0 || targetIdx >= siblings.length) return;
|
||||||
|
|
||||||
|
const targetSibling = siblings[targetIdx];
|
||||||
|
|
||||||
|
// Update cursor to the target sibling (backend walks to leaf)
|
||||||
|
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
|
||||||
|
|
||||||
|
// Update local state with the new path
|
||||||
|
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||||||
|
if (chat && resp.path) {
|
||||||
|
chat.messages = resp.path.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
parent_id: m.parent_id || null,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
model: m.model || '',
|
||||||
|
modelName: '',
|
||||||
|
timestamp: m.created_at,
|
||||||
|
tool_calls: m.tool_calls || null,
|
||||||
|
metadata: m.metadata || null,
|
||||||
|
siblingCount: m.sibling_count || 1,
|
||||||
|
siblingIndex: m.sibling_index || 0,
|
||||||
|
}));
|
||||||
|
chat.messageCount = chat.messages.length;
|
||||||
|
UI.renderMessages(chat.messages);
|
||||||
|
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Branch switch failed:', e.message);
|
||||||
|
UI.toast('Failed to switch branch', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat Listeners (extracted from initListeners) ──
|
||||||
|
|
||||||
|
function _initChatListeners() {
|
||||||
|
// Sidebar
|
||||||
|
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
|
||||||
|
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||||||
|
|
||||||
|
// Split button dropdown
|
||||||
|
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
document.getElementById('newChatDropdown').classList.toggle('open');
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.split-btn')) {
|
||||||
|
document.getElementById('newChatDropdown').classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// User flyout
|
||||||
|
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
UI.toggleUserMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flyout items
|
||||||
|
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
|
||||||
|
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
|
||||||
|
document.getElementById('menuTeamAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openTeamAdmin(); });
|
||||||
|
document.getElementById('teamAdminCloseBtn')?.addEventListener('click', () => UI.closeTeamAdmin());
|
||||||
|
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
|
||||||
|
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
|
||||||
|
|
||||||
|
// Sidebar chat search
|
||||||
|
const _chatSearchInput = document.getElementById('chatSearchInput');
|
||||||
|
_chatSearchInput?.addEventListener('input', () => UI.renderChatList());
|
||||||
|
document.getElementById('chatSearchClear')?.addEventListener('click', () => {
|
||||||
|
if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chat actions
|
||||||
|
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||||||
|
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
||||||
|
|
||||||
|
// Model selector (custom dropdown)
|
||||||
|
UI.initModelDropdown();
|
||||||
|
UI.initAppearance();
|
||||||
|
|
||||||
|
// Mobile hamburger
|
||||||
|
document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);
|
||||||
|
document.getElementById('sidebarOverlay')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('sidebar').classList.add('collapsed');
|
||||||
|
document.getElementById('sidebarOverlay').style.display = 'none';
|
||||||
|
localStorage.setItem('sb_sidebar', '1');
|
||||||
|
});
|
||||||
|
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
|
||||||
|
await fetchModels();
|
||||||
|
const visible = App.models.filter(m => !m.hidden).length;
|
||||||
|
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Input
|
||||||
|
const input = document.getElementById('messageInput');
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||||
|
});
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
this.style.height = 'auto';
|
||||||
|
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
||||||
|
updateInputTokens();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals on overlay click
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) closeModal(overlay.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
|
# File storage — mount ./data/storage on host
|
||||||
|
STORAGE_PATH: /data/storage
|
||||||
|
volumes:
|
||||||
|
- switchboard_storage:/data/storage
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -56,3 +60,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
switchboard_storage:
|
||||||
|
|||||||
931
index.html
Normal file
931
index.html
Normal file
@@ -0,0 +1,931 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
|
||||||
|
<base href="%%BASE_HREF%%">
|
||||||
|
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
||||||
|
<script>window.__VERSION__ = '%%APP_VERSION%%'; if (window.__VERSION__.includes('%%')) window.__VERSION__ = 'dev';</script>
|
||||||
|
<script>window.__ENV__ = '%%ENVIRONMENT%%'; if (window.__ENV__.includes('%%')) window.__ENV__ = 'development';</script>
|
||||||
|
<script>try { window.__BRANDING__ = %%BRANDING_JSON%%; } catch(e) { window.__BRANDING__ = {}; }</script>
|
||||||
|
<title>Chat Switchboard</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg?v=%%APP_VERSION%%">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico?v=%%APP_VERSION%%">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=%%APP_VERSION%%">
|
||||||
|
<link rel="icon" type="image/png" sizes="256x256" href="favicon-256.png?v=%%APP_VERSION%%">
|
||||||
|
<link rel="apple-touch-icon" sizes="256x256" href="favicon-256.png?v=%%APP_VERSION%%">
|
||||||
|
<link rel="manifest" href="manifest.json?v=%%APP_VERSION%%">
|
||||||
|
<meta name="theme-color" content="#0e0e10">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<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="branding/custom.css" onerror="this.remove()">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ── App Shell ───────────────────────────── -->
|
||||||
|
<div id="appContainer" class="app" style="display:none">
|
||||||
|
|
||||||
|
<!-- Environment Banner (top) -->
|
||||||
|
<div class="banner banner-top" id="bannerTop"></div>
|
||||||
|
|
||||||
|
<div class="app-body">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-top">
|
||||||
|
<button class="sb-brand" id="sidebarToggle" title="Toggle sidebar">
|
||||||
|
<span class="brand-logo" id="brandSidebarLogo"><img src="favicon-32.png" class="brand-logo-img" alt=""></span>
|
||||||
|
<svg class="brand-collapse" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||||
|
<span class="sb-label brand-text" id="brandSidebarText">Chat Switchboard</span>
|
||||||
|
</button>
|
||||||
|
<div class="split-btn" style="position:relative">
|
||||||
|
<button class="split-btn-main" id="newChatBtn" title="New Chat">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||||
|
<span class="sb-label">New Chat</span>
|
||||||
|
</button>
|
||||||
|
<button class="split-btn-drop" id="newChatDropBtn" title="More options">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="split-dropdown" id="newChatDropdown">
|
||||||
|
<button class="split-dropdown-item" onclick="newChat()">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
|
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
Group Chat <span class="dd-hint">soon</span>
|
||||||
|
</button>
|
||||||
|
<button class="split-dropdown-item disabled" title="Coming soon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
New Channel <span class="dd-hint">soon</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-search" id="sidebarSearch">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input type="text" id="chatSearchInput" placeholder="Search chats…" autocomplete="off">
|
||||||
|
<button class="sidebar-search-clear" id="chatSearchClear" title="Clear">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-chats" id="chatHistory"></div>
|
||||||
|
|
||||||
|
<!-- Notes shortcut -->
|
||||||
|
<div class="sidebar-notes-btn">
|
||||||
|
<button class="sb-notes" id="notesBtn" title="Notes">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||||
|
<span class="sb-label">Notes</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User area -->
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<button class="user-btn" id="userMenuBtn">
|
||||||
|
<div class="user-avatar" id="userAvatar"><span id="avatarLetter">?</span>
|
||||||
|
<span class="avatar-bug" title="Debug available">🐛</span>
|
||||||
|
</div>
|
||||||
|
<span class="sb-label user-name" id="userName">User</span>
|
||||||
|
</button>
|
||||||
|
<div class="user-flyout" id="userFlyout">
|
||||||
|
<button class="flyout-item" id="menuSettings">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
<button class="flyout-item" id="menuAdmin" style="display:none">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
<span>Admin Panel</span>
|
||||||
|
</button>
|
||||||
|
<button class="flyout-item" id="menuTeamAdmin" style="display:none">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
<span>Team Management</span>
|
||||||
|
</button>
|
||||||
|
<button class="flyout-item" id="menuDebug">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><path d="M4 14h4"/><path d="M16 14h4"/><path d="M12 18v-6"/><circle cx="12" cy="14" r="6"/></svg>
|
||||||
|
<span>Debug Log</span>
|
||||||
|
</button>
|
||||||
|
<div class="flyout-divider"></div>
|
||||||
|
<button class="flyout-item flyout-danger" id="menuSignout">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
|
<span>Sign Out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||||
|
|
||||||
|
<!-- Main Chat Area -->
|
||||||
|
<main class="chat-area">
|
||||||
|
<div class="model-bar">
|
||||||
|
<button class="mobile-menu-btn" id="mobileMenuBtn" title="Menu">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="model-dropdown" id="modelDropdown">
|
||||||
|
<button class="model-dropdown-btn" id="modelDropdownBtn" title="Select model">
|
||||||
|
<span class="model-dropdown-label" id="modelDropdownLabel">Select a model</span>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3.5L5 6.5L8 3.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="model-dropdown-menu" id="modelDropdownMenu"></div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" id="fetchModelsBtn" title="Refresh models">
|
||||||
|
<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>
|
||||||
|
<div class="model-caps" id="modelCaps"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="messages" id="chatMessages">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
|
||||||
|
<h2>Chat Switchboard</h2>
|
||||||
|
<p>Select a model and start chatting</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<div class="context-warning" id="contextWarning" style="display:none">
|
||||||
|
<span class="context-warning-icon">⚠️</span>
|
||||||
|
<span class="context-warning-text" id="contextWarningText"></span>
|
||||||
|
<button class="context-warning-action" id="summarizeBtn" onclick="summarizeAndContinue()" title="Summarize conversation and continue with reduced context" style="display:none">📝 Summarize & Continue</button>
|
||||||
|
<button class="context-warning-dismiss" onclick="dismissContextWarning()" title="Dismiss">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="attachment-strip" id="attachmentStrip" style="display:none"></div>
|
||||||
|
<div class="input-wrap">
|
||||||
|
<button class="action-btn attach-btn" id="attachBtn" title="Attach file" style="display:none">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
|
||||||
|
</button>
|
||||||
|
<input type="file" id="fileInput" multiple style="display:none">
|
||||||
|
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
||||||
|
<div class="input-actions">
|
||||||
|
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn send-btn" id="sendBtn" title="Send">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-meta" id="inputMeta">
|
||||||
|
<span class="input-token-count" id="inputTokenCount"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Side Panel (preview + notes) -->
|
||||||
|
<aside class="side-panel" id="sidePanel">
|
||||||
|
<div class="side-panel-resize" id="sidePanelResize"></div>
|
||||||
|
<div class="side-panel-header">
|
||||||
|
<div class="side-panel-tabs" id="sidePanelTabs">
|
||||||
|
<button class="side-panel-tab active" data-tab="preview" onclick="switchSidePanelTab('preview')">Preview</button>
|
||||||
|
<button class="side-panel-tab" data-tab="notes" onclick="switchSidePanelTab('notes')">Notes</button>
|
||||||
|
</div>
|
||||||
|
<div class="side-panel-actions">
|
||||||
|
<button class="side-panel-action-btn" id="sidePanelClearBtn" onclick="clearPreview()" title="Clear preview" style="display:none">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="side-panel-action-btn" id="sidePanelFullscreenBtn" onclick="toggleSidePanelFullscreen()" title="Toggle fullscreen">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="side-panel-close" onclick="closeSidePanel()" title="Close panel">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="side-panel-body">
|
||||||
|
<!-- Preview tab -->
|
||||||
|
<div class="side-panel-page" id="sidePanelPreview">
|
||||||
|
<div class="side-panel-empty" id="previewEmpty">
|
||||||
|
<p>Click <strong>Preview</strong> on any HTML code block to render it here.</p>
|
||||||
|
</div>
|
||||||
|
<iframe id="previewFrame" class="preview-frame" sandbox="allow-scripts" style="display:none"></iframe>
|
||||||
|
</div>
|
||||||
|
<!-- Notes tab -->
|
||||||
|
<div class="side-panel-page" id="sidePanelNotes" style="display:none">
|
||||||
|
<div class="notes-toolbar">
|
||||||
|
<div class="notes-search-wrap">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input type="text" id="notesSearchInput" placeholder="Search notes..." autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<select id="notesFolderFilter" class="notes-filter-select">
|
||||||
|
<option value="">All folders</option>
|
||||||
|
</select>
|
||||||
|
<select id="notesSortSelect" class="notes-filter-select" title="Sort by">
|
||||||
|
<option value="updated_desc">Updated ↓</option>
|
||||||
|
<option value="updated_asc">Updated ↑</option>
|
||||||
|
<option value="created_desc">Created ↓</option>
|
||||||
|
<option value="created_asc">Created ↑</option>
|
||||||
|
<option value="title_asc">Title A–Z</option>
|
||||||
|
<option value="title_desc">Title Z–A</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="notes-actions-bar">
|
||||||
|
<button class="btn-small" id="notesSelectModeBtn">Select</button>
|
||||||
|
<button class="btn-small btn-primary" id="notesNewBtn">+ New Note</button>
|
||||||
|
</div>
|
||||||
|
<div class="notes-selection-bar" id="notesSelectionBar" style="display:none">
|
||||||
|
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> <span id="notesSelectedCount">0</span> selected</label>
|
||||||
|
<button class="btn-small btn-danger" id="notesDeleteSelectedBtn">Delete selected</button>
|
||||||
|
<button class="btn-small" id="notesCancelSelectBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<!-- List view (default) -->
|
||||||
|
<div id="notesListView">
|
||||||
|
<div id="notesList" class="notes-list">
|
||||||
|
<div class="notes-empty">No notes yet. Create one or ask the AI to save a note.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Editor view (hidden by default) -->
|
||||||
|
<div id="notesEditorView" style="display:none">
|
||||||
|
<div class="notes-editor">
|
||||||
|
<div class="notes-editor-toolbar">
|
||||||
|
<button class="notes-back-btn" id="notesBackBtn">← Back to list</button>
|
||||||
|
<div class="notes-mode-toggle">
|
||||||
|
<button class="btn-small" id="noteEditBtn" style="display:none">Edit</button>
|
||||||
|
<button class="btn-small" id="notePreviewBtn" style="display:none">Preview</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Read mode: rendered markdown -->
|
||||||
|
<div id="noteReadMode" style="display:none">
|
||||||
|
<h3 class="note-read-title" id="noteReadTitle"></h3>
|
||||||
|
<div class="note-read-meta" id="noteReadMeta"></div>
|
||||||
|
<div class="note-read-content msg-text" id="noteReadContent"></div>
|
||||||
|
<div class="notes-editor-actions">
|
||||||
|
<button class="btn-small" id="noteCopyBtn" onclick="copyNoteContent()">Copy</button>
|
||||||
|
<button class="btn-small btn-primary" id="noteEditBtn2">Edit</button>
|
||||||
|
<button class="btn-small btn-danger" id="noteDeleteBtn2" style="display:none">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Edit mode: form fields -->
|
||||||
|
<div id="noteEditMode">
|
||||||
|
<div class="form-group"><input type="text" id="noteEditorTitle" placeholder="Note title" class="notes-title-input"></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><input type="text" id="noteEditorFolder" placeholder="Folder (e.g. /work/meetings)" class="notes-folder-input"></div>
|
||||||
|
<div class="form-group"><input type="text" id="noteEditorTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><textarea id="noteEditorContent" rows="12" placeholder="Note content (Markdown supported)..." class="notes-content-input"></textarea></div>
|
||||||
|
<div class="notes-editor-actions">
|
||||||
|
<button class="btn-small btn-primary" id="noteSaveBtn">Save</button>
|
||||||
|
<button class="btn-small" id="noteCancelEditBtn" style="display:none">Cancel</button>
|
||||||
|
<button class="btn-small btn-danger" id="noteDeleteBtn" style="display:none">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</div><!-- .app-body -->
|
||||||
|
|
||||||
|
<!-- Environment Banner (bottom) -->
|
||||||
|
<div class="banner banner-bottom" id="bannerBottom"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Auth Splash ─────────────────────────── -->
|
||||||
|
<div class="splash" id="splashGate">
|
||||||
|
<div class="splash-hero">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-logo-row">
|
||||||
|
<div class="hero-logo-mark" id="brandLogo">
|
||||||
|
<img src="favicon-256.png" alt="Chat Switchboard" style="width:100%;height:100%;border-radius:12px">
|
||||||
|
</div>
|
||||||
|
<div class="hero-wordmark" id="brandWordmark">Chat <span>Switchboard</span></div>
|
||||||
|
</div>
|
||||||
|
<h1 class="hero-headline" id="brandHeadline">One interface.<br>Every AI model.</h1>
|
||||||
|
<p class="hero-sub" id="brandTagline">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
|
||||||
|
<div class="hero-features" id="brandPills">
|
||||||
|
<div class="hero-pill accent"><span class="pill-icon">⚡</span> Streaming responses</div>
|
||||||
|
<div class="hero-pill purple"><span class="pill-icon"><img src="favicon-32.png" style="width:14px;height:14px;vertical-align:middle" alt=""></span> Multi-provider routing</div>
|
||||||
|
<div class="hero-pill"><span class="pill-icon">🔑</span> Bring your own API keys</div>
|
||||||
|
<div class="hero-pill"><span class="pill-icon">🏠</span> Self-hosted</div>
|
||||||
|
<div class="hero-pill"><span class="pill-icon">✈️</span> Air-gap ready</div>
|
||||||
|
<div class="hero-pill"><span class="pill-icon">🧠</span> Thinking blocks</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-version">v%%APP_VERSION%%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="splash-auth">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-card-header">
|
||||||
|
<h2>Welcome back</h2>
|
||||||
|
<p>Sign in to continue to your workspace</p>
|
||||||
|
</div>
|
||||||
|
<div class="auth-tabs">
|
||||||
|
<button class="auth-tab active" id="authTabLogin" onclick="switchAuthTab('login')">Sign In</button>
|
||||||
|
<button class="auth-tab" id="authTabRegister" onclick="switchAuthTab('register')">Register</button>
|
||||||
|
</div>
|
||||||
|
<div id="authLoginForm">
|
||||||
|
<div class="form-group"><label>Username or Email</label><input type="text" id="authLogin" autocomplete="username" placeholder="you@example.com"></div>
|
||||||
|
<div class="form-group"><label>Password</label><input type="password" id="authPassword" autocomplete="current-password" placeholder="••••••••" onkeydown="if(event.key==='Enter')handleLogin()"></div>
|
||||||
|
</div>
|
||||||
|
<div id="authRegisterForm" style="display:none">
|
||||||
|
<div class="form-group"><label>Username</label><input type="text" id="authUsername" autocomplete="username" placeholder="Choose a username"></div>
|
||||||
|
<div class="form-group"><label>Email</label><input type="email" id="authEmail" autocomplete="email" placeholder="you@example.com"></div>
|
||||||
|
<div class="form-group"><label>Password</label><input type="password" id="authRegPassword" autocomplete="new-password" placeholder="Min 8 characters" onkeydown="if(event.key==='Enter')handleRegister()"></div>
|
||||||
|
</div>
|
||||||
|
<div class="auth-error" id="authError"></div>
|
||||||
|
<div class="splash-error" id="splashError"></div>
|
||||||
|
<div class="auth-actions">
|
||||||
|
<button class="btn-primary btn-full" id="authLoginBtn" data-label="Sign In" onclick="handleLogin()">Sign In</button>
|
||||||
|
<button class="btn-primary btn-full" id="authRegisterBtn" data-label="Create Account" onclick="handleRegister()" style="display:none">Create Account</button>
|
||||||
|
</div>
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p id="brandAuthFooter">Self-hosted AI chat · Your keys, your data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Settings Modal ──────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="settingsModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn">✕</button></div>
|
||||||
|
<div class="modal-tabs settings-tabs">
|
||||||
|
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
|
||||||
|
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
|
||||||
|
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')" id="settingsProvidersTabBtn">Providers</button>
|
||||||
|
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
|
||||||
|
<button class="settings-tab" data-stab="personas" onclick="UI.switchSettingsTab('personas')" id="settingsPersonasTabBtn">Personas</button>
|
||||||
|
<button class="settings-tab" data-stab="usage" onclick="UI.switchSettingsTab('usage')" id="settingsUsageTabBtn">Usage</button>
|
||||||
|
<button class="settings-tab" data-stab="roles" onclick="UI.switchSettingsTab('roles')" id="settingsRolesTabBtn">Model Roles</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- General Tab -->
|
||||||
|
<div class="settings-tab-content" id="settingsGeneralTab">
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>Profile</h3>
|
||||||
|
<div class="avatar-upload-row">
|
||||||
|
<div class="avatar-preview" id="avatarPreview">
|
||||||
|
<span id="avatarPreviewLetter">?</span>
|
||||||
|
</div>
|
||||||
|
<div class="avatar-actions">
|
||||||
|
<button class="btn-small" id="avatarUploadBtn">Upload Photo</button>
|
||||||
|
<button class="btn-small btn-danger" id="avatarRemoveBtn" style="display:none">Remove</button>
|
||||||
|
<input type="file" id="avatarFileInput" accept="image/png,image/jpeg,image/gif" style="display:none">
|
||||||
|
<div class="form-hint">Auto-resized to 128×128</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Display Name</label><input type="text" id="profileDisplayName"></div>
|
||||||
|
<div class="form-group"><label>Email</label><input type="email" id="profileEmail"></div>
|
||||||
|
<button class="btn-small" id="profileChangePwBtn">Change Password</button>
|
||||||
|
<div id="profileChangePwForm" style="display:none">
|
||||||
|
<div class="form-group"><label>Current Password</label><input type="password" id="profileCurrentPw"></div>
|
||||||
|
<div class="form-group"><label>New Password</label><input type="password" id="profileNewPw"></div>
|
||||||
|
<button class="btn-small btn-primary" id="profileSavePwBtn">Save Password</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section" id="settingsTeamsSection" style="display:none">
|
||||||
|
<h3>My Teams</h3>
|
||||||
|
<div id="settingsTeamsList"></div>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>Chat</h3>
|
||||||
|
<div class="form-group"><label>Default Model</label><select id="settingsModel"></select></div>
|
||||||
|
<div class="form-group"><label>System Prompt</label><textarea id="settingsSystemPrompt" rows="3"></textarea></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Max Tokens <span class="form-hint" id="settingsMaxHint"></span></label>
|
||||||
|
<input type="number" id="settingsMaxTokens" placeholder="Auto (from model)">
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTemp" value="0.7" step="0.1" min="0" max="2"></div>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Auto-expand thinking blocks</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<!-- Appearance Tab -->
|
||||||
|
<div class="settings-tab-content" id="settingsAppearanceTab" style="display:none">
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>Display</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>UI Scale <span class="form-hint" id="scaleValue">100%</span></label>
|
||||||
|
<input type="range" id="settingsScale" min="80" max="150" step="5" value="100" class="range-input">
|
||||||
|
<div class="range-labels"><span>80%</span><span>150%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Message Font Size <span class="form-hint" id="msgFontValue">14px</span></label>
|
||||||
|
<input type="range" id="settingsMsgFont" min="12" max="20" step="1" value="14" class="range-input">
|
||||||
|
<div class="range-labels"><span>12px</span><span>20px</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<!-- Providers Tab -->
|
||||||
|
<div class="settings-tab-content" id="settingsProvidersTab" style="display:none">
|
||||||
|
<section class="settings-section">
|
||||||
|
<div id="providerList"></div>
|
||||||
|
<button class="btn-small" id="providerShowAddBtn">+ Add Provider</button>
|
||||||
|
<div id="providerAddForm" style="display:none">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div id="userProvidersDisabled" class="settings-notice" style="display:none">
|
||||||
|
<span>⚠️</span> User-configured providers have been disabled by the administrator.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Models Tab -->
|
||||||
|
<div class="settings-tab-content" id="settingsModelsTab" style="display:none">
|
||||||
|
<section class="settings-section">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
||||||
|
<h3 style="font-size:14px;margin:0">Available Models</h3>
|
||||||
|
<div>
|
||||||
|
<button class="btn-small" onclick="bulkSetUserModelVisibility(true)" title="Show all models in selector">Show All</button>
|
||||||
|
<button class="btn-small" onclick="bulkSetUserModelVisibility(false)" title="Hide all models from selector">Hide All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="section-hint" style="margin-bottom:8px">Toggle visibility in your model selector. Hidden models are still accessible through presets.</p>
|
||||||
|
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<!-- Personas Tab (hidden if admin disables user presets) -->
|
||||||
|
<div class="settings-tab-content" id="settingsPersonasTab" style="display:none">
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3 style="font-size:14px;margin-bottom:4px">My Personas</h3>
|
||||||
|
<p class="section-hint" style="margin-bottom:8px">Personal model presets with custom system prompts and settings.</p>
|
||||||
|
<div id="userPresetList"><div class="empty-hint">Loading...</div></div>
|
||||||
|
<div id="userAddPresetForm" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
||||||
|
<button class="btn-small" id="userAddPresetBtn" style="margin-top:6px">+ New Persona</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<!-- Usage Tab -->
|
||||||
|
<div class="settings-tab-content" id="settingsUsageTab" style="display:none">
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3 style="font-size:14px;margin-bottom:6px">My Usage</h3>
|
||||||
|
<p class="section-hint" style="margin-bottom:8px">Token consumption and estimated costs across all your conversations.</p>
|
||||||
|
<div class="form-row" style="gap:6px;margin-bottom:8px">
|
||||||
|
<select id="myUsagePeriod" style="font-size:12px;padding:3px 6px" onchange="UI.loadMyUsage()">
|
||||||
|
<option value="7d">7 days</option>
|
||||||
|
<option value="30d" selected>30 days</option>
|
||||||
|
<option value="90d">90 days</option>
|
||||||
|
</select>
|
||||||
|
<select id="myUsageGroupBy" style="font-size:12px;padding:3px 6px" onchange="UI.loadMyUsage()">
|
||||||
|
<option value="model">By Model</option>
|
||||||
|
<option value="day">By Day</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="myUsageTotals"></div>
|
||||||
|
<div id="myUsageResults"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<!-- Model Roles Tab (BYOK users — personal role overrides) -->
|
||||||
|
<div class="settings-tab-content" id="settingsRolesTab" style="display:none">
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3 style="font-size:14px;margin-bottom:4px">Personal Model Roles</h3>
|
||||||
|
<p class="section-hint" style="margin-bottom:8px">Override the org's default utility and embedding models with your own providers. Uses your personal API keys — no org cost.</p>
|
||||||
|
<div id="userRolesConfig"></div>
|
||||||
|
</section>
|
||||||
|
<div id="userRolesDisabled" class="settings-notice" style="display:none">
|
||||||
|
<span>ℹ️</span> Add a personal provider first to configure role overrides.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Team Management Modal ─────────────────── -->
|
||||||
|
<div class="modal-overlay" id="teamAdminModal">
|
||||||
|
<div class="modal modal-wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="teamAdminTitle">Team Management</h2>
|
||||||
|
<button class="modal-close" id="teamAdminCloseBtn">✕</button>
|
||||||
|
</div>
|
||||||
|
<!-- Team picker (shown when user admins multiple teams) -->
|
||||||
|
<div id="teamAdminPicker" style="display:none">
|
||||||
|
<div class="modal-body" id="teamAdminPickerList"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Tabbed management (shown after selecting a team) -->
|
||||||
|
<div id="teamAdminContent" style="display:none">
|
||||||
|
<div class="modal-tabs admin-tabs" id="teamAdminTabs">
|
||||||
|
<button class="admin-tab active" data-ttab="members" onclick="UI.switchTeamTab('members')">Members</button>
|
||||||
|
<button class="admin-tab" data-ttab="providers" onclick="UI.switchTeamTab('providers')">Providers</button>
|
||||||
|
<button class="admin-tab" data-ttab="presets" onclick="UI.switchTeamTab('presets')">Presets</button>
|
||||||
|
<button class="admin-tab" data-ttab="usage" onclick="UI.switchTeamTab('usage')">Usage</button>
|
||||||
|
<button class="admin-tab" data-ttab="activity" onclick="UI.switchTeamTab('activity')">Activity</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Members tab -->
|
||||||
|
<div class="team-tab-content" id="teamTabMembers">
|
||||||
|
<div id="settingsTeamMembers"></div>
|
||||||
|
<div id="settingsTeamAddMember" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>User</label><select id="settingsTeamMemberUser"></select></div>
|
||||||
|
<div class="form-group"><label>Role</label><select id="settingsTeamMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><button class="btn-small btn-primary" id="settingsTeamAddMemberSubmit">Add</button><button class="btn-small" id="settingsTeamCancelMember">Cancel</button></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:6px">+ Add Member</button>
|
||||||
|
</div>
|
||||||
|
<!-- Providers tab -->
|
||||||
|
<div class="team-tab-content" id="teamTabProviders" style="display:none">
|
||||||
|
<div id="settingsTeamProviders"></div>
|
||||||
|
<div id="settingsTeamProviderForm" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||||
|
</div>
|
||||||
|
<button class="btn-small" id="settingsTeamAddProviderBtn" style="margin-top:6px">+ Add Provider</button>
|
||||||
|
</div>
|
||||||
|
<!-- Presets tab -->
|
||||||
|
<div class="team-tab-content" id="teamTabPresets" style="display:none">
|
||||||
|
<div id="settingsTeamPresets"></div>
|
||||||
|
<div id="settingsTeamAddPreset" 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>
|
||||||
|
</div>
|
||||||
|
<!-- Usage tab -->
|
||||||
|
<div class="team-tab-content" id="teamTabUsage" style="display:none">
|
||||||
|
<div class="form-row" style="gap:6px;margin-bottom:8px">
|
||||||
|
<select id="teamUsagePeriod" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
|
||||||
|
<option value="7d">7 days</option>
|
||||||
|
<option value="30d" selected>30 days</option>
|
||||||
|
<option value="90d">90 days</option>
|
||||||
|
</select>
|
||||||
|
<select id="teamUsageGroupBy" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamUsage()">
|
||||||
|
<option value="model">By Model</option>
|
||||||
|
<option value="user">By User</option>
|
||||||
|
<option value="day">By Day</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="settingsTeamUsageTotals"></div>
|
||||||
|
<div id="settingsTeamUsageResults"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Activity tab -->
|
||||||
|
<div class="team-tab-content" id="teamTabActivity" style="display:none">
|
||||||
|
<div class="audit-filters" style="margin-bottom:8px">
|
||||||
|
<select id="teamAuditFilterAction" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamAuditLog()">
|
||||||
|
<option value="">All actions</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="settingsTeamAudit" style="max-height:400px;overflow-y:auto"></div>
|
||||||
|
<div id="teamAuditPagination" class="audit-pagination" style="display:none">
|
||||||
|
<button class="btn-small" id="teamAuditPrevBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage - 1)">← Prev</button>
|
||||||
|
<span id="teamAuditPageInfo" style="font-size:11px;color:var(--text-3)"></span>
|
||||||
|
<button class="btn-small" id="teamAuditNextBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage + 1)">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Admin Modal ─────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="adminModal">
|
||||||
|
<div class="modal modal-wide">
|
||||||
|
<div class="modal-header"><h2>Admin Panel</h2><button class="modal-close" id="adminCloseBtn">✕</button></div>
|
||||||
|
<div class="modal-tabs admin-tabs">
|
||||||
|
<button class="admin-tab active" data-tab="users">Users</button>
|
||||||
|
<button class="admin-tab" data-tab="providers">Providers</button>
|
||||||
|
<button class="admin-tab" data-tab="models">Models</button>
|
||||||
|
<button class="admin-tab" data-tab="presets">Presets</button>
|
||||||
|
<button class="admin-tab" data-tab="teams">Teams</button>
|
||||||
|
<button class="admin-tab" data-tab="settings">Settings</button>
|
||||||
|
<button class="admin-tab" data-tab="roles">Roles</button>
|
||||||
|
<button class="admin-tab" data-tab="usage">Usage</button>
|
||||||
|
<button class="admin-tab" data-tab="extensions">Extensions</button>
|
||||||
|
<button class="admin-tab" data-tab="storage">Storage</button>
|
||||||
|
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||||
|
<button class="admin-tab" data-tab="audit">Audit</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="admin-tab-content" id="adminUsersTab">
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<button class="btn-small btn-primary" id="adminAddUserBtn">+ Add User</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminAddUserForm" style="display:none" class="admin-inline-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username"></div>
|
||||||
|
<div class="form-group"><label>Email</label><input type="email" id="adminNewEmail" placeholder="email@example.com"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars"></div>
|
||||||
|
<div class="form-group"><label>Role</label><select id="adminNewRole"><option value="user">User</option><option value="admin">Admin</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateUserBtn">Create</button><button class="btn-small" id="adminCancelUserBtn">Cancel</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="adminUserList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-tab-content" id="adminProvidersTab" style="display:none">
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<button class="btn-small btn-primary" id="adminAddProviderBtn">+ Add Global Provider</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminAddProviderForm" style="display:none" class="admin-inline-form">
|
||||||
|
</div>
|
||||||
|
<div id="adminProviderList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-tab-content" id="adminModelsTab" style="display:none">
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<button class="btn-small btn-primary" id="adminFetchModelsBtn">⟳ Fetch Models</button>
|
||||||
|
<button class="btn-small" onclick="bulkSetVisibility('enabled')">All Enabled</button>
|
||||||
|
<button class="btn-small" onclick="bulkSetVisibility('team')">All Team</button>
|
||||||
|
<button class="btn-small" onclick="bulkSetVisibility('disabled')">All Disabled</button>
|
||||||
|
<span class="admin-hint" id="adminModelsHint"></span>
|
||||||
|
</div>
|
||||||
|
<div id="adminModelList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-tab-content" id="adminPresetsTab" style="display:none">
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<button class="btn-small btn-primary" id="adminAddPresetBtn">+ New Global Preset</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form"></div>
|
||||||
|
<div id="adminPresetList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-tab-content" id="adminTeamsTab" style="display:none">
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<button class="btn-small btn-primary" id="adminAddTeamBtn">+ New Team</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminAddTeamForm" style="display:none" class="admin-inline-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Team Name</label><input type="text" id="adminTeamName" placeholder="Engineering"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Description</label><input type="text" id="adminTeamDesc" placeholder="Core engineering team"></div>
|
||||||
|
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateTeamBtn">Create</button><button class="btn-small" id="adminCancelTeamBtn">Cancel</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="adminTeamList"></div>
|
||||||
|
<!-- Team detail / member management (shown when editing a team) -->
|
||||||
|
<div id="adminTeamDetail" style="display:none">
|
||||||
|
<button class="notes-back-btn" id="adminTeamBackBtn">← Back to teams</button>
|
||||||
|
<h3 id="adminTeamDetailName" style="margin:8px 0 12px;font-size:15px"></h3>
|
||||||
|
<section class="settings-section" style="margin-bottom:12px;padding:10px;background:rgba(255,255,255,0.03);border-radius:8px">
|
||||||
|
<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (data stays on-prem)</label>
|
||||||
|
<p class="section-hint" style="margin:4px 0 0">Team members can only use providers marked as "private".</p>
|
||||||
|
<label class="checkbox-label" style="margin-top:8px"><input type="checkbox" id="adminTeamAllowProviders" checked> Allow team admins to manage providers</label>
|
||||||
|
<p class="section-hint" style="margin:4px 0 0">When disabled, team admins cannot add or modify team providers.</p>
|
||||||
|
</section>
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<button class="btn-small btn-primary" id="adminAddMemberBtn">+ Add Member</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminAddMemberForm" style="display:none" class="admin-inline-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>User</label><select id="adminMemberUser"></select></div>
|
||||||
|
<div class="form-group"><label>Team Role</label><select id="adminMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><button class="btn-small btn-primary" id="adminAddMemberSubmit">Add</button><button class="btn-small" id="adminCancelMemberBtn">Cancel</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="adminMemberList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>System Prompt</h3>
|
||||||
|
<p class="section-hint" style="margin-bottom:6px">Injected into every conversation before user/preset prompts. Users cannot override or disable this.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea id="adminSystemPrompt" rows="4" placeholder="e.g. You are a helpful assistant for Acme Corp. Always respond professionally."></textarea>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>Registration</h3>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" id="adminRegToggle" checked> Allow new user registration</label>
|
||||||
|
<div class="form-group" style="margin-top: 8px;">
|
||||||
|
<label>Default state for new accounts</label>
|
||||||
|
<select id="adminRegDefaultState">
|
||||||
|
<option value="active">Active — immediate access</option>
|
||||||
|
<option value="pending">Pending — require admin approval</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>User Providers</h3>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle" checked> Allow users to configure their own API providers</label>
|
||||||
|
<p class="section-hint">When disabled, users can only use admin-configured global providers.</p>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>User Presets</h3>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal presets</label>
|
||||||
|
<p class="section-hint">When disabled, users can only use admin-created global presets.</p>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>Default Model</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<select id="adminDefaultModel">
|
||||||
|
<option value="">— None (first visible) —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="section-hint">Model selected by default for new users or when a saved model is no longer available.</p>
|
||||||
|
</section>
|
||||||
|
<section class="settings-section">
|
||||||
|
<h3>Environment Banner</h3>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>
|
||||||
|
<div id="bannerConfigFields" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Preset</label>
|
||||||
|
<select id="adminBannerPreset"><option value="">Custom</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Banner Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Position</label>
|
||||||
|
<select id="adminBannerPosition"><option value="both">Top & Bottom</option><option value="top">Top only</option><option value="bottom">Bottom only</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Background</label><div class="color-input-wrap"><input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" class="color-hex" value="#007a33" maxlength="7"></div></div>
|
||||||
|
<div class="form-group"><label>Text Color</label><div class="color-input-wrap"><input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" class="color-hex" value="#ffffff" maxlength="7"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="admin-section">
|
||||||
|
<h4>🔐 Encryption</h4>
|
||||||
|
<div id="adminVaultStatus"><span class="empty-hint">Loading…</span></div>
|
||||||
|
</section>
|
||||||
|
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Roles Tab ─────────────────── -->
|
||||||
|
<div class="admin-tab-content" id="adminRolesTab" style="display:none">
|
||||||
|
<p style="color:var(--text-muted);margin:0 0 12px">Configure system model roles. Each role has a primary and optional fallback model.</p>
|
||||||
|
<div id="adminRolesContent">
|
||||||
|
<div class="loading">Loading roles...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Usage Tab ─────────────────── -->
|
||||||
|
<div class="admin-tab-content" id="adminUsageTab" style="display:none">
|
||||||
|
<div class="admin-toolbar" style="margin-bottom:12px">
|
||||||
|
<select id="usagePeriod" style="min-width:100px">
|
||||||
|
<option value="7d">Last 7 days</option>
|
||||||
|
<option value="30d" selected>Last 30 days</option>
|
||||||
|
<option value="90d">Last 90 days</option>
|
||||||
|
</select>
|
||||||
|
<select id="usageGroupBy" style="min-width:100px">
|
||||||
|
<option value="model">By Model</option>
|
||||||
|
<option value="user">By User</option>
|
||||||
|
<option value="day">By Day</option>
|
||||||
|
<option value="provider">By Provider</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-secondary btn-small" id="usageRefreshBtn">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminUsageTotals"></div>
|
||||||
|
<div id="adminUsageResults"></div>
|
||||||
|
<h3 style="margin-top:16px;font-size:14px">Model Pricing</h3>
|
||||||
|
<div id="adminPricingTable"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-tab-content" id="adminExtensionsTab" style="display:none">
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<button class="btn-small btn-primary" id="adminInstallExtBtn">+ Install Extension</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminExtensionsList"></div>
|
||||||
|
<div id="adminInstallExtForm" style="display:none;margin-top:12px" class="admin-inline-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Extension ID</label><input type="text" id="extInstallId" placeholder="my-extension"></div>
|
||||||
|
<div class="form-group"><label>Name</label><input type="text" id="extInstallName" placeholder="My Extension"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Version</label><input type="text" id="extInstallVersion" placeholder="1.0.0" value="1.0.0"></div>
|
||||||
|
<div class="form-group"><label>Author</label><input type="text" id="extInstallAuthor"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Description</label><input type="text" id="extInstallDesc"></div>
|
||||||
|
<div class="form-group"><label>Manifest JSON <span class="text-muted">(optional)</span></label><textarea id="extInstallManifest" rows="4" placeholder='{"tools":[], "permissions":[]}'></textarea></div>
|
||||||
|
<div class="form-group"><label>Script <span class="text-muted">(JavaScript source)</span></label><textarea id="extInstallScript" rows="6" placeholder="Extensions.register({ id: 'my-ext', init(ctx) { ... } });"></textarea></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="toggle-label"><input type="checkbox" id="extInstallSystem"> System (users can't disable)</label>
|
||||||
|
<label class="toggle-label"><input type="checkbox" id="extInstallEnabled" checked> Enabled</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-small" onclick="document.getElementById('adminInstallExtForm').style.display='none'">Cancel</button>
|
||||||
|
<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-tab-content" id="adminStorageTab" style="display:none"><div id="adminStorageContent"></div></div>
|
||||||
|
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
|
||||||
|
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
|
||||||
|
<div class="admin-toolbar" style="margin-bottom:8px">
|
||||||
|
<select id="auditFilterAction" style="min-width:140px"><option value="">All actions</option></select>
|
||||||
|
<select id="auditFilterResource" style="min-width:120px">
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="team">team</option>
|
||||||
|
<option value="preset">preset</option>
|
||||||
|
<option value="channel">channel</option>
|
||||||
|
<option value="config">config</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-small" id="auditRefreshBtn">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminAuditList"></div>
|
||||||
|
<div class="pagination-row" id="auditPagination" style="display:none;margin-top:8px">
|
||||||
|
<button class="btn-small" id="auditPrevBtn">← Prev</button>
|
||||||
|
<span id="auditPageInfo" style="font-size:12px;color:var(--text-3)"></span>
|
||||||
|
<button class="btn-small" id="auditNextBtn">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Notes Modal ────────────────────────── -->
|
||||||
|
<!-- Debug Modal (Ctrl+Shift+L) -->
|
||||||
|
<div class="modal-overlay" id="debugModal">
|
||||||
|
<div class="modal debug-modal">
|
||||||
|
<div class="modal-header"><h2>Debug Log</h2><button class="modal-close" onclick="closeDebugModal()">✕</button></div>
|
||||||
|
<div class="modal-tabs debug-tabs">
|
||||||
|
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console (<span id="debugConsoleCount">0</span>)</button>
|
||||||
|
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network (<span id="debugNetworkCount">0</span>)</button>
|
||||||
|
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body debug-modal-body">
|
||||||
|
<div class="debug-tab-content" id="debugConsoleTab">
|
||||||
|
<div class="debug-toolbar">
|
||||||
|
<label class="debug-check"><input type="checkbox" id="debugFilterErrors"> Errors only</label>
|
||||||
|
<label class="debug-check"><input type="checkbox" id="debugAutoScroll" checked> Auto-scroll</label>
|
||||||
|
</div>
|
||||||
|
<div id="debugConsoleContent" class="debug-content"></div>
|
||||||
|
</div>
|
||||||
|
<div class="debug-tab-content" id="debugNetworkTab" style="display:none"><div id="debugNetworkContent" class="debug-content"></div></div>
|
||||||
|
<div class="debug-tab-content" id="debugStateTab" style="display:none"><div id="debugStateContent" class="debug-content"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer debug-footer">
|
||||||
|
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
|
||||||
|
<button class="btn-small btn-danger" onclick="purgeCache()">Purge Cache</button>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:0.5rem">
|
||||||
|
<button class="btn-small" onclick="clearDebugLog()">Clear</button>
|
||||||
|
<button class="btn-small" onclick="copyDebugLog()">Copy</button>
|
||||||
|
<button class="btn-small" onclick="exportDebugLog()">Export</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Command Palette (Ctrl+K) -->
|
||||||
|
<div class="cmd-palette-overlay" id="cmdPalette">
|
||||||
|
<div class="cmd-palette">
|
||||||
|
<div class="cmd-input-wrap">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input type="text" id="cmdInput" placeholder="Type a command…" autocomplete="off" spellcheck="false">
|
||||||
|
<kbd class="cmd-kbd">esc</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="cmd-results" id="cmdResults"></div>
|
||||||
|
<div class="cmd-footer">
|
||||||
|
<span><kbd>↑↓</kbd> navigate</span>
|
||||||
|
<span><kbd>↵</kbd> run</span>
|
||||||
|
<span><kbd>esc</kbd> close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<!-- Vendor libs -->
|
||||||
|
<script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
|
||||||
|
<script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script>
|
||||||
|
|
||||||
|
<script src="js/debug.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/events.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/extensions.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/api.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/ui-format.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/ui-primitives.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/ui-core.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/ui-settings.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/attachments.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/admin-handlers.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/app.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script>
|
||||||
|
// ── Service Worker Registration ─────────
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('sw.js').then(reg => {
|
||||||
|
console.log('SW registered, scope:', reg.scope);
|
||||||
|
// Check for updates periodically
|
||||||
|
setInterval(() => reg.update(), 60 * 60 * 1000);
|
||||||
|
}).catch(err => console.warn('SW registration failed:', err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PWA Install Prompt ──────────────────
|
||||||
|
let _deferredInstallPrompt = null;
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
_deferredInstallPrompt = e;
|
||||||
|
_showInstallBanner();
|
||||||
|
});
|
||||||
|
|
||||||
|
function _showInstallBanner() {
|
||||||
|
if (document.getElementById('pwaInstallBanner')) return;
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.id = 'pwaInstallBanner';
|
||||||
|
banner.className = 'pwa-install-banner';
|
||||||
|
banner.innerHTML =
|
||||||
|
'<span>Install Chat Switchboard for a better experience</span>' +
|
||||||
|
'<button class="btn-small btn-primary" onclick="_installPWA()">Install</button>' +
|
||||||
|
'<button class="btn-small" onclick="this.parentElement.remove()">Dismiss</button>';
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _installPWA() {
|
||||||
|
if (!_deferredInstallPrompt) return;
|
||||||
|
_deferredInstallPrompt.prompt();
|
||||||
|
_deferredInstallPrompt.userChoice.then(() => {
|
||||||
|
_deferredInstallPrompt = null;
|
||||||
|
document.getElementById('pwaInstallBanner')?.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
document.getElementById('pwaInstallBanner')?.remove();
|
||||||
|
_deferredInstallPrompt = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Image lightbox overlay -->
|
||||||
|
<div class="lightbox-overlay" id="lightbox">
|
||||||
|
<button class="lightbox-close" title="Close (Esc)">✕</button>
|
||||||
|
<img id="lightboxImg" alt="">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -7,6 +7,24 @@
|
|||||||
# switchboard-admin - username, password, email (optional)
|
# switchboard-admin - username, password, email (optional)
|
||||||
# switchboard-encryption - ENCRYPTION_KEY (required for v0.9.4+)
|
# switchboard-encryption - ENCRYPTION_KEY (required for v0.9.4+)
|
||||||
#
|
#
|
||||||
|
# PVC:
|
||||||
|
# switchboard-storage - Extraction scratch (always needed)
|
||||||
|
# Requires ReadWriteMany (RWX) — see k8s/storage-pvc.yaml
|
||||||
|
# With S3 backend, PVC is small (extraction status only, not blobs)
|
||||||
|
#
|
||||||
|
# Variables (Gitea CI):
|
||||||
|
# STORAGE_CLASS - StorageClass for PVC (e.g. "cephfs")
|
||||||
|
# STORAGE_SIZE - PVC size (e.g. "10Gi" for PVC, "1Gi" for S3)
|
||||||
|
# STORAGE_BACKEND - "pvc" or "s3" (default: "pvc")
|
||||||
|
# EXTRACTION_CONCURRENCY - Max concurrent extractions (default "3")
|
||||||
|
# EXTRACTOR_IMAGE - Sidecar image (LibreOffice headless)
|
||||||
|
#
|
||||||
|
# S3 secrets (Gitea Secrets — only when STORAGE_BACKEND=s3):
|
||||||
|
# S3_ENDPOINT - e.g. "http://minio:9000" or Ceph RGW URL
|
||||||
|
# S3_BUCKET - bucket name (must exist before deploy)
|
||||||
|
# S3_ACCESS_KEY - S3 access key
|
||||||
|
# S3_SECRET_KEY - S3 secret key
|
||||||
|
#
|
||||||
# Admin bootstrap: on every pod start, if SWITCHBOARD_ADMIN_* env vars
|
# Admin bootstrap: on every pod start, if SWITCHBOARD_ADMIN_* env vars
|
||||||
# are set, the backend upserts an admin user. Change the secret and
|
# are set, the backend upserts an admin user. Change the secret and
|
||||||
# restart the pod to reset the admin password.
|
# restart the pod to reset the admin password.
|
||||||
@@ -96,6 +114,57 @@ spec:
|
|||||||
name: switchboard-encryption
|
name: switchboard-encryption
|
||||||
key: ENCRYPTION_KEY
|
key: ENCRYPTION_KEY
|
||||||
optional: true
|
optional: true
|
||||||
|
# File storage (v0.12.0+)
|
||||||
|
- name: STORAGE_BACKEND
|
||||||
|
value: "${STORAGE_BACKEND}"
|
||||||
|
- name: STORAGE_PATH
|
||||||
|
value: "/data/storage"
|
||||||
|
- name: EXTRACTION_MODE
|
||||||
|
value: "sidecar"
|
||||||
|
- name: EXTRACTION_CONCURRENCY
|
||||||
|
value: "${EXTRACTION_CONCURRENCY}"
|
||||||
|
# S3 storage (optional — only used when STORAGE_BACKEND=s3)
|
||||||
|
- name: S3_ENDPOINT
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: switchboard-s3
|
||||||
|
key: S3_ENDPOINT
|
||||||
|
optional: true
|
||||||
|
- name: S3_BUCKET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: switchboard-s3
|
||||||
|
key: S3_BUCKET
|
||||||
|
optional: true
|
||||||
|
- name: S3_ACCESS_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: switchboard-s3
|
||||||
|
key: S3_ACCESS_KEY
|
||||||
|
optional: true
|
||||||
|
- name: S3_SECRET_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: switchboard-s3
|
||||||
|
key: S3_SECRET_KEY
|
||||||
|
optional: true
|
||||||
|
- name: S3_REGION
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: switchboard-s3
|
||||||
|
key: S3_REGION
|
||||||
|
optional: true
|
||||||
|
- name: S3_PREFIX
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: switchboard-s3
|
||||||
|
key: S3_PREFIX
|
||||||
|
optional: true
|
||||||
|
- name: S3_FORCE_PATH_STYLE
|
||||||
|
value: "true"
|
||||||
|
volumeMounts:
|
||||||
|
- name: storage
|
||||||
|
mountPath: /data/storage
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "${BE_MEMORY_REQUEST}"
|
memory: "${BE_MEMORY_REQUEST}"
|
||||||
@@ -119,6 +188,34 @@ spec:
|
|||||||
periodSeconds: 30
|
periodSeconds: 30
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
|
||||||
|
# Extractor sidecar (v0.12.0 Phase 2):
|
||||||
|
# Uncomment when switchboard-extractor image is built.
|
||||||
|
# See DESIGN-0.12.0.md — Phase 2: Extraction Pipeline.
|
||||||
|
#
|
||||||
|
# - name: extractor
|
||||||
|
# image: ${EXTRACTOR_IMAGE}:${IMAGE_TAG}
|
||||||
|
# imagePullPolicy: Always
|
||||||
|
# env:
|
||||||
|
# - name: STORAGE_PATH
|
||||||
|
# value: "/data/storage"
|
||||||
|
# - name: EXTRACTION_CONCURRENCY
|
||||||
|
# value: "${EXTRACTION_CONCURRENCY}"
|
||||||
|
# volumeMounts:
|
||||||
|
# - name: storage
|
||||||
|
# mountPath: /data/storage
|
||||||
|
# resources:
|
||||||
|
# requests:
|
||||||
|
# memory: "256Mi"
|
||||||
|
# cpu: "100m"
|
||||||
|
# limits:
|
||||||
|
# memory: "1Gi"
|
||||||
|
# cpu: "1000m"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: switchboard-storage${DEPLOY_SUFFIX}
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
32
k8s/storage-pvc.yaml
Normal file
32
k8s/storage-pvc.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# k8s/storage-pvc.yaml
|
||||||
|
# ============================================
|
||||||
|
# Chat Switchboard - Storage PVC
|
||||||
|
# ============================================
|
||||||
|
# Persistent storage for file attachments, extraction queue,
|
||||||
|
# and future knowledge base / compaction data.
|
||||||
|
#
|
||||||
|
# Requires ReadWriteMany (RWX) for multi-pod backend deployments.
|
||||||
|
# CephFS is recommended; NFS also works.
|
||||||
|
#
|
||||||
|
# Variables:
|
||||||
|
# NAMESPACE - deployment namespace
|
||||||
|
# STORAGE_CLASS - StorageClass name (e.g. "cephfs")
|
||||||
|
# STORAGE_SIZE - PVC size (e.g. "10Gi")
|
||||||
|
# ENVIRONMENT - deployment environment label
|
||||||
|
# ============================================
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: switchboard-storage${DEPLOY_SUFFIX}
|
||||||
|
namespace: ${NAMESPACE}
|
||||||
|
labels:
|
||||||
|
app: switchboard
|
||||||
|
component: storage
|
||||||
|
env: ${ENVIRONMENT}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteMany
|
||||||
|
storageClassName: "${STORAGE_CLASS}"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: ${STORAGE_SIZE}
|
||||||
508
main.go
Normal file
508
main.go
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/extraction"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/handlers"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||||
|
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// ── Subcommand dispatch ──────────────────
|
||||||
|
// Usage: switchboard vault rekey
|
||||||
|
if len(os.Args) > 2 && os.Args[1] == "vault" {
|
||||||
|
runVaultCommand(os.Args[2])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "version" {
|
||||||
|
fmt.Println("switchboard", Version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server startup ──────────────────────
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
// Register LLM providers
|
||||||
|
providers.Init()
|
||||||
|
|
||||||
|
var stores store.Stores
|
||||||
|
uekCache := crypto.NewUEKCache()
|
||||||
|
var keyResolver *crypto.KeyResolver
|
||||||
|
var objStore storage.ObjectStore
|
||||||
|
|
||||||
|
if err := database.Connect(cfg); err != nil {
|
||||||
|
log.Printf("⚠ Database unavailable: %v", err)
|
||||||
|
log.Println(" Running in unmanaged mode (no persistence)")
|
||||||
|
} else {
|
||||||
|
// Schema check: init if fresh, upgrade if behind, proceed if current
|
||||||
|
if err := database.Migrate(); err != nil {
|
||||||
|
log.Fatalf("❌ Schema migration failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vault: enforce encryption key + backfill plaintext keys
|
||||||
|
if err := crypto.EnforceEncryptionKey(database.DB, cfg.EncryptionKey); err != nil {
|
||||||
|
log.Fatalf("❌ Vault check failed: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.EncryptionKey != "" {
|
||||||
|
if err := crypto.BackfillEncryptedKeys(database.DB, cfg.EncryptionKey); err != nil {
|
||||||
|
log.Fatalf("❌ Vault backfill failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive env key for key resolver (nil if not set)
|
||||||
|
var envKey []byte
|
||||||
|
if cfg.EncryptionKey != "" {
|
||||||
|
var err error
|
||||||
|
envKey, err = crypto.DeriveKeyFromEnv(cfg.EncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ Failed to derive encryption key: %v", err)
|
||||||
|
}
|
||||||
|
log.Println(" 🔐 API key encryption active")
|
||||||
|
}
|
||||||
|
keyResolver = crypto.NewKeyResolver(envKey, uekCache)
|
||||||
|
|
||||||
|
// Initialize store layer
|
||||||
|
stores = postgres.NewStores(database.DB)
|
||||||
|
|
||||||
|
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
||||||
|
handlers.BootstrapAdmin(cfg, stores)
|
||||||
|
|
||||||
|
// Seed additional users from env (dev/test only, skipped in production)
|
||||||
|
handlers.SeedUsers(cfg, stores)
|
||||||
|
|
||||||
|
// Seed builtin extensions from disk (idempotent, version-aware)
|
||||||
|
handlers.SeedBuiltinExtensions(stores, "extensions/builtin")
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// ── File Storage ─────────────────────────
|
||||||
|
// Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND
|
||||||
|
// overrides auto-detection. nil objStore = storage features disabled.
|
||||||
|
if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath); err != nil {
|
||||||
|
if cfg.StorageBackend != "" {
|
||||||
|
// Explicit backend requested but failed — fatal
|
||||||
|
log.Fatalf("❌ Storage init failed: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("⚠ Storage init failed: %v", err)
|
||||||
|
} else {
|
||||||
|
objStore = s
|
||||||
|
}
|
||||||
|
handlers.SetStorageConfigured(objStore != nil)
|
||||||
|
|
||||||
|
// ── Extraction Queue ────────────────────
|
||||||
|
// Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
|
||||||
|
// Nil if storage is disabled.
|
||||||
|
var extQueue *extraction.Queue
|
||||||
|
if objStore != nil && cfg.StoragePath != "" {
|
||||||
|
q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠ Extraction queue init failed: %v", err)
|
||||||
|
} else {
|
||||||
|
extQueue = q
|
||||||
|
// Recover items stuck in "processing" from previous crash
|
||||||
|
if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil {
|
||||||
|
log.Printf("⚠ Extraction recovery failed: %v", err)
|
||||||
|
} else if recovered > 0 {
|
||||||
|
log.Printf(" 📋 Recovered %d stale extraction items", recovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role resolver for model role dispatch (needs stores + vault)
|
||||||
|
roleResolver := roles.NewResolver(stores, keyResolver)
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
|
// ── Base path group ──────────────────────
|
||||||
|
base := r.Group(cfg.BasePath)
|
||||||
|
|
||||||
|
// ── EventBus + WebSocket Hub ─────────────
|
||||||
|
bus := events.NewBus()
|
||||||
|
hub := events.NewHub(bus)
|
||||||
|
|
||||||
|
// Health check (k8s probes hit this directly)
|
||||||
|
base.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"version": Version,
|
||||||
|
"database": database.IsConnected(),
|
||||||
|
"schema_version": database.SchemaVersion(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// WebSocket endpoint
|
||||||
|
base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
|
||||||
|
|
||||||
|
// ── Auth routes (rate limited) ──────────────
|
||||||
|
auth := handlers.NewAuthHandler(cfg, stores, uekCache)
|
||||||
|
authLimiter := middleware.NewRateLimiter(1, 5)
|
||||||
|
|
||||||
|
api := base.Group("/api/v1")
|
||||||
|
{
|
||||||
|
// Health (routable through ingress)
|
||||||
|
api.GET("/health", func(c *gin.Context) {
|
||||||
|
info := gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"version": Version,
|
||||||
|
"schema_version": database.SchemaVersion(),
|
||||||
|
"database": database.IsConnected(),
|
||||||
|
"providers": providers.List(),
|
||||||
|
}
|
||||||
|
if database.IsConnected() {
|
||||||
|
info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
|
||||||
|
}
|
||||||
|
c.JSON(200, info)
|
||||||
|
})
|
||||||
|
|
||||||
|
authGroup := api.Group("/auth")
|
||||||
|
authGroup.Use(authLimiter.Limit())
|
||||||
|
{
|
||||||
|
authGroup.POST("/register", auth.Register)
|
||||||
|
authGroup.POST("/login", auth.Login)
|
||||||
|
authGroup.POST("/refresh", auth.Refresh)
|
||||||
|
authGroup.POST("/logout", auth.Logout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public extension assets ────────────────
|
||||||
|
// Script tags can't send Authorization headers, so asset serving must be public.
|
||||||
|
// Extension JS is the same for all users — no user-specific data.
|
||||||
|
extH := handlers.NewExtensionHandler(stores)
|
||||||
|
api.GET("/extensions/:id/assets/*path", extH.ServeExtensionAsset)
|
||||||
|
|
||||||
|
// ── Protected routes ────────────────────
|
||||||
|
protected := api.Group("")
|
||||||
|
protected.Use(middleware.Auth(cfg))
|
||||||
|
{
|
||||||
|
// Channels
|
||||||
|
channels := handlers.NewChannelHandler()
|
||||||
|
protected.GET("/channels", channels.ListChannels)
|
||||||
|
protected.POST("/channels", channels.CreateChannel)
|
||||||
|
protected.GET("/channels/:id", channels.GetChannel)
|
||||||
|
protected.PUT("/channels/:id", channels.UpdateChannel)
|
||||||
|
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
|
||||||
|
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||||
|
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||||
|
|
||||||
|
// Message tree (forking)
|
||||||
|
protected.GET("/channels/:id/path", msgs.GetActivePath)
|
||||||
|
protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
|
||||||
|
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
|
||||||
|
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
|
||||||
|
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
||||||
|
|
||||||
|
// Chat Completions
|
||||||
|
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore)
|
||||||
|
protected.POST("/chat/completions", comp.Complete)
|
||||||
|
|
||||||
|
// Summarize & Continue
|
||||||
|
summarize := handlers.NewSummarizeHandler(stores, roleResolver)
|
||||||
|
protected.POST("/channels/:id/summarize", summarize.Summarize)
|
||||||
|
|
||||||
|
// Provider Configs (user-facing — replaces /api-configs)
|
||||||
|
provCfg := handlers.NewProviderConfigHandler(stores, keyResolver)
|
||||||
|
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
|
||||||
|
protected.POST("/api-configs", provCfg.CreateConfig)
|
||||||
|
protected.GET("/api-configs/:id", provCfg.GetConfig)
|
||||||
|
protected.PUT("/api-configs/:id", provCfg.UpdateConfig)
|
||||||
|
protected.DELETE("/api-configs/:id", provCfg.DeleteConfig)
|
||||||
|
protected.GET("/api-configs/:id/models", provCfg.ListModels)
|
||||||
|
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
|
||||||
|
|
||||||
|
// Models (unified resolver — replaces scattered endpoints)
|
||||||
|
modelH := handlers.NewModelHandler(stores)
|
||||||
|
protected.GET("/models/enabled", modelH.ListEnabledModels)
|
||||||
|
protected.GET("/models", modelH.ListEnabledModels) // alias
|
||||||
|
|
||||||
|
// Model Preferences
|
||||||
|
modelPrefs := handlers.NewModelPrefsHandler(stores)
|
||||||
|
protected.GET("/models/preferences", modelPrefs.GetPreferences)
|
||||||
|
protected.PUT("/models/preferences", modelPrefs.SetPreference)
|
||||||
|
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
|
||||||
|
|
||||||
|
// User Settings & Profile
|
||||||
|
settings := handlers.NewSettingsHandler(uekCache)
|
||||||
|
protected.GET("/profile", settings.GetProfile)
|
||||||
|
protected.PUT("/profile", settings.UpdateProfile)
|
||||||
|
protected.POST("/profile/password", settings.ChangePassword)
|
||||||
|
protected.POST("/profile/avatar", settings.UploadAvatar)
|
||||||
|
protected.DELETE("/profile/avatar", settings.DeleteAvatar)
|
||||||
|
protected.GET("/settings", settings.GetSettings)
|
||||||
|
protected.PUT("/settings", settings.UpdateSettings)
|
||||||
|
|
||||||
|
// Usage (personal)
|
||||||
|
usage := handlers.NewUsageHandler(stores)
|
||||||
|
protected.GET("/usage", usage.PersonalUsage)
|
||||||
|
|
||||||
|
// Personas (replaces /presets)
|
||||||
|
personas := handlers.NewPersonaHandler(stores)
|
||||||
|
protected.GET("/presets", personas.ListUserPersonas) // backward compat
|
||||||
|
protected.POST("/presets", personas.CreateUserPersona)
|
||||||
|
protected.PUT("/presets/:id", personas.UpdateUserPersona)
|
||||||
|
protected.DELETE("/presets/:id", personas.DeleteUserPersona)
|
||||||
|
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
||||||
|
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
notes := handlers.NewNoteHandler()
|
||||||
|
protected.GET("/notes", notes.List)
|
||||||
|
protected.POST("/notes", notes.Create)
|
||||||
|
protected.GET("/notes/search", notes.Search)
|
||||||
|
protected.GET("/notes/folders", notes.ListFolders)
|
||||||
|
protected.POST("/notes/bulk-delete", notes.BulkDelete)
|
||||||
|
protected.GET("/notes/:id", notes.Get)
|
||||||
|
protected.PUT("/notes/:id", notes.Update)
|
||||||
|
protected.DELETE("/notes/:id", notes.Delete)
|
||||||
|
|
||||||
|
// Attachments (file upload/download)
|
||||||
|
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||||
|
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||||
|
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
|
||||||
|
protected.GET("/attachments/:id", attachH.GetMetadata)
|
||||||
|
protected.GET("/attachments/:id/download", attachH.Download)
|
||||||
|
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
|
||||||
|
|
||||||
|
// Hook: clean up storage files when channels are deleted
|
||||||
|
handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage)
|
||||||
|
|
||||||
|
// Teams (user: my teams)
|
||||||
|
teams := handlers.NewTeamHandler(keyResolver)
|
||||||
|
protected.GET("/teams/mine", teams.MyTeams)
|
||||||
|
|
||||||
|
// Team admin self-service
|
||||||
|
teamScoped := protected.Group("/teams/:teamId")
|
||||||
|
teamScoped.Use(middleware.RequireTeamAdmin())
|
||||||
|
{
|
||||||
|
teamScoped.GET("/members", teams.ListMembers)
|
||||||
|
teamScoped.POST("/members", teams.AddMember)
|
||||||
|
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
|
||||||
|
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
||||||
|
teamScoped.GET("/models", teams.ListAvailableModels)
|
||||||
|
|
||||||
|
// Team providers
|
||||||
|
teamScoped.GET("/providers", teams.ListTeamProviders)
|
||||||
|
teamScoped.POST("/providers", teams.CreateTeamProvider)
|
||||||
|
teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider)
|
||||||
|
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||||
|
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||||
|
|
||||||
|
// Team audit log (team admins only — RequireTeamAdmin on group)
|
||||||
|
teamScoped.GET("/audit", teams.ListTeamAuditLog)
|
||||||
|
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
|
||||||
|
|
||||||
|
// Team usage (team admins only — usage against team-owned providers)
|
||||||
|
teamUsage := handlers.NewUsageHandler(stores)
|
||||||
|
teamScoped.GET("/usage", teamUsage.TeamUsage)
|
||||||
|
|
||||||
|
// Team personas
|
||||||
|
teamPersonas := handlers.NewPersonaHandler(stores)
|
||||||
|
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
|
||||||
|
teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
|
||||||
|
teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
|
||||||
|
|
||||||
|
// Team role overrides
|
||||||
|
teamRoles := handlers.NewRolesHandler(stores, roleResolver)
|
||||||
|
teamScoped.GET("/roles", teamRoles.ListTeamRoles)
|
||||||
|
teamScoped.PUT("/roles/:role", teamRoles.UpdateTeamRole)
|
||||||
|
teamScoped.DELETE("/roles/:role", teamRoles.DeleteTeamRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public global settings (non-admin users can read safe subset)
|
||||||
|
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
|
||||||
|
protected.GET("/settings/public", adm.PublicSettings)
|
||||||
|
|
||||||
|
// Extensions (user-facing)
|
||||||
|
protected.GET("/extensions", extH.ListUserExtensions)
|
||||||
|
protected.POST("/extensions/:id/settings", extH.UpdateUserExtensionSettings)
|
||||||
|
protected.GET("/extensions/:id/manifest", extH.GetExtensionManifest)
|
||||||
|
protected.GET("/extensions/tools", extH.ListBrowserToolSchemas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin routes ────────────────────────
|
||||||
|
admin := api.Group("/admin")
|
||||||
|
admin.Use(middleware.Auth(cfg))
|
||||||
|
admin.Use(middleware.RequireAdmin())
|
||||||
|
{
|
||||||
|
adm := handlers.NewAdminHandler(stores, keyResolver, uekCache)
|
||||||
|
|
||||||
|
// User management
|
||||||
|
admin.GET("/users", adm.ListUsers)
|
||||||
|
admin.POST("/users", adm.CreateUser)
|
||||||
|
admin.PUT("/users/:id/role", adm.UpdateUserRole)
|
||||||
|
admin.PUT("/users/:id/active", adm.ToggleUserActive)
|
||||||
|
admin.POST("/users/:id/reset-password", adm.ResetPassword)
|
||||||
|
admin.DELETE("/users/:id", adm.DeleteUser)
|
||||||
|
|
||||||
|
// Global settings
|
||||||
|
admin.GET("/settings", adm.ListGlobalSettings)
|
||||||
|
admin.GET("/settings/:key", adm.GetGlobalSetting)
|
||||||
|
admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
admin.GET("/stats", adm.GetStats)
|
||||||
|
|
||||||
|
// Global Provider Configs
|
||||||
|
admin.GET("/configs", adm.ListGlobalConfigs)
|
||||||
|
admin.POST("/configs", adm.CreateGlobalConfig)
|
||||||
|
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
|
||||||
|
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
|
||||||
|
|
||||||
|
// Model Catalog
|
||||||
|
admin.GET("/models", adm.ListModelConfigs)
|
||||||
|
admin.POST("/models/fetch", adm.FetchModels)
|
||||||
|
admin.PUT("/models/bulk", adm.BulkUpdateModels)
|
||||||
|
admin.PUT("/models/:id", adm.UpdateModelConfig)
|
||||||
|
admin.DELETE("/models/:id", adm.DeleteModelConfig)
|
||||||
|
|
||||||
|
// Personas (admin global)
|
||||||
|
personaAdm := handlers.NewPersonaHandler(stores)
|
||||||
|
admin.GET("/presets", personaAdm.ListAdminPersonas)
|
||||||
|
admin.POST("/presets", personaAdm.CreateAdminPersona)
|
||||||
|
admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona)
|
||||||
|
admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona)
|
||||||
|
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
||||||
|
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
||||||
|
|
||||||
|
// Teams (admin)
|
||||||
|
teamAdm := handlers.NewTeamHandler(keyResolver)
|
||||||
|
admin.GET("/teams", teamAdm.ListTeams)
|
||||||
|
admin.POST("/teams", teamAdm.CreateTeam)
|
||||||
|
admin.GET("/teams/:id", teamAdm.GetTeam)
|
||||||
|
admin.PUT("/teams/:id", teamAdm.UpdateTeam)
|
||||||
|
admin.DELETE("/teams/:id", teamAdm.DeleteTeam)
|
||||||
|
admin.GET("/teams/:id/members", teamAdm.ListMembers)
|
||||||
|
admin.POST("/teams/:id/members", teamAdm.AddMember)
|
||||||
|
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
|
||||||
|
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
admin.GET("/audit", adm.ListAuditLog)
|
||||||
|
admin.GET("/audit/actions", adm.ListAuditActions)
|
||||||
|
|
||||||
|
// Model Roles
|
||||||
|
rolesH := handlers.NewRolesHandler(stores, roleResolver)
|
||||||
|
admin.GET("/roles", rolesH.ListRoles)
|
||||||
|
admin.GET("/roles/:role", rolesH.GetRole)
|
||||||
|
admin.PUT("/roles/:role", rolesH.UpdateRole)
|
||||||
|
admin.POST("/roles/:role/test", rolesH.TestRole)
|
||||||
|
|
||||||
|
// Usage & Pricing
|
||||||
|
usageH := handlers.NewUsageHandler(stores)
|
||||||
|
admin.GET("/usage", usageH.AdminUsage)
|
||||||
|
admin.GET("/usage/users/:id", usageH.AdminUserUsage)
|
||||||
|
admin.GET("/usage/teams/:id", usageH.AdminTeamUsage)
|
||||||
|
admin.GET("/pricing", usageH.ListPricing)
|
||||||
|
admin.PUT("/pricing", usageH.UpsertPricing)
|
||||||
|
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
|
||||||
|
|
||||||
|
// Storage status
|
||||||
|
storageH := handlers.NewStorageHandler(objStore)
|
||||||
|
admin.GET("/storage/status", storageH.Status)
|
||||||
|
|
||||||
|
// Vault
|
||||||
|
admin.GET("/vault/status", adm.VaultStatus)
|
||||||
|
|
||||||
|
// Storage management (orphan cleanup)
|
||||||
|
attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||||
|
admin.GET("/storage/orphans", attachAdm.OrphanCount)
|
||||||
|
admin.POST("/storage/cleanup", attachAdm.CleanupOrphans)
|
||||||
|
admin.GET("/storage/extraction", attachAdm.ExtractionStatus)
|
||||||
|
|
||||||
|
// Extensions (admin)
|
||||||
|
extAdm := handlers.NewExtensionHandler(stores)
|
||||||
|
admin.GET("/extensions", extAdm.AdminListExtensions)
|
||||||
|
admin.POST("/extensions", extAdm.AdminInstallExtension)
|
||||||
|
admin.PUT("/extensions/:id", extAdm.AdminUpdateExtension)
|
||||||
|
admin.DELETE("/extensions/:id", extAdm.AdminUninstallExtension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bp := cfg.BasePath
|
||||||
|
if bp == "" {
|
||||||
|
bp = "/"
|
||||||
|
}
|
||||||
|
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
|
||||||
|
log.Printf(" Base path: %s", bp)
|
||||||
|
log.Printf(" Schema: %s", database.SchemaVersion())
|
||||||
|
log.Printf(" Providers: %v", providers.List())
|
||||||
|
if objStore != nil {
|
||||||
|
log.Printf(" Storage: %s", objStore.Backend())
|
||||||
|
if extQueue != nil {
|
||||||
|
log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency)
|
||||||
|
} else {
|
||||||
|
log.Printf(" Extraction: disabled")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf(" Storage: disabled")
|
||||||
|
}
|
||||||
|
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
|
||||||
|
if err := r.Run(":" + cfg.Port); err != nil {
|
||||||
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vault CLI Commands ──────────────────────
|
||||||
|
|
||||||
|
func runVaultCommand(subcmd string) {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
switch strings.ToLower(subcmd) {
|
||||||
|
case "rekey":
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
log.Fatal("DATABASE_URL is required for vault operations")
|
||||||
|
}
|
||||||
|
if err := database.Connect(cfg); err != nil {
|
||||||
|
log.Fatalf("❌ Database connection failed: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
oldKey := os.Getenv("ENCRYPTION_KEY")
|
||||||
|
newKey := os.Getenv("NEW_ENCRYPTION_KEY")
|
||||||
|
|
||||||
|
if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil {
|
||||||
|
log.Fatalf("❌ Vault rekey failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "status":
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
log.Fatal("DATABASE_URL is required for vault operations")
|
||||||
|
}
|
||||||
|
if err := database.Connect(cfg); err != nil {
|
||||||
|
log.Fatalf("❌ Database connection failed: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ Failed to get vault status: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet)
|
||||||
|
fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys)
|
||||||
|
fmt.Printf("Vault users (active): %d\n", status.VaultUsers)
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd)
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: switchboard vault <rekey|status>\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
115
rekey.go
Normal file
115
rekey.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rekey re-encrypts all global/team API keys from oldKey to newKey.
|
||||||
|
// Personal BYOK keys are unaffected (they're keyed to the user's UEK,
|
||||||
|
// not the environment-derived key).
|
||||||
|
//
|
||||||
|
// Runs in a single transaction — all-or-nothing. On failure, no keys
|
||||||
|
// are modified.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// switchboard vault rekey
|
||||||
|
// ENCRYPTION_KEY = current/old key
|
||||||
|
// NEW_ENCRYPTION_KEY = new key to re-encrypt with
|
||||||
|
func Rekey(db *sql.DB, oldEnvKey, newEnvKey string) error {
|
||||||
|
if oldEnvKey == "" {
|
||||||
|
return fmt.Errorf("ENCRYPTION_KEY (current key) is required")
|
||||||
|
}
|
||||||
|
if newEnvKey == "" {
|
||||||
|
return fmt.Errorf("NEW_ENCRYPTION_KEY is required")
|
||||||
|
}
|
||||||
|
if oldEnvKey == newEnvKey {
|
||||||
|
return fmt.Errorf("ENCRYPTION_KEY and NEW_ENCRYPTION_KEY are identical — nothing to do")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldKey, err := DeriveKeyFromEnv(oldEnvKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to derive old key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newKey, err := DeriveKeyFromEnv(newEnvKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to derive new key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() // no-op after commit
|
||||||
|
|
||||||
|
// Find all global/team keys that are encrypted
|
||||||
|
rows, err := tx.Query(`
|
||||||
|
SELECT id, api_key_enc, key_nonce
|
||||||
|
FROM provider_configs
|
||||||
|
WHERE key_scope IN ('global', 'team')
|
||||||
|
AND api_key_enc IS NOT NULL
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to query encrypted keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type rekeyRow struct {
|
||||||
|
id string
|
||||||
|
ciphertext []byte
|
||||||
|
nonce []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
var toRekey []rekeyRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r rekeyRow
|
||||||
|
if err := rows.Scan(&r.id, &r.ciphertext, &r.nonce); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("failed to scan row: %w", err)
|
||||||
|
}
|
||||||
|
toRekey = append(toRekey, r)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("row iteration error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toRekey) == 0 {
|
||||||
|
log.Println("No global/team encrypted keys found — nothing to rekey.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Rekeying %d provider config(s)...", len(toRekey))
|
||||||
|
|
||||||
|
// Decrypt with old key, re-encrypt with new key, update in place
|
||||||
|
for _, r := range toRekey {
|
||||||
|
plaintext, err := Decrypt(r.ciphertext, r.nonce, oldKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt config %s with old key: %w (is ENCRYPTION_KEY correct?)", r.id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newCiphertext, newNonce, err := Encrypt(plaintext, newKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to re-encrypt config %s: %w", r.id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE provider_configs
|
||||||
|
SET api_key_enc = $1, key_nonce = $2
|
||||||
|
WHERE id = $3
|
||||||
|
`, newCiphertext, newNonce, r.id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update config %s: %w", r.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("failed to commit rekey transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ Successfully rekeyed %d provider config(s).", len(toRekey))
|
||||||
|
log.Println("Update ENCRYPTION_KEY to the new value and restart the server.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Chat Switchboard v0.9 - Server Environment Variables
|
# Chat Switchboard v0.12 - Server Environment Variables
|
||||||
# Copy this file to .env and fill in the values
|
# Copy this file to .env and fill in the values
|
||||||
|
|
||||||
# ── Server ───────────────────────────────────
|
# ── Server ───────────────────────────────────
|
||||||
@@ -44,3 +44,22 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
|
|||||||
|
|
||||||
# ── Logging ──────────────────────────────────
|
# ── Logging ──────────────────────────────────
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# ── File Storage ─────────────────────────────
|
||||||
|
# Backend: "pvc" (local filesystem), "s3" (S3-compatible), or empty (auto-detect)
|
||||||
|
STORAGE_BACKEND=
|
||||||
|
STORAGE_PATH=/data/storage
|
||||||
|
|
||||||
|
# S3 settings (only when STORAGE_BACKEND=s3)
|
||||||
|
# Works with MinIO, Ceph RGW, AWS S3.
|
||||||
|
# S3_ENDPOINT=http://minio:9000
|
||||||
|
# S3_BUCKET=switchboard-storage
|
||||||
|
# S3_ACCESS_KEY=
|
||||||
|
# S3_SECRET_KEY=
|
||||||
|
# S3_REGION=us-east-1
|
||||||
|
# S3_PREFIX= # optional key prefix for shared buckets
|
||||||
|
# S3_FORCE_PATH_STYLE=true # required for MinIO, Ceph RGW
|
||||||
|
|
||||||
|
# ── Extraction ───────────────────────────────
|
||||||
|
# EXTRACTION_MODE=inline # "inline" or "sidecar"
|
||||||
|
# EXTRACTION_CONCURRENCY=3
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
@@ -26,6 +27,28 @@ type Config struct {
|
|||||||
// Used to derive AES-256 key for global/team provider API keys.
|
// Used to derive AES-256 key for global/team provider API keys.
|
||||||
// Personal keys use per-user UEK (derived from password).
|
// Personal keys use per-user UEK (derived from password).
|
||||||
EncryptionKey string
|
EncryptionKey string
|
||||||
|
|
||||||
|
// File storage (v0.12.0+)
|
||||||
|
// STORAGE_BACKEND: "pvc" or "s3". Empty = auto-detect (pvc if path writable).
|
||||||
|
// STORAGE_PATH: mount point for PVC backend (default /data/storage).
|
||||||
|
StorageBackend string
|
||||||
|
StoragePath string
|
||||||
|
|
||||||
|
// S3-compatible storage (v0.12.0+)
|
||||||
|
// Works with AWS S3, MinIO, Ceph RGW, or any S3-compatible API.
|
||||||
|
S3Endpoint string // custom endpoint URL (required for MinIO/Ceph, optional for AWS)
|
||||||
|
S3Bucket string // bucket name (required when STORAGE_BACKEND=s3)
|
||||||
|
S3Region string // AWS region (default "us-east-1")
|
||||||
|
S3AccessKey string // access key ID
|
||||||
|
S3SecretKey string // secret access key
|
||||||
|
S3Prefix string // optional key prefix within bucket (e.g. "switchboard/")
|
||||||
|
S3ForcePathStyle bool // use path-style URLs (required for MinIO, most self-hosted)
|
||||||
|
|
||||||
|
// Extraction pipeline (v0.12.0+)
|
||||||
|
// EXTRACTION_MODE: "inline" (in-process) or "sidecar" (shared PVC watcher).
|
||||||
|
// EXTRACTION_CONCURRENCY: max concurrent extraction jobs (default 3).
|
||||||
|
ExtractionMode string
|
||||||
|
ExtractionConcurrency int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables.
|
// Load reads configuration from environment variables.
|
||||||
@@ -45,6 +68,18 @@ func Load() *Config {
|
|||||||
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
|
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
|
||||||
SeedUsers: getEnv("SEED_USERS", ""),
|
SeedUsers: getEnv("SEED_USERS", ""),
|
||||||
EncryptionKey: getEnv("ENCRYPTION_KEY", ""),
|
EncryptionKey: getEnv("ENCRYPTION_KEY", ""),
|
||||||
|
|
||||||
|
StorageBackend: getEnv("STORAGE_BACKEND", ""),
|
||||||
|
StoragePath: getEnv("STORAGE_PATH", "/data/storage"),
|
||||||
|
S3Endpoint: getEnv("S3_ENDPOINT", ""),
|
||||||
|
S3Bucket: getEnv("S3_BUCKET", ""),
|
||||||
|
S3Region: getEnv("S3_REGION", "us-east-1"),
|
||||||
|
S3AccessKey: getEnv("S3_ACCESS_KEY", ""),
|
||||||
|
S3SecretKey: getEnv("S3_SECRET_KEY", ""),
|
||||||
|
S3Prefix: getEnv("S3_PREFIX", ""),
|
||||||
|
S3ForcePathStyle: getEnv("S3_FORCE_PATH_STYLE", "true") == "true",
|
||||||
|
ExtractionMode: getEnv("EXTRACTION_MODE", "inline"),
|
||||||
|
ExtractionConcurrency: getEnvInt("EXTRACTION_CONCURRENCY", 3),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,3 +106,12 @@ func getEnv(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, fallback int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|||||||
115
server/crypto/rekey.go
Normal file
115
server/crypto/rekey.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rekey re-encrypts all global/team API keys from oldKey to newKey.
|
||||||
|
// Personal BYOK keys are unaffected (they're keyed to the user's UEK,
|
||||||
|
// not the environment-derived key).
|
||||||
|
//
|
||||||
|
// Runs in a single transaction — all-or-nothing. On failure, no keys
|
||||||
|
// are modified.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// switchboard vault rekey
|
||||||
|
// ENCRYPTION_KEY = current/old key
|
||||||
|
// NEW_ENCRYPTION_KEY = new key to re-encrypt with
|
||||||
|
func Rekey(db *sql.DB, oldEnvKey, newEnvKey string) error {
|
||||||
|
if oldEnvKey == "" {
|
||||||
|
return fmt.Errorf("ENCRYPTION_KEY (current key) is required")
|
||||||
|
}
|
||||||
|
if newEnvKey == "" {
|
||||||
|
return fmt.Errorf("NEW_ENCRYPTION_KEY is required")
|
||||||
|
}
|
||||||
|
if oldEnvKey == newEnvKey {
|
||||||
|
return fmt.Errorf("ENCRYPTION_KEY and NEW_ENCRYPTION_KEY are identical — nothing to do")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldKey, err := DeriveKeyFromEnv(oldEnvKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to derive old key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newKey, err := DeriveKeyFromEnv(newEnvKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to derive new key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback() // no-op after commit
|
||||||
|
|
||||||
|
// Find all global/team keys that are encrypted
|
||||||
|
rows, err := tx.Query(`
|
||||||
|
SELECT id, api_key_enc, key_nonce
|
||||||
|
FROM provider_configs
|
||||||
|
WHERE key_scope IN ('global', 'team')
|
||||||
|
AND api_key_enc IS NOT NULL
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to query encrypted keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type rekeyRow struct {
|
||||||
|
id string
|
||||||
|
ciphertext []byte
|
||||||
|
nonce []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
var toRekey []rekeyRow
|
||||||
|
for rows.Next() {
|
||||||
|
var r rekeyRow
|
||||||
|
if err := rows.Scan(&r.id, &r.ciphertext, &r.nonce); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("failed to scan row: %w", err)
|
||||||
|
}
|
||||||
|
toRekey = append(toRekey, r)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("row iteration error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toRekey) == 0 {
|
||||||
|
log.Println("No global/team encrypted keys found — nothing to rekey.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Rekeying %d provider config(s)...", len(toRekey))
|
||||||
|
|
||||||
|
// Decrypt with old key, re-encrypt with new key, update in place
|
||||||
|
for _, r := range toRekey {
|
||||||
|
plaintext, err := Decrypt(r.ciphertext, r.nonce, oldKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt config %s with old key: %w (is ENCRYPTION_KEY correct?)", r.id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newCiphertext, newNonce, err := Encrypt(plaintext, newKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to re-encrypt config %s: %w", r.id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE provider_configs
|
||||||
|
SET api_key_enc = $1, key_nonce = $2
|
||||||
|
WHERE id = $3
|
||||||
|
`, newCiphertext, newNonce, r.id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update config %s: %w", r.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("failed to commit rekey transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("✅ Successfully rekeyed %d provider config(s).", len(toRekey))
|
||||||
|
log.Println("Update ENCRYPTION_KEY to the new value and restart the server.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
46
server/crypto/status.go
Normal file
46
server/crypto/status.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VaultStatusInfo holds vault health information for admin display.
|
||||||
|
type VaultStatusInfo struct {
|
||||||
|
EncryptionKeySet bool `json:"encryption_key_set"`
|
||||||
|
EncryptedKeys int `json:"encrypted_keys"` // provider_configs with api_key_enc
|
||||||
|
VaultUsers int `json:"vault_users"` // users with vault_set = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaultStatus gathers vault health metrics from the database.
|
||||||
|
// Used by both the CLI (`switchboard vault status`) and the admin API endpoint.
|
||||||
|
func VaultStatus(db *sql.DB, encryptionKey string) (*VaultStatusInfo, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, fmt.Errorf("database not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &VaultStatusInfo{
|
||||||
|
EncryptionKeySet: encryptionKey != "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count encrypted provider keys (global + team + personal)
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM provider_configs
|
||||||
|
WHERE api_key_enc IS NOT NULL
|
||||||
|
`).Scan(&info.EncryptedKeys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("count encrypted keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count users with active vaults
|
||||||
|
err = db.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM users
|
||||||
|
WHERE vault_set = true
|
||||||
|
`).Scan(&info.VaultUsers)
|
||||||
|
if err != nil {
|
||||||
|
// vault_set column might not exist on very old installs
|
||||||
|
info.VaultUsers = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
32
server/database/migrations/007_attachments.sql
Normal file
32
server/database/migrations/007_attachments.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- 007_attachments.sql
|
||||||
|
-- File attachments for chat messages (v0.12.0)
|
||||||
|
--
|
||||||
|
-- Blobs live in object storage (PVC / S3). This table holds metadata.
|
||||||
|
-- Access control: always join through channels — attachments inherit
|
||||||
|
-- channel membership as their access boundary.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
content_type VARCHAR(127) NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
storage_key TEXT NOT NULL,
|
||||||
|
extracted_text TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Primary access pattern: find attachments for a channel (auth check joins here)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachments_channel ON attachments(channel_id);
|
||||||
|
|
||||||
|
-- Quota calculation: SUM(size_bytes) WHERE user_id = $1
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachments_user_size ON attachments(user_id);
|
||||||
|
|
||||||
|
-- Find attachments for a specific message
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id) WHERE message_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Orphan cleanup: find unlinked attachments older than threshold
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE message_id IS NULL;
|
||||||
294
server/extraction/queue.go
Normal file
294
server/extraction/queue.go
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
package extraction
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Status Constants ───────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending = "pending"
|
||||||
|
StatusProcessing = "processing"
|
||||||
|
StatusComplete = "complete"
|
||||||
|
StatusFailed = "failed"
|
||||||
|
StatusSkipped = "skipped" // not extractable (images, etc.)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Queue Item ─────────────────────────────
|
||||||
|
|
||||||
|
// QueueItem is the status.json written to the processing directory.
|
||||||
|
// The sidecar extractor watches this directory for pending items.
|
||||||
|
type QueueItem struct {
|
||||||
|
AttachmentID string `json:"attachment_id"`
|
||||||
|
StorageKey string `json:"storage_key"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
OutputKey string `json:"output_key,omitempty"` // path to extracted text
|
||||||
|
QueuedAt string `json:"queued_at"`
|
||||||
|
StartedAt string `json:"started_at,omitempty"`
|
||||||
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Queue Manager ──────────────────────────
|
||||||
|
|
||||||
|
// Queue manages the filesystem-based extraction queue.
|
||||||
|
// Processing state lives in {storagePath}/processing/{attachment_id}/status.json.
|
||||||
|
// The sidecar extractor watches for pending items and updates status.
|
||||||
|
type Queue struct {
|
||||||
|
storagePath string
|
||||||
|
concurrency int
|
||||||
|
sem chan struct{} // semaphore for concurrent extraction limit
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQueue creates a new extraction queue manager.
|
||||||
|
// storagePath is the PVC mount (e.g., /data/storage).
|
||||||
|
// concurrency limits parallel extractions (default 3).
|
||||||
|
func NewQueue(storagePath string, concurrency int) (*Queue, error) {
|
||||||
|
if concurrency <= 0 {
|
||||||
|
concurrency = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
procDir := filepath.Join(storagePath, "processing")
|
||||||
|
if err := os.MkdirAll(procDir, 0750); err != nil {
|
||||||
|
return nil, fmt.Errorf("extraction: create processing dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Queue{
|
||||||
|
storagePath: storagePath,
|
||||||
|
concurrency: concurrency,
|
||||||
|
sem: make(chan struct{}, concurrency),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue adds an attachment to the extraction queue.
|
||||||
|
// Creates {storagePath}/processing/{id}/status.json with status "pending".
|
||||||
|
func (q *Queue) Enqueue(attachmentID, storageKey, contentType, filename string) error {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
itemDir := filepath.Join(q.storagePath, "processing", attachmentID)
|
||||||
|
if err := os.MkdirAll(itemDir, 0750); err != nil {
|
||||||
|
return fmt.Errorf("extraction: create item dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := QueueItem{
|
||||||
|
AttachmentID: attachmentID,
|
||||||
|
StorageKey: storageKey,
|
||||||
|
ContentType: contentType,
|
||||||
|
Filename: filename,
|
||||||
|
Status: StatusPending,
|
||||||
|
QueuedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
return q.writeStatus(attachmentID, &item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus reads the current extraction status for an attachment.
|
||||||
|
func (q *Queue) GetStatus(attachmentID string) (*QueueItem, error) {
|
||||||
|
statusPath := filepath.Join(q.storagePath, "processing", attachmentID, "status.json")
|
||||||
|
data, err := os.ReadFile(statusPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil // not queued
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("extraction: read status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var item QueueItem
|
||||||
|
if err := json.Unmarshal(data, &item); err != nil {
|
||||||
|
return nil, fmt.Errorf("extraction: parse status: %w", err)
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkComplete updates status to complete and records the output key.
|
||||||
|
func (q *Queue) MarkComplete(attachmentID, outputKey string) error {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
item, err := q.GetStatus(attachmentID)
|
||||||
|
if err != nil || item == nil {
|
||||||
|
return fmt.Errorf("extraction: item not found: %s", attachmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Status = StatusComplete
|
||||||
|
item.OutputKey = outputKey
|
||||||
|
item.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
return q.writeStatus(attachmentID, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkFailed updates status to failed with an error message.
|
||||||
|
func (q *Queue) MarkFailed(attachmentID, errMsg string) error {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
item, err := q.GetStatus(attachmentID)
|
||||||
|
if err != nil || item == nil {
|
||||||
|
return fmt.Errorf("extraction: item not found: %s", attachmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Status = StatusFailed
|
||||||
|
item.Error = errMsg
|
||||||
|
item.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
return q.writeStatus(attachmentID, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup removes the processing directory for a completed/failed item.
|
||||||
|
func (q *Queue) Cleanup(attachmentID string) error {
|
||||||
|
itemDir := filepath.Join(q.storagePath, "processing", attachmentID)
|
||||||
|
return os.RemoveAll(itemDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoverStale scans for items stuck in "processing" state (crash recovery).
|
||||||
|
// Items older than maxAge are reset to "pending" for re-processing.
|
||||||
|
func (q *Queue) RecoverStale(maxAge time.Duration) (int, error) {
|
||||||
|
procDir := filepath.Join(q.storagePath, "processing")
|
||||||
|
entries, err := os.ReadDir(procDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
recovered := 0
|
||||||
|
cutoff := time.Now().Add(-maxAge)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, err := q.GetStatus(entry.Name())
|
||||||
|
if err != nil || item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Status != StatusProcessing {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if started_at is older than maxAge
|
||||||
|
if item.StartedAt != "" {
|
||||||
|
startedAt, err := time.Parse(time.RFC3339, item.StartedAt)
|
||||||
|
if err == nil && startedAt.Before(cutoff) {
|
||||||
|
q.mu.Lock()
|
||||||
|
item.Status = StatusPending
|
||||||
|
item.StartedAt = ""
|
||||||
|
item.Error = "recovered from stale processing state"
|
||||||
|
q.writeStatus(entry.Name(), item)
|
||||||
|
q.mu.Unlock()
|
||||||
|
recovered++
|
||||||
|
log.Printf("extraction: recovered stale item %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return recovered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPending returns all items with status "pending".
|
||||||
|
func (q *Queue) ListPending() ([]QueueItem, error) {
|
||||||
|
return q.listByStatus(StatusPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll returns all items in the processing directory.
|
||||||
|
func (q *Queue) ListAll() ([]QueueItem, error) {
|
||||||
|
procDir := filepath.Join(q.storagePath, "processing")
|
||||||
|
entries, err := os.ReadDir(procDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []QueueItem
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, err := q.GetStatus(entry.Name())
|
||||||
|
if err != nil || item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, *item)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal Helpers ───────────────────────
|
||||||
|
|
||||||
|
func (q *Queue) writeStatus(attachmentID string, item *QueueItem) error {
|
||||||
|
statusPath := filepath.Join(q.storagePath, "processing", attachmentID, "status.json")
|
||||||
|
data, err := json.MarshalIndent(item, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic write: temp + rename
|
||||||
|
tmpPath := statusPath + ".tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, data, 0640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmpPath, statusPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) listByStatus(status string) ([]QueueItem, error) {
|
||||||
|
all, err := q.ListAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []QueueItem
|
||||||
|
for _, item := range all {
|
||||||
|
if item.Status == status {
|
||||||
|
filtered = append(filtered, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extractable Check ──────────────────────
|
||||||
|
|
||||||
|
// IsExtractable returns true if the content type supports text extraction.
|
||||||
|
// Images and plain text do not need extraction (handled inline by upload handler).
|
||||||
|
var extractableTypes = map[string]bool{
|
||||||
|
"application/pdf": true,
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
|
||||||
|
"application/msword": true,
|
||||||
|
"application/vnd.ms-excel": true,
|
||||||
|
"application/vnd.oasis.opendocument.text": true,
|
||||||
|
"application/vnd.oasis.opendocument.spreadsheet": true,
|
||||||
|
"application/vnd.oasis.opendocument.presentation": true,
|
||||||
|
"application/rtf": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExtractable returns true if the given content type requires sidecar extraction.
|
||||||
|
func IsExtractable(contentType string) bool {
|
||||||
|
return extractableTypes[contentType]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Null Queue ─────────────────────────────
|
||||||
|
|
||||||
|
// NullQueue is a no-op implementation for when extraction is disabled.
|
||||||
|
type NullQueue struct{}
|
||||||
|
|
||||||
|
func (NullQueue) Enqueue(_, _, _, _ string) error { return nil }
|
||||||
|
func (NullQueue) GetStatus(_ string) (*QueueItem, error) { return nil, nil }
|
||||||
|
func (NullQueue) MarkComplete(_, _ string) error { return nil }
|
||||||
|
func (NullQueue) MarkFailed(_, _ string) error { return nil }
|
||||||
|
func (NullQueue) Cleanup(_ string) error { return nil }
|
||||||
|
func (NullQueue) RecoverStale(_ time.Duration) (int, error) { return 0, nil }
|
||||||
|
func (NullQueue) ListPending() ([]QueueItem, error) { return nil, nil }
|
||||||
|
func (NullQueue) ListAll() ([]QueueItem, error) { return nil, nil }
|
||||||
240
server/extraction/queue_test.go
Normal file
240
server/extraction/queue_test.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package extraction
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tempQueue(t *testing.T) *Queue {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
q, err := NewQueue(dir, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewQueue: %v", err)
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnqueue_CreatesStatusFile(t *testing.T) {
|
||||||
|
q := tempQueue(t)
|
||||||
|
|
||||||
|
err := q.Enqueue("att-123", "attachments/ch/att-123_doc.pdf", "application/pdf", "doc.pdf")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := q.GetStatus("att-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetStatus: %v", err)
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
t.Fatal("expected non-nil item")
|
||||||
|
}
|
||||||
|
if item.Status != StatusPending {
|
||||||
|
t.Errorf("status = %q, want %q", item.Status, StatusPending)
|
||||||
|
}
|
||||||
|
if item.AttachmentID != "att-123" {
|
||||||
|
t.Errorf("attachment_id = %q, want att-123", item.AttachmentID)
|
||||||
|
}
|
||||||
|
if item.ContentType != "application/pdf" {
|
||||||
|
t.Errorf("content_type = %q, want application/pdf", item.ContentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetStatus_NotQueued(t *testing.T) {
|
||||||
|
q := tempQueue(t)
|
||||||
|
|
||||||
|
item, err := q.GetStatus("nonexistent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetStatus: %v", err)
|
||||||
|
}
|
||||||
|
if item != nil {
|
||||||
|
t.Error("expected nil for non-queued item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkComplete(t *testing.T) {
|
||||||
|
q := tempQueue(t)
|
||||||
|
q.Enqueue("att-456", "key", "application/pdf", "test.pdf")
|
||||||
|
|
||||||
|
err := q.MarkComplete("att-456", "processing/att-456/extracted.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarkComplete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item, _ := q.GetStatus("att-456")
|
||||||
|
if item.Status != StatusComplete {
|
||||||
|
t.Errorf("status = %q, want %q", item.Status, StatusComplete)
|
||||||
|
}
|
||||||
|
if item.OutputKey != "processing/att-456/extracted.txt" {
|
||||||
|
t.Errorf("output_key = %q", item.OutputKey)
|
||||||
|
}
|
||||||
|
if item.CompletedAt == "" {
|
||||||
|
t.Error("completed_at should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkFailed(t *testing.T) {
|
||||||
|
q := tempQueue(t)
|
||||||
|
q.Enqueue("att-789", "key", "application/pdf", "test.pdf")
|
||||||
|
|
||||||
|
err := q.MarkFailed("att-789", "libreoffice crashed")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarkFailed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item, _ := q.GetStatus("att-789")
|
||||||
|
if item.Status != StatusFailed {
|
||||||
|
t.Errorf("status = %q, want %q", item.Status, StatusFailed)
|
||||||
|
}
|
||||||
|
if item.Error != "libreoffice crashed" {
|
||||||
|
t.Errorf("error = %q", item.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanup_RemovesDirectory(t *testing.T) {
|
||||||
|
q := tempQueue(t)
|
||||||
|
q.Enqueue("att-cleanup", "key", "application/pdf", "test.pdf")
|
||||||
|
|
||||||
|
// Verify directory exists
|
||||||
|
dir := filepath.Join(q.storagePath, "processing", "att-cleanup")
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
t.Fatal("expected processing dir to exist before cleanup")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.Cleanup("att-cleanup"); err != nil {
|
||||||
|
t.Fatalf("Cleanup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected processing dir to be removed after cleanup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPending(t *testing.T) {
|
||||||
|
q := tempQueue(t)
|
||||||
|
q.Enqueue("att-a", "key-a", "application/pdf", "a.pdf")
|
||||||
|
q.Enqueue("att-b", "key-b", "application/pdf", "b.pdf")
|
||||||
|
q.MarkComplete("att-b", "out")
|
||||||
|
|
||||||
|
pending, err := q.ListPending()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPending: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 {
|
||||||
|
t.Fatalf("expected 1 pending, got %d", len(pending))
|
||||||
|
}
|
||||||
|
if pending[0].AttachmentID != "att-a" {
|
||||||
|
t.Errorf("pending[0].AttachmentID = %q, want att-a", pending[0].AttachmentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAll(t *testing.T) {
|
||||||
|
q := tempQueue(t)
|
||||||
|
q.Enqueue("att-1", "key-1", "application/pdf", "1.pdf")
|
||||||
|
q.Enqueue("att-2", "key-2", "application/pdf", "2.pdf")
|
||||||
|
q.Enqueue("att-3", "key-3", "application/pdf", "3.pdf")
|
||||||
|
|
||||||
|
all, err := q.ListAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAll: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Errorf("expected 3 items, got %d", len(all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverStale(t *testing.T) {
|
||||||
|
q := tempQueue(t)
|
||||||
|
|
||||||
|
// Create item and manually set it to "processing" with old timestamp
|
||||||
|
q.Enqueue("att-stale", "key", "application/pdf", "stale.pdf")
|
||||||
|
item, _ := q.GetStatus("att-stale")
|
||||||
|
item.Status = StatusProcessing
|
||||||
|
item.StartedAt = time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339)
|
||||||
|
q.mu.Lock()
|
||||||
|
q.writeStatus("att-stale", item)
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
// Recover items stale for > 1 hour
|
||||||
|
recovered, err := q.RecoverStale(1 * time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecoverStale: %v", err)
|
||||||
|
}
|
||||||
|
if recovered != 1 {
|
||||||
|
t.Errorf("recovered = %d, want 1", recovered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's back to pending
|
||||||
|
item, _ = q.GetStatus("att-stale")
|
||||||
|
if item.Status != StatusPending {
|
||||||
|
t.Errorf("status = %q, want %q", item.Status, StatusPending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverStale_SkipsRecent(t *testing.T) {
|
||||||
|
q := tempQueue(t)
|
||||||
|
|
||||||
|
// Create item "processing" but recent (not stale)
|
||||||
|
q.Enqueue("att-recent", "key", "application/pdf", "recent.pdf")
|
||||||
|
item, _ := q.GetStatus("att-recent")
|
||||||
|
item.Status = StatusProcessing
|
||||||
|
item.StartedAt = time.Now().Add(-5 * time.Minute).UTC().Format(time.RFC3339)
|
||||||
|
q.mu.Lock()
|
||||||
|
q.writeStatus("att-recent", item)
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
recovered, err := q.RecoverStale(1 * time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecoverStale: %v", err)
|
||||||
|
}
|
||||||
|
if recovered != 0 {
|
||||||
|
t.Errorf("recovered = %d, want 0 (item is recent)", recovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExtractable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
contentType string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"application/pdf", true},
|
||||||
|
{"application/vnd.openxmlformats-officedocument.wordprocessingml.document", true},
|
||||||
|
{"image/png", false},
|
||||||
|
{"text/plain", false},
|
||||||
|
{"image/jpeg", false},
|
||||||
|
{"application/rtf", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := IsExtractable(tc.contentType)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("IsExtractable(%q) = %v, want %v", tc.contentType, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAtomicWrite(t *testing.T) {
|
||||||
|
q := tempQueue(t)
|
||||||
|
|
||||||
|
// Enqueue and verify the file is valid JSON
|
||||||
|
q.Enqueue("att-atomic", "key", "application/pdf", "test.pdf")
|
||||||
|
|
||||||
|
statusPath := filepath.Join(q.storagePath, "processing", "att-atomic", "status.json")
|
||||||
|
data, err := os.ReadFile(statusPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no .tmp file left behind
|
||||||
|
tmpPath := statusPath + ".tmp"
|
||||||
|
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
|
||||||
|
t.Error("temp file should not exist after atomic write")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Error("status.json should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/minio/minio-go/v7 v7.0.82
|
||||||
golang.org/x/crypto v0.14.0
|
golang.org/x/crypto v0.14.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
|||||||
"banner": banner,
|
"banner": banner,
|
||||||
"branding": branding,
|
"branding": branding,
|
||||||
"has_admin_prompt": hasAdminPrompt,
|
"has_admin_prompt": hasAdminPrompt,
|
||||||
|
"storage_configured": storageConfigured,
|
||||||
"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"],
|
||||||
@@ -288,6 +289,22 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Vault Status ────────────────────────────
|
||||||
|
|
||||||
|
func (h *AdminHandler) VaultStatus(c *gin.Context) {
|
||||||
|
hasKey := h.vault != nil && h.vault.HasEnvKey()
|
||||||
|
keyStr := ""
|
||||||
|
if hasKey {
|
||||||
|
keyStr = "set" // non-empty signals to VaultStatus that key is configured
|
||||||
|
}
|
||||||
|
status, err := crypto.VaultStatus(database.DB, keyStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Provider Configs (Global) ───────────────
|
// ── Provider Configs (Global) ───────────────
|
||||||
|
|
||||||
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
|
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
|
||||||
|
|||||||
476
server/handlers/attachments.go
Normal file
476
server/handlers/attachments.go
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/extraction"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Default Limits ─────────────────────────
|
||||||
|
// Overridable via global_settings keys.
|
||||||
|
const (
|
||||||
|
defaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||||
|
defaultMaxUploadSize = 50 * 1024 * 1024 // 50 MB total per request (future: multi-file)
|
||||||
|
defaultMaxAttachmentsPerMsg = 5 // future: multi-file per message
|
||||||
|
defaultOrphanMaxAge = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// allowedMIMETypes is the default allowlist. Admin can override via global_settings.
|
||||||
|
var allowedMIMETypes = map[string]bool{
|
||||||
|
// Images
|
||||||
|
"image/jpeg": true, "image/png": true, "image/gif": true,
|
||||||
|
"image/webp": true, "image/svg+xml": true,
|
||||||
|
// Documents
|
||||||
|
"application/pdf": true,
|
||||||
|
"text/plain": true, "text/markdown": true, "text/csv": true,
|
||||||
|
// Microsoft Office
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
|
||||||
|
"application/msword": true, "application/vnd.ms-excel": true,
|
||||||
|
// OpenDocument
|
||||||
|
"application/vnd.oasis.opendocument.text": true,
|
||||||
|
"application/vnd.oasis.opendocument.spreadsheet": true,
|
||||||
|
"application/vnd.oasis.opendocument.presentation": true,
|
||||||
|
// Other
|
||||||
|
"application/rtf": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler ────────────────────────────────
|
||||||
|
|
||||||
|
type AttachmentHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
objStore storage.ObjectStore
|
||||||
|
extQueue *extraction.Queue // nil if extraction disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAttachmentHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *AttachmentHandler {
|
||||||
|
return &AttachmentHandler{stores: stores, objStore: objStore, extQueue: extQueue}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upload ─────────────────────────────────
|
||||||
|
// POST /api/v1/channels/:id/attachments
|
||||||
|
// Multipart form: file field "file", returns attachment metadata.
|
||||||
|
func (h *AttachmentHandler) Upload(c *gin.Context) {
|
||||||
|
if h.objStore == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(c)
|
||||||
|
channelID := c.Param("id")
|
||||||
|
|
||||||
|
// Verify channel ownership
|
||||||
|
if !h.verifyChannelAccess(c, channelID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse multipart
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Size check
|
||||||
|
if header.Size > defaultMaxFileSize {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("file too large (max %d MB)", defaultMaxFileSize/(1024*1024)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME detection: read first 512 bytes for sniffing, then reset
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
n, _ := file.Read(buf)
|
||||||
|
detectedType := http.DetectContentType(buf[:n])
|
||||||
|
|
||||||
|
// Reset reader to start
|
||||||
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize MIME type (strip params like charset)
|
||||||
|
contentType := detectedType
|
||||||
|
if idx := strings.Index(contentType, ";"); idx > 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
// For types that DetectContentType can't distinguish (returns application/octet-stream),
|
||||||
|
// fall back to extension-based detection
|
||||||
|
if contentType == "application/octet-stream" {
|
||||||
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
if mapped, ok := extToMIME[ext]; ok {
|
||||||
|
contentType = mapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowlist check
|
||||||
|
if !allowedMIMETypes[contentType] {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": fmt.Sprintf("file type %q not allowed", contentType),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build storage key: attachments/{channel_id}/{attachment_id}_{filename}
|
||||||
|
// We generate the ID first via a temp UUID, then use it in the key.
|
||||||
|
att := &models.Attachment{
|
||||||
|
ChannelID: channelID,
|
||||||
|
UserID: userID,
|
||||||
|
Filename: sanitizeFilename(header.Filename),
|
||||||
|
ContentType: contentType,
|
||||||
|
SizeBytes: header.Size,
|
||||||
|
Metadata: models.JSONMap{
|
||||||
|
"extraction_status": "pending",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create PG row first to get the UUID
|
||||||
|
// storage_key is set after we have the ID
|
||||||
|
att.StorageKey = "placeholder"
|
||||||
|
if err := h.stores.Attachments.Create(c.Request.Context(), att); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create attachment record"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now build the real storage key and update
|
||||||
|
att.StorageKey = fmt.Sprintf("attachments/%s/%s_%s", channelID, att.ID, att.Filename)
|
||||||
|
|
||||||
|
// Write to object store
|
||||||
|
if err := h.objStore.Put(c.Request.Context(), att.StorageKey, file, header.Size, contentType); err != nil {
|
||||||
|
// Rollback PG row on storage failure
|
||||||
|
h.stores.Attachments.Delete(c.Request.Context(), att.ID)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update storage_key in PG
|
||||||
|
database.DB.ExecContext(c.Request.Context(),
|
||||||
|
`UPDATE attachments SET storage_key = $1 WHERE id = $2`,
|
||||||
|
att.StorageKey, att.ID)
|
||||||
|
|
||||||
|
// For images, mark extraction as not needed (complete immediately)
|
||||||
|
if strings.HasPrefix(contentType, "image/") {
|
||||||
|
h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
|
||||||
|
"extraction_status": "complete",
|
||||||
|
})
|
||||||
|
att.Metadata["extraction_status"] = "complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
// For text/plain, extract inline (trivial — just read the file)
|
||||||
|
if contentType == "text/plain" || contentType == "text/markdown" || contentType == "text/csv" {
|
||||||
|
if _, err := file.Seek(0, io.SeekStart); err == nil {
|
||||||
|
if textBytes, err := io.ReadAll(file); err == nil {
|
||||||
|
text := string(textBytes)
|
||||||
|
h.stores.Attachments.SetExtractedText(c.Request.Context(), att.ID, text)
|
||||||
|
h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
|
||||||
|
"extraction_status": "complete",
|
||||||
|
})
|
||||||
|
att.Metadata["extraction_status"] = "complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For documents requiring extraction (PDF, DOCX, etc.), enqueue for sidecar
|
||||||
|
if extraction.IsExtractable(contentType) && h.extQueue != nil {
|
||||||
|
if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil {
|
||||||
|
log.Printf("extraction enqueue failed for %s: %v", att.ID, err)
|
||||||
|
// Non-fatal: file is uploaded, just won't have extracted text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, att)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Download ───────────────────────────────
|
||||||
|
// GET /api/v1/attachments/:id/download
|
||||||
|
// Streams file content with auth check via channel membership.
|
||||||
|
func (h *AttachmentHandler) Download(c *gin.Context) {
|
||||||
|
if h.objStore == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(c)
|
||||||
|
attID := c.Param("id")
|
||||||
|
|
||||||
|
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel-scoped access check
|
||||||
|
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, size, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
c.Header("Content-Type", att.ContentType)
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename))
|
||||||
|
c.Header("Content-Length", fmt.Sprintf("%d", size))
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
io.Copy(c.Writer, reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Get Metadata ───────────────────────────
|
||||||
|
// GET /api/v1/attachments/:id
|
||||||
|
func (h *AttachmentHandler) GetMetadata(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
attID := c.Param("id")
|
||||||
|
|
||||||
|
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, att)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List Channel Attachments ───────────────
|
||||||
|
// GET /api/v1/channels/:id/attachments
|
||||||
|
func (h *AttachmentHandler) ListByChannel(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
channelID := c.Param("id")
|
||||||
|
|
||||||
|
if !h.verifyChannelAccess(c, channelID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments, err := h.stores.Attachments.GetByChannel(c.Request.Context(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list attachments"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if attachments == nil {
|
||||||
|
attachments = []models.Attachment{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"attachments": attachments})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ─────────────────────────────────
|
||||||
|
// DELETE /api/v1/attachments/:id
|
||||||
|
func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) {
|
||||||
|
userID := getUserID(c)
|
||||||
|
attID := c.Param("id")
|
||||||
|
|
||||||
|
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from PG (returns the row for storage cleanup)
|
||||||
|
deleted, err := h.stores.Attachments.Delete(c.Request.Context(), attID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete attachment"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up storage (async-safe: fire and forget with background context)
|
||||||
|
if h.objStore != nil && deleted != nil {
|
||||||
|
storageKey := deleted.StorageKey
|
||||||
|
go func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := h.objStore.Delete(ctx, storageKey); err != nil {
|
||||||
|
log.Printf("storage cleanup failed for %s: %v", storageKey, err)
|
||||||
|
}
|
||||||
|
// Also clean up thumbnail if it exists
|
||||||
|
h.objStore.Delete(ctx, storageKey+"_thumb.jpg")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "attachment deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: Orphan Cleanup ──────────────────
|
||||||
|
// POST /admin/storage/cleanup
|
||||||
|
func (h *AttachmentHandler) CleanupOrphans(c *gin.Context) {
|
||||||
|
orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orphans"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted := 0
|
||||||
|
var freedBytes int64
|
||||||
|
|
||||||
|
for _, att := range orphans {
|
||||||
|
if _, err := h.stores.Attachments.Delete(c.Request.Context(), att.ID); err != nil {
|
||||||
|
log.Printf("orphan cleanup: failed to delete %s from PG: %v", att.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if h.objStore != nil {
|
||||||
|
h.objStore.Delete(c.Request.Context(), att.StorageKey)
|
||||||
|
h.objStore.Delete(c.Request.Context(), att.StorageKey+"_thumb.jpg")
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
freedBytes += att.SizeBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"deleted": deleted,
|
||||||
|
"freed_bytes": freedBytes,
|
||||||
|
"scanned": len(orphans),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: Orphan Count ────────────────────
|
||||||
|
// GET /admin/storage/orphans (for the admin panel card)
|
||||||
|
func (h *AttachmentHandler) OrphanCount(c *gin.Context) {
|
||||||
|
orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count orphans"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalBytes int64
|
||||||
|
for _, att := range orphans {
|
||||||
|
totalBytes += att.SizeBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"count": len(orphans),
|
||||||
|
"reclaimable_bytes": totalBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin: Extraction Queue Status ─────────
|
||||||
|
// GET /admin/storage/extraction
|
||||||
|
func (h *AttachmentHandler) ExtractionStatus(c *gin.Context) {
|
||||||
|
if h.extQueue == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"enabled": false,
|
||||||
|
"items": []interface{}{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := h.extQueue.ListAll()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list extraction queue"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []extraction.QueueItem{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count by status
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, item := range items {
|
||||||
|
counts[item.Status]++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"enabled": true,
|
||||||
|
"total": len(items),
|
||||||
|
"counts": counts,
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channel Delete Hook ────────────────────
|
||||||
|
// Called by ChannelHandler.DeleteChannel to clean up storage.
|
||||||
|
func (h *AttachmentHandler) CleanupChannelStorage(channelID string) {
|
||||||
|
if h.objStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// CASCADE already deleted PG rows. Clean up filesystem.
|
||||||
|
prefix := fmt.Sprintf("attachments/%s", channelID)
|
||||||
|
if err := h.objStore.DeletePrefix(context.Background(), prefix); err != nil {
|
||||||
|
log.Printf("storage cleanup for channel %s failed: %v", channelID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────
|
||||||
|
|
||||||
|
// verifyChannelAccess checks that the requesting user owns the channel.
|
||||||
|
// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission).
|
||||||
|
func (h *AttachmentHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
|
||||||
|
var ownerID string
|
||||||
|
err := database.DB.QueryRowContext(c.Request.Context(),
|
||||||
|
`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ownerID != userID {
|
||||||
|
// Check if user is admin (admins can access any channel)
|
||||||
|
role, _ := c.Get("role")
|
||||||
|
if role != "admin" {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeFilename cleans a filename for safe storage.
|
||||||
|
func sanitizeFilename(name string) string {
|
||||||
|
// Take only the base name (strip path separators)
|
||||||
|
name = filepath.Base(name)
|
||||||
|
// Replace problematic characters
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"/", "_", "\\", "_", "..", "_", "\x00", "",
|
||||||
|
)
|
||||||
|
name = replacer.Replace(name)
|
||||||
|
if name == "" || name == "." {
|
||||||
|
name = "unnamed"
|
||||||
|
}
|
||||||
|
// Truncate to 200 chars (leave room for UUID prefix in storage key)
|
||||||
|
if len(name) > 200 {
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
name = name[:200-len(ext)] + ext
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// extToMIME maps file extensions to MIME types for cases where
|
||||||
|
// http.DetectContentType returns application/octet-stream.
|
||||||
|
var extToMIME = map[string]string{
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
".doc": "application/msword",
|
||||||
|
".xls": "application/vnd.ms-excel",
|
||||||
|
".odt": "application/vnd.oasis.opendocument.text",
|
||||||
|
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
||||||
|
".odp": "application/vnd.oasis.opendocument.presentation",
|
||||||
|
".rtf": "application/rtf",
|
||||||
|
".md": "text/markdown",
|
||||||
|
".csv": "text/csv",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -36,6 +37,7 @@ type updateChannelRequest struct {
|
|||||||
IsPinned *bool `json:"is_pinned,omitempty"`
|
IsPinned *bool `json:"is_pinned,omitempty"`
|
||||||
Folder *string `json:"folder,omitempty"`
|
Folder *string `json:"folder,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelResponse struct {
|
type channelResponse struct {
|
||||||
@@ -51,6 +53,7 @@ type channelResponse struct {
|
|||||||
IsPinned bool `json:"is_pinned"`
|
IsPinned bool `json:"is_pinned"`
|
||||||
Folder *string `json:"folder"`
|
Folder *string `json:"folder"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
|
Settings json.RawMessage `json:"settings,omitempty"`
|
||||||
MessageCount int `json:"message_count"`
|
MessageCount int `json:"message_count"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
@@ -72,6 +75,16 @@ func NewChannelHandler() *ChannelHandler {
|
|||||||
return &ChannelHandler{}
|
return &ChannelHandler{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// channelDeleteHook is called after a channel is successfully deleted.
|
||||||
|
// Set at startup by SetChannelDeleteHook to clean up storage files.
|
||||||
|
var channelDeleteHook func(channelID string)
|
||||||
|
|
||||||
|
// SetChannelDeleteHook registers a callback invoked after channel deletion.
|
||||||
|
// Used to clean up attachment files on the storage backend.
|
||||||
|
func SetChannelDeleteHook(fn func(channelID string)) {
|
||||||
|
channelDeleteHook = fn
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────
|
// ── Helpers ─────────────────────────────────
|
||||||
|
|
||||||
// getUserID extracts the authenticated user's ID from context.
|
// getUserID extracts the authenticated user's ID from context.
|
||||||
@@ -141,7 +154,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
// Fetch channels with message count
|
// Fetch channels with message count
|
||||||
query := `
|
query := `
|
||||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
|
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
|
||||||
COALESCE(mc.cnt, 0) AS message_count,
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
c.created_at, c.updated_at
|
c.created_at, c.updated_at
|
||||||
FROM channels c
|
FROM channels c
|
||||||
@@ -186,7 +199,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
|||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||||
pq.Array(&tags),
|
pq.Array(&tags), &ch.Settings,
|
||||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -236,13 +249,13 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
|||||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||||
is_archived, is_pinned, folder, tags, created_at, updated_at
|
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
|
||||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||||
req.Folder, pq.Array(req.Tags),
|
req.Folder, pq.Array(req.Tags),
|
||||||
).Scan(
|
).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||||
pq.Array(&tags), &ch.CreatedAt, &ch.UpdatedAt,
|
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||||
@@ -284,7 +297,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
|||||||
var tags []string
|
var tags []string
|
||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
|
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
|
||||||
COALESCE(mc.cnt, 0) AS message_count,
|
COALESCE(mc.cnt, 0) AS message_count,
|
||||||
c.created_at, c.updated_at
|
c.created_at, c.updated_at
|
||||||
FROM channels c
|
FROM channels c
|
||||||
@@ -295,7 +308,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
|||||||
`, channelID, userID).Scan(
|
`, channelID, userID).Scan(
|
||||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||||
pq.Array(&tags),
|
pq.Array(&tags), &ch.Settings,
|
||||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -383,6 +396,12 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
|||||||
if req.Tags != nil {
|
if req.Tags != nil {
|
||||||
addClause("tags", pq.Array(req.Tags))
|
addClause("tags", pq.Array(req.Tags))
|
||||||
}
|
}
|
||||||
|
if req.Settings != nil {
|
||||||
|
// JSONB merge: new settings keys overwrite existing, unmentioned keys preserved
|
||||||
|
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
|
||||||
|
args = append(args, []byte(*req.Settings))
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
if len(setClauses) == 0 {
|
if len(setClauses) == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||||
@@ -430,5 +449,10 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up storage files (CASCADE already removed PG attachment rows)
|
||||||
|
if channelDeleteHook != nil {
|
||||||
|
go channelDeleteHook(channelID)
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,24 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"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/crypto"
|
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +37,7 @@ type completionRequest struct {
|
|||||||
Temperature *float64 `json:"temperature,omitempty"`
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
TopP *float64 `json:"top_p,omitempty"`
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
Stream *bool `json:"stream,omitempty"`
|
Stream *bool `json:"stream,omitempty"`
|
||||||
|
AttachmentIDs []string `json:"attachment_ids,omitempty"` // staged attachment UUIDs to include in request
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompletionHandler proxies LLM requests through the backend.
|
// CompletionHandler proxies LLM requests through the backend.
|
||||||
@@ -41,11 +45,12 @@ type CompletionHandler struct {
|
|||||||
vault *crypto.KeyResolver
|
vault *crypto.KeyResolver
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
hub *events.Hub // WebSocket hub for browser tool bridge
|
hub *events.Hub // WebSocket hub for browser tool bridge
|
||||||
|
objStore storage.ObjectStore // file storage for attachment content (nil = disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCompletionHandler creates a new handler.
|
// NewCompletionHandler creates a new handler.
|
||||||
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *CompletionHandler {
|
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *CompletionHandler {
|
||||||
return &CompletionHandler{vault: vault, stores: stores, hub: hub}
|
return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat Completion ─────────────────────────
|
// ── Chat Completion ─────────────────────────
|
||||||
@@ -138,26 +143,53 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the new user message
|
// Resolve capabilities early — needed for vision gating below
|
||||||
messages = append(messages, providers.Message{
|
caps := h.getModelCapabilities(c, model, configID)
|
||||||
|
|
||||||
|
// Build user message — multimodal if attachments are present
|
||||||
|
userMsg := providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
})
|
}
|
||||||
|
|
||||||
// Persist user message
|
if len(req.AttachmentIDs) > 0 && h.objStore != nil {
|
||||||
if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil); err != nil {
|
parts, augContent, _, err := h.buildMultimodalParts(c, channelID, req.Content, req.AttachmentIDs, caps)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(parts) > 0 {
|
||||||
|
// Multimodal (images present): use ContentParts
|
||||||
|
userMsg.ContentParts = parts
|
||||||
|
} else if augContent != req.Content {
|
||||||
|
// Doc-only: use augmented text content
|
||||||
|
userMsg.Content = augContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, userMsg)
|
||||||
|
|
||||||
|
// Persist user message (text-only for storage — multimodal parts are ephemeral)
|
||||||
|
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil)
|
||||||
|
if err != nil {
|
||||||
log.Printf("Failed to persist user message: %v", err)
|
log.Printf("Failed to persist user message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link attachments to the persisted message
|
||||||
|
if msgID != "" && len(req.AttachmentIDs) > 0 {
|
||||||
|
for _, attID := range req.AttachmentIDs {
|
||||||
|
if err := h.stores.Attachments.SetMessageID(c.Request.Context(), attID, msgID); err != nil {
|
||||||
|
log.Printf("Failed to link attachment %s to message %s: %v", attID, msgID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build provider request
|
// Build provider request
|
||||||
provReq := providers.CompletionRequest{
|
provReq := providers.CompletionRequest{
|
||||||
Model: model,
|
Model: model,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve capabilities for this model — auto-set defaults
|
|
||||||
caps := h.getModelCapabilities(c, model, configID)
|
|
||||||
|
|
||||||
if req.MaxTokens > 0 {
|
if req.MaxTokens > 0 {
|
||||||
provReq.MaxTokens = req.MaxTokens
|
provReq.MaxTokens = req.MaxTokens
|
||||||
} else {
|
} else {
|
||||||
@@ -405,6 +437,128 @@ func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfi
|
|||||||
return ResolveModelCaps(c, model, apiConfigID)
|
return ResolveModelCaps(c, model, apiConfigID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Multimodal Assembly ─────────────────────
|
||||||
|
// Builds content parts from attachments for the user message.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - parts: ContentParts array (non-nil only when images are present)
|
||||||
|
// - augContent: enriched text content with document context (for doc-only case)
|
||||||
|
// - validIDs: attachment IDs that were successfully processed
|
||||||
|
// - error: if any attachment is invalid or vision is needed but missing
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - Images → base64 data URI (requires vision capability)
|
||||||
|
// - Documents with extracted_text → text injection
|
||||||
|
// - Documents without extraction → filename placeholder
|
||||||
|
// - Text is always the first part
|
||||||
|
|
||||||
|
func (h *CompletionHandler) buildMultimodalParts(
|
||||||
|
c *gin.Context,
|
||||||
|
channelID, textContent string,
|
||||||
|
attachmentIDs []string,
|
||||||
|
caps models.ModelCapabilities,
|
||||||
|
) ([]providers.ContentPart, string, []string, error) {
|
||||||
|
|
||||||
|
parts := []providers.ContentPart{
|
||||||
|
{Type: "text", Text: textContent},
|
||||||
|
}
|
||||||
|
var docTexts []string
|
||||||
|
var validIDs []string
|
||||||
|
hasImage := false
|
||||||
|
|
||||||
|
for _, attID := range attachmentIDs {
|
||||||
|
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", nil, fmt.Errorf("attachment %s not found", attID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: verify attachment belongs to this channel
|
||||||
|
if att.ChannelID != channelID {
|
||||||
|
return nil, "", nil, fmt.Errorf("attachment %s does not belong to this channel", attID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isImageContentType(att.ContentType) {
|
||||||
|
// Vision gating: reject images if model lacks vision
|
||||||
|
if !caps.Vision {
|
||||||
|
return nil, "", nil, fmt.Errorf("model does not support image input; remove image attachments or choose a vision-capable model")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read image from storage, base64 encode, build data URI
|
||||||
|
reader, _, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to read attachment %s from storage: %v", attID, err)
|
||||||
|
parts = append(parts, providers.ContentPart{
|
||||||
|
Type: "text",
|
||||||
|
Text: fmt.Sprintf("[Image: %s — failed to read from storage]", att.Filename),
|
||||||
|
})
|
||||||
|
validIDs = append(validIDs, attID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
reader.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to read attachment %s bytes: %v", attID, err)
|
||||||
|
validIDs = append(validIDs, attID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(data)
|
||||||
|
dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64)
|
||||||
|
|
||||||
|
parts = append(parts, providers.ContentPart{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &providers.ImageURL{
|
||||||
|
URL: dataURI,
|
||||||
|
Detail: "auto",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
hasImage = true
|
||||||
|
|
||||||
|
} else if att.ExtractedText != nil && *att.ExtractedText != "" {
|
||||||
|
// Document with extracted text → inject as context
|
||||||
|
docText := fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText)
|
||||||
|
parts = append(parts, providers.ContentPart{
|
||||||
|
Type: "text",
|
||||||
|
Text: docText,
|
||||||
|
})
|
||||||
|
docTexts = append(docTexts, docText)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Document without extraction (pending, failed, or not extractable)
|
||||||
|
status := "pending"
|
||||||
|
if s, ok := att.Metadata["extraction_status"].(string); ok {
|
||||||
|
status = s
|
||||||
|
}
|
||||||
|
placeholder := fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status)
|
||||||
|
parts = append(parts, providers.ContentPart{
|
||||||
|
Type: "text",
|
||||||
|
Text: placeholder,
|
||||||
|
})
|
||||||
|
docTexts = append(docTexts, placeholder)
|
||||||
|
}
|
||||||
|
|
||||||
|
validIDs = append(validIDs, attID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If images present → use ContentParts (multimodal array)
|
||||||
|
if hasImage {
|
||||||
|
return parts, textContent, validIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doc-only: merge document context into a single text string (more efficient)
|
||||||
|
augmented := textContent
|
||||||
|
for _, dt := range docTexts {
|
||||||
|
augmented += "\n\n" + dt
|
||||||
|
}
|
||||||
|
return nil, augmented, validIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isImageContentType returns true for MIME types that should be sent as
|
||||||
|
// base64 image content parts rather than text extraction.
|
||||||
|
func isImageContentType(ct string) bool {
|
||||||
|
return strings.HasPrefix(ct, "image/")
|
||||||
|
}
|
||||||
|
|
||||||
// ── Config Resolution ───────────────────────
|
// ── Config Resolution ───────────────────────
|
||||||
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Test Harness ────────────────────────────
|
// ── Test Harness ────────────────────────────
|
||||||
@@ -132,9 +132,19 @@ func setupHarness(t *testing.T) *testHarness {
|
|||||||
channels := NewChannelHandler()
|
channels := NewChannelHandler()
|
||||||
protected.GET("/channels", channels.ListChannels)
|
protected.GET("/channels", channels.ListChannels)
|
||||||
protected.POST("/channels", channels.CreateChannel)
|
protected.POST("/channels", channels.CreateChannel)
|
||||||
|
protected.GET("/channels/:id", channels.GetChannel)
|
||||||
|
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||||
|
|
||||||
|
// Attachments (nil storage = upload returns 503, but metadata works)
|
||||||
|
attachH := NewAttachmentHandler(stores, nil, nil)
|
||||||
|
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||||
|
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
|
||||||
|
protected.GET("/attachments/:id", attachH.GetMetadata)
|
||||||
|
protected.GET("/attachments/:id/download", attachH.Download)
|
||||||
|
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
|
||||||
|
|
||||||
// Completions
|
// Completions
|
||||||
completions := NewCompletionHandler(nil, stores, nil)
|
completions := NewCompletionHandler(nil, stores, nil, nil)
|
||||||
protected.POST("/chat/completions", completions.Complete)
|
protected.POST("/chat/completions", completions.Complete)
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||||
)
|
)
|
||||||
@@ -61,11 +62,12 @@ type MessageHandler struct {
|
|||||||
vault *crypto.KeyResolver
|
vault *crypto.KeyResolver
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
hub *events.Hub
|
hub *events.Hub
|
||||||
|
objStore storage.ObjectStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessageHandler creates a new message handler.
|
// NewMessageHandler creates a new message handler.
|
||||||
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *MessageHandler {
|
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *MessageHandler {
|
||||||
return &MessageHandler{vault: vault, stores: stores, hub: hub}
|
return &MessageHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── List Messages (flat, all branches) ──────
|
// ── List Messages (flat, all branches) ──────
|
||||||
@@ -360,7 +362,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Resolve model + provider ──
|
// ── Resolve model + provider ──
|
||||||
|
|
||||||
comp := NewCompletionHandler(h.vault, h.stores, h.hub)
|
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore)
|
||||||
|
|
||||||
var presetSystemPrompt string
|
var presetSystemPrompt string
|
||||||
model := req.Model
|
model := req.Model
|
||||||
|
|||||||
62
server/handlers/storage.go
Normal file
62
server/handlers/storage.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// storageConfigured is a package-level flag set during init.
|
||||||
|
// Read by PublicSettings to include in the boot payload.
|
||||||
|
var storageConfigured bool
|
||||||
|
|
||||||
|
// SetStorageConfigured sets the package-level flag indicating whether
|
||||||
|
// file storage is available. Called from main.go after storage init.
|
||||||
|
func SetStorageConfigured(configured bool) {
|
||||||
|
storageConfigured = configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageHandler handles file storage admin endpoints.
|
||||||
|
type StorageHandler struct {
|
||||||
|
store storage.ObjectStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStorageHandler creates a StorageHandler.
|
||||||
|
// store may be nil if storage is not configured.
|
||||||
|
func NewStorageHandler(store storage.ObjectStore) *StorageHandler {
|
||||||
|
return &StorageHandler{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured returns true if a storage backend is available.
|
||||||
|
func (h *StorageHandler) Configured() bool {
|
||||||
|
return h.store != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns storage backend status and statistics.
|
||||||
|
//
|
||||||
|
// GET /admin/storage/status
|
||||||
|
func (h *StorageHandler) Status(c *gin.Context) {
|
||||||
|
if h.store == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"backend": "none",
|
||||||
|
"configured": false,
|
||||||
|
"healthy": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := h.store.Stats(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "failed to collect storage stats",
|
||||||
|
"backend": h.store.Backend(),
|
||||||
|
"configured": true,
|
||||||
|
"healthy": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
148
server/main.go
148
server/main.go
@@ -1,7 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -9,16 +13,30 @@ import (
|
|||||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/extraction"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/handlers"
|
"git.gobha.me/xcaliber/chat-switchboard/handlers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||||
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
|
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// ── Subcommand dispatch ──────────────────
|
||||||
|
// Usage: switchboard vault rekey
|
||||||
|
if len(os.Args) > 2 && os.Args[1] == "vault" {
|
||||||
|
runVaultCommand(os.Args[2])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "version" {
|
||||||
|
fmt.Println("switchboard", Version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server startup ──────────────────────
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
// Register LLM providers
|
// Register LLM providers
|
||||||
@@ -27,6 +45,7 @@ func main() {
|
|||||||
var stores store.Stores
|
var stores store.Stores
|
||||||
uekCache := crypto.NewUEKCache()
|
uekCache := crypto.NewUEKCache()
|
||||||
var keyResolver *crypto.KeyResolver
|
var keyResolver *crypto.KeyResolver
|
||||||
|
var objStore storage.ObjectStore
|
||||||
|
|
||||||
if err := database.Connect(cfg); err != nil {
|
if err := database.Connect(cfg); err != nil {
|
||||||
log.Printf("⚠ Database unavailable: %v", err)
|
log.Printf("⚠ Database unavailable: %v", err)
|
||||||
@@ -73,6 +92,51 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
|
// ── File Storage ─────────────────────────
|
||||||
|
// Auto-detects PVC if STORAGE_PATH is writable. Explicit STORAGE_BACKEND
|
||||||
|
// overrides auto-detection. nil objStore = storage features disabled.
|
||||||
|
var s3Cfg *storage.S3Config
|
||||||
|
if cfg.S3Bucket != "" {
|
||||||
|
s3Cfg = &storage.S3Config{
|
||||||
|
Endpoint: cfg.S3Endpoint,
|
||||||
|
Bucket: cfg.S3Bucket,
|
||||||
|
Region: cfg.S3Region,
|
||||||
|
AccessKey: cfg.S3AccessKey,
|
||||||
|
SecretKey: cfg.S3SecretKey,
|
||||||
|
Prefix: cfg.S3Prefix,
|
||||||
|
ForcePathStyle: cfg.S3ForcePathStyle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s, err := storage.Init(cfg.StorageBackend, cfg.StoragePath, s3Cfg); err != nil {
|
||||||
|
if cfg.StorageBackend != "" {
|
||||||
|
// Explicit backend requested but failed — fatal
|
||||||
|
log.Fatalf("❌ Storage init failed: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("⚠ Storage init failed: %v", err)
|
||||||
|
} else {
|
||||||
|
objStore = s
|
||||||
|
}
|
||||||
|
handlers.SetStorageConfigured(objStore != nil)
|
||||||
|
|
||||||
|
// ── Extraction Queue ────────────────────
|
||||||
|
// Filesystem-based queue for document text extraction (PDF, DOCX, etc.)
|
||||||
|
// Nil if storage is disabled.
|
||||||
|
var extQueue *extraction.Queue
|
||||||
|
if objStore != nil && cfg.StoragePath != "" {
|
||||||
|
q, err := extraction.NewQueue(cfg.StoragePath, cfg.ExtractionConcurrency)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠ Extraction queue init failed: %v", err)
|
||||||
|
} else {
|
||||||
|
extQueue = q
|
||||||
|
// Recover items stuck in "processing" from previous crash
|
||||||
|
if recovered, err := extQueue.RecoverStale(30 * time.Minute); err != nil {
|
||||||
|
log.Printf("⚠ Extraction recovery failed: %v", err)
|
||||||
|
} else if recovered > 0 {
|
||||||
|
log.Printf(" 📋 Recovered %d stale extraction items", recovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Role resolver for model role dispatch (needs stores + vault)
|
// Role resolver for model role dispatch (needs stores + vault)
|
||||||
roleResolver := roles.NewResolver(stores, keyResolver)
|
roleResolver := roles.NewResolver(stores, keyResolver)
|
||||||
|
|
||||||
@@ -148,7 +212,7 @@ func main() {
|
|||||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
msgs := handlers.NewMessageHandler(keyResolver, stores, hub)
|
msgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
|
||||||
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
protected.GET("/channels/:id/messages", msgs.ListMessages)
|
||||||
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
protected.POST("/channels/:id/messages", msgs.CreateMessage)
|
||||||
|
|
||||||
@@ -160,7 +224,7 @@ func main() {
|
|||||||
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
|
||||||
|
|
||||||
// Chat Completions
|
// Chat Completions
|
||||||
comp := handlers.NewCompletionHandler(keyResolver, stores, hub)
|
comp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore)
|
||||||
protected.POST("/chat/completions", comp.Complete)
|
protected.POST("/chat/completions", comp.Complete)
|
||||||
|
|
||||||
// Summarize & Continue
|
// Summarize & Continue
|
||||||
@@ -222,6 +286,17 @@ func main() {
|
|||||||
protected.PUT("/notes/:id", notes.Update)
|
protected.PUT("/notes/:id", notes.Update)
|
||||||
protected.DELETE("/notes/:id", notes.Delete)
|
protected.DELETE("/notes/:id", notes.Delete)
|
||||||
|
|
||||||
|
// Attachments (file upload/download)
|
||||||
|
attachH := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||||
|
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||||
|
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
|
||||||
|
protected.GET("/attachments/:id", attachH.GetMetadata)
|
||||||
|
protected.GET("/attachments/:id/download", attachH.Download)
|
||||||
|
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
|
||||||
|
|
||||||
|
// Hook: clean up storage files when channels are deleted
|
||||||
|
handlers.SetChannelDeleteHook(attachH.CleanupChannelStorage)
|
||||||
|
|
||||||
// Teams (user: my teams)
|
// Teams (user: my teams)
|
||||||
teams := handlers.NewTeamHandler(keyResolver)
|
teams := handlers.NewTeamHandler(keyResolver)
|
||||||
protected.GET("/teams/mine", teams.MyTeams)
|
protected.GET("/teams/mine", teams.MyTeams)
|
||||||
@@ -352,6 +427,19 @@ func main() {
|
|||||||
admin.PUT("/pricing", usageH.UpsertPricing)
|
admin.PUT("/pricing", usageH.UpsertPricing)
|
||||||
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
|
admin.DELETE("/pricing/:provider/:model", usageH.DeletePricing)
|
||||||
|
|
||||||
|
// Storage status
|
||||||
|
storageH := handlers.NewStorageHandler(objStore)
|
||||||
|
admin.GET("/storage/status", storageH.Status)
|
||||||
|
|
||||||
|
// Vault
|
||||||
|
admin.GET("/vault/status", adm.VaultStatus)
|
||||||
|
|
||||||
|
// Storage management (orphan cleanup)
|
||||||
|
attachAdm := handlers.NewAttachmentHandler(stores, objStore, extQueue)
|
||||||
|
admin.GET("/storage/orphans", attachAdm.OrphanCount)
|
||||||
|
admin.POST("/storage/cleanup", attachAdm.CleanupOrphans)
|
||||||
|
admin.GET("/storage/extraction", attachAdm.ExtractionStatus)
|
||||||
|
|
||||||
// Extensions (admin)
|
// Extensions (admin)
|
||||||
extAdm := handlers.NewExtensionHandler(stores)
|
extAdm := handlers.NewExtensionHandler(stores)
|
||||||
admin.GET("/extensions", extAdm.AdminListExtensions)
|
admin.GET("/extensions", extAdm.AdminListExtensions)
|
||||||
@@ -369,8 +457,64 @@ func main() {
|
|||||||
log.Printf(" Base path: %s", bp)
|
log.Printf(" Base path: %s", bp)
|
||||||
log.Printf(" Schema: %s", database.SchemaVersion())
|
log.Printf(" Schema: %s", database.SchemaVersion())
|
||||||
log.Printf(" Providers: %v", providers.List())
|
log.Printf(" Providers: %v", providers.List())
|
||||||
|
if objStore != nil {
|
||||||
|
log.Printf(" Storage: %s", objStore.Backend())
|
||||||
|
if extQueue != nil {
|
||||||
|
log.Printf(" Extraction: enabled (concurrency=%d)", cfg.ExtractionConcurrency)
|
||||||
|
} else {
|
||||||
|
log.Printf(" Extraction: disabled")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf(" Storage: disabled")
|
||||||
|
}
|
||||||
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
|
log.Printf(" EventBus: ready, WebSocket on %s/ws", cfg.BasePath)
|
||||||
if err := r.Run(":" + cfg.Port); err != nil {
|
if err := r.Run(":" + cfg.Port); err != nil {
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Vault CLI Commands ──────────────────────
|
||||||
|
|
||||||
|
func runVaultCommand(subcmd string) {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
switch strings.ToLower(subcmd) {
|
||||||
|
case "rekey":
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
log.Fatal("DATABASE_URL is required for vault operations")
|
||||||
|
}
|
||||||
|
if err := database.Connect(cfg); err != nil {
|
||||||
|
log.Fatalf("❌ Database connection failed: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
oldKey := os.Getenv("ENCRYPTION_KEY")
|
||||||
|
newKey := os.Getenv("NEW_ENCRYPTION_KEY")
|
||||||
|
|
||||||
|
if err := crypto.Rekey(database.DB, oldKey, newKey); err != nil {
|
||||||
|
log.Fatalf("❌ Vault rekey failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "status":
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
log.Fatal("DATABASE_URL is required for vault operations")
|
||||||
|
}
|
||||||
|
if err := database.Connect(cfg); err != nil {
|
||||||
|
log.Fatalf("❌ Database connection failed: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
status, err := crypto.VaultStatus(database.DB, cfg.EncryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ Failed to get vault status: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Encryption key set: %v\n", status.EncryptionKeySet)
|
||||||
|
fmt.Printf("Encrypted keys: %d\n", status.EncryptedKeys)
|
||||||
|
fmt.Printf("Vault users (active): %d\n", status.VaultUsers)
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Unknown vault command: %s\n", subcmd)
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: switchboard vault <rekey|status>\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -389,6 +389,24 @@ type Note struct {
|
|||||||
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
TeamID *string `json:"team_id,omitempty" db:"team_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// ATTACHMENTS
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
ChannelID string `json:"channel_id" db:"channel_id"`
|
||||||
|
UserID string `json:"user_id" db:"user_id"`
|
||||||
|
MessageID *string `json:"message_id,omitempty" db:"message_id"`
|
||||||
|
Filename string `json:"filename" db:"filename"`
|
||||||
|
ContentType string `json:"content_type" db:"content_type"`
|
||||||
|
SizeBytes int64 `json:"size_bytes" db:"size_bytes"`
|
||||||
|
StorageKey string `json:"-" db:"storage_key"` // never expose filesystem path
|
||||||
|
ExtractedText *string `json:"extracted_text,omitempty" db:"extracted_text"`
|
||||||
|
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// AUDIT LOG
|
// AUDIT LOG
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -263,6 +263,40 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
|||||||
Content: m.Content,
|
Content: m.Content,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
} else if len(m.ContentParts) > 0 && m.Role == "user" {
|
||||||
|
// Multimodal user message: convert ContentParts to Anthropic blocks
|
||||||
|
for _, p := range m.ContentParts {
|
||||||
|
switch p.Type {
|
||||||
|
case "image_url":
|
||||||
|
if p.ImageURL != nil {
|
||||||
|
// Parse data URI: "data:image/jpeg;base64,{data}"
|
||||||
|
mediaType, b64Data := parseDataURI(p.ImageURL.URL)
|
||||||
|
if b64Data != "" {
|
||||||
|
antMsg.Content = append(antMsg.Content, anthropicContentBlock{
|
||||||
|
Type: "image",
|
||||||
|
Source: &anthropicImageSource{
|
||||||
|
Type: "base64",
|
||||||
|
MediaType: mediaType,
|
||||||
|
Data: b64Data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // "text", "document"
|
||||||
|
if p.Text != "" {
|
||||||
|
antMsg.Content = append(antMsg.Content, anthropicContentBlock{
|
||||||
|
Type: "text",
|
||||||
|
Text: p.Text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(antMsg.Content) == 0 {
|
||||||
|
// Fallback: shouldn't happen, but safety net
|
||||||
|
antMsg.Content = []anthropicContentBlock{
|
||||||
|
{Type: "text", Text: m.Content},
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular text message
|
// Regular text message
|
||||||
antMsg.Content = []anthropicContentBlock{
|
antMsg.Content = []anthropicContentBlock{
|
||||||
@@ -354,6 +388,8 @@ type anthropicContentBlock struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
// text
|
// text
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
|
// image (type="image")
|
||||||
|
Source *anthropicImageSource `json:"source,omitempty"`
|
||||||
// tool_use
|
// tool_use
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
@@ -363,6 +399,15 @@ type anthropicContentBlock struct {
|
|||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anthropicImageSource is the Anthropic-native image format.
|
||||||
|
// Anthropic uses {"type":"base64","media_type":"image/jpeg","data":"..."}
|
||||||
|
// rather than OpenAI's data URI approach.
|
||||||
|
type anthropicImageSource struct {
|
||||||
|
Type string `json:"type"` // "base64"
|
||||||
|
MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc.
|
||||||
|
Data string `json:"data"` // raw base64 (no data: prefix)
|
||||||
|
}
|
||||||
|
|
||||||
type anthropicMessage struct {
|
type anthropicMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content []anthropicContentBlock `json:"content"`
|
Content []anthropicContentBlock `json:"content"`
|
||||||
@@ -425,3 +470,33 @@ type anthropicStreamEvent struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"content_block,omitempty"`
|
} `json:"content_block,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseDataURI splits a data URI into media type and base64 data.
|
||||||
|
// Input: "data:image/jpeg;base64,/9j/4AAQ..."
|
||||||
|
// Output: "image/jpeg", "/9j/4AAQ..."
|
||||||
|
// Returns empty strings if the URI is malformed.
|
||||||
|
func parseDataURI(uri string) (mediaType, data string) {
|
||||||
|
// Strip "data:" prefix
|
||||||
|
if !strings.HasPrefix(uri, "data:") {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
rest := uri[5:]
|
||||||
|
|
||||||
|
// Split on comma (separates header from data)
|
||||||
|
commaIdx := strings.Index(rest, ",")
|
||||||
|
if commaIdx < 0 {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
header := rest[:commaIdx]
|
||||||
|
data = rest[commaIdx+1:]
|
||||||
|
|
||||||
|
// Extract media type from header (before ";base64")
|
||||||
|
if semiIdx := strings.Index(header, ";"); semiIdx >= 0 {
|
||||||
|
mediaType = header[:semiIdx]
|
||||||
|
} else {
|
||||||
|
mediaType = header
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaType, data
|
||||||
|
}
|
||||||
|
|||||||
202
server/providers/multimodal_test.go
Normal file
202
server/providers/multimodal_test.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── ContentPart Tests ──────────────────────
|
||||||
|
|
||||||
|
func TestContentPart_TextOnly(t *testing.T) {
|
||||||
|
msg := Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "hello",
|
||||||
|
ContentParts: []ContentPart{
|
||||||
|
{Type: "text", Text: "hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if len(msg.ContentParts) != 1 {
|
||||||
|
t.Fatalf("expected 1 part, got %d", len(msg.ContentParts))
|
||||||
|
}
|
||||||
|
if msg.ContentParts[0].Type != "text" {
|
||||||
|
t.Errorf("type = %q, want text", msg.ContentParts[0].Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentPart_WithImage(t *testing.T) {
|
||||||
|
msg := Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: "describe this",
|
||||||
|
ContentParts: []ContentPart{
|
||||||
|
{Type: "text", Text: "describe this"},
|
||||||
|
{Type: "image_url", ImageURL: &ImageURL{URL: "data:image/png;base64,abc123", Detail: "auto"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if len(msg.ContentParts) != 2 {
|
||||||
|
t.Fatalf("expected 2 parts, got %d", len(msg.ContentParts))
|
||||||
|
}
|
||||||
|
if msg.ContentParts[1].ImageURL == nil {
|
||||||
|
t.Fatal("expected non-nil ImageURL")
|
||||||
|
}
|
||||||
|
if msg.ContentParts[1].ImageURL.URL != "data:image/png;base64,abc123" {
|
||||||
|
t.Errorf("URL = %q", msg.ContentParts[1].ImageURL.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OpenAI Multimodal Serialization ────────
|
||||||
|
|
||||||
|
func TestOpenAI_TextOnlyContent_String(t *testing.T) {
|
||||||
|
// When Content is a string, it should serialize as a JSON string
|
||||||
|
msg := openaiMessage{
|
||||||
|
Role: "user",
|
||||||
|
Content: "hello world",
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
json.Unmarshal(data, &raw)
|
||||||
|
|
||||||
|
content := raw["content"]
|
||||||
|
if s, ok := content.(string); !ok || s != "hello world" {
|
||||||
|
t.Errorf("content = %v (%T), want string 'hello world'", content, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAI_MultimodalContent_Array(t *testing.T) {
|
||||||
|
// When Content is an array, it should serialize as a JSON array
|
||||||
|
msg := openaiMessage{
|
||||||
|
Role: "user",
|
||||||
|
Content: []openaiContentPart{
|
||||||
|
{Type: "text", Text: "describe this"},
|
||||||
|
{Type: "image_url", ImageURL: &openaiImageURL{URL: "data:image/png;base64,abc", Detail: "auto"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
json.Unmarshal(data, &raw)
|
||||||
|
|
||||||
|
content := raw["content"]
|
||||||
|
arr, ok := content.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("content is %T, want array", content)
|
||||||
|
}
|
||||||
|
if len(arr) != 2 {
|
||||||
|
t.Fatalf("content has %d elements, want 2", len(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify first part is text
|
||||||
|
part0 := arr[0].(map[string]interface{})
|
||||||
|
if part0["type"] != "text" {
|
||||||
|
t.Errorf("part[0].type = %v, want text", part0["type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify second part is image_url
|
||||||
|
part1 := arr[1].(map[string]interface{})
|
||||||
|
if part1["type"] != "image_url" {
|
||||||
|
t.Errorf("part[1].type = %v, want image_url", part1["type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anthropic Image Source ─────────────────
|
||||||
|
|
||||||
|
func TestAnthropic_ImageSourceBlock(t *testing.T) {
|
||||||
|
block := anthropicContentBlock{
|
||||||
|
Type: "image",
|
||||||
|
Source: &anthropicImageSource{
|
||||||
|
Type: "base64",
|
||||||
|
MediaType: "image/jpeg",
|
||||||
|
Data: "abc123",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
json.Unmarshal(data, &raw)
|
||||||
|
|
||||||
|
if raw["type"] != "image" {
|
||||||
|
t.Errorf("type = %v", raw["type"])
|
||||||
|
}
|
||||||
|
source := raw["source"].(map[string]interface{})
|
||||||
|
if source["type"] != "base64" {
|
||||||
|
t.Errorf("source.type = %v", source["type"])
|
||||||
|
}
|
||||||
|
if source["media_type"] != "image/jpeg" {
|
||||||
|
t.Errorf("source.media_type = %v", source["media_type"])
|
||||||
|
}
|
||||||
|
if source["data"] != "abc123" {
|
||||||
|
t.Errorf("source.data = %v", source["data"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropic_TextBlockUnchanged(t *testing.T) {
|
||||||
|
block := anthropicContentBlock{
|
||||||
|
Type: "text",
|
||||||
|
Text: "hello",
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]interface{}
|
||||||
|
json.Unmarshal(data, &raw)
|
||||||
|
|
||||||
|
if raw["type"] != "text" {
|
||||||
|
t.Errorf("type = %v", raw["type"])
|
||||||
|
}
|
||||||
|
if raw["text"] != "hello" {
|
||||||
|
t.Errorf("text = %v", raw["text"])
|
||||||
|
}
|
||||||
|
// Should NOT have source field
|
||||||
|
if _, ok := raw["source"]; ok {
|
||||||
|
t.Error("text block should not have source field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parseDataURI ───────────────────────────
|
||||||
|
|
||||||
|
func TestParseDataURI_Valid(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
uri string
|
||||||
|
wantType string
|
||||||
|
wantData string
|
||||||
|
}{
|
||||||
|
{"data:image/jpeg;base64,/9j/4AAQ", "image/jpeg", "/9j/4AAQ"},
|
||||||
|
{"data:image/png;base64,abc123", "image/png", "abc123"},
|
||||||
|
{"data:image/webp;base64,RIFF", "image/webp", "RIFF"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
mediaType, data := parseDataURI(tc.uri)
|
||||||
|
if mediaType != tc.wantType {
|
||||||
|
t.Errorf("parseDataURI(%q) mediaType = %q, want %q", tc.uri, mediaType, tc.wantType)
|
||||||
|
}
|
||||||
|
if data != tc.wantData {
|
||||||
|
t.Errorf("parseDataURI(%q) data = %q, want %q", tc.uri, data, tc.wantData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDataURI_Invalid(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
"",
|
||||||
|
"not-a-data-uri",
|
||||||
|
"data:nocomma",
|
||||||
|
"https://example.com/image.png",
|
||||||
|
}
|
||||||
|
for _, uri := range tests {
|
||||||
|
mediaType, data := parseDataURI(uri)
|
||||||
|
if mediaType != "" || data != "" {
|
||||||
|
t.Errorf("parseDataURI(%q) = (%q, %q), want empty", uri, mediaType, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,8 +37,14 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig,
|
|||||||
return nil, fmt.Errorf("no choices in response")
|
return nil, fmt.Errorf("no choices in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract content string from response (responses are always string, not array)
|
||||||
|
var contentStr string
|
||||||
|
if s, ok := resp.Choices[0].Message.Content.(string); ok {
|
||||||
|
contentStr = s
|
||||||
|
}
|
||||||
|
|
||||||
result := &CompletionResponse{
|
result := &CompletionResponse{
|
||||||
Content: resp.Choices[0].Message.Content,
|
Content: contentStr,
|
||||||
Model: resp.Model,
|
Model: resp.Model,
|
||||||
FinishReason: resp.Choices[0].FinishReason,
|
FinishReason: resp.Choices[0].FinishReason,
|
||||||
InputTokens: resp.Usage.PromptTokens,
|
InputTokens: resp.Usage.PromptTokens,
|
||||||
@@ -360,11 +366,42 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert messages — handle all roles including tool
|
// Convert messages — handle all roles including tool and multimodal
|
||||||
for _, m := range req.Messages {
|
for _, m := range req.Messages {
|
||||||
oaiMsg := openaiMessage{
|
oaiMsg := openaiMessage{
|
||||||
Role: m.Role,
|
Role: m.Role,
|
||||||
Content: m.Content,
|
}
|
||||||
|
|
||||||
|
// Build content: multimodal parts or plain string
|
||||||
|
if len(m.ContentParts) > 0 && m.Role == "user" {
|
||||||
|
// Multimodal: convert to OpenAI content array format
|
||||||
|
parts := make([]openaiContentPart, 0, len(m.ContentParts))
|
||||||
|
for _, p := range m.ContentParts {
|
||||||
|
switch p.Type {
|
||||||
|
case "image_url":
|
||||||
|
if p.ImageURL != nil {
|
||||||
|
detail := p.ImageURL.Detail
|
||||||
|
if detail == "" {
|
||||||
|
detail = "auto"
|
||||||
|
}
|
||||||
|
parts = append(parts, openaiContentPart{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &openaiImageURL{
|
||||||
|
URL: p.ImageURL.URL,
|
||||||
|
Detail: detail,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
default: // "text", "document" → text
|
||||||
|
parts = append(parts, openaiContentPart{
|
||||||
|
Type: "text",
|
||||||
|
Text: p.Text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oaiMsg.Content = parts
|
||||||
|
} else {
|
||||||
|
oaiMsg.Content = m.Content
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assistant messages with tool calls
|
// Assistant messages with tool calls
|
||||||
@@ -426,12 +463,25 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
|
|||||||
|
|
||||||
type openaiMessage struct {
|
type openaiMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content,omitempty"`
|
Content interface{} `json:"content,omitempty"` // string or []openaiContentPart
|
||||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
|
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // assistant requesting tools
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
|
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool result
|
||||||
Name string `json:"name,omitempty"` // tool name for role=tool
|
Name string `json:"name,omitempty"` // tool name for role=tool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openaiContentPart represents a single element in a multimodal content array.
|
||||||
|
// OpenAI format: [{"type":"text","text":"..."}, {"type":"image_url","image_url":{"url":"..."}}]
|
||||||
|
type openaiContentPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *openaiImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type openaiToolCallFunction struct {
|
type openaiToolCallFunction struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Arguments string `json:"arguments,omitempty"`
|
Arguments string `json:"arguments,omitempty"`
|
||||||
|
|||||||
@@ -52,12 +52,36 @@ type Message struct {
|
|||||||
Role string `json:"role"` // user, assistant, system, tool
|
Role string `json:"role"` // user, assistant, system, tool
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
|
||||||
|
// Multimodal content parts (v0.12.0+). When set, providers use these
|
||||||
|
// instead of Content for the user message. Content is still set as
|
||||||
|
// a fallback and for message persistence (extracted text summary).
|
||||||
|
ContentParts []ContentPart `json:"content_parts,omitempty"`
|
||||||
|
|
||||||
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
|
// Tool calling fields (assistant → tool_calls, tool → tool_call_id)
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set when assistant requests tools
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
|
ToolCallID string `json:"tool_call_id,omitempty"` // set for role="tool" result messages
|
||||||
Name string `json:"name,omitempty"` // tool name for role="tool"
|
Name string `json:"name,omitempty"` // tool name for role="tool"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContentPart is a single element in a multimodal message.
|
||||||
|
// Providers convert these to their native wire format.
|
||||||
|
type ContentPart struct {
|
||||||
|
Type string `json:"type"` // "text", "image_url", "document"
|
||||||
|
|
||||||
|
// Text content (type="text")
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
|
||||||
|
// Image content (type="image_url") — base64 data URI
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageURL holds image data for multimodal requests.
|
||||||
|
// URL is a data URI: "data:image/jpeg;base64,..."
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
|
||||||
|
}
|
||||||
|
|
||||||
// ToolCall represents a function call requested by the LLM.
|
// ToolCall represents a function call requested by the LLM.
|
||||||
type ToolCall struct {
|
type ToolCall struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
78
server/storage/init.go
Normal file
78
server/storage/init.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init creates an ObjectStore based on the configuration.
|
||||||
|
//
|
||||||
|
// Auto-detection when backend is empty:
|
||||||
|
// - If storagePath is writable → PVC (implicit)
|
||||||
|
// - If storagePath is missing/unwritable → nil (storage disabled)
|
||||||
|
//
|
||||||
|
// Explicit backends:
|
||||||
|
// - "pvc" → PVC, fail if path is not writable
|
||||||
|
// - "s3" → S3-compatible (MinIO, Ceph RGW, AWS), fail if not reachable
|
||||||
|
func Init(backend, storagePath string, s3Cfg *S3Config) (ObjectStore, error) {
|
||||||
|
switch backend {
|
||||||
|
case "pvc":
|
||||||
|
// Explicit PVC — fail if not writable
|
||||||
|
s, err := NewPVC(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: PVC backend at %q: %w", storagePath, err)
|
||||||
|
}
|
||||||
|
log.Printf(" 📁 Storage: PVC at %s", storagePath)
|
||||||
|
return s, nil
|
||||||
|
|
||||||
|
case "s3":
|
||||||
|
if s3Cfg == nil {
|
||||||
|
return nil, fmt.Errorf("storage: S3 backend requires S3_BUCKET and credentials")
|
||||||
|
}
|
||||||
|
s, err := NewS3(*s3Cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage: S3 backend: %w", err)
|
||||||
|
}
|
||||||
|
endpoint := s3Cfg.Endpoint
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = "AWS"
|
||||||
|
}
|
||||||
|
log.Printf(" 📁 Storage: S3 at %s/%s (prefix=%q)", endpoint, s3Cfg.Bucket, s3Cfg.Prefix)
|
||||||
|
return s, nil
|
||||||
|
|
||||||
|
case "":
|
||||||
|
// Auto-detect: try PVC if path exists or is creatable
|
||||||
|
s, err := NewPVC(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
// Path not writable — storage disabled, not an error
|
||||||
|
log.Printf(" 📁 Storage: disabled (path %s not writable)", storagePath)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
log.Printf(" 📁 Storage: PVC at %s (auto-detected)", storagePath)
|
||||||
|
return s, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("storage: unknown backend %q (expected pvc or s3)", backend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPathWritable checks if a directory path exists and is writable.
|
||||||
|
// Used by auto-detection. Does not create the directory.
|
||||||
|
func IsPathWritable(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try writing a probe file
|
||||||
|
probe := path + "/.writable-probe"
|
||||||
|
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
os.Remove(probe)
|
||||||
|
return true
|
||||||
|
}
|
||||||
278
server/storage/pvc.go
Normal file
278
server/storage/pvc.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PVC Store ──────────────────────────────
|
||||||
|
|
||||||
|
// PVCStore implements ObjectStore using the local filesystem.
|
||||||
|
// Used with Kubernetes PersistentVolumeClaims (CephFS, NFS, local PV)
|
||||||
|
// or plain Docker volume mounts.
|
||||||
|
type PVCStore struct {
|
||||||
|
basePath string
|
||||||
|
|
||||||
|
// Content-type cache: key → content type.
|
||||||
|
// Populated on Put, used by Get. PVC doesn't store metadata
|
||||||
|
// alongside files, so we keep MIME types in memory. On restart,
|
||||||
|
// Get falls back to "application/octet-stream" — the DB has the
|
||||||
|
// authoritative content_type for serving to clients.
|
||||||
|
mu sync.RWMutex
|
||||||
|
mimeMap map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPVC creates a PVCStore rooted at basePath.
|
||||||
|
// Returns an error if the path does not exist or is not writable.
|
||||||
|
func NewPVC(basePath string) (*PVCStore, error) {
|
||||||
|
// Ensure base path exists
|
||||||
|
if err := os.MkdirAll(basePath, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage/pvc: cannot create base path %q: %w", basePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create well-known subdirectories
|
||||||
|
for _, sub := range []string{"attachments", "processing"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(basePath, sub), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("storage/pvc: cannot create %s dir: %w", sub, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &PVCStore{
|
||||||
|
basePath: basePath,
|
||||||
|
mimeMap: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate writability
|
||||||
|
if err := s.Healthy(context.Background()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PVCStore) Backend() string { return "pvc" }
|
||||||
|
|
||||||
|
// Put writes data to the filesystem at the resolved key path.
|
||||||
|
func (s *PVCStore) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath := s.resolve(key)
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("storage/pvc: mkdir for %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to temp file first, then rename (atomic on same filesystem)
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(absPath), ".upload-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage/pvc: create temp for %q: %w", key, err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer func() {
|
||||||
|
tmp.Close()
|
||||||
|
// Clean up temp file on failure
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
written, err := io.Copy(tmp, r)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage/pvc: write %q: %w", key, err)
|
||||||
|
}
|
||||||
|
if err = tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("storage/pvc: close %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify size if provided (0 means unknown)
|
||||||
|
if size > 0 && written != size {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return fmt.Errorf("storage/pvc: size mismatch for %q: expected %d, got %d", key, size, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
if err = os.Rename(tmpName, absPath); err != nil {
|
||||||
|
return fmt.Errorf("storage/pvc: rename %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache content type
|
||||||
|
if contentType != "" {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.mimeMap[key] = contentType
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a reader for the file at key.
|
||||||
|
func (s *PVCStore) Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return nil, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath := s.resolve(key)
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, 0, "", ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, 0, "", fmt.Errorf("storage/pvc: stat %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, "", fmt.Errorf("storage/pvc: open %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up cached content type
|
||||||
|
s.mu.RLock()
|
||||||
|
ct := s.mimeMap[key]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, info.Size(), ct, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the file at key.
|
||||||
|
func (s *PVCStore) Delete(ctx context.Context, key string) error {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath := s.resolve(key)
|
||||||
|
|
||||||
|
err := os.Remove(absPath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("storage/pvc: delete %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up cache
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.mimeMap, key)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePrefix removes all files and directories under the given prefix.
|
||||||
|
func (s *PVCStore) DeletePrefix(ctx context.Context, prefix string) error {
|
||||||
|
if err := validateKey(prefix); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath := s.resolve(prefix)
|
||||||
|
|
||||||
|
// Check if the path exists
|
||||||
|
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||||
|
return nil // Nothing to delete
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.RemoveAll(absPath); err != nil {
|
||||||
|
return fmt.Errorf("storage/pvc: delete prefix %q: %w", prefix, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up cache entries with this prefix
|
||||||
|
s.mu.Lock()
|
||||||
|
for k := range s.mimeMap {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
delete(s.mimeMap, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a file exists at key.
|
||||||
|
func (s *PVCStore) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(s.resolve(key))
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("storage/pvc: exists %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthy verifies the storage path is writable.
|
||||||
|
func (s *PVCStore) Healthy(ctx context.Context) error {
|
||||||
|
probe := filepath.Join(s.basePath, ".health-probe")
|
||||||
|
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("storage/pvc: not writable: %w", err)
|
||||||
|
}
|
||||||
|
os.Remove(probe)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns file count and total size under the base path.
|
||||||
|
func (s *PVCStore) Stats(ctx context.Context) (*StorageStats, error) {
|
||||||
|
stats := &StorageStats{
|
||||||
|
Backend: "pvc",
|
||||||
|
Path: s.basePath,
|
||||||
|
Configured: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check health
|
||||||
|
if err := s.Healthy(ctx); err != nil {
|
||||||
|
stats.Healthy = false
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
stats.Healthy = true
|
||||||
|
|
||||||
|
// Walk attachments directory for counts
|
||||||
|
attDir := filepath.Join(s.basePath, "attachments")
|
||||||
|
err := filepath.Walk(attDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // skip errors (e.g. permission denied)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
stats.TotalFiles++
|
||||||
|
stats.TotalBytes += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return stats, fmt.Errorf("storage/pvc: stats walk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────
|
||||||
|
|
||||||
|
// resolve converts a storage key to an absolute filesystem path.
|
||||||
|
func (s *PVCStore) resolve(key string) string {
|
||||||
|
return filepath.Join(s.basePath, filepath.FromSlash(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateKey ensures the key is safe (no path traversal, no absolute paths).
|
||||||
|
func validateKey(key string) error {
|
||||||
|
if key == "" {
|
||||||
|
return fmt.Errorf("storage: empty key")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(key, "/") || strings.HasPrefix(key, "\\") {
|
||||||
|
return fmt.Errorf("storage: absolute key not allowed: %q", key)
|
||||||
|
}
|
||||||
|
cleaned := filepath.Clean(key)
|
||||||
|
if strings.Contains(cleaned, "..") {
|
||||||
|
return fmt.Errorf("storage: path traversal not allowed: %q", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
295
server/storage/pvc_test.go
Normal file
295
server/storage/pvc_test.go
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tempStore(t *testing.T) *PVCStore {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
s, err := NewPVC(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPVC(%q): %v", dir, err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_PutGetRoundTrip(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
data := []byte("hello, storage world")
|
||||||
|
key := "attachments/channel-1/att-1_test.txt"
|
||||||
|
|
||||||
|
// Put
|
||||||
|
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get
|
||||||
|
rc, size, ct, err := s.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
if size != int64(len(data)) {
|
||||||
|
t.Errorf("size = %d, want %d", size, len(data))
|
||||||
|
}
|
||||||
|
if ct != "text/plain" {
|
||||||
|
t.Errorf("content_type = %q, want %q", ct, "text/plain")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := io.ReadAll(rc)
|
||||||
|
if !bytes.Equal(got, data) {
|
||||||
|
t.Errorf("data = %q, want %q", got, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_PutAtomicWrite(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
key := "attachments/ch/file.bin"
|
||||||
|
data := []byte("atomic test data")
|
||||||
|
|
||||||
|
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "application/octet-stream")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File should exist at final path, no temp files left
|
||||||
|
absPath := filepath.Join(s.basePath, "attachments", "ch")
|
||||||
|
entries, _ := os.ReadDir(absPath)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name() != "file.bin" {
|
||||||
|
t.Errorf("unexpected file in dir: %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_PutSizeMismatch(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
data := []byte("short")
|
||||||
|
err := s.Put(ctx, "attachments/ch/f.txt", bytes.NewReader(data), 999, "text/plain")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected size mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_GetNotFound(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt")
|
||||||
|
if err != ErrNotFound {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_Delete(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
key := "attachments/ch/delete-me.txt"
|
||||||
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain")
|
||||||
|
|
||||||
|
err := s.Delete(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ := s.Exists(ctx, key)
|
||||||
|
if exists {
|
||||||
|
t.Error("file still exists after Delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_DeleteIdempotent(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Delete a file that doesn't exist — should not error
|
||||||
|
err := s.Delete(ctx, "attachments/no-such/file.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Delete nonexistent: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_DeletePrefix(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create several files under a channel prefix
|
||||||
|
prefix := "attachments/channel-abc/"
|
||||||
|
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||||
|
key := prefix + name
|
||||||
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also create a file in a different channel
|
||||||
|
other := "attachments/channel-other/keep.txt"
|
||||||
|
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
||||||
|
|
||||||
|
// Delete the channel prefix
|
||||||
|
err := s.DeletePrefix(ctx, "attachments/channel-abc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeletePrefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify channel files are gone
|
||||||
|
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||||
|
exists, _ := s.Exists(ctx, prefix+name)
|
||||||
|
if exists {
|
||||||
|
t.Errorf("%s still exists after DeletePrefix", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify other channel is untouched
|
||||||
|
exists, _ := s.Exists(ctx, other)
|
||||||
|
if !exists {
|
||||||
|
t.Error("other channel file was incorrectly deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_DeletePrefixNonexistent(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Should not error
|
||||||
|
err := s.DeletePrefix(ctx, "attachments/does-not-exist/")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("DeletePrefix nonexistent: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_Exists(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
key := "attachments/ch/exists.txt"
|
||||||
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain")
|
||||||
|
|
||||||
|
exists, err := s.Exists(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exists: %v", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
t.Error("Exists returned false for existing file")
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err = s.Exists(ctx, "attachments/ch/nope.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exists (missing): %v", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
t.Error("Exists returned true for missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_Healthy(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
if err := s.Healthy(context.Background()); err != nil {
|
||||||
|
t.Errorf("Healthy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_Stats(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Empty store
|
||||||
|
stats, err := s.Stats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stats: %v", err)
|
||||||
|
}
|
||||||
|
if stats.TotalFiles != 0 || stats.TotalBytes != 0 {
|
||||||
|
t.Errorf("empty store: files=%d bytes=%d", stats.TotalFiles, stats.TotalBytes)
|
||||||
|
}
|
||||||
|
if !stats.Healthy || !stats.Configured {
|
||||||
|
t.Error("expected healthy + configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add some files
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
key := filepath.Join("attachments", "ch", string(rune('a'+i))+".txt")
|
||||||
|
data := bytes.Repeat([]byte("x"), 100*(i+1))
|
||||||
|
_ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err = s.Stats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stats: %v", err)
|
||||||
|
}
|
||||||
|
if stats.TotalFiles != 3 {
|
||||||
|
t.Errorf("files = %d, want 3", stats.TotalFiles)
|
||||||
|
}
|
||||||
|
if stats.TotalBytes != 600 { // 100 + 200 + 300
|
||||||
|
t.Errorf("bytes = %d, want 600", stats.TotalBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_PathTraversal(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
bad := []string{
|
||||||
|
"../etc/passwd",
|
||||||
|
"attachments/../../etc/shadow",
|
||||||
|
"/absolute/path",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range bad {
|
||||||
|
err := s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Put(%q) should have failed", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, err = s.Get(ctx, key)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Get(%q) should have failed", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.Delete(ctx, key)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Delete(%q) should have failed", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPVC_SubdirCreation(t *testing.T) {
|
||||||
|
s := tempStore(t)
|
||||||
|
|
||||||
|
// Verify well-known subdirs were created
|
||||||
|
for _, sub := range []string{"attachments", "processing"} {
|
||||||
|
info, err := os.Stat(filepath.Join(s.basePath, sub))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("subdir %q not created: %v", sub, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Errorf("%q is not a directory", sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPVC_InvalidPath(t *testing.T) {
|
||||||
|
// Path that can't be created (nested under a file)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
filePath := filepath.Join(tmp, "blocker")
|
||||||
|
os.WriteFile(filePath, []byte("x"), 0o644)
|
||||||
|
|
||||||
|
_, err := NewPVC(filepath.Join(filePath, "subdir"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid path")
|
||||||
|
}
|
||||||
|
}
|
||||||
312
server/storage/s3.go
Normal file
312
server/storage/s3.go
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/minio/minio-go/v7"
|
||||||
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── S3 Store ──────────────────────────────
|
||||||
|
|
||||||
|
// S3Config holds all configuration for the S3 backend.
|
||||||
|
type S3Config struct {
|
||||||
|
Endpoint string // e.g. "minio.example.com:9000" or "s3.amazonaws.com"
|
||||||
|
Bucket string // bucket name
|
||||||
|
Region string // AWS region (default "us-east-1")
|
||||||
|
AccessKey string // access key ID
|
||||||
|
SecretKey string // secret access key
|
||||||
|
Prefix string // optional key prefix (e.g. "switchboard/")
|
||||||
|
ForcePathStyle bool // path-style URLs (required for MinIO, most self-hosted)
|
||||||
|
UseSSL bool // use HTTPS (derived from endpoint scheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3Store implements ObjectStore using any S3-compatible API.
|
||||||
|
// Tested with: MinIO, Ceph RGW, AWS S3.
|
||||||
|
type S3Store struct {
|
||||||
|
client *minio.Client
|
||||||
|
bucket string
|
||||||
|
prefix string // prepended to all keys (e.g. "switchboard/")
|
||||||
|
endpoint string // original endpoint for display in Stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewS3 creates an S3Store connected to the given endpoint.
|
||||||
|
// Returns an error if the bucket does not exist or is not accessible.
|
||||||
|
func NewS3(cfg S3Config) (*S3Store, error) {
|
||||||
|
if cfg.Bucket == "" {
|
||||||
|
return nil, fmt.Errorf("storage/s3: bucket name is required")
|
||||||
|
}
|
||||||
|
if cfg.AccessKey == "" || cfg.SecretKey == "" {
|
||||||
|
return nil, fmt.Errorf("storage/s3: access key and secret key are required")
|
||||||
|
}
|
||||||
|
if cfg.Region == "" {
|
||||||
|
cfg.Region = "us-east-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse endpoint: strip scheme if present, detect SSL
|
||||||
|
endpoint := cfg.Endpoint
|
||||||
|
useSSL := cfg.UseSSL
|
||||||
|
if strings.HasPrefix(endpoint, "https://") {
|
||||||
|
endpoint = strings.TrimPrefix(endpoint, "https://")
|
||||||
|
useSSL = true
|
||||||
|
} else if strings.HasPrefix(endpoint, "http://") {
|
||||||
|
endpoint = strings.TrimPrefix(endpoint, "http://")
|
||||||
|
useSSL = false
|
||||||
|
}
|
||||||
|
// Default to SSL for AWS endpoints
|
||||||
|
if endpoint == "" || strings.Contains(endpoint, "amazonaws.com") {
|
||||||
|
useSSL = true
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &minio.Options{
|
||||||
|
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||||
|
Secure: useSSL,
|
||||||
|
Region: cfg.Region,
|
||||||
|
}
|
||||||
|
|
||||||
|
// BucketLookup controls path-style vs virtual-hosted-style URLs.
|
||||||
|
// MinIO, Ceph, and most self-hosted S3 require path-style.
|
||||||
|
if cfg.ForcePathStyle {
|
||||||
|
opts.BucketLookup = minio.BucketLookupPath
|
||||||
|
} else {
|
||||||
|
opts.BucketLookup = minio.BucketLookupAuto
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := minio.New(endpoint, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage/s3: client init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize prefix: ensure trailing slash if non-empty, no leading slash
|
||||||
|
prefix := strings.TrimPrefix(cfg.Prefix, "/")
|
||||||
|
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &S3Store{
|
||||||
|
client: client,
|
||||||
|
bucket: cfg.Bucket,
|
||||||
|
prefix: prefix,
|
||||||
|
endpoint: endpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate: check bucket exists and is accessible
|
||||||
|
ctx := context.Background()
|
||||||
|
exists, err := client.BucketExists(ctx, cfg.Bucket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("storage/s3: cannot reach endpoint: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("storage/s3: bucket %q does not exist", cfg.Bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *S3Store) Backend() string { return "s3" }
|
||||||
|
|
||||||
|
// Put writes data to S3 at the given key.
|
||||||
|
func (s *S3Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := minio.PutObjectOptions{
|
||||||
|
ContentType: contentType,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If size is unknown (0), we need to buffer or use -1.
|
||||||
|
// minio-go handles -1 with multipart upload.
|
||||||
|
uploadSize := size
|
||||||
|
if uploadSize == 0 {
|
||||||
|
uploadSize = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.client.PutObject(ctx, s.bucket, s.fullKey(key), r, uploadSize, opts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage/s3: put %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a reader for the object at key.
|
||||||
|
func (s *S3Store) Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return nil, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
obj, err := s.client.GetObject(ctx, s.bucket, s.fullKey(key), minio.GetObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, "", fmt.Errorf("storage/s3: get %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat the object to get size and content type.
|
||||||
|
// GetObject is lazy — the actual HTTP request happens on Read or Stat.
|
||||||
|
info, err := obj.Stat()
|
||||||
|
if err != nil {
|
||||||
|
obj.Close()
|
||||||
|
if isS3NotFound(err) {
|
||||||
|
return nil, 0, "", ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, 0, "", fmt.Errorf("storage/s3: stat %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := info.ContentType
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj, info.Size, ct, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the object at key.
|
||||||
|
func (s *S3Store) Delete(ctx context.Context, key string) error {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.client.RemoveObject(ctx, s.bucket, s.fullKey(key), minio.RemoveObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
// S3 RemoveObject is already idempotent (no error for missing keys)
|
||||||
|
return fmt.Errorf("storage/s3: delete %q: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePrefix removes all objects under the given prefix.
|
||||||
|
func (s *S3Store) DeletePrefix(ctx context.Context, prefix string) error {
|
||||||
|
if err := validateKey(prefix); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPrefix := s.fullKey(prefix)
|
||||||
|
// Ensure trailing slash for directory-like prefix
|
||||||
|
if !strings.HasSuffix(fullPrefix, "/") {
|
||||||
|
fullPrefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all objects under prefix
|
||||||
|
objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{
|
||||||
|
Prefix: fullPrefix,
|
||||||
|
Recursive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build removal channel
|
||||||
|
errCh := s.client.RemoveObjects(ctx, s.bucket, toRemoveChannel(objectsCh), minio.RemoveObjectsOptions{})
|
||||||
|
|
||||||
|
// Drain errors
|
||||||
|
var firstErr error
|
||||||
|
for e := range errCh {
|
||||||
|
if e.Err != nil && firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("storage/s3: delete prefix %q: %w", prefix, e.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if an object exists at key.
|
||||||
|
func (s *S3Store) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.client.StatObject(ctx, s.bucket, s.fullKey(key), minio.StatObjectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
if isS3NotFound(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("storage/s3: exists %q: %w", key, err)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthy checks connectivity by performing a HeadBucket.
|
||||||
|
func (s *S3Store) Healthy(ctx context.Context) error {
|
||||||
|
exists, err := s.client.BucketExists(ctx, s.bucket)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage/s3: health check failed: %w", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("storage/s3: bucket %q no longer exists", s.bucket)
|
||||||
|
}
|
||||||
|
// Write + read a probe object to verify full access
|
||||||
|
probeKey := s.fullKey(".health-probe")
|
||||||
|
_, err = s.client.PutObject(ctx, s.bucket, probeKey,
|
||||||
|
bytes.NewReader([]byte("ok")), 2,
|
||||||
|
minio.PutObjectOptions{ContentType: "text/plain"})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storage/s3: not writable: %w", err)
|
||||||
|
}
|
||||||
|
s.client.RemoveObject(ctx, s.bucket, probeKey, minio.RemoveObjectOptions{})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns object count and total size in the bucket (under prefix).
|
||||||
|
func (s *S3Store) Stats(ctx context.Context) (*StorageStats, error) {
|
||||||
|
stats := &StorageStats{
|
||||||
|
Backend: "s3",
|
||||||
|
Bucket: s.bucket,
|
||||||
|
Endpoint: s.endpoint,
|
||||||
|
Configured: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check health first
|
||||||
|
if err := s.Healthy(ctx); err != nil {
|
||||||
|
stats.Healthy = false
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
stats.Healthy = true
|
||||||
|
|
||||||
|
// Count objects under the attachments prefix
|
||||||
|
attPrefix := s.fullKey("attachments/")
|
||||||
|
objectsCh := s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{
|
||||||
|
Prefix: attPrefix,
|
||||||
|
Recursive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
for obj := range objectsCh {
|
||||||
|
if obj.Err != nil {
|
||||||
|
log.Printf("storage/s3: stats list error: %v", obj.Err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
stats.TotalFiles++
|
||||||
|
stats.TotalBytes += obj.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────
|
||||||
|
|
||||||
|
// fullKey prepends the configured prefix to a storage key.
|
||||||
|
func (s *S3Store) fullKey(key string) string {
|
||||||
|
return s.prefix + key
|
||||||
|
}
|
||||||
|
|
||||||
|
// isS3NotFound checks if an error represents a "not found" condition.
|
||||||
|
func isS3NotFound(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
resp := minio.ToErrorResponse(err)
|
||||||
|
return resp.Code == "NoSuchKey" || resp.StatusCode == 404
|
||||||
|
}
|
||||||
|
|
||||||
|
// toRemoveChannel converts a ListObjects channel to a RemoveObjects input channel.
|
||||||
|
func toRemoveChannel(objects <-chan minio.ObjectInfo) <-chan minio.ObjectInfo {
|
||||||
|
// RemoveObjects accepts the same channel type — pass through directly.
|
||||||
|
// The channel is already producing ObjectInfo values with Key set.
|
||||||
|
return objects
|
||||||
|
}
|
||||||
326
server/storage/s3_test.go
Normal file
326
server/storage/s3_test.go
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Unit Tests (always run) ──────────────
|
||||||
|
|
||||||
|
func TestS3_NewS3_Validation(t *testing.T) {
|
||||||
|
// Missing bucket
|
||||||
|
_, err := NewS3(S3Config{
|
||||||
|
AccessKey: "test",
|
||||||
|
SecretKey: "test",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing bucket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing credentials
|
||||||
|
_, err = NewS3(S3Config{
|
||||||
|
Bucket: "test-bucket",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing access key only
|
||||||
|
_, err = NewS3(S3Config{
|
||||||
|
Bucket: "test-bucket",
|
||||||
|
SecretKey: "test",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing access key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_FullKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
prefix string
|
||||||
|
key string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", "attachments/ch/f.txt", "attachments/ch/f.txt"},
|
||||||
|
{"switchboard/", "attachments/ch/f.txt", "switchboard/attachments/ch/f.txt"},
|
||||||
|
{"prefix", "attachments/ch/f.txt", "prefix/attachments/ch/f.txt"}, // trailing slash added by NewS3
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
s := &S3Store{prefix: tt.prefix}
|
||||||
|
// Normalize prefix same as NewS3 does
|
||||||
|
if s.prefix != "" && s.prefix[len(s.prefix)-1] != '/' {
|
||||||
|
s.prefix += "/"
|
||||||
|
}
|
||||||
|
got := s.fullKey(tt.key)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("fullKey(%q, %q) = %q, want %q", tt.prefix, tt.key, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_KeyValidation(t *testing.T) {
|
||||||
|
// validateKey is shared between PVC and S3 — test with S3 context
|
||||||
|
bad := []string{
|
||||||
|
"",
|
||||||
|
"../etc/passwd",
|
||||||
|
"/absolute/path",
|
||||||
|
"attachments/../../etc/shadow",
|
||||||
|
}
|
||||||
|
for _, key := range bad {
|
||||||
|
if err := validateKey(key); err == nil {
|
||||||
|
t.Errorf("validateKey(%q) should have failed", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
good := []string{
|
||||||
|
"attachments/ch/f.txt",
|
||||||
|
"attachments/channel-1/att-abc_test.pdf",
|
||||||
|
"processing/abc123/status.json",
|
||||||
|
}
|
||||||
|
for _, key := range good {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
t.Errorf("validateKey(%q) failed: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Integration Tests (require live S3 endpoint) ──────────────
|
||||||
|
//
|
||||||
|
// Set these environment variables to run:
|
||||||
|
//
|
||||||
|
// S3_TEST_ENDPOINT=localhost:9000
|
||||||
|
// S3_TEST_BUCKET=switchboard-test
|
||||||
|
// S3_TEST_ACCESS_KEY=minioadmin
|
||||||
|
// S3_TEST_SECRET_KEY=minioadmin
|
||||||
|
//
|
||||||
|
// Example with MinIO:
|
||||||
|
//
|
||||||
|
// docker run -d -p 9000:9000 -p 9001:9001 \
|
||||||
|
// -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
|
||||||
|
// minio/minio server /data --console-address :9001
|
||||||
|
// mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||||
|
// mc mb local/switchboard-test
|
||||||
|
|
||||||
|
func testS3Store(t *testing.T) *S3Store {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
endpoint := os.Getenv("S3_TEST_ENDPOINT")
|
||||||
|
bucket := os.Getenv("S3_TEST_BUCKET")
|
||||||
|
accessKey := os.Getenv("S3_TEST_ACCESS_KEY")
|
||||||
|
secretKey := os.Getenv("S3_TEST_SECRET_KEY")
|
||||||
|
|
||||||
|
if endpoint == "" || bucket == "" {
|
||||||
|
t.Skip("S3_TEST_ENDPOINT and S3_TEST_BUCKET not set — skipping S3 integration tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := NewS3(S3Config{
|
||||||
|
Endpoint: endpoint,
|
||||||
|
Bucket: bucket,
|
||||||
|
Region: "us-east-1",
|
||||||
|
AccessKey: accessKey,
|
||||||
|
SecretKey: secretKey,
|
||||||
|
Prefix: "test-" + t.Name() + "/",
|
||||||
|
ForcePathStyle: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewS3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up prefix after test
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = s.DeletePrefix(ctx, "attachments")
|
||||||
|
_ = s.DeletePrefix(ctx, "processing")
|
||||||
|
})
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_Integration_PutGetRoundTrip(t *testing.T) {
|
||||||
|
s := testS3Store(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
data := []byte("hello, S3 storage world")
|
||||||
|
key := "attachments/channel-1/att-1_test.txt"
|
||||||
|
|
||||||
|
err := s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, size, ct, err := s.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
if size != int64(len(data)) {
|
||||||
|
t.Errorf("size = %d, want %d", size, len(data))
|
||||||
|
}
|
||||||
|
if ct != "text/plain" {
|
||||||
|
t.Errorf("content_type = %q, want %q", ct, "text/plain")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := io.ReadAll(rc)
|
||||||
|
if !bytes.Equal(got, data) {
|
||||||
|
t.Errorf("data = %q, want %q", got, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_Integration_GetNotFound(t *testing.T) {
|
||||||
|
s := testS3Store(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, _, _, err := s.Get(ctx, "attachments/nonexistent/file.txt")
|
||||||
|
if err != ErrNotFound {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_Integration_Delete(t *testing.T) {
|
||||||
|
s := testS3Store(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
key := "attachments/ch/delete-me.txt"
|
||||||
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("bye")), 3, "text/plain")
|
||||||
|
|
||||||
|
err := s.Delete(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ := s.Exists(ctx, key)
|
||||||
|
if exists {
|
||||||
|
t.Error("file still exists after Delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_Integration_DeleteIdempotent(t *testing.T) {
|
||||||
|
s := testS3Store(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// S3 delete is inherently idempotent
|
||||||
|
err := s.Delete(ctx, "attachments/no-such/file.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Delete nonexistent: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_Integration_DeletePrefix(t *testing.T) {
|
||||||
|
s := testS3Store(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
prefix := "attachments/channel-abc/"
|
||||||
|
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||||
|
key := prefix + name
|
||||||
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("x")), 1, "text/plain")
|
||||||
|
}
|
||||||
|
|
||||||
|
other := "attachments/channel-other/keep.txt"
|
||||||
|
_ = s.Put(ctx, other, bytes.NewReader([]byte("y")), 1, "text/plain")
|
||||||
|
|
||||||
|
err := s.DeletePrefix(ctx, "attachments/channel-abc")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeletePrefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"a.txt", "b.png", "c.pdf"} {
|
||||||
|
exists, _ := s.Exists(ctx, prefix+name)
|
||||||
|
if exists {
|
||||||
|
t.Errorf("%s still exists after DeletePrefix", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, _ := s.Exists(ctx, other)
|
||||||
|
if !exists {
|
||||||
|
t.Error("other channel file was incorrectly deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_Integration_Exists(t *testing.T) {
|
||||||
|
s := testS3Store(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
key := "attachments/ch/exists.txt"
|
||||||
|
_ = s.Put(ctx, key, bytes.NewReader([]byte("hi")), 2, "text/plain")
|
||||||
|
|
||||||
|
exists, err := s.Exists(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exists: %v", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
t.Error("Exists returned false for existing file")
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err = s.Exists(ctx, "attachments/ch/nope.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exists (missing): %v", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
t.Error("Exists returned true for missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_Integration_Healthy(t *testing.T) {
|
||||||
|
s := testS3Store(t)
|
||||||
|
if err := s.Healthy(context.Background()); err != nil {
|
||||||
|
t.Errorf("Healthy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_Integration_Stats(t *testing.T) {
|
||||||
|
s := testS3Store(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Add some files
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
key := "attachments/ch/" + string(rune('a'+i)) + ".txt"
|
||||||
|
data := bytes.Repeat([]byte("x"), 100*(i+1))
|
||||||
|
_ = s.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "text/plain")
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := s.Stats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stats: %v", err)
|
||||||
|
}
|
||||||
|
if stats.TotalFiles != 3 {
|
||||||
|
t.Errorf("files = %d, want 3", stats.TotalFiles)
|
||||||
|
}
|
||||||
|
if stats.TotalBytes != 600 {
|
||||||
|
t.Errorf("bytes = %d, want 600", stats.TotalBytes)
|
||||||
|
}
|
||||||
|
if !stats.Healthy || !stats.Configured {
|
||||||
|
t.Error("expected healthy + configured")
|
||||||
|
}
|
||||||
|
if stats.Backend != "s3" {
|
||||||
|
t.Errorf("backend = %q, want s3", stats.Backend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3_Integration_PutUnknownSize(t *testing.T) {
|
||||||
|
s := testS3Store(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
data := []byte("unknown size upload")
|
||||||
|
key := "attachments/ch/unknown-size.txt"
|
||||||
|
|
||||||
|
// size=0 means unknown — S3 should handle via multipart
|
||||||
|
err := s.Put(ctx, key, bytes.NewReader(data), 0, "text/plain")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put with size=0: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, size, _, err := s.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
if size != int64(len(data)) {
|
||||||
|
t.Errorf("size = %d, want %d", size, len(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
73
server/storage/storage.go
Normal file
73
server/storage/storage.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Errors ─────────────────────────────────
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNotFound is returned when a requested object does not exist.
|
||||||
|
ErrNotFound = errors.New("storage: object not found")
|
||||||
|
|
||||||
|
// ErrNotConfigured is returned when storage operations are attempted
|
||||||
|
// but no backend has been configured or the backend is unhealthy.
|
||||||
|
ErrNotConfigured = errors.New("storage: backend not configured")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── ObjectStore Interface ──────────────────
|
||||||
|
|
||||||
|
// ObjectStore is the abstraction for blob storage.
|
||||||
|
// Implementations: PVC (filesystem), S3 (minio-go v7).
|
||||||
|
//
|
||||||
|
// Keys are slash-delimited paths relative to the storage root:
|
||||||
|
//
|
||||||
|
// attachments/{channel_id}/{attachment_id}_{filename}
|
||||||
|
//
|
||||||
|
// Implementations must create intermediate directories as needed.
|
||||||
|
type ObjectStore interface {
|
||||||
|
// Put writes data to the given key.
|
||||||
|
// Creates parent directories as needed. Overwrites if exists.
|
||||||
|
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||||
|
|
||||||
|
// Get returns a reader for the given key.
|
||||||
|
// Returns the reader, size in bytes, content type, and error.
|
||||||
|
// Caller must close the returned reader.
|
||||||
|
// Returns ErrNotFound if key does not exist.
|
||||||
|
Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error)
|
||||||
|
|
||||||
|
// Delete removes the object at key.
|
||||||
|
// No error if the key does not exist (idempotent).
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
|
|
||||||
|
// DeletePrefix removes all objects under the given prefix.
|
||||||
|
// Used for channel deletion (bulk cleanup).
|
||||||
|
// Example: DeletePrefix(ctx, "attachments/{channel_id}/")
|
||||||
|
DeletePrefix(ctx context.Context, prefix string) error
|
||||||
|
|
||||||
|
// Exists checks if an object exists at key without reading it.
|
||||||
|
Exists(ctx context.Context, key string) (bool, error)
|
||||||
|
|
||||||
|
// Healthy returns nil if the backend is operational (writable).
|
||||||
|
Healthy(ctx context.Context) error
|
||||||
|
|
||||||
|
// Stats returns aggregate storage statistics.
|
||||||
|
Stats(ctx context.Context) (*StorageStats, error)
|
||||||
|
|
||||||
|
// Backend returns the backend type identifier ("pvc", "s3").
|
||||||
|
Backend() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageStats holds aggregate storage metrics.
|
||||||
|
type StorageStats struct {
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
Path string `json:"path,omitempty"` // PVC only
|
||||||
|
Endpoint string `json:"endpoint,omitempty"` // S3 only
|
||||||
|
Bucket string `json:"bucket,omitempty"` // S3 only
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
TotalFiles int64 `json:"total_files"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ type Stores struct {
|
|||||||
Usage UsageStore
|
Usage UsageStore
|
||||||
Pricing PricingStore
|
Pricing PricingStore
|
||||||
Extensions ExtensionStore
|
Extensions ExtensionStore
|
||||||
|
Attachments AttachmentStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -340,6 +341,24 @@ type ExtensionStore interface {
|
|||||||
DeleteUserSettings(ctx context.Context, extID, userID string) error
|
DeleteUserSettings(ctx context.Context, extID, userID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// ATTACHMENT STORE
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
type AttachmentStore interface {
|
||||||
|
Create(ctx context.Context, a *models.Attachment) error
|
||||||
|
GetByID(ctx context.Context, id string) (*models.Attachment, error)
|
||||||
|
GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error)
|
||||||
|
GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error)
|
||||||
|
SetMessageID(ctx context.Context, attachmentID, messageID string) error
|
||||||
|
UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error
|
||||||
|
SetExtractedText(ctx context.Context, id string, text string) error
|
||||||
|
Delete(ctx context.Context, id string) (*models.Attachment, error) // returns deleted row for storage cleanup
|
||||||
|
DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys
|
||||||
|
UserUsageBytes(ctx context.Context, userID string) (int64, error)
|
||||||
|
ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error)
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// SHARED TYPES
|
// SHARED TYPES
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
187
server/store/postgres/attachment.go
Normal file
187
server/store/postgres/attachment.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AttachmentStore struct{}
|
||||||
|
|
||||||
|
func NewAttachmentStore() *AttachmentStore { return &AttachmentStore{} }
|
||||||
|
|
||||||
|
// ── columns shared across queries ──────────
|
||||||
|
const attachmentCols = `id, channel_id, user_id, message_id, filename, content_type,
|
||||||
|
size_bytes, storage_key, extracted_text, metadata, created_at`
|
||||||
|
|
||||||
|
// scanAttachment scans a row into an Attachment struct.
|
||||||
|
func scanAttachment(row interface{ Scan(dest ...interface{}) error }) (*models.Attachment, error) {
|
||||||
|
var a models.Attachment
|
||||||
|
var messageID sql.NullString
|
||||||
|
var extractedText sql.NullString
|
||||||
|
var metadataJSON []byte
|
||||||
|
|
||||||
|
err := row.Scan(
|
||||||
|
&a.ID, &a.ChannelID, &a.UserID, &messageID,
|
||||||
|
&a.Filename, &a.ContentType, &a.SizeBytes,
|
||||||
|
&a.StorageKey, &extractedText, &metadataJSON, &a.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.MessageID = NullableStringPtr(messageID)
|
||||||
|
if extractedText.Valid {
|
||||||
|
a.ExtractedText = &extractedText.String
|
||||||
|
}
|
||||||
|
if len(metadataJSON) > 0 {
|
||||||
|
json.Unmarshal(metadataJSON, &a.Metadata)
|
||||||
|
}
|
||||||
|
return &a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AttachmentStore) Create(ctx context.Context, a *models.Attachment) error {
|
||||||
|
return DB.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO attachments (channel_id, user_id, message_id, filename, content_type,
|
||||||
|
size_bytes, storage_key, extracted_text, metadata)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
a.ChannelID, a.UserID, models.NullString(a.MessageID),
|
||||||
|
a.Filename, a.ContentType, a.SizeBytes,
|
||||||
|
a.StorageKey, models.NullString(a.ExtractedText),
|
||||||
|
ToJSON(a.Metadata),
|
||||||
|
).Scan(&a.ID, &a.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AttachmentStore) GetByID(ctx context.Context, id string) (*models.Attachment, error) {
|
||||||
|
row := DB.QueryRowContext(ctx, `SELECT `+attachmentCols+` FROM attachments WHERE id = $1`, id)
|
||||||
|
return scanAttachment(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AttachmentStore) GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+attachmentCols+` FROM attachments WHERE channel_id = $1 ORDER BY created_at`, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []models.Attachment
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAttachment(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AttachmentStore) GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+attachmentCols+` FROM attachments WHERE message_id = $1 ORDER BY created_at`, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []models.Attachment
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAttachment(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AttachmentStore) SetMessageID(ctx context.Context, attachmentID, messageID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`UPDATE attachments SET message_id = $1 WHERE id = $2`,
|
||||||
|
messageID, attachmentID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AttachmentStore) UpdateMetadata(ctx context.Context, id string, metadata map[string]interface{}) error {
|
||||||
|
// Merge into existing metadata using jsonb || operator
|
||||||
|
metaJSON, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = DB.ExecContext(ctx,
|
||||||
|
`UPDATE attachments SET metadata = metadata || $1::jsonb WHERE id = $2`,
|
||||||
|
metaJSON, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AttachmentStore) SetExtractedText(ctx context.Context, id string, text string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`UPDATE attachments SET extracted_text = $1 WHERE id = $2`,
|
||||||
|
text, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes an attachment and returns the deleted row (for storage cleanup).
|
||||||
|
func (s *AttachmentStore) Delete(ctx context.Context, id string) (*models.Attachment, error) {
|
||||||
|
row := DB.QueryRowContext(ctx,
|
||||||
|
`DELETE FROM attachments WHERE id = $1
|
||||||
|
RETURNING `+attachmentCols, id)
|
||||||
|
return scanAttachment(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteByChannel removes all attachments for a channel and returns storage keys.
|
||||||
|
func (s *AttachmentStore) DeleteByChannel(ctx context.Context, channelID string) ([]string, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`DELETE FROM attachments WHERE channel_id = $1 RETURNING storage_key`, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var keys []string
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
if err := rows.Scan(&key); err != nil {
|
||||||
|
return keys, err
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
return keys, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AttachmentStore) UserUsageBytes(ctx context.Context, userID string) (int64, error) {
|
||||||
|
var total sql.NullInt64
|
||||||
|
err := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT COALESCE(SUM(size_bytes), 0) FROM attachments WHERE user_id = $1`,
|
||||||
|
userID).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return total.Int64, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AttachmentStore) ListOrphans(ctx context.Context, olderThan time.Duration) ([]models.Attachment, error) {
|
||||||
|
cutoff := time.Now().Add(-olderThan)
|
||||||
|
rows, err := DB.QueryContext(ctx,
|
||||||
|
`SELECT `+attachmentCols+` FROM attachments
|
||||||
|
WHERE message_id IS NULL AND created_at < $1
|
||||||
|
ORDER BY created_at`, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []models.Attachment
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanAttachment(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *a)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -26,5 +26,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Usage: NewUsageStore(),
|
Usage: NewUsageStore(),
|
||||||
Pricing: NewPricingStore(),
|
Pricing: NewPricingStore(),
|
||||||
Extensions: NewExtensionStore(),
|
Extensions: NewExtensionStore(),
|
||||||
|
Attachments: NewAttachmentStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -901,6 +901,133 @@ a:hover { text-decoration: underline; }
|
|||||||
.stop-btn { display: none; color: var(--warning); }
|
.stop-btn { display: none; color: var(--warning); }
|
||||||
.stop-btn.visible { display: flex; }
|
.stop-btn.visible { display: flex; }
|
||||||
|
|
||||||
|
/* ── Attachments ─────────────────────────── */
|
||||||
|
|
||||||
|
.attach-btn { color: var(--text-3); padding: 6px 4px 12px 10px; }
|
||||||
|
.attach-btn:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.attachment-strip {
|
||||||
|
max-width: 768px; margin: 0 auto; padding: 6px 8px 2px;
|
||||||
|
display: flex; gap: 6px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-chip {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 4px 6px;
|
||||||
|
font-size: 12px; max-width: 220px; position: relative;
|
||||||
|
}
|
||||||
|
.att-chip.att-ready { border-color: color-mix(in srgb, var(--success) 40%, var(--border)); }
|
||||||
|
.att-chip.att-error { border-color: color-mix(in srgb, var(--error) 40%, var(--border)); }
|
||||||
|
.att-chip.att-pending { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
|
||||||
|
|
||||||
|
.att-thumb {
|
||||||
|
width: 32px; height: 32px; object-fit: cover;
|
||||||
|
border-radius: 3px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.att-icon { font-size: 18px; flex-shrink: 0; line-height: 1; }
|
||||||
|
|
||||||
|
.att-info { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.att-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); font-weight: 500; }
|
||||||
|
.att-meta { color: var(--text-3); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.att-remove {
|
||||||
|
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||||
|
padding: 2px 4px; font-size: 13px; line-height: 1; border-radius: 3px;
|
||||||
|
flex-shrink: 0; margin-left: auto;
|
||||||
|
}
|
||||||
|
.att-remove:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
|
||||||
|
/* Drag-and-drop overlay on chat area */
|
||||||
|
.messages.drag-over {
|
||||||
|
outline: 2px dashed var(--accent);
|
||||||
|
outline-offset: -4px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 4%, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Message Attachments ────────────────────── */
|
||||||
|
|
||||||
|
.msg-attachments {
|
||||||
|
display: flex; gap: 8px; flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-att-image {
|
||||||
|
cursor: pointer; position: relative; border-radius: var(--radius);
|
||||||
|
overflow: hidden; border: 1px solid var(--border);
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
.msg-att-image:hover { border-color: var(--accent); }
|
||||||
|
.msg-att-image img {
|
||||||
|
display: block; max-height: 280px; border-radius: var(--radius);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
.msg-att-image img.att-load-error {
|
||||||
|
min-width: 120px; min-height: 60px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 13px; color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.att-vision-hint {
|
||||||
|
position: absolute; top: 4px; right: 4px;
|
||||||
|
background: color-mix(in srgb, var(--bg) 85%, transparent);
|
||||||
|
border-radius: 4px; padding: 2px 5px; font-size: 14px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-att-doc {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); background: var(--bg-raised);
|
||||||
|
text-decoration: none; color: var(--text);
|
||||||
|
max-width: 280px; transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
.msg-att-doc:hover { border-color: var(--accent); }
|
||||||
|
.att-doc-icon { font-size: 20px; flex-shrink: 0; }
|
||||||
|
.att-doc-info { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.att-doc-name {
|
||||||
|
font-size: 13px; font-weight: 500;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.att-doc-size { font-size: 11px; color: var(--text-3); }
|
||||||
|
.att-doc-dl { color: var(--text-3); font-size: 14px; margin-left: auto; }
|
||||||
|
|
||||||
|
/* ── Image Lightbox ─────────────────────────── */
|
||||||
|
|
||||||
|
.lightbox-overlay {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 10000;
|
||||||
|
background: rgba(0,0,0,0.85); align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.lightbox-overlay.active { display: flex; }
|
||||||
|
.lightbox-overlay img {
|
||||||
|
max-width: 92vw; max-height: 90vh; object-fit: contain;
|
||||||
|
border-radius: 4px; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute; top: 16px; right: 20px;
|
||||||
|
background: rgba(255,255,255,0.15); border: none; color: #fff;
|
||||||
|
font-size: 22px; width: 40px; height: 40px; border-radius: 50%;
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.lightbox-close:hover { background: rgba(255,255,255,0.3); }
|
||||||
|
|
||||||
|
/* ── Admin Storage Panel ────────────────────── */
|
||||||
|
|
||||||
|
.admin-storage-card {
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 16px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.admin-storage-card h4 { margin: 0 0 10px 0; font-size: 14px; }
|
||||||
|
.admin-storage-desc { font-size: 12px; color: var(--text-3); margin: 0 0 10px 0; }
|
||||||
|
.admin-storage-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.admin-storage-item { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.admin-storage-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.admin-storage-value { font-size: 14px; font-weight: 500; }
|
||||||
|
|
||||||
/* ── Buttons ─────────────────────────────── */
|
/* ── Buttons ─────────────────────────────── */
|
||||||
|
|
||||||
button { font-family: var(--font); cursor: pointer; }
|
button { font-family: var(--font); cursor: pointer; }
|
||||||
@@ -1354,6 +1481,9 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
.team-admin-back:hover { opacity: 1; }
|
.team-admin-back:hover { opacity: 1; }
|
||||||
.team-tab-content { padding: 0; }
|
.team-tab-content { padding: 0; }
|
||||||
.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
|
.badge-success { background: rgba(34,197,94,0.2); color: #4ade80; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
|
.badge-warning { background: rgba(234,179,8,0.2); color: #fbbf24; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
|
.badge-danger { background: rgba(239,68,68,0.2); color: #f87171; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
.admin-user-row.user-inactive { opacity: 0.7; }
|
.admin-user-row.user-inactive { opacity: 0.7; }
|
||||||
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
|
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
|
||||||
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
|
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
|
||||||
|
|||||||
@@ -147,7 +147,12 @@
|
|||||||
<button class="context-warning-action" id="summarizeBtn" onclick="summarizeAndContinue()" title="Summarize conversation and continue with reduced context" style="display:none">📝 Summarize & Continue</button>
|
<button class="context-warning-action" id="summarizeBtn" onclick="summarizeAndContinue()" title="Summarize conversation and continue with reduced context" style="display:none">📝 Summarize & Continue</button>
|
||||||
<button class="context-warning-dismiss" onclick="dismissContextWarning()" title="Dismiss">✕</button>
|
<button class="context-warning-dismiss" onclick="dismissContextWarning()" title="Dismiss">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="attachment-strip" id="attachmentStrip" style="display:none"></div>
|
||||||
<div class="input-wrap">
|
<div class="input-wrap">
|
||||||
|
<button class="action-btn attach-btn" id="attachBtn" title="Attach file" style="display:none">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>
|
||||||
|
</button>
|
||||||
|
<input type="file" id="fileInput" multiple style="display:none">
|
||||||
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
|
||||||
<div class="input-actions">
|
<div class="input-actions">
|
||||||
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
|
<button class="action-btn stop-btn" id="stopBtn" title="Stop">
|
||||||
@@ -566,6 +571,7 @@
|
|||||||
<button class="admin-tab" data-tab="roles">Roles</button>
|
<button class="admin-tab" data-tab="roles">Roles</button>
|
||||||
<button class="admin-tab" data-tab="usage">Usage</button>
|
<button class="admin-tab" data-tab="usage">Usage</button>
|
||||||
<button class="admin-tab" data-tab="extensions">Extensions</button>
|
<button class="admin-tab" data-tab="extensions">Extensions</button>
|
||||||
|
<button class="admin-tab" data-tab="storage">Storage</button>
|
||||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||||
<button class="admin-tab" data-tab="audit">Audit</button>
|
<button class="admin-tab" data-tab="audit">Audit</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -706,6 +712,10 @@
|
|||||||
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
|
<div class="banner-preview" id="bannerPreview">PREVIEW</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="admin-section">
|
||||||
|
<h4>🔐 Encryption</h4>
|
||||||
|
<div id="adminVaultStatus"><span class="empty-hint">Loading…</span></div>
|
||||||
|
</section>
|
||||||
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
|
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -766,6 +776,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="admin-tab-content" id="adminStorageTab" style="display:none"><div id="adminStorageContent"></div></div>
|
||||||
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
|
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
|
||||||
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
|
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
|
||||||
<div class="admin-toolbar" style="margin-bottom:8px">
|
<div class="admin-toolbar" style="margin-bottom:8px">
|
||||||
@@ -858,6 +869,7 @@
|
|||||||
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
|
<script src="js/ui-admin.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
|
<script src="js/tokens.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
|
<script src="js/notes.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/attachments.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>
|
||||||
@@ -908,5 +920,12 @@ window.addEventListener('appinstalled', () => {
|
|||||||
_deferredInstallPrompt = null;
|
_deferredInstallPrompt = null;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Image lightbox overlay -->
|
||||||
|
<div class="lightbox-overlay" id="lightbox">
|
||||||
|
<button class="lightbox-close" title="Close (Esc)">✕</button>
|
||||||
|
<img id="lightboxImg" alt="">
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ const API = {
|
|||||||
|
|
||||||
// ── Completions ──────────────────────────
|
// ── Completions ──────────────────────────
|
||||||
|
|
||||||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {
|
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds) {
|
||||||
const body = { channel_id: channelId, content, stream: true };
|
const body = { channel_id: channelId, content, stream: true };
|
||||||
if (presetId) {
|
if (presetId) {
|
||||||
body.preset_id = presetId;
|
body.preset_id = presetId;
|
||||||
@@ -238,6 +238,7 @@ const API = {
|
|||||||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||||||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||||||
|
if (attachmentIds?.length) body.attachment_ids = attachmentIds;
|
||||||
|
|
||||||
let resp = await fetch(BASE + '/api/v1/chat/completions', {
|
let resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -455,6 +456,66 @@ const API = {
|
|||||||
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
|
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
|
||||||
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
|
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
|
||||||
|
|
||||||
|
// ── Attachments ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to a channel. Uses multipart/form-data (not JSON).
|
||||||
|
* Returns the created attachment object with id, metadata, etc.
|
||||||
|
*/
|
||||||
|
async uploadAttachment(channelId, file) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file, file.name);
|
||||||
|
|
||||||
|
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/attachments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||||
|
// No Content-Type — browser sets multipart boundary automatically
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = await doFetch();
|
||||||
|
if (resp.status === 401 && this.refreshToken) {
|
||||||
|
if (await this.refresh()) resp = await doFetch();
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
|
||||||
|
err.status = resp.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return this._parseJSON(resp, `/api/v1/channels/${channelId}/attachments`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAttachment(id) { return this._get(`/api/v1/attachments/${id}`); },
|
||||||
|
|
||||||
|
async downloadAttachmentBlob(id) {
|
||||||
|
const doFetch = () => fetch(BASE + `/api/v1/attachments/${id}/download`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
let resp = await doFetch();
|
||||||
|
if (resp.status === 401 && this.refreshToken) {
|
||||||
|
if (await this.refresh()) resp = await doFetch();
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.blob();
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAttachment(id) { return this._del(`/api/v1/attachments/${id}`); },
|
||||||
|
listChannelAttachments(channelId) { return this._get(`/api/v1/channels/${channelId}/attachments`); },
|
||||||
|
|
||||||
|
// ── Admin Storage ───────────────────────
|
||||||
|
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
|
||||||
|
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
|
||||||
|
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
|
||||||
|
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
|
||||||
|
|
||||||
|
// Vault
|
||||||
|
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
|
||||||
|
|
||||||
// ── Admin Roles ─────────────────────────
|
// ── Admin Roles ─────────────────────────
|
||||||
adminListRoles() { return this._get('/api/v1/admin/roles'); },
|
adminListRoles() { return this._get('/api/v1/admin/roles'); },
|
||||||
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
|
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const App = {
|
|||||||
abortController: null,
|
abortController: null,
|
||||||
serverSettings: {},
|
serverSettings: {},
|
||||||
policies: {},
|
policies: {},
|
||||||
|
stagedAttachments: [],
|
||||||
|
channelAttachments: {}, // messageId → [attachment, ...] — populated on chat load
|
||||||
|
storageConfigured: false,
|
||||||
|
|
||||||
// Find model by composite ID, with fallback to bare model_id match
|
// Find model by composite ID, with fallback to bare model_id match
|
||||||
findModel(id) {
|
findModel(id) {
|
||||||
@@ -183,6 +186,7 @@ async function startApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await initBanners();
|
await initBanners();
|
||||||
|
initAttachments();
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
UI.updateModelSelector();
|
UI.updateModelSelector();
|
||||||
UI.updateUser();
|
UI.updateUser();
|
||||||
@@ -370,6 +374,7 @@ function initListeners() {
|
|||||||
_initSettingsListeners(); // from settings-handlers.js
|
_initSettingsListeners(); // from settings-handlers.js
|
||||||
_initAdminListeners(); // from admin-handlers.js
|
_initAdminListeners(); // from admin-handlers.js
|
||||||
_initNotesListeners(); // from notes.js
|
_initNotesListeners(); // from notes.js
|
||||||
|
_initAttachmentListeners(); // from attachments.js
|
||||||
_initGlobalKeyboard(); // local: Escape, Ctrl+K, resize
|
_initGlobalKeyboard(); // local: Escape, Ctrl+K, resize
|
||||||
_initSidePanelResize(); // from ui-format.js
|
_initSidePanelResize(); // from ui-format.js
|
||||||
}
|
}
|
||||||
@@ -380,6 +385,9 @@ function _initGlobalKeyboard() {
|
|||||||
// Escape: stop generation → close command palette → close side panel → close topmost modal
|
// Escape: stop generation → close command palette → close side panel → close topmost modal
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (App.isGenerating) { stopGeneration(); return; }
|
if (App.isGenerating) { stopGeneration(); return; }
|
||||||
|
if (document.getElementById('lightbox')?.classList.contains('active')) {
|
||||||
|
closeLightbox(); return;
|
||||||
|
}
|
||||||
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
|
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
|
||||||
closeCmdPalette(); return;
|
closeCmdPalette(); return;
|
||||||
}
|
}
|
||||||
@@ -532,6 +540,9 @@ async function initBanners() {
|
|||||||
// Store policies for user-facing checks (allow_user_byok, etc.)
|
// Store policies for user-facing checks (allow_user_byok, etc.)
|
||||||
App.policies = data.policies || {};
|
App.policies = data.policies || {};
|
||||||
|
|
||||||
|
// Storage capabilities (file upload, vision)
|
||||||
|
App.storageConfigured = !!data.storage_configured;
|
||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
836
src/js/attachments.js
Normal file
836
src/js/attachments.js
Normal file
@@ -0,0 +1,836 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – Attachments
|
||||||
|
// ==========================================
|
||||||
|
// File upload, smart paste, drag-and-drop, extraction
|
||||||
|
// polling, staged lifecycle, message rendering, lightbox,
|
||||||
|
// and admin storage panel.
|
||||||
|
//
|
||||||
|
// Depends on: API, App, UI (toast, getModelValue, renderChatList)
|
||||||
|
// Consumed by: chat.js (send flow), ui-core.js (message rendering),
|
||||||
|
// ui-admin.js (storage tab)
|
||||||
|
|
||||||
|
// ── Constants ───────────────────────────────
|
||||||
|
|
||||||
|
const ATTACHMENT_POLL_INTERVAL = 2000; // ms between extraction status checks
|
||||||
|
const ATTACHMENT_MAX_PER_MSG = 5; // mirrors backend defaultMaxAttachmentsPerMsg
|
||||||
|
|
||||||
|
// MIME categories for capability-aware filtering
|
||||||
|
const IMAGE_TYPES = new Set([
|
||||||
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────
|
||||||
|
|
||||||
|
// Staged attachments waiting to be sent with the next message.
|
||||||
|
// Each entry:
|
||||||
|
// {
|
||||||
|
// localId: string — crypto.randomUUID(), stable key for DOM
|
||||||
|
// serverId: string? — attachment UUID from backend (null while uploading)
|
||||||
|
// filename: string
|
||||||
|
// contentType: string
|
||||||
|
// sizeBytes: number
|
||||||
|
// previewUrl: string? — object URL for image preview (revoked on remove)
|
||||||
|
// status: 'uploading' | 'queued' | 'processing' | 'complete' | 'failed' | 'error'
|
||||||
|
// uploadProgress: number — 0–100 (only meaningful during 'uploading')
|
||||||
|
// extractionStatus: string? — mirrors backend metadata.extraction_status
|
||||||
|
// queuePosition: number? — extraction queue position (0 = processing)
|
||||||
|
// error: string? — error message if status is 'error' or 'failed'
|
||||||
|
// }
|
||||||
|
|
||||||
|
// App.stagedAttachments is initialized in app.js.
|
||||||
|
// Polling timers are tracked here for cleanup.
|
||||||
|
const _pollTimers = new Map(); // localId → intervalId
|
||||||
|
|
||||||
|
|
||||||
|
// ── Upload ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage a file for upload. Creates the staged entry, uploads to backend,
|
||||||
|
* and starts extraction polling.
|
||||||
|
*
|
||||||
|
* If no current chat exists, creates one first (same pattern as sendMessage).
|
||||||
|
*
|
||||||
|
* @param {File} file — File object from input, paste, or drop
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function stageFile(file) {
|
||||||
|
if (!App.storageConfigured) return;
|
||||||
|
|
||||||
|
// Enforce per-message limit
|
||||||
|
if (App.stagedAttachments.length >= ATTACHMENT_MAX_PER_MSG) {
|
||||||
|
UI.toast(`Maximum ${ATTACHMENT_MAX_PER_MSG} files per message`, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build staged entry
|
||||||
|
const staged = {
|
||||||
|
localId: crypto.randomUUID(),
|
||||||
|
serverId: null,
|
||||||
|
filename: file.name,
|
||||||
|
contentType: file.type || 'application/octet-stream',
|
||||||
|
sizeBytes: file.size,
|
||||||
|
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
|
||||||
|
status: 'uploading',
|
||||||
|
uploadProgress: 0,
|
||||||
|
extractionStatus: null,
|
||||||
|
queuePosition: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
App.stagedAttachments.push(staged);
|
||||||
|
_renderStrip();
|
||||||
|
_updateSendBlock();
|
||||||
|
|
||||||
|
// Ensure a channel exists (attachments are channel-scoped)
|
||||||
|
if (!App.currentChatId) {
|
||||||
|
try {
|
||||||
|
const title = file.name.slice(0, 50);
|
||||||
|
const selectedId = UI.getModelValue();
|
||||||
|
const modelInfo = App.findModel(selectedId);
|
||||||
|
const model = modelInfo?.baseModelId || selectedId;
|
||||||
|
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
|
||||||
|
const chat = {
|
||||||
|
id: resp.id, title: resp.title, type: 'direct',
|
||||||
|
model, messages: [], messageCount: 0, updatedAt: resp.updated_at,
|
||||||
|
};
|
||||||
|
App.chats.unshift(chat);
|
||||||
|
App.currentChatId = chat.id;
|
||||||
|
UI.renderChatList();
|
||||||
|
} catch (e) {
|
||||||
|
staged.status = 'error';
|
||||||
|
staged.error = 'Failed to create chat: ' + e.message;
|
||||||
|
_renderStrip();
|
||||||
|
_updateSendBlock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
try {
|
||||||
|
const result = await API.uploadAttachment(App.currentChatId, file);
|
||||||
|
staged.serverId = result.id;
|
||||||
|
staged.status = _mapExtractionStatus(result.metadata?.extraction_status);
|
||||||
|
staged.extractionStatus = result.metadata?.extraction_status || null;
|
||||||
|
staged.queuePosition = result.metadata?.queue_position ?? null;
|
||||||
|
|
||||||
|
// Start polling if extraction is pending/processing
|
||||||
|
if (staged.status === 'queued' || staged.status === 'processing') {
|
||||||
|
_startPolling(staged);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
staged.status = 'error';
|
||||||
|
staged.error = e.message;
|
||||||
|
console.error('Attachment upload failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderStrip();
|
||||||
|
_updateSendBlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a staged attachment by localId.
|
||||||
|
* Revokes object URL, stops polling, and optionally deletes from backend.
|
||||||
|
*/
|
||||||
|
async function unstageFile(localId) {
|
||||||
|
const idx = App.stagedAttachments.findIndex(a => a.localId === localId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const staged = App.stagedAttachments[idx];
|
||||||
|
|
||||||
|
// Clean up preview blob
|
||||||
|
if (staged.previewUrl) {
|
||||||
|
URL.revokeObjectURL(staged.previewUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop polling
|
||||||
|
_stopPolling(localId);
|
||||||
|
|
||||||
|
// Delete from backend if it was uploaded
|
||||||
|
if (staged.serverId) {
|
||||||
|
try {
|
||||||
|
await API.deleteAttachment(staged.serverId);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to delete staged attachment:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
App.stagedAttachments.splice(idx, 1);
|
||||||
|
_renderStrip();
|
||||||
|
_updateSendBlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all staged attachments (chat switch, cancel, etc.)
|
||||||
|
* Does NOT delete from backend — orphan cleanup handles abandoned uploads.
|
||||||
|
*/
|
||||||
|
function clearStaged() {
|
||||||
|
for (const staged of App.stagedAttachments) {
|
||||||
|
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
|
||||||
|
_stopPolling(staged.localId);
|
||||||
|
}
|
||||||
|
App.stagedAttachments = [];
|
||||||
|
_renderStrip();
|
||||||
|
_updateSendBlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Send Integration ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns attachment IDs (server-side UUIDs) for the current staged set.
|
||||||
|
* Only includes successfully uploaded attachments.
|
||||||
|
*/
|
||||||
|
function getStagedAttachmentIds() {
|
||||||
|
return App.stagedAttachments
|
||||||
|
.filter(a => a.serverId)
|
||||||
|
.map(a => a.serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the send button should be blocked due to in-flight uploads.
|
||||||
|
* Per design: blocked ONLY while any file is actively uploading (bytes in flight).
|
||||||
|
* Queued/processing/failed extraction does NOT block send.
|
||||||
|
*/
|
||||||
|
function isSendBlocked() {
|
||||||
|
return App.stagedAttachments.some(a => a.status === 'uploading');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether there are any staged attachments (for UI visibility).
|
||||||
|
*/
|
||||||
|
function hasStagedAttachments() {
|
||||||
|
return App.stagedAttachments.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-send cleanup: detach staged list without deleting from backend
|
||||||
|
* (attachments are now linked to the sent message).
|
||||||
|
*/
|
||||||
|
function consumeStaged() {
|
||||||
|
for (const staged of App.stagedAttachments) {
|
||||||
|
if (staged.previewUrl) URL.revokeObjectURL(staged.previewUrl);
|
||||||
|
_stopPolling(staged.localId);
|
||||||
|
}
|
||||||
|
App.stagedAttachments = [];
|
||||||
|
_renderStrip();
|
||||||
|
_updateSendBlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Extraction Polling ──────────────────────
|
||||||
|
|
||||||
|
function _startPolling(staged) {
|
||||||
|
if (_pollTimers.has(staged.localId)) return;
|
||||||
|
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
if (!staged.serverId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await API.getAttachment(staged.serverId);
|
||||||
|
const meta = data.metadata || {};
|
||||||
|
staged.extractionStatus = meta.extraction_status || null;
|
||||||
|
staged.queuePosition = meta.queue_position ?? null;
|
||||||
|
staged.status = _mapExtractionStatus(meta.extraction_status);
|
||||||
|
|
||||||
|
if (meta.extraction_error) {
|
||||||
|
staged.error = meta.extraction_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderStrip();
|
||||||
|
|
||||||
|
// Stop polling on terminal state
|
||||||
|
if (staged.status === 'complete' || staged.status === 'failed') {
|
||||||
|
_stopPolling(staged.localId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Extraction poll failed for', staged.localId, e.message);
|
||||||
|
// Don't stop polling on transient errors — backend might be restarting
|
||||||
|
}
|
||||||
|
}, ATTACHMENT_POLL_INTERVAL);
|
||||||
|
|
||||||
|
_pollTimers.set(staged.localId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopPolling(localId) {
|
||||||
|
const timer = _pollTimers.get(localId);
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
_pollTimers.delete(localId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopAllPolling() {
|
||||||
|
for (const [id, timer] of _pollTimers) {
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
_pollTimers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Status Helpers ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map backend extraction_status to frontend staged status.
|
||||||
|
* Images have no extraction — they go straight to 'complete'.
|
||||||
|
*/
|
||||||
|
function _mapExtractionStatus(backendStatus) {
|
||||||
|
switch (backendStatus) {
|
||||||
|
case 'pending': return 'queued';
|
||||||
|
case 'processing': return 'processing';
|
||||||
|
case 'complete': return 'complete';
|
||||||
|
case 'failed': return 'failed';
|
||||||
|
case 'unavailable': return 'complete'; // no extraction needed (e.g., images)
|
||||||
|
default: return 'queued';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable status label for a staged attachment.
|
||||||
|
*/
|
||||||
|
function attachmentStatusLabel(staged) {
|
||||||
|
switch (staged.status) {
|
||||||
|
case 'uploading':
|
||||||
|
return staged.uploadProgress > 0 ? `Uploading ${staged.uploadProgress}%` : 'Uploading…';
|
||||||
|
case 'queued':
|
||||||
|
if (staged.queuePosition != null && staged.queuePosition > 0) {
|
||||||
|
return `Queued (${staged.queuePosition} ahead)`;
|
||||||
|
}
|
||||||
|
return 'Queued';
|
||||||
|
case 'processing': return 'Extracting…';
|
||||||
|
case 'complete': return '✓ Ready';
|
||||||
|
case 'failed': return '⚠ Extraction failed';
|
||||||
|
case 'error': return '✕ ' + (staged.error || 'Upload failed');
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the given content type is an image (for vision gating).
|
||||||
|
*/
|
||||||
|
function isImageAttachment(contentType) {
|
||||||
|
return IMAGE_TYPES.has(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size for display.
|
||||||
|
*/
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Strip Rendering ─────────────────────────
|
||||||
|
|
||||||
|
function _renderStrip() {
|
||||||
|
const strip = document.getElementById('attachmentStrip');
|
||||||
|
if (!strip) return;
|
||||||
|
|
||||||
|
if (App.stagedAttachments.length === 0) {
|
||||||
|
strip.style.display = 'none';
|
||||||
|
strip.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
strip.style.display = 'flex';
|
||||||
|
strip.innerHTML = App.stagedAttachments.map(a => {
|
||||||
|
const icon = isImageAttachment(a.contentType) ? '🖼' : '📄';
|
||||||
|
const name = _truncFilename(a.filename, 20);
|
||||||
|
const size = formatFileSize(a.sizeBytes);
|
||||||
|
const label = attachmentStatusLabel(a);
|
||||||
|
const statusClass = a.status === 'error' || a.status === 'failed' ? 'att-error' :
|
||||||
|
a.status === 'complete' ? 'att-ready' : 'att-pending';
|
||||||
|
|
||||||
|
return `<div class="att-chip ${statusClass}" data-local-id="${a.localId}">
|
||||||
|
${a.previewUrl ? `<img class="att-thumb" src="${a.previewUrl}" alt="">` : `<span class="att-icon">${icon}</span>`}
|
||||||
|
<span class="att-info">
|
||||||
|
<span class="att-name" title="${_esc(a.filename)}">${_esc(name)}</span>
|
||||||
|
<span class="att-meta">${_esc(size)}${label ? ' · ' + label : ''}</span>
|
||||||
|
</span>
|
||||||
|
<button class="att-remove" onclick="unstageFile('${a.localId}')" title="Remove">✕</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _truncFilename(name, max) {
|
||||||
|
if (name.length <= max) return name;
|
||||||
|
const ext = name.lastIndexOf('.');
|
||||||
|
if (ext > 0 && name.length - ext <= 6) {
|
||||||
|
// Preserve extension: "very-long-name...pdf"
|
||||||
|
const suffix = name.slice(ext);
|
||||||
|
return name.slice(0, max - suffix.length - 1) + '…' + suffix;
|
||||||
|
}
|
||||||
|
return name.slice(0, max - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Channel Attachment Loading ──────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all attachments for a channel, build messageId → [att] map.
|
||||||
|
* Called after loading messages in selectChat / reloadActivePath.
|
||||||
|
* Non-blocking: failures just leave the map empty (attachments are optional UX).
|
||||||
|
*/
|
||||||
|
async function loadChannelAttachments(channelId) {
|
||||||
|
App.channelAttachments = {};
|
||||||
|
if (!channelId || !App.storageConfigured) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await API.listChannelAttachments(channelId);
|
||||||
|
const list = data.attachments || data || [];
|
||||||
|
for (const att of list) {
|
||||||
|
if (!att.message_id) continue;
|
||||||
|
if (!App.channelAttachments[att.message_id]) {
|
||||||
|
App.channelAttachments[att.message_id] = [];
|
||||||
|
}
|
||||||
|
App.channelAttachments[att.message_id].push(att);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('Attachment load skipped:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Message Attachment Rendering ────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns HTML string for attachments associated with a message.
|
||||||
|
* Called from ui-core.js _messageHTML(). Returns '' if none.
|
||||||
|
*
|
||||||
|
* Images use data-att-id; actual src is loaded post-render with auth
|
||||||
|
* via loadAuthImages(). Doc downloads use onclick handlers.
|
||||||
|
*/
|
||||||
|
function renderMessageAttachments(msgId) {
|
||||||
|
if (!msgId) return '';
|
||||||
|
const atts = App.channelAttachments[msgId];
|
||||||
|
if (!atts || atts.length === 0) return '';
|
||||||
|
|
||||||
|
// Check current model vision capability for hint
|
||||||
|
const selectedId = UI.getModelValue();
|
||||||
|
const modelInfo = App.findModel(selectedId);
|
||||||
|
const hasVision = !!modelInfo?.capabilities?.vision;
|
||||||
|
|
||||||
|
const items = atts.map(att => {
|
||||||
|
const isImage = isImageAttachment(att.content_type);
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
const dims = att.metadata?.thumb_dimensions || att.metadata?.dimensions;
|
||||||
|
const w = Math.min(dims?.width || 280, 320);
|
||||||
|
const visionHint = !hasVision
|
||||||
|
? '<span class="att-vision-hint" title="Current model does not support images">👁️🗨️</span>'
|
||||||
|
: '';
|
||||||
|
return `<div class="msg-att msg-att-image" onclick="openLightbox('${_esc(att.id)}')">
|
||||||
|
<img class="msg-att-img" data-att-id="${_esc(att.id)}" alt="${_esc(att.filename)}"
|
||||||
|
style="max-width:${w}px" loading="lazy">
|
||||||
|
${visionHint}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document chip
|
||||||
|
const icon = _docIcon(att.content_type);
|
||||||
|
const size = formatFileSize(att.size_bytes);
|
||||||
|
return `<a class="msg-att msg-att-doc" href="#" onclick="downloadAttachment('${_esc(att.id)}','${_esc(att.filename)}');return false">
|
||||||
|
<span class="att-doc-icon">${icon}</span>
|
||||||
|
<span class="att-doc-info">
|
||||||
|
<span class="att-doc-name">${_esc(att.filename)}</span>
|
||||||
|
<span class="att-doc-size">${size}</span>
|
||||||
|
</span>
|
||||||
|
<span class="att-doc-dl">⬇</span>
|
||||||
|
</a>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<div class="msg-attachments">${items.join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _docIcon(contentType) {
|
||||||
|
if (contentType === 'application/pdf') return '📕';
|
||||||
|
if (contentType?.includes('spreadsheet') || contentType?.includes('excel')) return '📊';
|
||||||
|
if (contentType?.includes('presentation') || contentType?.includes('powerpoint')) return '📙';
|
||||||
|
if (contentType?.includes('word') || contentType?.includes('document')) return '📄';
|
||||||
|
if (contentType?.startsWith('text/')) return '📝';
|
||||||
|
return '📎';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Image Lightbox ──────────────────────────
|
||||||
|
|
||||||
|
function openLightbox(attachmentId) {
|
||||||
|
const overlay = document.getElementById('lightbox');
|
||||||
|
const img = document.getElementById('lightboxImg');
|
||||||
|
if (!overlay || !img) return;
|
||||||
|
|
||||||
|
img.src = '';
|
||||||
|
img.alt = 'Loading…';
|
||||||
|
overlay.classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Fetch full-size image with auth
|
||||||
|
API.downloadAttachmentBlob(attachmentId)
|
||||||
|
.then(blob => { img.src = URL.createObjectURL(blob); })
|
||||||
|
.catch(() => { img.alt = 'Failed to load image'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
const overlay = document.getElementById('lightbox');
|
||||||
|
const img = document.getElementById('lightboxImg');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
// Revoke previous blob URL to free memory
|
||||||
|
if (img?.src?.startsWith('blob:')) URL.revokeObjectURL(img.src);
|
||||||
|
if (img) { img.src = ''; img.alt = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Auth Image Loading ──────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-render pass: finds all <img data-att-id="..."> in the given
|
||||||
|
* container and fetches them with auth headers, setting blob URLs.
|
||||||
|
* Called after renderMessages and reloadActivePath.
|
||||||
|
*/
|
||||||
|
function loadAuthImages(container) {
|
||||||
|
if (!container) container = document.getElementById('chatMessages');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const imgs = container.querySelectorAll('img[data-att-id]:not([data-loaded])');
|
||||||
|
for (const img of imgs) {
|
||||||
|
const attId = img.dataset.attId;
|
||||||
|
if (!attId) continue;
|
||||||
|
img.dataset.loaded = '1';
|
||||||
|
|
||||||
|
API.downloadAttachmentBlob(attId)
|
||||||
|
.then(blob => { img.src = URL.createObjectURL(blob); })
|
||||||
|
.catch(() => {
|
||||||
|
img.alt = '⚠ Load failed';
|
||||||
|
img.classList.add('att-load-error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Document Download ───────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download an attachment file via auth-aware fetch.
|
||||||
|
* Creates a temporary <a> element to trigger the browser download dialog.
|
||||||
|
*/
|
||||||
|
async function downloadAttachment(attachmentId, filename) {
|
||||||
|
try {
|
||||||
|
const blob = await API.downloadAttachmentBlob(attachmentId);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename || 'download';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
UI.toast('Download failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Admin Storage Panel ─────────────────────
|
||||||
|
|
||||||
|
async function loadAdminStorage() {
|
||||||
|
const el = document.getElementById('adminStorageContent');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [status, orphans] = await Promise.all([
|
||||||
|
API.adminGetStorageStatus().catch(() => null),
|
||||||
|
API.adminGetOrphanCount().catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Status card
|
||||||
|
if (status) {
|
||||||
|
const healthClass = status.healthy ? 'badge-success' : 'badge-danger';
|
||||||
|
const healthLabel = status.healthy ? 'Healthy' : 'Unhealthy';
|
||||||
|
html += `<div class="admin-storage-card">
|
||||||
|
<h4>Storage Backend</h4>
|
||||||
|
<div class="admin-storage-grid">
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Backend</span>
|
||||||
|
<span class="admin-storage-value">${_esc(status.backend || 'none')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Status</span>
|
||||||
|
<span class="badge ${healthClass}">${healthLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Total Files</span>
|
||||||
|
<span class="admin-storage-value">${status.total_files ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Total Size</span>
|
||||||
|
<span class="admin-storage-value">${status.total_bytes ? formatFileSize(status.total_bytes) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
${status.path ? `<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Path</span>
|
||||||
|
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.path)}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${status.endpoint ? `<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Endpoint</span>
|
||||||
|
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.endpoint)}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${status.bucket ? `<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Bucket</span>
|
||||||
|
<span class="admin-storage-value" style="font-family:monospace;font-size:12px">${_esc(status.bucket)}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="admin-storage-card">
|
||||||
|
<h4>Storage Backend</h4>
|
||||||
|
<p class="empty-hint">Storage not configured. Set STORAGE_BACKEND=pvc or STORAGE_BACKEND=s3 to enable file uploads.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orphan cleanup card
|
||||||
|
if (orphans) {
|
||||||
|
const count = orphans.count ?? 0;
|
||||||
|
const bytes = orphans.total_bytes ?? 0;
|
||||||
|
html += `<div class="admin-storage-card">
|
||||||
|
<h4>Orphan Files</h4>
|
||||||
|
<p class="admin-storage-desc">Files uploaded but never linked to a message for >24 hours.</p>
|
||||||
|
<div class="admin-storage-grid">
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Orphaned</span>
|
||||||
|
<span class="admin-storage-value">${count} file${count !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Reclaimable</span>
|
||||||
|
<span class="admin-storage-value">${formatFileSize(bytes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${count > 0 ? `<button class="btn-small" id="adminRunCleanup" style="margin-top:8px">Run Cleanup Now</button>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
// Wire cleanup button
|
||||||
|
document.getElementById('adminRunCleanup')?.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Cleaning…';
|
||||||
|
try {
|
||||||
|
const result = await API.adminRunStorageCleanup();
|
||||||
|
UI.toast(`Cleaned ${result.deleted || 0} orphan${result.deleted !== 1 ? 's' : ''}, freed ${formatFileSize(result.freed_bytes || 0)}`, 'success');
|
||||||
|
await loadAdminStorage(); // refresh counts
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast('Cleanup failed: ' + err.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Run Cleanup Now';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<div class="empty-hint">Failed to load storage status: ${_esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Constants (Paste) ───────────────────────
|
||||||
|
// Threshold for auto-attaching pasted text as a file.
|
||||||
|
// TODO: sync from backend global_settings (storage_paste_to_file_chars)
|
||||||
|
// once it's included in the PublicSettings boot payload.
|
||||||
|
const PASTE_TO_FILE_CHARS = 2000;
|
||||||
|
|
||||||
|
// Accept string for file picker — mirrors backend allowedMIMETypes.
|
||||||
|
// Backend is authoritative (rejects disallowed types server-side),
|
||||||
|
// this just gives the OS file dialog a better filter.
|
||||||
|
const FILE_ACCEPT = [
|
||||||
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||||
|
'application/pdf',
|
||||||
|
'text/plain', 'text/markdown', 'text/csv',
|
||||||
|
'.docx', '.xlsx', '.pptx', '.doc', '.xls', '.odt', '.ods', '.odp', '.rtf',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
// ── MIME → Extension ────────────────────────
|
||||||
|
|
||||||
|
const _mimeToExt = {
|
||||||
|
'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
|
||||||
|
'image/webp': 'webp', 'image/svg+xml': 'svg',
|
||||||
|
'application/pdf': 'pdf', 'text/plain': 'txt', 'text/markdown': 'md',
|
||||||
|
'text/csv': 'csv', 'text/html': 'html',
|
||||||
|
};
|
||||||
|
|
||||||
|
function _extFromMime(mime) {
|
||||||
|
return _mimeToExt[mime] || mime.split('/').pop() || 'bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Send Button Blocking ────────────────────
|
||||||
|
|
||||||
|
function _updateSendBlock() {
|
||||||
|
const sendBtn = document.getElementById('sendBtn');
|
||||||
|
if (!sendBtn) return;
|
||||||
|
|
||||||
|
// Merge with existing generation check — don't override if generating
|
||||||
|
if (App.isGenerating) return;
|
||||||
|
|
||||||
|
if (isSendBlocked()) {
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.title = 'Upload in progress…';
|
||||||
|
} else {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.title = 'Send';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Init + Listeners ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from app.js startApp(). Reads storage_configured from boot
|
||||||
|
* payload and sets up attachment UI visibility.
|
||||||
|
*/
|
||||||
|
function initAttachments() {
|
||||||
|
const attachBtn = document.getElementById('attachBtn');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
if (attachBtn) {
|
||||||
|
attachBtn.style.display = App.storageConfigured ? '' : 'none';
|
||||||
|
}
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.accept = FILE_ACCEPT;
|
||||||
|
}
|
||||||
|
console.log(`📎 Attachments: storage ${App.storageConfigured ? 'configured' : 'not configured'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from app.js initListeners(). Wires up all attachment input
|
||||||
|
* handlers: button, file picker, paste, and drag-and-drop.
|
||||||
|
*/
|
||||||
|
function _initAttachmentListeners() {
|
||||||
|
const attachBtn = document.getElementById('attachBtn');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const messageInput = document.getElementById('messageInput');
|
||||||
|
const chatArea = document.getElementById('chatMessages');
|
||||||
|
const lightbox = document.getElementById('lightbox');
|
||||||
|
|
||||||
|
// ── 📎 Button → File Picker ─────────────
|
||||||
|
if (attachBtn && fileInput) {
|
||||||
|
attachBtn.addEventListener('click', () => {
|
||||||
|
if (!App.storageConfigured) return;
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
const files = fileInput.files;
|
||||||
|
if (!files?.length) return;
|
||||||
|
for (const file of files) stageFile(file);
|
||||||
|
fileInput.value = ''; // reset so same file can be re-selected
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Smart Paste ─────────────────────────
|
||||||
|
if (messageInput) {
|
||||||
|
messageInput.addEventListener('paste', (e) => {
|
||||||
|
if (!App.storageConfigured) return; // fall through to normal paste
|
||||||
|
|
||||||
|
const items = [...(e.clipboardData?.items || [])];
|
||||||
|
|
||||||
|
// Binary detection: images/files from clipboard
|
||||||
|
const binaryItems = items.filter(i => i.kind === 'file');
|
||||||
|
if (binaryItems.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
for (const item of binaryItems) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (!file) continue;
|
||||||
|
// Generate UUID filename — original clipboard items have no useful name
|
||||||
|
const ext = _extFromMime(file.type);
|
||||||
|
const renamed = new File([file], `${crypto.randomUUID()}.${ext}`, { type: file.type });
|
||||||
|
stageFile(renamed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large text detection — auto-attach as .txt file
|
||||||
|
if (PASTE_TO_FILE_CHARS > 0) {
|
||||||
|
const text = e.clipboardData?.getData('text/plain');
|
||||||
|
if (text && text.length > PASTE_TO_FILE_CHARS) {
|
||||||
|
e.preventDefault();
|
||||||
|
const blob = new Blob([text], { type: 'text/plain' });
|
||||||
|
const file = new File([blob], `${crypto.randomUUID()}.txt`, { type: 'text/plain' });
|
||||||
|
stageFile(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Below threshold or no text: normal paste (no preventDefault)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag-and-Drop ───────────────────────
|
||||||
|
if (chatArea) {
|
||||||
|
// Track drag enter/leave depth to avoid flicker from child elements
|
||||||
|
let dragDepth = 0;
|
||||||
|
|
||||||
|
chatArea.addEventListener('dragenter', (e) => {
|
||||||
|
if (!App.storageConfigured) return;
|
||||||
|
if (!_hasDragFiles(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragDepth++;
|
||||||
|
if (dragDepth === 1) chatArea.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
chatArea.addEventListener('dragover', (e) => {
|
||||||
|
if (!App.storageConfigured) return;
|
||||||
|
if (!_hasDragFiles(e)) return;
|
||||||
|
e.preventDefault(); // required to allow drop
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
});
|
||||||
|
|
||||||
|
chatArea.addEventListener('dragleave', (e) => {
|
||||||
|
if (!App.storageConfigured) return;
|
||||||
|
dragDepth--;
|
||||||
|
if (dragDepth <= 0) {
|
||||||
|
dragDepth = 0;
|
||||||
|
chatArea.classList.remove('drag-over');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chatArea.addEventListener('drop', (e) => {
|
||||||
|
if (!App.storageConfigured) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragDepth = 0;
|
||||||
|
chatArea.classList.remove('drag-over');
|
||||||
|
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (!files?.length) return;
|
||||||
|
for (const file of files) stageFile(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lightbox ────────────────────────────
|
||||||
|
if (lightbox) {
|
||||||
|
// Click overlay (not the image) to close
|
||||||
|
lightbox.addEventListener('click', (e) => {
|
||||||
|
if (e.target === lightbox || e.target.classList.contains('lightbox-close')) {
|
||||||
|
closeLightbox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a drag event contains files (not just text selection drag).
|
||||||
|
*/
|
||||||
|
function _hasDragFiles(e) {
|
||||||
|
if (!e.dataTransfer?.types) return false;
|
||||||
|
return e.dataTransfer.types.includes('Files');
|
||||||
|
}
|
||||||
@@ -64,7 +64,8 @@ async function loadChats() {
|
|||||||
model: c.model || '',
|
model: c.model || '',
|
||||||
messageCount: c.message_count || 0,
|
messageCount: c.message_count || 0,
|
||||||
messages: [],
|
messages: [],
|
||||||
updatedAt: c.updated_at
|
updatedAt: c.updated_at,
|
||||||
|
settings: c.settings || {},
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load chats:', e.message);
|
console.error('Failed to load chats:', e.message);
|
||||||
@@ -73,6 +74,7 @@ async function loadChats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectChat(chatId) {
|
async function selectChat(chatId) {
|
||||||
|
clearStaged(); // Discard any staged attachments from previous chat
|
||||||
App.currentChatId = chatId;
|
App.currentChatId = chatId;
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
if (window.innerWidth <= 768) {
|
if (window.innerWidth <= 768) {
|
||||||
@@ -109,6 +111,9 @@ async function selectChat(chatId) {
|
|||||||
// Restore the last-used model/preset for this chat
|
// Restore the last-used model/preset for this chat
|
||||||
_restoreChatModel(chatId, chat);
|
_restoreChatModel(chatId, chat);
|
||||||
|
|
||||||
|
// Load attachments for message rendering (non-blocking, best-effort)
|
||||||
|
await loadChannelAttachments(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;
|
||||||
@@ -117,46 +122,63 @@ async function selectChat(chatId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-Chat Model/Preset Persistence ────────
|
// ── Per-Chat Model/Preset Persistence ────────
|
||||||
// BANDAID: localStorage — doesn't roam across devices.
|
// Server-side: saved in channel.settings.last_selector_id (JSONB merge).
|
||||||
// TODO: move to channel.settings.last_selector_id (server-side). See ROADMAP TBD.
|
// localStorage used as write-through cache for instant restore without
|
||||||
|
// waiting for the channel list API call.
|
||||||
|
|
||||||
function _saveChatModel(chatId) {
|
function _saveChatModel(chatId) {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
const selectorId = UI.getModelValue();
|
const selectorId = UI.getModelValue();
|
||||||
if (!selectorId) return;
|
if (!selectorId) return;
|
||||||
|
|
||||||
|
// Write-through: localStorage for instant restore on next visit
|
||||||
try {
|
try {
|
||||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||||
map[chatId] = selectorId;
|
map[chatId] = selectorId;
|
||||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||||
} catch (e) { /* quota exceeded, etc */ }
|
} catch (e) { /* quota exceeded, etc */ }
|
||||||
|
|
||||||
|
// Update in-memory chat object
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (chat) {
|
||||||
|
if (!chat.settings) chat.settings = {};
|
||||||
|
chat.settings.last_selector_id = selectorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to server (fire-and-forget — non-critical)
|
||||||
|
API.updateChannel(chatId, { settings: { last_selector_id: selectorId } })
|
||||||
|
.catch(e => console.debug('Failed to persist model selection:', e.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function _restoreChatModel(chatId, chat) {
|
function _restoreChatModel(chatId, chat) {
|
||||||
// Priority: stored selector ID → channel base model → keep current
|
// Priority: server settings → localStorage fallback → channel base model → keep current
|
||||||
let targetId = null;
|
let targetId = null;
|
||||||
|
|
||||||
// 1. Check localStorage for exact selector ID (handles presets)
|
// 1. Server-side settings (most authoritative, roams across devices)
|
||||||
|
if (chat?.settings?.last_selector_id) {
|
||||||
|
targetId = chat.settings.last_selector_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. localStorage fallback (covers race where settings haven't loaded yet)
|
||||||
|
if (!targetId) {
|
||||||
try {
|
try {
|
||||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||||
if (map[chatId]) targetId = map[chatId];
|
if (map[chatId]) targetId = map[chatId];
|
||||||
} catch (e) { /* */ }
|
} catch (e) { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Verify the stored ID still exists in available models
|
// 3. Verify the stored ID still exists in available models
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
const found = App.models.find(m => m.id === targetId);
|
const found = App.models.find(m => m.id === targetId);
|
||||||
if (found) {
|
if (found) {
|
||||||
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
|
UI.setModelValue(found.id, found.name + (found.provider ? ` (${found.provider})` : ''));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Stored model removed — clean up stale entry
|
// Stored model removed — clean up stale entries
|
||||||
try {
|
_cleanStaleChatModel(chatId);
|
||||||
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
|
||||||
delete map[chatId];
|
|
||||||
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
|
||||||
} catch (e) { /* */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fall back to channel's base model ID (match by baseModelId)
|
// 4. Fall back to channel's base model ID (match by baseModelId)
|
||||||
if (chat?.model) {
|
if (chat?.model) {
|
||||||
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
|
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -165,7 +187,7 @@ function _restoreChatModel(chatId, chat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Stored model gone — fall back to admin default → first visible
|
// 5. Stored model gone — fall back to admin default → first visible
|
||||||
const visible = App.models.filter(m => !m.hidden);
|
const visible = App.models.filter(m => !m.hidden);
|
||||||
const def = App.defaultModel
|
const def = App.defaultModel
|
||||||
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
|
? visible.find(m => m.id === App.defaultModel || m.baseModelId === App.defaultModel)
|
||||||
@@ -176,7 +198,22 @@ function _restoreChatModel(chatId, chat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _cleanStaleChatModel(chatId) {
|
||||||
|
// Remove from localStorage
|
||||||
|
try {
|
||||||
|
const map = JSON.parse(localStorage.getItem('cs-chat-models') || '{}');
|
||||||
|
delete map[chatId];
|
||||||
|
localStorage.setItem('cs-chat-models', JSON.stringify(map));
|
||||||
|
} catch (e) { /* */ }
|
||||||
|
// Clear from in-memory settings (server cleanup is fire-and-forget)
|
||||||
|
const chat = App.chats.find(c => c.id === chatId);
|
||||||
|
if (chat?.settings?.last_selector_id) {
|
||||||
|
delete chat.settings.last_selector_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function newChat() {
|
async function newChat() {
|
||||||
|
clearStaged(); // Discard any staged attachments
|
||||||
App.currentChatId = null;
|
App.currentChatId = null;
|
||||||
UI.renderChatList();
|
UI.renderChatList();
|
||||||
UI.showEmptyState();
|
UI.showEmptyState();
|
||||||
@@ -213,11 +250,17 @@ async function deleteChat(chatId) {
|
|||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const input = document.getElementById('messageInput');
|
const input = document.getElementById('messageInput');
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text || App.isGenerating) return;
|
const hasAttachments = hasStagedAttachments();
|
||||||
|
|
||||||
|
// Need text or attachments, not generating, not blocked by uploads
|
||||||
|
if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return;
|
||||||
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
input.style.height = 'auto';
|
input.style.height = 'auto';
|
||||||
|
|
||||||
|
// Snapshot attachment IDs before clearing staged state
|
||||||
|
const attachmentIds = getStagedAttachmentIds();
|
||||||
|
|
||||||
const selectedId = UI.getModelValue();
|
const selectedId = UI.getModelValue();
|
||||||
const modelInfo = App.findModel(selectedId);
|
const modelInfo = App.findModel(selectedId);
|
||||||
// For presets, send the base model ID; for regular models, send the model ID
|
// For presets, send the base model ID; for regular models, send the model ID
|
||||||
@@ -239,15 +282,19 @@ async function sendMessage() {
|
|||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
|
|
||||||
// Optimistic: show user message immediately
|
// Optimistic: show user message immediately
|
||||||
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
|
const userContent = text || (hasAttachments ? `[${attachmentIds.length} file${attachmentIds.length > 1 ? 's' : ''} attached]` : '');
|
||||||
|
chat.messages.push({ role: 'user', content: userContent, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
|
|
||||||
|
// Clear staged attachments (they're now part of this message)
|
||||||
|
if (hasAttachments) consumeStaged();
|
||||||
|
|
||||||
App.isGenerating = true;
|
App.isGenerating = true;
|
||||||
App.abortController = new AbortController();
|
App.abortController = new AbortController();
|
||||||
UI.setGenerating(true);
|
UI.setGenerating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
|
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds);
|
||||||
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
|
||||||
|
|
||||||
// Reload active path from server to get proper IDs and sibling metadata
|
// Reload active path from server to get proper IDs and sibling metadata
|
||||||
@@ -307,6 +354,7 @@ async function reloadActivePath() {
|
|||||||
siblingIndex: m.sibling_index || 0,
|
siblingIndex: m.sibling_index || 0,
|
||||||
}));
|
}));
|
||||||
chat.messageCount = chat.messages.length;
|
chat.messageCount = chat.messages.length;
|
||||||
|
await loadChannelAttachments(App.currentChatId);
|
||||||
UI.renderMessages(chat.messages);
|
UI.renderMessages(chat.messages);
|
||||||
updateContextWarning();
|
updateContextWarning();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ Object.assign(UI, {
|
|||||||
if (tab === 'roles') await this.loadAdminRoles();
|
if (tab === 'roles') await this.loadAdminRoles();
|
||||||
if (tab === 'usage') await this.loadAdminUsage();
|
if (tab === 'usage') await this.loadAdminUsage();
|
||||||
if (tab === 'extensions') await this.loadAdminExtensions();
|
if (tab === 'extensions') await this.loadAdminExtensions();
|
||||||
|
if (tab === 'storage' && typeof loadAdminStorage === 'function') await loadAdminStorage();
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadAdminUsers(quiet) {
|
async loadAdminUsers(quiet) {
|
||||||
@@ -679,6 +680,34 @@ Object.assign(UI, {
|
|||||||
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
|
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
|
||||||
});
|
});
|
||||||
UI._bannerPresets = presets;
|
UI._bannerPresets = presets;
|
||||||
|
|
||||||
|
// Vault / Encryption status
|
||||||
|
const vaultEl = document.getElementById('adminVaultStatus');
|
||||||
|
if (vaultEl) {
|
||||||
|
try {
|
||||||
|
const vault = await API.adminGetVaultStatus();
|
||||||
|
const keyBadge = vault.encryption_key_set
|
||||||
|
? '<span class="badge badge-success">Active</span>'
|
||||||
|
: '<span class="badge badge-warning">Not Set</span>';
|
||||||
|
vaultEl.innerHTML = `
|
||||||
|
<div class="admin-storage-grid" style="margin-top:6px">
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Encryption</span>
|
||||||
|
${keyBadge}
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Encrypted Keys</span>
|
||||||
|
<span class="admin-storage-value">${vault.encrypted_keys}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Vault Users</span>
|
||||||
|
<span class="admin-storage-value">${vault.vault_users}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
vaultEl.innerHTML = '<span class="empty-hint">Could not load vault status</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) { console.debug('Failed to load admin settings:', e); }
|
} catch (e) { console.debug('Failed to load admin settings:', e); }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ const UI = {
|
|||||||
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(el);
|
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(el);
|
||||||
|
if (typeof loadAuthImages === 'function') loadAuthImages(el);
|
||||||
this._scrollToBottom(true);
|
this._scrollToBottom(true);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -420,6 +421,7 @@ const UI = {
|
|||||||
</div>
|
</div>
|
||||||
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
|
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}
|
||||||
<div class="msg-text">${formatMessage(msg.content)}</div>
|
<div class="msg-text">${formatMessage(msg.content)}</div>
|
||||||
|
${typeof renderMessageAttachments === 'function' ? renderMessageAttachments(msgId) : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const SHELL_FILES = [
|
|||||||
'./js/ui-admin.js',
|
'./js/ui-admin.js',
|
||||||
'./js/tokens.js',
|
'./js/tokens.js',
|
||||||
'./js/notes.js',
|
'./js/notes.js',
|
||||||
|
'./js/attachments.js',
|
||||||
'./js/chat.js',
|
'./js/chat.js',
|
||||||
'./js/settings-handlers.js',
|
'./js/settings-handlers.js',
|
||||||
'./js/admin-handlers.js',
|
'./js/admin-handlers.js',
|
||||||
|
|||||||
46
status.go
Normal file
46
status.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VaultStatusInfo holds vault health information for admin display.
|
||||||
|
type VaultStatusInfo struct {
|
||||||
|
EncryptionKeySet bool `json:"encryption_key_set"`
|
||||||
|
EncryptedKeys int `json:"encrypted_keys"` // provider_configs with api_key_enc
|
||||||
|
VaultUsers int `json:"vault_users"` // users with vault_set = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaultStatus gathers vault health metrics from the database.
|
||||||
|
// Used by both the CLI (`switchboard vault status`) and the admin API endpoint.
|
||||||
|
func VaultStatus(db *sql.DB, encryptionKey string) (*VaultStatusInfo, error) {
|
||||||
|
if db == nil {
|
||||||
|
return nil, fmt.Errorf("database not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &VaultStatusInfo{
|
||||||
|
EncryptionKeySet: encryptionKey != "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count encrypted provider keys (global + team + personal)
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM provider_configs
|
||||||
|
WHERE api_key_enc IS NOT NULL
|
||||||
|
`).Scan(&info.EncryptedKeys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("count encrypted keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count users with active vaults
|
||||||
|
err = db.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM users
|
||||||
|
WHERE vault_set = true
|
||||||
|
`).Scan(&info.VaultUsers)
|
||||||
|
if err != nil {
|
||||||
|
// vault_set column might not exist on very old installs
|
||||||
|
info.VaultUsers = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
1893
styles.css
Normal file
1893
styles.css
Normal file
File diff suppressed because it is too large
Load Diff
727
ui-admin.js
Normal file
727
ui-admin.js
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – UI Admin
|
||||||
|
// ==========================================
|
||||||
|
// Extends UI with admin modal tabs: users, stats, roles,
|
||||||
|
// usage, audit, providers, models, presets, teams, settings.
|
||||||
|
|
||||||
|
Object.assign(UI, {
|
||||||
|
// ── Admin Modal ──────────────────────────
|
||||||
|
|
||||||
|
openAdmin() { openModal('adminModal'); UI.switchAdminTab('users'); },
|
||||||
|
closeAdmin() { closeModal('adminModal'); },
|
||||||
|
|
||||||
|
openTeamAdmin() {
|
||||||
|
const adminTeams = (UI._myTeams || []).filter(t => t.my_role === 'admin');
|
||||||
|
if (adminTeams.length === 0) {
|
||||||
|
UI.toast('You are not an admin of any teams', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openModal('teamAdminModal');
|
||||||
|
if (adminTeams.length === 1) {
|
||||||
|
// Skip picker, go directly to the team
|
||||||
|
UI.openTeamManage(adminTeams[0].id, adminTeams[0].name);
|
||||||
|
} else {
|
||||||
|
// Show team picker
|
||||||
|
document.getElementById('teamAdminTitle').textContent = 'Team Management';
|
||||||
|
document.getElementById('teamAdminPicker').style.display = '';
|
||||||
|
document.getElementById('teamAdminContent').style.display = 'none';
|
||||||
|
document.getElementById('teamAdminPickerList').innerHTML = adminTeams.map(t => `
|
||||||
|
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')">
|
||||||
|
<div class="team-card-info">
|
||||||
|
<strong>${esc(t.name)}</strong>
|
||||||
|
<span class="badge-admin">admin</span>
|
||||||
|
<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>
|
||||||
|
</div>
|
||||||
|
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
|
||||||
|
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeTeamAdmin() { closeModal('teamAdminModal'); },
|
||||||
|
|
||||||
|
switchTeamTab(tab) {
|
||||||
|
const tabs = document.querySelectorAll('#teamAdminTabs .admin-tab');
|
||||||
|
tabs.forEach(t => {
|
||||||
|
t.classList.remove('active');
|
||||||
|
if (t.dataset.ttab === tab) t.classList.add('active');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.team-tab-content').forEach(c => c.style.display = 'none');
|
||||||
|
const panel = document.getElementById(`teamTab${tab.charAt(0).toUpperCase() + tab.slice(1)}`);
|
||||||
|
if (panel) panel.style.display = '';
|
||||||
|
|
||||||
|
// Lazy-load tab data
|
||||||
|
const teamId = UI._managingTeamId;
|
||||||
|
if (tab === 'members') UI.loadTeamManageMembers(teamId);
|
||||||
|
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
|
||||||
|
if (tab === 'presets') UI.loadTeamManagePresets(teamId);
|
||||||
|
if (tab === 'usage') UI.loadTeamUsage();
|
||||||
|
if (tab === 'activity') UI.loadTeamAuditLog(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async switchAdminTab(tab) {
|
||||||
|
document.querySelectorAll('#adminModal .admin-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||||
|
document.querySelectorAll('.admin-tab-content').forEach(c => c.style.display = 'none');
|
||||||
|
const panel = document.getElementById(`admin${tab.charAt(0).toUpperCase() + tab.slice(1)}Tab`);
|
||||||
|
if (panel) panel.style.display = '';
|
||||||
|
|
||||||
|
if (tab === 'users') await this.loadAdminUsers();
|
||||||
|
if (tab === 'stats') await this.loadAdminStats();
|
||||||
|
if (tab === 'audit') await this.loadAuditLog();
|
||||||
|
if (tab === 'providers') await this.loadAdminProviders();
|
||||||
|
if (tab === 'models') await this.loadAdminModels();
|
||||||
|
if (tab === 'presets') await this.loadAdminPresets();
|
||||||
|
if (tab === 'teams') await this.loadAdminTeams();
|
||||||
|
if (tab === 'settings') await this.loadAdminSettings();
|
||||||
|
if (tab === 'roles') await this.loadAdminRoles();
|
||||||
|
if (tab === 'usage') await this.loadAdminUsage();
|
||||||
|
if (tab === 'extensions') await this.loadAdminExtensions();
|
||||||
|
if (tab === 'storage' && typeof loadAdminStorage === 'function') await loadAdminStorage();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAdminUsers(quiet) {
|
||||||
|
const el = document.getElementById('adminUserList');
|
||||||
|
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
try {
|
||||||
|
const resp = await API.adminListUsers();
|
||||||
|
const users = resp.users || resp.data || [];
|
||||||
|
el.innerHTML = users.map(u => {
|
||||||
|
const teamBadges = (u.teams || []).map(t =>
|
||||||
|
`<span class="badge-team" title="${esc(t.role)}">${esc(t.team_name)}</span>`
|
||||||
|
).join(' ');
|
||||||
|
const isPending = !u.is_active;
|
||||||
|
return `
|
||||||
|
<div class="admin-user-row${isPending ? ' user-inactive' : ''}">
|
||||||
|
<div class="admin-user-info">
|
||||||
|
<div><strong>${esc(u.username)}</strong> <span class="badge-${u.role}">${u.role}</span>
|
||||||
|
${isPending ? '<span class="badge-pending">pending</span>' : ''}
|
||||||
|
${teamBadges}</div>
|
||||||
|
<div class="admin-user-email">${esc(u.email)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-user-actions">
|
||||||
|
${isPending
|
||||||
|
? `<button class="btn-approve" onclick="showApproveForm('${u.id}', '${esc(u.username)}')">Approve</button>`
|
||||||
|
: `<button onclick="toggleUserActive('${u.id}', false)">Disable</button>`
|
||||||
|
}
|
||||||
|
<button onclick="toggleUserRole('${u.id}', '${u.role === 'admin' ? 'user' : 'admin'}')">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
||||||
|
<button onclick="adminResetUserPassword('${u.id}', '${esc(u.username)}')">Reset PW</button>
|
||||||
|
<button class="btn-danger" onclick="deleteUser('${u.id}', '${esc(u.username)}')">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-approve-form" id="approveForm-${u.id}" style="display:none">
|
||||||
|
<div class="approve-teams" id="approveTeams-${u.id}"></div>
|
||||||
|
<div class="form-row" style="margin-top:8px">
|
||||||
|
<button class="btn-small btn-primary" onclick="submitApproval('${u.id}')">Activate & Assign</button>
|
||||||
|
<button class="btn-small" onclick="hideApproveForm('${u.id}')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('') || '<div class="empty-hint">No users</div>';
|
||||||
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAdminStats() {
|
||||||
|
const el = document.getElementById('adminStats');
|
||||||
|
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
try {
|
||||||
|
const s = await API.adminGetStats();
|
||||||
|
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' };
|
||||||
|
el.innerHTML = '<div class="stats-grid">' +
|
||||||
|
Object.entries(s).map(([k, v]) => `
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">${typeof v === 'number' ? v.toLocaleString() : esc(String(v))}</div>
|
||||||
|
<div class="stat-label">${labels[k] || k.replace(/_/g, ' ')}</div>
|
||||||
|
</div>`).join('') +
|
||||||
|
'</div>';
|
||||||
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Admin: Roles ────────────────────────
|
||||||
|
|
||||||
|
async loadAdminRoles() {
|
||||||
|
const el = document.getElementById('adminRolesContent');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (!UI._adminRoleConfig) {
|
||||||
|
UI._adminRoleConfig = renderRoleConfig(el, {
|
||||||
|
scope: 'admin',
|
||||||
|
showFallback: true,
|
||||||
|
modelIdField: 'model_id',
|
||||||
|
modelNameField: 'display_name',
|
||||||
|
providerIdField: 'provider_config_id',
|
||||||
|
fetchProviders: async () => {
|
||||||
|
const configs = await API.adminListGlobalConfigs();
|
||||||
|
return configs.configs || configs || [];
|
||||||
|
},
|
||||||
|
fetchModels: async () => {
|
||||||
|
const models = await API.adminListModels();
|
||||||
|
return models.models || models.data || models || [];
|
||||||
|
},
|
||||||
|
fetchRoles: () => API.adminListRoles(),
|
||||||
|
onSave: async (roleId, config) => {
|
||||||
|
await API.adminUpdateRole(roleId, config);
|
||||||
|
},
|
||||||
|
onTest: (roleId) => API.adminTestRole(roleId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await UI._adminRoleConfig.refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Admin: Usage ────────────────────────
|
||||||
|
|
||||||
|
async loadAdminUsage() {
|
||||||
|
// Usage stats via primitive
|
||||||
|
const totalsEl = document.getElementById('adminUsageTotals');
|
||||||
|
if (!totalsEl) return;
|
||||||
|
// Wrap totals+results in a container for the primitive (first time only)
|
||||||
|
let usageContainer = document.getElementById('_adminUsageContainer');
|
||||||
|
if (!usageContainer) {
|
||||||
|
usageContainer = document.createElement('div');
|
||||||
|
usageContainer.id = '_adminUsageContainer';
|
||||||
|
const resultsEl = document.getElementById('adminUsageResults');
|
||||||
|
totalsEl.parentElement.insertBefore(usageContainer, totalsEl);
|
||||||
|
usageContainer.appendChild(totalsEl);
|
||||||
|
if (resultsEl) usageContainer.appendChild(resultsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!UI._adminUsageDash) {
|
||||||
|
UI._adminUsageDash = renderUsageDashboard(usageContainer, {
|
||||||
|
apiFetch: (p) => API.adminGetUsage(p),
|
||||||
|
periodElId: 'usagePeriod',
|
||||||
|
groupByElId: 'usageGroupBy',
|
||||||
|
compact: false,
|
||||||
|
showUserColumn: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await UI._adminUsageDash.refresh();
|
||||||
|
|
||||||
|
// Pricing table (admin-only, separate from usage primitive)
|
||||||
|
const pricingEl = document.getElementById('adminPricingTable');
|
||||||
|
if (pricingEl) {
|
||||||
|
try {
|
||||||
|
const pricing = await API.adminListPricing();
|
||||||
|
const entries = pricing || [];
|
||||||
|
if (entries.length === 0) {
|
||||||
|
pricingEl.innerHTML = '<div class="empty-hint">No pricing configured. Sync providers to populate from catalog.</div>';
|
||||||
|
} else {
|
||||||
|
pricingEl.innerHTML = `
|
||||||
|
<table class="admin-table" style="margin-top:8px">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Model</th>
|
||||||
|
<th style="text-align:right">Input $/M</th>
|
||||||
|
<th style="text-align:right">Output $/M</th>
|
||||||
|
<th>Source</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${entries.map(p => `<tr>
|
||||||
|
<td>${esc(p.model_id)}</td>
|
||||||
|
<td style="text-align:right">${p.input_per_m != null ? '$' + Number(p.input_per_m).toFixed(4) : '—'}</td>
|
||||||
|
<td style="text-align:right">${p.output_per_m != null ? '$' + Number(p.output_per_m).toFixed(4) : '—'}</td>
|
||||||
|
<td><span class="badge-${p.source === 'manual' ? 'admin' : 'user'}">${p.source}</span></td>
|
||||||
|
</tr>`).join('')}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
} catch (e) { pricingEl.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAdminExtensions() {
|
||||||
|
const el = document.getElementById('adminExtensionsList');
|
||||||
|
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
try {
|
||||||
|
const resp = await API._get('/api/v1/admin/extensions');
|
||||||
|
const exts = resp.data || [];
|
||||||
|
this._adminExtensions = exts; // store for edit access
|
||||||
|
if (exts.length === 0) {
|
||||||
|
el.innerHTML = '<div class="text-muted" style="padding:16px">No extensions installed</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = exts.map(ext => {
|
||||||
|
const statusBadge = ext.is_enabled
|
||||||
|
? '<span class="badge-admin" style="font-size:11px">enabled</span>'
|
||||||
|
: '<span class="badge-user" style="font-size:11px;opacity:0.5">disabled</span>';
|
||||||
|
const systemBadge = ext.is_system
|
||||||
|
? ' <span class="badge-user" style="font-size:11px">system</span>'
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="admin-user-row" style="padding:10px 0;align-items:flex-start">
|
||||||
|
<div class="admin-user-info" style="flex:1">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<strong>${esc(ext.name)}</strong>
|
||||||
|
<code style="font-size:11px;color:var(--text-3)">${esc(ext.ext_id)}</code>
|
||||||
|
${statusBadge}${systemBadge}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted" style="font-size:12px;margin-top:2px">
|
||||||
|
${esc(ext.description || 'No description')}
|
||||||
|
${ext.author ? ` · by ${esc(ext.author)}` : ''}
|
||||||
|
· v${esc(ext.version)} · ${ext.tier}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-shrink:0">
|
||||||
|
<button class="btn-small" onclick="editAdminExtension('${ext.id}')">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button class="btn-small" onclick="toggleAdminExtension('${ext.id}', ${!ext.is_enabled})">
|
||||||
|
${ext.is_enabled ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
<button class="btn-small btn-danger" onclick="deleteAdminExtension('${ext.id}', '${esc(ext.name)}')">
|
||||||
|
Uninstall
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = '<div class="text-muted" style="padding:16px">Failed to load extensions</div>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_auditPage: 1,
|
||||||
|
_auditPerPage: 30,
|
||||||
|
|
||||||
|
async loadAuditLog(page) {
|
||||||
|
if (page) UI._auditPage = page;
|
||||||
|
const el = document.getElementById('adminAuditList');
|
||||||
|
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
|
||||||
|
// Populate action filter on first load
|
||||||
|
const actionSel = document.getElementById('auditFilterAction');
|
||||||
|
if (actionSel && actionSel.options.length <= 1) {
|
||||||
|
try {
|
||||||
|
const resp = await API.adminListAuditActions();
|
||||||
|
(resp.actions || []).forEach(a => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a;
|
||||||
|
opt.textContent = a;
|
||||||
|
actionSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
} catch (e) { /* optional */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
page: UI._auditPage,
|
||||||
|
per_page: UI._auditPerPage,
|
||||||
|
action: document.getElementById('auditFilterAction')?.value || '',
|
||||||
|
resource_type: document.getElementById('auditFilterResource')?.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.adminListAudit(params);
|
||||||
|
const entries = resp.data || [];
|
||||||
|
const total = resp.total || 0;
|
||||||
|
const totalPages = Math.ceil(total / UI._auditPerPage);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
el.innerHTML = '<div class="empty-hint">No audit entries found</div>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = entries.map(e => {
|
||||||
|
const ts = new Date(e.created_at);
|
||||||
|
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||||
|
const actor = e.actor_name || 'system';
|
||||||
|
const meta = UI._formatAuditMeta(e.metadata);
|
||||||
|
return `<div class="audit-row">
|
||||||
|
<div class="audit-main">
|
||||||
|
<span class="audit-action">${esc(e.action)}</span>
|
||||||
|
<span class="audit-actor">${esc(actor)}</span>
|
||||||
|
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-meta">
|
||||||
|
${meta ? `<span class="audit-details">${meta}</span>` : ''}
|
||||||
|
<span class="audit-time">${timeStr}</span>
|
||||||
|
${e.ip_address ? `<span class="audit-ip">${esc(e.ip_address)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const pgEl = document.getElementById('auditPagination');
|
||||||
|
if (totalPages > 1) {
|
||||||
|
pgEl.style.display = 'flex';
|
||||||
|
document.getElementById('auditPageInfo').textContent = `Page ${UI._auditPage} of ${totalPages} (${total} entries)`;
|
||||||
|
document.getElementById('auditPrevBtn').disabled = UI._auditPage <= 1;
|
||||||
|
document.getElementById('auditNextBtn').disabled = UI._auditPage >= totalPages;
|
||||||
|
} else {
|
||||||
|
pgEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
_formatAuditMeta(metaStr) {
|
||||||
|
try {
|
||||||
|
const m = typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr;
|
||||||
|
if (!m || Object.keys(m).length === 0) return '';
|
||||||
|
return Object.entries(m).map(([k,v]) => `${esc(k)}=${esc(String(v))}`).join(' · ');
|
||||||
|
} catch { return ''; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAdminProviders(quiet) {
|
||||||
|
const el = document.getElementById('adminProviderList');
|
||||||
|
const formEl = document.getElementById('adminAddProviderForm');
|
||||||
|
|
||||||
|
// Initialize admin provider form primitive (once)
|
||||||
|
if (!UI._adminProvForm && formEl) {
|
||||||
|
UI._adminProvForm = renderProviderForm(formEl, {
|
||||||
|
prefix: 'adminProv',
|
||||||
|
showPrivate: true,
|
||||||
|
showDefaultModel: true,
|
||||||
|
onSubmit: async (vals, isEdit) => {
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
const updates = {};
|
||||||
|
if (vals.name) updates.name = vals.name;
|
||||||
|
if (vals.endpoint) updates.endpoint = vals.endpoint;
|
||||||
|
if (vals.api_key) updates.api_key = vals.api_key;
|
||||||
|
updates.model_default = vals.model_default;
|
||||||
|
updates.is_private = vals.is_private || false;
|
||||||
|
await API.adminUpdateGlobalConfig(vals._editId, updates);
|
||||||
|
UI.toast('Provider updated', 'success');
|
||||||
|
} else {
|
||||||
|
if (!vals.name || !vals.endpoint || !vals.api_key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
|
||||||
|
await API.adminCreateGlobalConfig(vals.name, vals.provider, vals.endpoint, vals.api_key, vals.model_default, vals.is_private || false);
|
||||||
|
UI.toast('Provider added', 'success');
|
||||||
|
}
|
||||||
|
formEl.style.display = 'none';
|
||||||
|
UI._adminProvForm.setCreateMode();
|
||||||
|
UI._adminProvList.refresh();
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
formEl.style.display = 'none';
|
||||||
|
UI._adminProvForm.setCreateMode();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize admin provider list primitive (once)
|
||||||
|
if (!UI._adminProvList) {
|
||||||
|
UI._adminProvList = renderProviderList(el, {
|
||||||
|
apiFetch: () => API.adminListGlobalConfigs(),
|
||||||
|
showEndpoint: true,
|
||||||
|
showPrivate: true,
|
||||||
|
emptyMsg: 'No global providers — add one above',
|
||||||
|
onEdit: (prov) => {
|
||||||
|
if (UI._adminProvForm) {
|
||||||
|
UI._adminProvForm.setEditMode(prov.id, prov);
|
||||||
|
formEl.style.display = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete: async (prov) => {
|
||||||
|
if (!await showConfirm('Delete this global provider?')) return;
|
||||||
|
try {
|
||||||
|
await API.adminDeleteGlobalConfig(prov.id);
|
||||||
|
UI.toast('Provider deleted', 'success');
|
||||||
|
UI._adminProvList.refresh();
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await UI._adminProvList.refresh(quiet);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAdminModels(quiet) {
|
||||||
|
const el = document.getElementById('adminModelList');
|
||||||
|
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
try {
|
||||||
|
const data = await API.adminListModels();
|
||||||
|
const list = data.models || data.data || data || [];
|
||||||
|
const arr = Array.isArray(list) ? list : [];
|
||||||
|
el.innerHTML = arr.map(m => {
|
||||||
|
const caps = m.capabilities || {};
|
||||||
|
const badges = renderCapBadges(caps, { compact: true });
|
||||||
|
// Support both old is_enabled (bool) and new visibility (string)
|
||||||
|
const vis = m.visibility || (m.is_enabled === true ? 'enabled' : m.is_enabled === false ? 'disabled' : 'disabled');
|
||||||
|
const visLabel = vis === 'enabled' ? '✓ Enabled' : vis === 'team' ? '👥 Team' : 'Disabled';
|
||||||
|
const visClass = vis === 'enabled' ? 'enabled' : vis === 'team' ? 'team' : '';
|
||||||
|
return `<div class="admin-model-row">
|
||||||
|
<span class="model-name">${esc(m.model_id || m.id)}</span>
|
||||||
|
<span class="model-caps-inline">${badges}</span>
|
||||||
|
<span class="provider-meta">${esc(m.provider_name || '')}</span>
|
||||||
|
<button class="admin-model-toggle ${visClass}" onclick="cycleModelVisibility('${m.id}', '${vis}')">${visLabel}</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('') || '<div class="empty-hint">No models — add a provider first, then click Fetch Models</div>';
|
||||||
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAdminPresets(quiet) {
|
||||||
|
const el = document.getElementById('adminPresetList');
|
||||||
|
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
try {
|
||||||
|
const data = await API.adminListPresets();
|
||||||
|
const list = data.presets || [];
|
||||||
|
|
||||||
|
// Ensure form is initialized for dropdown population
|
||||||
|
ensureAdminPresetForm();
|
||||||
|
|
||||||
|
// Populate model dropdown from admin model list (all synced models)
|
||||||
|
const modelSel = _adminPresetForm?.getModelSelect();
|
||||||
|
if (modelSel) {
|
||||||
|
modelSel.innerHTML = '<option value="">Select base model...</option>';
|
||||||
|
try {
|
||||||
|
const modelData = await API.adminListModels();
|
||||||
|
const allModels = modelData.models || modelData.data || [];
|
||||||
|
const arr = Array.isArray(allModels) ? allModels : [];
|
||||||
|
arr.forEach(m => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
const mid = m.model_id || m.id;
|
||||||
|
opt.value = mid;
|
||||||
|
opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : '');
|
||||||
|
modelSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
} catch (e) { console.warn('Failed to load models for preset form:', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate config dropdown
|
||||||
|
const cfgSel = _adminPresetForm?.getConfigSelect();
|
||||||
|
if (cfgSel && cfgSel.options.length <= 1) {
|
||||||
|
try {
|
||||||
|
const cfgData = await API.adminListGlobalConfigs();
|
||||||
|
const cfgs = cfgData.configs || cfgData.data || [];
|
||||||
|
(Array.isArray(cfgs) ? cfgs : []).forEach(c => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = c.id;
|
||||||
|
opt.textContent = c.name + ' (' + c.provider + ')';
|
||||||
|
cfgSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
} catch (e) { /* optional */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
UI._presetCache = {};
|
||||||
|
el.innerHTML = list.map(p => {
|
||||||
|
UI._presetCache[p.id] = p;
|
||||||
|
const avatarEl = p.avatar
|
||||||
|
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
|
||||||
|
: '';
|
||||||
|
// Scope badge with attribution
|
||||||
|
let scopeBadge = '';
|
||||||
|
if (p.scope === 'global') {
|
||||||
|
scopeBadge = '<span class="badge-admin">global</span>';
|
||||||
|
} else if (p.scope === 'team' && p.team_name) {
|
||||||
|
scopeBadge = `<span class="badge-team">👥 ${esc(p.team_name)}</span>`;
|
||||||
|
} else if (p.scope === 'personal') {
|
||||||
|
scopeBadge = '<span class="badge-user">personal</span>';
|
||||||
|
}
|
||||||
|
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
|
||||||
|
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
|
||||||
|
return `<div class="admin-preset-row">
|
||||||
|
<div class="preset-info">
|
||||||
|
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
|
||||||
|
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
|
||||||
|
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="preset-actions">
|
||||||
|
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
|
||||||
|
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||||
|
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('') || '<div class="empty-hint">No presets — create one to give users preconfigured model experiences</div>';
|
||||||
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Teams Admin ─────────────────────────
|
||||||
|
_teamEditId: null,
|
||||||
|
|
||||||
|
async loadAdminTeams(quiet) {
|
||||||
|
const el = document.getElementById('adminTeamList');
|
||||||
|
const detail = document.getElementById('adminTeamDetail');
|
||||||
|
if (!quiet) {
|
||||||
|
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
detail.style.display = 'none';
|
||||||
|
el.style.display = '';
|
||||||
|
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await API.adminListTeams();
|
||||||
|
const teams = resp.data || [];
|
||||||
|
el.innerHTML = teams.map(t => `
|
||||||
|
<div class="admin-user-row">
|
||||||
|
<div class="admin-user-info">
|
||||||
|
<div><strong>${esc(t.name)}</strong>
|
||||||
|
${t.is_active ? '' : '<span class="badge-pending">inactive</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">${esc(t.description || 'No description')} · ${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-user-actions">
|
||||||
|
<button class="btn-small" onclick="UI.openTeamDetail('${t.id}', '${esc(t.name)}')">Members</button>
|
||||||
|
<button class="admin-model-toggle ${t.is_active ? 'enabled' : ''}" onclick="toggleTeamActive('${t.id}', ${!t.is_active})">${t.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||||
|
<button class="btn-delete" onclick="deleteTeam('${t.id}', '${esc(t.name)}')" title="Delete">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') || '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>';
|
||||||
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async openTeamDetail(teamId, teamName) {
|
||||||
|
this._teamEditId = teamId;
|
||||||
|
document.getElementById('adminTeamList').style.display = 'none';
|
||||||
|
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||||
|
const detail = document.getElementById('adminTeamDetail');
|
||||||
|
detail.style.display = '';
|
||||||
|
document.getElementById('adminTeamDetailName').textContent = teamName;
|
||||||
|
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||||
|
|
||||||
|
// Load team settings for policy checkboxes
|
||||||
|
try {
|
||||||
|
const team = await API.adminGetTeam(teamId);
|
||||||
|
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
|
||||||
|
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
|
||||||
|
// allow_team_providers defaults to true if not explicitly set
|
||||||
|
document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false;
|
||||||
|
} catch (e) { /* proceed with defaults */ }
|
||||||
|
|
||||||
|
await this.loadTeamMembers(teamId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTeamMembers(teamId) {
|
||||||
|
const el = document.getElementById('adminMemberList');
|
||||||
|
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
try {
|
||||||
|
const resp = await API.adminListMembers(teamId);
|
||||||
|
const members = resp.data || [];
|
||||||
|
|
||||||
|
el.innerHTML = members.map(m => `
|
||||||
|
<div class="admin-user-row">
|
||||||
|
<div class="admin-user-info">
|
||||||
|
<div><strong>${esc(m.display_name || m.email)}</strong>
|
||||||
|
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}">${m.role}</span>
|
||||||
|
${m.user_role === 'admin' ? '<span class="badge-admin">sys-admin</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted">${esc(m.email)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-user-actions">
|
||||||
|
<select onchange="updateTeamMember('${teamId}', '${m.id}', this.value)" class="inline-select">
|
||||||
|
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
|
||||||
|
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-delete" onclick="removeTeamMember('${teamId}', '${m.id}', '${esc(m.email)}')" title="Remove">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') || '<div class="empty-hint">No members — add users to this team</div>';
|
||||||
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadMemberUserDropdown(teamId) {
|
||||||
|
const sel = document.getElementById('adminMemberUser');
|
||||||
|
sel.innerHTML = '<option value="">Loading...</option>';
|
||||||
|
try {
|
||||||
|
const resp = await API.adminListUsers(1, 200);
|
||||||
|
const users = resp.users || resp.data || [];
|
||||||
|
// Also get existing members to exclude
|
||||||
|
const memberResp = await API.adminListMembers(teamId);
|
||||||
|
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
|
||||||
|
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
|
||||||
|
sel.innerHTML = '<option value="">Select user...</option>' +
|
||||||
|
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
|
||||||
|
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAdminSettings() {
|
||||||
|
try {
|
||||||
|
const data = await API.adminGetSettings();
|
||||||
|
// v0.9: { settings: { banner: {...}, ... }, policies: { allow_registration: "true", ... } }
|
||||||
|
const settings = data.settings || {};
|
||||||
|
const policies = data.policies || {};
|
||||||
|
|
||||||
|
// Helper to read from settings map (JSONB values)
|
||||||
|
const getSetting = (key, fallback) => {
|
||||||
|
const v = settings[key];
|
||||||
|
if (v === undefined || v === null) return fallback;
|
||||||
|
// Unwrap {value: X} wrapper if present
|
||||||
|
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Registration (policy: allow_registration)
|
||||||
|
document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true';
|
||||||
|
|
||||||
|
// Admin system prompt (global_settings)
|
||||||
|
const sysPrompt = getSetting('system_prompt', {}) || {};
|
||||||
|
document.getElementById('adminSystemPrompt').value = sysPrompt.content || '';
|
||||||
|
|
||||||
|
// Registration default state (policy: default_user_active → 'true' means auto-active)
|
||||||
|
const defaultActive = policies.default_user_active === 'true';
|
||||||
|
document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending';
|
||||||
|
|
||||||
|
// User providers / BYOK (policy: allow_user_byok)
|
||||||
|
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
|
||||||
|
|
||||||
|
// User presets / personas (policy: allow_user_personas)
|
||||||
|
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
|
||||||
|
|
||||||
|
// Default model (policy: default_model) — populate from current model list
|
||||||
|
const defModelSel = document.getElementById('adminDefaultModel');
|
||||||
|
if (defModelSel) {
|
||||||
|
defModelSel.innerHTML = '<option value="">— None (first visible) —</option>';
|
||||||
|
App.models.filter(m => !m.hidden).forEach(m => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m.baseModelId || m.id;
|
||||||
|
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||||
|
defModelSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
defModelSel.value = policies.default_model || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banner (global_settings)
|
||||||
|
const banner = getSetting('banner', {}) || {};
|
||||||
|
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
|
||||||
|
document.getElementById('adminBannerText').value = banner.text || '';
|
||||||
|
document.getElementById('adminBannerPosition').value = banner.position || 'both';
|
||||||
|
document.getElementById('adminBannerBg').value = banner.bg || '#007a33';
|
||||||
|
document.getElementById('adminBannerBgHex').value = banner.bg || '#007a33';
|
||||||
|
document.getElementById('adminBannerFg').value = banner.fg || '#ffffff';
|
||||||
|
document.getElementById('adminBannerFgHex').value = banner.fg || '#ffffff';
|
||||||
|
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
|
||||||
|
UI.updateBannerPreview();
|
||||||
|
|
||||||
|
// Load banner presets (global_settings)
|
||||||
|
const presets = getSetting('banner_presets', {}) || {};
|
||||||
|
const sel = document.getElementById('adminBannerPreset');
|
||||||
|
sel.innerHTML = '<option value="">Custom</option>';
|
||||||
|
Object.entries(presets).forEach(([key, p]) => {
|
||||||
|
sel.innerHTML += `<option value="${esc(key)}">${esc(p.text || key)}</option>`;
|
||||||
|
});
|
||||||
|
UI._bannerPresets = presets;
|
||||||
|
|
||||||
|
// Vault / Encryption status
|
||||||
|
const vaultEl = document.getElementById('adminVaultStatus');
|
||||||
|
if (vaultEl) {
|
||||||
|
try {
|
||||||
|
const vault = await API.adminGetVaultStatus();
|
||||||
|
const keyBadge = vault.encryption_key_set
|
||||||
|
? '<span class="badge badge-success">Active</span>'
|
||||||
|
: '<span class="badge badge-warning">Not Set</span>';
|
||||||
|
vaultEl.innerHTML = `
|
||||||
|
<div class="admin-storage-grid" style="margin-top:6px">
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Encryption</span>
|
||||||
|
${keyBadge}
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Encrypted Keys</span>
|
||||||
|
<span class="admin-storage-value">${vault.encrypted_keys}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Vault Users</span>
|
||||||
|
<span class="admin-storage-value">${vault.vault_users}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
vaultEl.innerHTML = '<span class="empty-hint">Could not load vault status</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.debug('Failed to load admin settings:', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
_bannerPresets: {},
|
||||||
|
|
||||||
|
updateBannerPreview() {
|
||||||
|
const prev = document.getElementById('bannerPreview');
|
||||||
|
if (!prev) return;
|
||||||
|
const text = document.getElementById('adminBannerText').value || 'PREVIEW';
|
||||||
|
const bg = document.getElementById('adminBannerBg').value;
|
||||||
|
const fg = document.getElementById('adminBannerFg').value;
|
||||||
|
prev.textContent = text;
|
||||||
|
prev.style.background = bg;
|
||||||
|
prev.style.color = fg;
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user