LobeChat deep customization: welcome tagline / example prompts / default agent / model-admin lock / enterprise identity bridge
E2E CI / Check Duplicate Run (push) Failing after 3s
🔄 Branch Synchronization / sync-branches (push) Failing after 3s
Test CI / Check Duplicate Run (push) Failing after 3s
E2E CI / Test Web App (push) Has been skipped
Test CI / Test App (shard 1/3) (push) Has been skipped
Test CI / Test Packages (push) Has been skipped
Test CI / Test App (shard 3/3) (push) Has been skipped
Test CI / Test App (shard 2/3) (push) Has been skipped
Test CI / Test Database (push) Has been skipped
Test CI / Test Desktop App (push) Has been skipped
Test CI / Merge and Upload App Coverage (push) Has been skipped

This commit is contained in:
xiaohei
2026-04-21 20:34:20 +08:00
parent 5a0ec4b3f5
commit c3210b2eec
28 changed files with 704 additions and 268 deletions
+87 -87
View File
@@ -51,50 +51,56 @@
"hotPaths": [
{
"path": "gateway/scripts/acceptance.sh",
"accessCount": 35,
"lastAccessed": 1776746745293,
"accessCount": 37,
"lastAccessed": 1776762981224,
"type": "file"
},
{
"path": "gateway/src/server.ts",
"accessCount": 24,
"lastAccessed": 1776746523169,
"accessCount": 29,
"lastAccessed": 1776762902077,
"type": "file"
},
{
"path": "src",
"accessCount": 20,
"lastAccessed": 1776746567860,
"accessCount": 25,
"lastAccessed": 1776772483106,
"type": "directory"
},
{
"path": "gateway/prisma/seed.ts",
"accessCount": 20,
"lastAccessed": 1776763220682,
"type": "file"
},
{
"path": "gateway/src/core/gateway.ts",
"accessCount": 19,
"lastAccessed": 1776690611538,
"type": "file"
},
{
"path": "docker-compose.yml",
"accessCount": 19,
"lastAccessed": 1776773523884,
"type": "file"
},
{
"path": "gateway/src/env.ts",
"accessCount": 17,
"lastAccessed": 1776746557069,
"accessCount": 18,
"lastAccessed": 1776762725387,
"type": "file"
},
{
"path": "",
"accessCount": 15,
"lastAccessed": 1776746558851,
"accessCount": 18,
"lastAccessed": 1776772696282,
"type": "directory"
},
{
"path": "docker-compose.yml",
"accessCount": 14,
"lastAccessed": 1776747021059,
"type": "file"
},
{
"path": "gateway/src/auth/middleware.ts",
"accessCount": 13,
"lastAccessed": 1776746524853,
"accessCount": 14,
"lastAccessed": 1776762725289,
"type": "file"
},
{
@@ -105,8 +111,32 @@
},
{
"path": "gateway/src/core/capabilities.ts",
"accessCount": 9,
"lastAccessed": 1776690611653,
"accessCount": 12,
"lastAccessed": 1776762816556,
"type": "file"
},
{
"path": "gateway/src/routes/admin/tools.ts",
"accessCount": 12,
"lastAccessed": 1776762925506,
"type": "file"
},
{
"path": ".env",
"accessCount": 11,
"lastAccessed": 1776772939687,
"type": "file"
},
{
"path": "README.md",
"accessCount": 10,
"lastAccessed": 1776748224192,
"type": "file"
},
{
"path": "gateway/src/core/filter.ts",
"accessCount": 10,
"lastAccessed": 1776762756452,
"type": "file"
},
{
@@ -116,45 +146,15 @@
"type": "file"
},
{
"path": "gateway/src/core/filter.ts",
"path": "gateway/package.json",
"accessCount": 9,
"lastAccessed": 1776746584958,
"type": "file"
},
{
"path": "gateway/prisma/seed.ts",
"accessCount": 9,
"lastAccessed": 1776746639419,
"type": "file"
},
{
"path": "gateway/src/routes/admin/tools.ts",
"accessCount": 9,
"lastAccessed": 1776746645871,
"type": "file"
},
{
"path": "README.md",
"accessCount": 9,
"lastAccessed": 1776746769787,
"type": "file"
},
{
"path": ".env",
"accessCount": 9,
"lastAccessed": 1776747010786,
"lastAccessed": 1776762710635,
"type": "file"
},
{
"path": "gateway/src/routes/admin/identityMap.ts",
"accessCount": 8,
"lastAccessed": 1776687700868,
"type": "file"
},
{
"path": "gateway/package.json",
"accessCount": 8,
"lastAccessed": 1776690396624,
"accessCount": 9,
"lastAccessed": 1776762742659,
"type": "file"
},
{
@@ -177,14 +177,20 @@
},
{
"path": "gateway/src/routes/lobechatPlugin.ts",
"accessCount": 7,
"lastAccessed": 1776687652467,
"accessCount": 8,
"lastAccessed": 1776772711425,
"type": "file"
},
{
"path": "gateway/src/auth/devAuth.ts",
"accessCount": 8,
"lastAccessed": 1776773039528,
"type": "file"
},
{
"path": "gateway/prisma/schema.prisma",
"accessCount": 7,
"lastAccessed": 1776746816210,
"lastAccessed": 1776773457701,
"type": "file"
},
{
@@ -205,6 +211,12 @@
"lastAccessed": 1776746550016,
"type": "file"
},
{
"path": "gateway/src/routes/capabilities.ts",
"accessCount": 5,
"lastAccessed": 1776762824807,
"type": "file"
},
{
"path": "lobechat-plugin-manifest/README.md",
"accessCount": 4,
@@ -241,12 +253,6 @@
"lastAccessed": 1776683085106,
"type": "file"
},
{
"path": "gateway/src/routes/capabilities.ts",
"accessCount": 3,
"lastAccessed": 1776684617954,
"type": "file"
},
{
"path": "gateway/src/auth/casdoor.ts",
"accessCount": 3,
@@ -265,36 +271,42 @@
"lastAccessed": 1776687022651,
"type": "file"
},
{
"path": "gateway/prisma/schema.prisma",
"accessCount": 3,
"lastAccessed": 1776746530468,
"type": "file"
},
{
"path": "gateway/src/tools/index.ts",
"accessCount": 3,
"lastAccessed": 1776746541218,
"type": "file"
},
{
"path": "gateway/Dockerfile",
"accessCount": 3,
"lastAccessed": 1776763028735,
"type": "file"
},
{
"path": "docker-compose/production/grafana/docker-compose.yml",
"accessCount": 2,
"lastAccessed": 1776681245580,
"type": "directory"
},
{
"path": "gateway/Dockerfile",
"accessCount": 2,
"lastAccessed": 1776684619369,
"type": "file"
},
{
"path": "docker-compose.gateway.yml",
"accessCount": 2,
"lastAccessed": 1776690209005,
"type": "file"
},
{
"path": "db-init/01-create-dbs.sql",
"accessCount": 2,
"lastAccessed": 1776772673608,
"type": "file"
},
{
"path": "src/config/featureFlags/schema.ts",
"accessCount": 2,
"lastAccessed": 1776773481021,
"type": "file"
},
{
"path": ".env.example.gateway",
"accessCount": 1,
@@ -319,18 +331,6 @@
"lastAccessed": 1776681185251,
"type": "directory"
},
{
"path": "db-init/01-create-dbs.sql",
"accessCount": 1,
"lastAccessed": 1776681258116,
"type": "file"
},
{
"path": "src/config/featureFlags/schema.ts",
"accessCount": 1,
"lastAccessed": 1776681889494,
"type": "file"
},
{
"path": "src/config/featureFlags/index.ts",
"accessCount": 1,
@@ -1,8 +1,8 @@
{
"session_id": "427b0f1b-876b-4ce1-be27-adbaf9f7bd35",
"ended_at": "2026-04-21T03:33:58.985Z",
"ended_at": "2026-04-21T05:28:52.414Z",
"reason": "other",
"agents_spawned": 1,
"agents_completed": 1,
"agents_spawned": 5,
"agents_completed": 5,
"modes_used": []
}
@@ -0,0 +1,10 @@
{"t":0,"agent":"a113e02","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a89bbb4","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a89bbb4","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":310029}
{"t":0,"agent":"a113e02","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":376428}
{"t":0,"agent":"a9905af","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a9905af","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":467901}
{"t":0,"agent":"a689e0e","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a689e0e","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":186848}
{"t":0,"agent":"a7c24ef","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a7c24ef","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":318391}
@@ -1,10 +0,0 @@
{"t":0,"agent":"a7228f4","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a7228f4","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":439201}
{"t":0,"agent":"a8b5b0a","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a9bdbd9","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a7a194c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a68a052","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a9bdbd9","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":192255}
{"t":0,"agent":"a8b5b0a","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":298745}
{"t":0,"agent":"a7a194c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":405255}
{"t":0,"agent":"a68a052","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":386757}
+1 -1
View File
@@ -1,3 +1,3 @@
{
"lastSentAt": "2026-04-21T04:52:16.780Z"
"lastSentAt": "2026-04-21T12:33:30.508Z"
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"tool_name": "Bash",
"tool_input_preview": "{\"command\":\"grep -q \\\"^\\\\.env$\\\" .gitignore && echo \\\".env ignored\\\" || echo \\\"WARN .env NOT ignored\\\"\\ngrep -c \\\"^CASDOOR_ADMIN_SECRET=\\\" .env 2>/dev/null\\n# append (or update) without echoing the va...",
"error": "Exit code 1\n.env ignored\n0\nTraceback (most recent call last):\n File \"<stdin>\", line 2, in <module>\n File \"<frozen os>\", line 709, in __getitem__\nKeyError: 'S'",
"timestamp": "2026-04-21T04:52:00.827Z",
"tool_input_preview": "{\"command\":\"date\\necho \\\"=== current build step ===\\\"; tail -3 /tmp/lobechat-build.log 2>&1\\necho \\\"=== build age ===\\\"; pgrep -fl \\\"com.docker.build\\\" | head -3\\nps -o etimes=,comm= -p $(pgrep -f \\\"c...",
"error": "Exit code 1\nTue Apr 21 20:23:36 CST 2026\n=== current build step ===\n#36 resolving provenance for metadata file\n#36 DONE 0.0s\n Image lobechat-lobechat Built \n=== build age ===\n10856 /Applications/Docker.app/Contents/MacOS/com.docker.build --log-no-timestamp\n48702 bash -c \ncd /Users/gongzhiyong/go/lobechat\nps: etimes: keyword not found\n/Applications/Docker.app/Contents/MacOS/com.docker.build",
"timestamp": "2026-04-21T12:23:36.620Z",
"retry_count": 1
}
+44 -78
View File
@@ -1,141 +1,107 @@
{
"updatedAt": "2026-04-21T04:49:00.597Z",
"updatedAt": "2026-04-21T12:15:55.025Z",
"missions": [
{
"id": "session:427b0f1b-876b-4ce1-be27-adbaf9f7bd35:none",
"id": "session:17a7f260-0c2b-4628-b576-00051879584a:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-04-21T03:48:43.130Z",
"updatedAt": "2026-04-21T04:49:00.597Z",
"createdAt": "2026-04-21T09:11:40.540Z",
"updatedAt": "2026-04-21T12:15:55.025Z",
"status": "done",
"workerCount": 6,
"workerCount": 5,
"taskCounts": {
"total": 6,
"total": 5,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 6,
"completed": 5,
"failed": 0
},
"agents": [
{
"name": "executor:a02dceb",
"name": "executor:a113e02",
"role": "executor",
"ownership": "a02dceb4096c12fed",
"ownership": "a113e029b71f81ee2",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-04-21T03:52:14.768Z"
"updatedAt": "2026-04-21T09:17:56.968Z"
},
{
"name": "executor:a7228f4",
"name": "executor:a89bbb4",
"role": "executor",
"ownership": "a7228f431ca80c4c2",
"ownership": "a89bbb4ebd56ed319",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-04-21T04:28:00.414Z"
"updatedAt": "2026-04-21T09:17:15.300Z"
},
{
"name": "executor:a8b5b0a",
"name": "executor:a9905af",
"role": "executor",
"ownership": "a8b5b0a64a894f1c8",
"ownership": "a9905affb95123dd1",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-04-21T04:46:09.197Z"
"updatedAt": "2026-04-21T12:05:32.432Z"
},
{
"name": "executor:a9bdbd9",
"name": "executor:a689e0e",
"role": "executor",
"ownership": "a9bdbd9f83692ba51",
"ownership": "a689e0edc18c030c7",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-04-21T04:44:47.329Z"
"updatedAt": "2026-04-21T12:09:43.444Z"
},
{
"name": "executor:a7a194c",
"name": "executor:a7c24ef",
"role": "executor",
"ownership": "a7a194c1e64a029df",
"ownership": "a7c24efc187b1b149",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-04-21T04:48:54.196Z"
},
{
"name": "executor:a68a052",
"role": "executor",
"ownership": "a68a052707f8c76ad",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-04-21T04:49:00.597Z"
"updatedAt": "2026-04-21T12:15:55.025Z"
}
],
"timeline": [
{
"id": "session-start:a9bdbd9f83692ba51:2026-04-21T04:41:35.074Z",
"at": "2026-04-21T04:41:35.074Z",
"id": "session-start:a689e0edc18c030c7:2026-04-21T12:06:36.596Z",
"at": "2026-04-21T12:06:36.596Z",
"kind": "update",
"agent": "executor:a9bdbd9",
"detail": "started executor:a9bdbd9",
"sourceKey": "session-start:a9bdbd9f83692ba51"
"agent": "executor:a689e0e",
"detail": "started executor:a689e0e",
"sourceKey": "session-start:a689e0edc18c030c7"
},
{
"id": "session-start:a7a194c1e64a029df:2026-04-21T04:42:08.941Z",
"at": "2026-04-21T04:42:08.941Z",
"id": "session-stop:a689e0edc18c030c7:2026-04-21T12:09:43.444Z",
"at": "2026-04-21T12:09:43.444Z",
"kind": "completion",
"agent": "executor:a689e0e",
"detail": "completed",
"sourceKey": "session-stop:a689e0edc18c030c7"
},
{
"id": "session-start:a7c24efc187b1b149:2026-04-21T12:10:36.634Z",
"at": "2026-04-21T12:10:36.634Z",
"kind": "update",
"agent": "executor:a7a194c",
"detail": "started executor:a7a194c",
"sourceKey": "session-start:a7a194c1e64a029df"
"agent": "executor:a7c24ef",
"detail": "started executor:a7c24ef",
"sourceKey": "session-start:a7c24efc187b1b149"
},
{
"id": "session-start:a68a052707f8c76ad:2026-04-21T04:42:33.840Z",
"at": "2026-04-21T04:42:33.840Z",
"kind": "update",
"agent": "executor:a68a052",
"detail": "started executor:a68a052",
"sourceKey": "session-start:a68a052707f8c76ad"
},
{
"id": "session-stop:a9bdbd9f83692ba51:2026-04-21T04:44:47.329Z",
"at": "2026-04-21T04:44:47.329Z",
"id": "session-stop:a7c24efc187b1b149:2026-04-21T12:15:55.025Z",
"at": "2026-04-21T12:15:55.025Z",
"kind": "completion",
"agent": "executor:a9bdbd9",
"agent": "executor:a7c24ef",
"detail": "completed",
"sourceKey": "session-stop:a9bdbd9f83692ba51"
},
{
"id": "session-stop:a8b5b0a64a894f1c8:2026-04-21T04:46:09.197Z",
"at": "2026-04-21T04:46:09.197Z",
"kind": "completion",
"agent": "executor:a8b5b0a",
"detail": "completed",
"sourceKey": "session-stop:a8b5b0a64a894f1c8"
},
{
"id": "session-stop:a7a194c1e64a029df:2026-04-21T04:48:54.196Z",
"at": "2026-04-21T04:48:54.196Z",
"kind": "completion",
"agent": "executor:a7a194c",
"detail": "completed",
"sourceKey": "session-stop:a7a194c1e64a029df"
},
{
"id": "session-stop:a68a052707f8c76ad:2026-04-21T04:49:00.597Z",
"at": "2026-04-21T04:49:00.597Z",
"kind": "completion",
"agent": "executor:a68a052",
"detail": "completed",
"sourceKey": "session-stop:a68a052707f8c76ad"
"sourceKey": "session-stop:a7c24efc187b1b149"
}
]
}
+21 -21
View File
@@ -1,53 +1,53 @@
{
"agents": [
{
"agent_id": "a7228f431ca80c4c2",
"agent_id": "a113e029b71f81ee2",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-04-21T04:20:41.213Z",
"started_at": "2026-04-21T09:11:40.540Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-04-21T04:28:00.414Z",
"duration_ms": 439201
"completed_at": "2026-04-21T09:17:56.968Z",
"duration_ms": 376428
},
{
"agent_id": "a8b5b0a64a894f1c8",
"agent_id": "a89bbb4ebd56ed319",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-04-21T04:41:10.452Z",
"started_at": "2026-04-21T09:12:05.271Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-04-21T04:46:09.197Z",
"duration_ms": 298745
"completed_at": "2026-04-21T09:17:15.300Z",
"duration_ms": 310029
},
{
"agent_id": "a9bdbd9f83692ba51",
"agent_id": "a9905affb95123dd1",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-04-21T04:41:35.074Z",
"started_at": "2026-04-21T11:57:44.531Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-04-21T04:44:47.329Z",
"duration_ms": 192255
"completed_at": "2026-04-21T12:05:32.432Z",
"duration_ms": 467901
},
{
"agent_id": "a7a194c1e64a029df",
"agent_id": "a689e0edc18c030c7",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-04-21T04:42:08.941Z",
"started_at": "2026-04-21T12:06:36.596Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-04-21T04:48:54.196Z",
"duration_ms": 405255
"completed_at": "2026-04-21T12:09:43.444Z",
"duration_ms": 186848
},
{
"agent_id": "a68a052707f8c76ad",
"agent_id": "a7c24efc187b1b149",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-04-21T04:42:33.840Z",
"started_at": "2026-04-21T12:10:36.634Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-04-21T04:49:00.597Z",
"duration_ms": 386757
"completed_at": "2026-04-21T12:15:55.025Z",
"duration_ms": 318391
}
],
"total_spawned": 5,
"total_completed": 5,
"total_failed": 0,
"last_updated": "2026-04-21T04:49:00.700Z"
"last_updated": "2026-04-21T12:15:55.129Z"
}
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Bootstrap every existing LobeChat user so the UI is immediately usable:
* 1. Enables the built-in "azure" (Azure OpenAI) provider with encrypted keyVaults.
* 2. Inserts one "azure/<deploymentName>" ai_models row enabled=true.
* 3. Installs one customPlugin "enterprise-gateway" pointing at the gateway
* manifest URL (identity-aware; each user sees only their own allowed tools).
*
* Runs inside the lobechat container so it can use the bundled `pg` module and
* reach `db:5432` / `gateway:3001` on the compose network. Idempotent:
* re-running does not duplicate or error.
*
* Required env (passed through by the wrapper script):
* LOBECHAT_DATABASE_URL postgres URL for the lobechat DB
* KEY_VAULTS_SECRET same secret LobeChat uses to decrypt key_vaults
* AZURE_OPENAI_API_KEY Azure OpenAI key
* AZURE_OPENAI_ENDPOINT https://<resource>.openai.azure.com (or .cognitiveservices.azure.com)
* AZURE_OPENAI_API_VERSION e.g. 2025-04-01-preview
* AZURE_OPENAI_DEPLOYMENT deployment name (used as the model id)
* GATEWAY_MANIFEST_URL defaults to http://gateway:3001/api/lobechat/manifest
*/
const crypto = require('crypto');
const { Client } = require('pg');
const DB_URL = process.env.LOBECHAT_DATABASE_URL;
const KEY_VAULTS_SECRET = process.env.KEY_VAULTS_SECRET;
const AZ_KEY = process.env.AZURE_OPENAI_API_KEY;
const AZ_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT;
const AZ_VERSION = process.env.AZURE_OPENAI_API_VERSION || '2025-04-01-preview';
const AZ_DEPLOY = process.env.AZURE_OPENAI_DEPLOYMENT || 'gpt-5.4';
const MANIFEST_URL =
process.env.GATEWAY_MANIFEST_URL || 'http://gateway:3001/api/lobechat/manifest';
function die(msg) {
console.error('[bootstrap] FATAL:', msg);
process.exit(1);
}
if (!DB_URL) die('LOBECHAT_DATABASE_URL is required');
if (!KEY_VAULTS_SECRET) die('KEY_VAULTS_SECRET is required');
if (!AZ_KEY || !AZ_ENDPOINT) die('AZURE_OPENAI_API_KEY and AZURE_OPENAI_ENDPOINT are required');
/**
* Reproduces src/server/modules/KeyVaultsEncrypt. LobeChat stores
* `${ivHex}:${authTagHex}:${cipherHex}` using AES-GCM with the raw bytes of
* KEY_VAULTS_SECRET base64-decoded (must be 16/24/32 bytes).
*/
function encryptKeyVaults(plaintext) {
const rawKey = Buffer.from(KEY_VAULTS_SECRET, 'base64');
if (![16, 24, 32].includes(rawKey.length)) {
die(
`KEY_VAULTS_SECRET must decode to 16/24/32 bytes; got ${rawKey.length}. ` +
`Regenerate with: openssl rand -base64 32 (and update .env + restart lobechat).`,
);
}
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', rawKey, iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${enc.toString('hex')}`;
}
async function main() {
const client = new Client({ connectionString: DB_URL });
await client.connect();
console.log('[bootstrap] connected to', DB_URL.replace(/:[^:@]*@/, ':***@'));
const { rows: users } = await client.query('SELECT id, email FROM users ORDER BY created_at');
console.log(`[bootstrap] found ${users.length} user(s)`);
// keyVaults shape for Azure OpenAI — see packages/types/src/user/settings/keyVaults.ts
const keyVaultsJson = JSON.stringify({
apiKey: AZ_KEY,
apiVersion: AZ_VERSION,
baseURL: AZ_ENDPOINT,
endpoint: AZ_ENDPOINT, // deprecated alias; included for older runtime code paths
});
// CustomPlugin manifest + params. LobeChat fetches `manifestUrl` on first use
// and caches into the `manifest` column; we pre-populate nothing so the
// identity-aware manifest is always re-fetched per user.
const pluginIdentifier = 'enterprise-gateway';
const customParams = {
apiMode: 'simple',
avatar: '🏢',
description:
'Identity-aware Enterprise Gateway tools. Exposed tool list filtered by caller RBAC.',
manifestMode: 'url',
manifestUrl: MANIFEST_URL,
};
let providersInserted = 0;
let modelsInserted = 0;
let pluginsInserted = 0;
for (const u of users) {
// Fresh ciphertext per user so two users sharing a leaked row hash nothing.
const encKV = encryptKeyVaults(keyVaultsJson);
// --- ai_providers (builtin "azure") -----------------------------------
const prov = await client.query(
`INSERT INTO ai_providers (id, user_id, enabled, key_vaults, source, name, logo, settings, config)
VALUES ($1, $2, TRUE, $3, 'builtin', 'Azure OpenAI', NULL, '{}'::jsonb, '{}'::jsonb)
ON CONFLICT (id, user_id) DO UPDATE
SET enabled = TRUE,
key_vaults = EXCLUDED.key_vaults
WHERE ai_providers.key_vaults IS NULL OR ai_providers.key_vaults = ''
RETURNING id`,
['azure', u.id, encKV],
);
if (prov.rowCount) providersInserted++;
// --- ai_models (one row for the deployment) ---------------------------
const mdl = await client.query(
`INSERT INTO ai_models
(id, provider_id, user_id, enabled, display_name, type, source, config, abilities, parameters, settings)
VALUES ($1, 'azure', $2, TRUE, $3, 'chat', 'custom',
$4::jsonb,
'{"functionCall":true,"vision":true}'::jsonb,
'{}'::jsonb, '{}'::jsonb)
ON CONFLICT (id, provider_id, user_id) DO UPDATE SET enabled = TRUE
RETURNING id`,
[
AZ_DEPLOY,
u.id,
`${AZ_DEPLOY} (Azure)`,
JSON.stringify({ deploymentName: AZ_DEPLOY }),
],
);
if (mdl.rowCount) modelsInserted++;
// --- user_installed_plugins (customPlugin) ----------------------------
const plg = await client.query(
`INSERT INTO user_installed_plugins
(user_id, identifier, type, manifest, settings, custom_params, source)
VALUES ($1, $2, 'customPlugin', NULL, '{}'::jsonb, $3::jsonb, 'custom')
ON CONFLICT (user_id, identifier) DO UPDATE
SET custom_params = EXCLUDED.custom_params
RETURNING identifier`,
[u.id, pluginIdentifier, JSON.stringify(customParams)],
);
if (plg.rowCount) pluginsInserted++;
console.log(`[bootstrap] ✓ ${u.email || u.id}`);
}
console.log(
`[bootstrap] done. providers=${providersInserted} models=${modelsInserted} plugins=${pluginsInserted}`,
);
await client.end();
}
main().catch((err) => {
console.error('[bootstrap] error:', err);
process.exit(1);
});
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Bootstrap every existing LobeChat user with:
# - Enabled Azure OpenAI provider (encrypted keyVaults)
# - One Azure chat model matching $AZURE_OPENAI_DEPLOYMENT
# - "enterprise-gateway" customPlugin pointing at the gateway manifest
#
# Usage: ./gateway/scripts/bootstrap-lobechat-users.sh
# Pre-req: docker compose stack is up; .env populated with Azure + KEY_VAULTS_SECRET.
# Idempotent: safe to re-run.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
SCRIPT_PATH="$ROOT_DIR/gateway/scripts/bootstrap-lobechat-users.js"
# Load .env so we get KEY_VAULTS_SECRET, Azure creds, etc.
# Write a stripped copy to a tempfile (removes inline `# ...` comments), then
# source it with `set -a` so every assignment becomes exported.
if [[ -f "$ROOT_DIR/.env" ]]; then
TMP_ENV="$(mktemp)"
trap 'rm -f "$TMP_ENV"' EXIT
# keep only KEY=VALUE lines; strip trailing " # comment"; preserve quotes/slashes in VALUE
awk '
/^[[:space:]]*#/ { next }
/^[[:space:]]*$/ { next }
{
# split off trailing comment only if preceded by whitespace
sub(/[[:space:]]+#.*$/, "")
if ($0 ~ /^[A-Za-z_][A-Za-z0-9_]*=/) print
}
' "$ROOT_DIR/.env" > "$TMP_ENV"
set -a
# shellcheck disable=SC1090
source "$TMP_ENV"
set +a
fi
: "${KEY_VAULTS_SECRET:?KEY_VAULTS_SECRET must be set (see .env). Must decode to 16/24/32 bytes.}"
: "${AZURE_OPENAI_API_KEY:?AZURE_OPENAI_API_KEY must be set in .env}"
: "${AZURE_OPENAI_ENDPOINT:?AZURE_OPENAI_ENDPOINT must be set in .env}"
AZURE_OPENAI_API_VERSION="${AZURE_OPENAI_API_VERSION:-2025-04-01-preview}"
AZURE_OPENAI_DEPLOYMENT="${AZURE_OPENAI_DEPLOYMENT:-gpt-5.4}"
POSTGRES_USER="${POSTGRES_USER:-eg}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-eg_pw}"
LOBE_DB_NAME="${LOBE_DB_NAME:-lobechat}"
LOBECHAT_DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${LOBE_DB_NAME}"
CONTAINER="${LOBECHAT_CONTAINER:-lobechat-lobechat-1}"
echo "[bootstrap] using container: $CONTAINER"
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}\$"; then
echo "[bootstrap] ERROR: container '$CONTAINER' not running." >&2
echo " set LOBECHAT_CONTAINER=<name> or start the stack." >&2
exit 1
fi
# Copy script into the container (it has pg + node baked in).
docker cp "$SCRIPT_PATH" "$CONTAINER:/tmp/bootstrap-lobechat-users.js"
# Run via node, pointing NODE_PATH at the app's node_modules so `require('pg')` resolves.
docker exec \
-e NODE_PATH=/app/node_modules \
-e LOBECHAT_DATABASE_URL="$LOBECHAT_DATABASE_URL" \
-e KEY_VAULTS_SECRET="$KEY_VAULTS_SECRET" \
-e AZURE_OPENAI_API_KEY="$AZURE_OPENAI_API_KEY" \
-e AZURE_OPENAI_ENDPOINT="$AZURE_OPENAI_ENDPOINT" \
-e AZURE_OPENAI_API_VERSION="$AZURE_OPENAI_API_VERSION" \
-e AZURE_OPENAI_DEPLOYMENT="$AZURE_OPENAI_DEPLOYMENT" \
-e GATEWAY_MANIFEST_URL="${GATEWAY_MANIFEST_URL:-http://gateway:3001/api/lobechat/manifest}" \
"$CONTAINER" node /tmp/bootstrap-lobechat-users.js
echo "[bootstrap] done. Sign in as admin@eg.local or user1@eg.local and open the model + plugin pickers."
+4 -2
View File
@@ -122,7 +122,9 @@
{
"avatar": "📊",
"backgroundColor": "#E8F8F5",
"plugins": ["steam"],
"plugins": [
"steam"
],
"systemRole": "你擅长处理和解释数据,通过图表和统计分析揭示数据背后的规律和趋势。",
"title": "数据分析师"
},
@@ -294,7 +296,7 @@
"23": "已进入待命状态",
"24": "准备迎接挑战",
"25": "新思路正在生成中",
"26": "前路清晰,启程",
"26": "清晰前路,启程",
"27": "系统在线,随时帮你",
"28": "努力加载好心情",
"29": "掌控节奏,从现在开始",
+4 -2
View File
@@ -122,7 +122,9 @@
{
"avatar": "📊",
"backgroundColor": "#E8F8F5",
"plugins": ["steam"],
"plugins": [
"steam"
],
"systemRole": "你擅長處理與解讀數據,透過圖表與統計分析揭示數據背後的規律與趨勢。",
"title": "數據分析師"
},
@@ -294,7 +296,7 @@
"23": "已進入待命狀態",
"24": "準備迎接挑戰",
"25": "新思路正在生成中",
"26": "前路清晰,啟程!",
"26": "Enterprise AI Workspace",
"27": "系統在線,隨時為你服務 💡",
"28": "努力加載好心情",
"29": "掌握節奏,從現在開始 🎵",
+1 -1
View File
@@ -94,7 +94,7 @@ export class AgentModel {
return rows.map(({ slug, ...row }) => ({
...row,
avatar: row.avatar || (slug === INBOX_SESSION_ID ? DEFAULT_INBOX_AVATAR : null),
title: row.title || (slug === INBOX_SESSION_ID ? 'LobeAI' : null),
title: row.title || (slug === INBOX_SESSION_ID ? 'Enterprise Assistant' : null),
}));
};
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import {
isModelAdmin,
resolveEnterpriseRoles,
} from '@/server/services/enterpriseIdentity';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const GET = async (req: NextRequest) => {
try {
const session = await auth.api.getSession({ headers: req.headers });
const email = session?.user?.email ?? null;
if (!email) {
return NextResponse.json(
{ email: null, enterprise_roles: [], is_admin: false },
{ status: 200 },
);
}
const roles = await resolveEnterpriseRoles(email);
return NextResponse.json(
{
email,
enterprise_roles: roles,
is_admin: isModelAdmin(roles),
},
{ status: 200 },
);
} catch (err) {
console.error('[enterprise/me] error', err);
return NextResponse.json(
{ email: null, enterprise_roles: [], is_admin: false },
{ status: 200 },
);
}
};
+37
View File
@@ -0,0 +1,37 @@
import useSWR from 'swr';
export interface EnterpriseMe {
email: string | null;
enterprise_roles: string[];
is_admin: boolean;
}
const DEFAULT: EnterpriseMe = {
email: null,
enterprise_roles: [],
is_admin: false,
};
const fetcher = async (url: string): Promise<EnterpriseMe> => {
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) return DEFAULT;
try {
return (await res.json()) as EnterpriseMe;
} catch {
return DEFAULT;
}
};
/**
* Fetches the current user's Enterprise Gateway role membership.
* Returns `is_admin=true` for super_admin / permission_admin.
* Safe to call on the server (returns defaults) — SWR will revalidate
* on the client.
*/
export const useEnterpriseRoles = (): EnterpriseMe => {
const { data } = useSWR<EnterpriseMe>('/webapi/enterprise/me', fetcher, {
dedupingInterval: 60_000,
revalidateOnFocus: false,
});
return data ?? DEFAULT;
};
+2 -2
View File
@@ -157,8 +157,8 @@ export default {
'historyRange': 'History Range',
'historySummary': 'Historical Message Summary',
'inactive': 'Inactive',
'inbox.desc': 'Collaborate in one Workspace and move ideas into outcomes.',
'inbox.title': 'Lobe AI',
'inbox.desc': 'Ask anything about your enterprise — tools, tickets, customers, knowledge.',
'inbox.title': 'Enterprise Assistant',
'input.addAi': 'Add an AI message',
'input.addUser': 'Add a user message',
'input.disclaimer': 'Agents can make mistakes. Use your judgment for critical info.',
+1 -1
View File
@@ -12,6 +12,6 @@ export default {
'starter.developing': 'Coming soon',
'starter.image': 'Image',
'starter.imageGeneration': 'Image Generation',
'starter.videoGeneration': 'Seedance 2.0',
'starter.videoGeneration': 'Video',
'starter.write': 'Write',
};
@@ -49,7 +49,7 @@ const InboxEntry = memo(() => {
inboxAgentId ? operationSelectors.isAgentRunning(inboxAgentId) : () => false,
);
const title = inboxMeta.title || 'Lobe AI';
const title = inboxMeta.title || 'Enterprise Assistant';
const avatar = inboxMeta.avatar || DEFAULT_INBOX_AVATAR;
const url = SESSION_CHAT_URL(inboxAgentId, false);
@@ -79,13 +79,14 @@ const Examples = memo<ExamplesProps>(({ suggestMode, onExampleClick }) => {
</Flexbox>
<Flexbox gap={12} style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)' }}>
{questions.map((item) => {
const prompt = tSuggest(item.promptKey as any);
// promptKey / titleKey carry literal enterprise prompt strings, not i18n keys.
const prompt = item.promptKey;
return (
<ExampleItem
description={prompt}
key={item.id}
prompt={prompt}
title={tSuggest(item.titleKey as any)}
title={item.titleKey}
onClick={onExampleClick}
/>
);
@@ -82,7 +82,6 @@ const StarterList = memo(() => {
titleKey: 'starter.imageGeneration',
},
{
hot: true,
icon: VideoIcon,
key: 'video',
titleKey: 'starter.videoGeneration',
@@ -2,7 +2,6 @@
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Item from './Item';
import { type QuestionItem } from './useRandomQuestions';
@@ -12,8 +11,6 @@ interface ListProps {
}
const List = memo<ListProps>(({ questions }) => {
const { t } = useTranslation('suggestQuestions');
if (questions.length === 0) {
return null;
}
@@ -21,14 +18,11 @@ const List = memo<ListProps>(({ questions }) => {
return (
<Flexbox gap={12} style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)' }}>
{questions.map((item) => {
const prompt = t(item.promptKey as any);
// promptKey / titleKey now carry literal display strings (enterprise prompts),
// not i18n lookup keys.
const prompt = item.promptKey;
return (
<Item
description={prompt}
key={item.id}
prompt={prompt}
title={t(item.titleKey as any)}
/>
<Item description={prompt} key={item.id} prompt={prompt} title={item.titleKey} />
);
})}
</Flexbox>
@@ -2,9 +2,42 @@ import { useCallback, useState } from 'react';
import { type StarterMode } from '@/store/home';
const QUESTION_COUNT = 40;
const DISPLAY_COUNT = 6;
// Enterprise AI Workspace example prompts. Literal strings (not i18n keys)
// so they bypass the upstream LobeChat suggestQuestions namespace entirely.
interface EnterprisePrompt {
prompt: string;
title: string;
}
const ENTERPRISE_PROMPTS: EnterprisePrompt[] = [
{
prompt: '显示我今天提交的所有工单状态',
title: '查询我的工单',
},
{
prompt: '在知识库里查一下我们关于 Azure 成本优化的最新规范',
title: '搜索知识库',
},
{
prompt: '为我生成本周的项目进展周报 Word 版本',
title: '生成周报文档',
},
{
prompt: '拉一下本月云成本总览',
title: '云成本总览',
},
{
prompt: '帮我分析客户 XX 最近的活动和风险点',
title: '销售客户洞察',
},
{
prompt: '在沙盒里跑一段 Python 计算斐波那契',
title: '代码沙盒',
},
];
const shuffleArray = <T>(array: T[]): T[] => {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
@@ -14,24 +47,26 @@ const shuffleArray = <T>(array: T[]): T[] => {
return shuffled;
};
const generateQuestions = (mode: StarterMode) => {
const generateQuestions = (mode: StarterMode): QuestionItem[] => {
const modeKey = mode ?? 'chat';
if (!['agent', 'group', 'write', 'chat'].includes(modeKey)) {
return [];
}
const ids = Array.from({ length: QUESTION_COUNT }, (_, i) => i + 1);
const shuffled = shuffleArray(ids);
return shuffled.slice(0, DISPLAY_COUNT).map((id) => ({
id,
promptKey: `${modeKey}.${String(id).padStart(2, '0')}.prompt`,
titleKey: `${modeKey}.${String(id).padStart(2, '0')}.title`,
return shuffleArray(ENTERPRISE_PROMPTS)
.slice(0, DISPLAY_COUNT)
.map((item, idx) => ({
id: idx + 1,
promptKey: item.prompt,
titleKey: item.title,
}));
};
export interface QuestionItem {
id: number;
// These fields retain the *Key suffix for backward compatibility with
// the List component, but carry literal display strings rather than i18n keys.
promptKey: string;
titleKey: string;
}
@@ -1,16 +1,7 @@
import { Center } from '@lobehub/ui';
import { sample } from 'es-toolkit/compat';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { memo } from 'react';
const WelcomeText = memo(() => {
const { t } = useTranslation('welcome');
const sentence = useMemo(() => {
const messages = t('welcomeMessages', { returnObjects: true }) as Record<string, string>;
return sample(Object.values(messages));
}, [t]);
return (
<Center
style={{
@@ -19,7 +10,7 @@ const WelcomeText = memo(() => {
marginBlock: '36px 24px',
}}
>
{sentence}
{'Enterprise AI Workspace'}
</Center>
);
});
@@ -24,6 +24,7 @@ import {
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useEnterpriseRoles } from '@/hooks/useEnterpriseRoles';
import { useElectronStore } from '@/store/electron';
import { electronSyncSelectors } from '@/store/electron/selectors';
import { SettingsTabs } from '@/store/global/initialState';
@@ -67,6 +68,7 @@ export const useCategory = () => {
]);
const remoteServerUrl = useElectronStore(electronSyncSelectors.remoteServerUrl);
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const { is_admin: isEnterpriseModelAdmin } = useEnterpriseRoles();
const avatarUrl = useMemo(() => {
if (!avatar) return undefined;
@@ -134,12 +136,13 @@ export const useCategory = () => {
// Agent group
const agentItems: CategoryItem[] = [
(!enableBusinessFeatures || isDevMode) && {
(!enableBusinessFeatures || isDevMode) &&
isEnterpriseModelAdmin && {
icon: Brain,
key: SettingsTabs.Provider,
label: t('tab.provider'),
},
{
isEnterpriseModelAdmin && {
icon: Sparkles,
key: SettingsTabs.ServiceModel,
label: t('tab.serviceModel'),
@@ -222,6 +225,7 @@ export const useCategory = () => {
mobile,
showApiKeyManage,
isDevMode,
isEnterpriseModelAdmin,
avatarUrl,
username,
]);
+16 -9
View File
@@ -14,6 +14,7 @@ import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { getServerGlobalConfig } from '@/server/globalConfig';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { requireModelAdmin } from '@/server/services/enterpriseIdentity/guard';
import { type ProviderConfig } from '@/types/user/settings';
const aiModelProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
@@ -36,8 +37,14 @@ const aiModelProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
});
});
// Enterprise admin gate for model mutations.
const aiModelAdminProcedure = aiModelProcedure.use(async (opts) => {
await requireModelAdmin(opts.ctx.serverDB, opts.ctx.userId);
return opts.next();
});
export const aiModelRouter = router({
batchToggleAiModels: aiModelProcedure
batchToggleAiModels: aiModelAdminProcedure
.input(
z.object({
enabled: z.boolean(),
@@ -48,7 +55,7 @@ export const aiModelRouter = router({
.mutation(async ({ input, ctx }) => {
return ctx.aiModelModel.batchToggleAiModels(input.id, input.models, input.enabled);
}),
batchUpdateAiModels: aiModelProcedure
batchUpdateAiModels: aiModelAdminProcedure
.input(
z.object({
id: z.string(),
@@ -60,18 +67,18 @@ export const aiModelRouter = router({
return ctx.aiModelModel.batchUpdateAiModels(input.id, input.models);
}),
clearModelsByProvider: aiModelProcedure
clearModelsByProvider: aiModelAdminProcedure
.input(z.object({ providerId: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.aiModelModel.clearModelsByProvider(input.providerId);
}),
clearRemoteModels: aiModelProcedure
clearRemoteModels: aiModelAdminProcedure
.input(z.object({ providerId: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.aiModelModel.clearRemoteModels(input.providerId);
}),
createAiModel: aiModelProcedure.input(CreateAiModelSchema).mutation(async ({ input, ctx }) => {
createAiModel: aiModelAdminProcedure.input(CreateAiModelSchema).mutation(async ({ input, ctx }) => {
const data = await ctx.aiModelModel.create(input);
return data?.id;
@@ -103,19 +110,19 @@ export const aiModelRouter = router({
});
}),
removeAiModel: aiModelProcedure
removeAiModel: aiModelAdminProcedure
.input(z.object({ id: z.string(), providerId: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.aiModelModel.delete(input.id, input.providerId);
}),
toggleModelEnabled: aiModelProcedure
toggleModelEnabled: aiModelAdminProcedure
.input(ToggleAiModelEnableSchema)
.mutation(async ({ input, ctx }) => {
return ctx.aiModelModel.toggleModelEnabled(input);
}),
updateAiModel: aiModelProcedure
updateAiModel: aiModelAdminProcedure
.input(
z.object({
id: z.string(),
@@ -127,7 +134,7 @@ export const aiModelRouter = router({
return ctx.aiModelModel.update(input.id, input.providerId, input.value);
}),
updateAiModelOrder: aiModelProcedure
updateAiModelOrder: aiModelAdminProcedure
.input(
z.object({
providerId: z.string(),
+14 -6
View File
@@ -9,6 +9,7 @@ import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { getServerGlobalConfig } from '@/server/globalConfig';
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { requireModelAdmin } from '@/server/services/enterpriseIdentity/guard';
import { type AiProviderDetailItem, type AiProviderRuntimeState } from '@/types/aiProvider';
import {
CreateAiProviderSchema,
@@ -37,6 +38,13 @@ const aiProviderProcedure = authedProcedure.use(serverDatabase).use(async (opts)
});
});
// Mutations that change provider/model configuration must come from an
// Enterprise Gateway admin. Reads use `aiProviderProcedure` directly.
const aiProviderAdminProcedure = aiProviderProcedure.use(async (opts) => {
await requireModelAdmin(opts.ctx.serverDB, opts.ctx.userId);
return opts.next();
});
export const aiProviderRouter = router({
checkProviderConnectivity: aiProviderProcedure
.input(
@@ -85,7 +93,7 @@ export const aiProviderRouter = router({
}
}),
createAiProvider: aiProviderProcedure
createAiProvider: aiProviderAdminProcedure
.input(CreateAiProviderSchema)
.mutation(async ({ input, ctx }) => {
try {
@@ -120,13 +128,13 @@ export const aiProviderRouter = router({
return ctx.aiInfraRepos.getAiProviderRuntimeState(KeyVaultsGateKeeper.getUserKeyVaults);
}),
removeAiProvider: aiProviderProcedure
removeAiProvider: aiProviderAdminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.aiProviderModel.delete(input.id);
}),
toggleProviderEnabled: aiProviderProcedure
toggleProviderEnabled: aiProviderAdminProcedure
.input(
z.object({
enabled: z.boolean(),
@@ -137,7 +145,7 @@ export const aiProviderRouter = router({
return ctx.aiProviderModel.toggleProviderEnabled(input.id, input.enabled);
}),
updateAiProvider: aiProviderProcedure
updateAiProvider: aiProviderAdminProcedure
.input(
z.object({
id: z.string(),
@@ -148,7 +156,7 @@ export const aiProviderRouter = router({
return ctx.aiProviderModel.update(input.id, input.value);
}),
updateAiProviderConfig: aiProviderProcedure
updateAiProviderConfig: aiProviderAdminProcedure
.input(
z.object({
id: z.string(),
@@ -164,7 +172,7 @@ export const aiProviderRouter = router({
);
}),
updateAiProviderOrder: aiProviderProcedure
updateAiProviderOrder: aiProviderAdminProcedure
.input(
z.object({
sortMap: z.array(
@@ -0,0 +1,33 @@
import { TRPCError } from '@trpc/server';
import { UserModel } from '@/database/models/user';
import { type LobeChatDatabase } from '@/database/type';
import { isModelAdmin, resolveEnterpriseRoles } from './index';
const ADMIN_BACKEND_URL =
process.env.ENTERPRISE_ADMIN_URL || 'http://localhost:3001/admin/models';
const BYPASS = process.env.DISABLE_ENTERPRISE_MODEL_GUARD === '1';
/**
* Throws TRPC FORBIDDEN unless the current user is an Enterprise Gateway
* super_admin or permission_admin. Model/provider mutations are funneled
* through this guard.
*/
export const requireModelAdmin = async (
db: LobeChatDatabase,
userId: string,
): Promise<void> => {
if (BYPASS) return;
const user = await UserModel.findById(db, userId);
const roles = await resolveEnterpriseRoles(user?.email);
if (!isModelAdmin(roles)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: `Model configuration is managed in the Enterprise Admin backend at ${ADMIN_BACKEND_URL}`,
});
}
};
@@ -0,0 +1,86 @@
import { Pool } from 'pg';
/**
* Enterprise Identity bridge.
*
* Maps a LobeChat user (by email) to Enterprise Gateway roles by querying
* the shared postgres instance (different logical DB: `enterprise_gateway`).
*
* - `resolveEnterpriseRoles(email)` returns an array of role keys (e.g.
* `['super_admin']`). If no enterprise user matches, returns `[]`.
* - Results are cached in-memory for 60 seconds per email.
* - The admin roles that can manage model/provider configuration are
* `super_admin` and `permission_admin`.
*/
export const MODEL_ADMIN_ROLES = ['super_admin', 'permission_admin'] as const;
const CACHE_TTL_MS = 60_000;
const cache = new Map<string, { expiresAt: number; roles: string[] }>();
let pool: Pool | null = null;
const deriveEnterpriseDbUrl = (): string | undefined => {
const direct = process.env.ENTERPRISE_GATEWAY_DATABASE_URL;
if (direct) return direct;
const lobeUrl = process.env.DATABASE_URL;
if (!lobeUrl) return undefined;
// Swap the database name in the URL path (e.g. `/lobechat` -> `/enterprise_gateway`).
try {
const url = new URL(lobeUrl);
url.pathname = '/enterprise_gateway';
return url.toString();
} catch {
return undefined;
}
};
const getPool = (): Pool | null => {
if (pool) return pool;
const connectionString = deriveEnterpriseDbUrl();
if (!connectionString) return null;
pool = new Pool({ connectionString, max: 2 });
// Swallow pool errors to avoid crashing the process on transient connection
// issues; the caller treats any query failure as "no roles".
pool.on('error', (err) => {
console.warn('[enterpriseIdentity] pg pool error:', err.message);
});
return pool;
};
export const resolveEnterpriseRoles = async (email?: string | null): Promise<string[]> => {
if (!email) return [];
const key = email.toLowerCase();
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) return hit.roles;
const p = getPool();
if (!p) return [];
try {
const { rows } = await p.query<{ key: string }>(
`SELECT r.key
FROM enterprise_users u
JOIN enterprise_user_roles ur ON ur.user_id = u.id
JOIN enterprise_roles r ON r.id = ur.role_id
WHERE lower(u.email) = $1 AND u.is_active = true`,
[key],
);
const roles = rows.map((r) => r.key);
cache.set(key, { expiresAt: now + CACHE_TTL_MS, roles });
return roles;
} catch (err) {
console.warn(
'[enterpriseIdentity] resolveEnterpriseRoles failed:',
(err as Error).message,
);
return [];
}
};
export const isModelAdmin = (roles: string[]): boolean =>
roles.some((r) => (MODEL_ADMIN_ROLES as readonly string[]).includes(r));