From c3210b2eec9dad88b23109c8142e908dce4e3da7 Mon Sep 17 00:00:00 2001 From: xiaohei Date: Tue, 21 Apr 2026 20:34:20 +0800 Subject: [PATCH] LobeChat deep customization: welcome tagline / example prompts / default agent / model-admin lock / enterprise identity bridge --- .omc/project-memory.json | 174 +++++++++--------- .../427b0f1b-876b-4ce1-be27-adbaf9f7bd35.json | 6 +- ...17a7f260-0c2b-4628-b576-00051879584a.jsonl | 10 + ...427b0f1b-876b-4ce1-be27-adbaf9f7bd35.jsonl | 10 - .omc/state/idle-notif-cooldown.json | 2 +- .omc/state/last-tool-error.json | 6 +- .omc/state/mission-state.json | 122 +++++------- .omc/state/subagent-tracking.json | 42 ++--- gateway/scripts/bootstrap-lobechat-users.js | 158 ++++++++++++++++ gateway/scripts/bootstrap-lobechat-users.sh | 73 ++++++++ locales/zh-CN/welcome.json | 8 +- locales/zh-TW/welcome.json | 8 +- packages/database/src/models/agent.ts | 2 +- .../(backend)/webapi/enterprise/me/route.ts | 40 ++++ src/hooks/useEnterpriseRoles.ts | 37 ++++ src/locales/default/chat.ts | 4 +- src/locales/default/home.ts | 2 +- .../(main)/home/_layout/Body/InboxEntry.tsx | 2 +- .../home/_layout/hooks/useCreateModal.tsx | 5 +- .../home/features/InputArea/StarterList.tsx | 1 - .../home/features/SuggestQuestions/List.tsx | 14 +- .../SuggestQuestions/useRandomQuestions.ts | 53 +++++- .../home/features/WelcomeText/index.tsx | 13 +- .../(main)/settings/hooks/useCategory.tsx | 16 +- src/server/routers/lambda/aiModel.ts | 25 ++- src/server/routers/lambda/aiProvider.ts | 20 +- .../services/enterpriseIdentity/guard.ts | 33 ++++ .../services/enterpriseIdentity/index.ts | 86 +++++++++ 28 files changed, 704 insertions(+), 268 deletions(-) create mode 100644 .omc/state/agent-replay-17a7f260-0c2b-4628-b576-00051879584a.jsonl delete mode 100644 .omc/state/agent-replay-427b0f1b-876b-4ce1-be27-adbaf9f7bd35.jsonl create mode 100755 gateway/scripts/bootstrap-lobechat-users.js create mode 100755 gateway/scripts/bootstrap-lobechat-users.sh create mode 100644 src/app/(backend)/webapi/enterprise/me/route.ts create mode 100644 src/hooks/useEnterpriseRoles.ts create mode 100644 src/server/services/enterpriseIdentity/guard.ts create mode 100644 src/server/services/enterpriseIdentity/index.ts diff --git a/.omc/project-memory.json b/.omc/project-memory.json index 83c7211..6a46de4 100644 --- a/.omc/project-memory.json +++ b/.omc/project-memory.json @@ -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, diff --git a/.omc/sessions/427b0f1b-876b-4ce1-be27-adbaf9f7bd35.json b/.omc/sessions/427b0f1b-876b-4ce1-be27-adbaf9f7bd35.json index d504377..522bf2c 100644 --- a/.omc/sessions/427b0f1b-876b-4ce1-be27-adbaf9f7bd35.json +++ b/.omc/sessions/427b0f1b-876b-4ce1-be27-adbaf9f7bd35.json @@ -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": [] } \ No newline at end of file diff --git a/.omc/state/agent-replay-17a7f260-0c2b-4628-b576-00051879584a.jsonl b/.omc/state/agent-replay-17a7f260-0c2b-4628-b576-00051879584a.jsonl new file mode 100644 index 0000000..b63385e --- /dev/null +++ b/.omc/state/agent-replay-17a7f260-0c2b-4628-b576-00051879584a.jsonl @@ -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} diff --git a/.omc/state/agent-replay-427b0f1b-876b-4ce1-be27-adbaf9f7bd35.jsonl b/.omc/state/agent-replay-427b0f1b-876b-4ce1-be27-adbaf9f7bd35.jsonl deleted file mode 100644 index d19088e..0000000 --- a/.omc/state/agent-replay-427b0f1b-876b-4ce1-be27-adbaf9f7bd35.jsonl +++ /dev/null @@ -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} diff --git a/.omc/state/idle-notif-cooldown.json b/.omc/state/idle-notif-cooldown.json index 9a7d747..8e04fa5 100644 --- a/.omc/state/idle-notif-cooldown.json +++ b/.omc/state/idle-notif-cooldown.json @@ -1,3 +1,3 @@ { - "lastSentAt": "2026-04-21T04:52:16.780Z" + "lastSentAt": "2026-04-21T12:33:30.508Z" } \ No newline at end of file diff --git a/.omc/state/last-tool-error.json b/.omc/state/last-tool-error.json index c7f9e95..a54c1ff 100644 --- a/.omc/state/last-tool-error.json +++ b/.omc/state/last-tool-error.json @@ -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 \"\", line 2, in \n File \"\", 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 } \ No newline at end of file diff --git a/.omc/state/mission-state.json b/.omc/state/mission-state.json index 26f1d85..1de79e2 100644 --- a/.omc/state/mission-state.json +++ b/.omc/state/mission-state.json @@ -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" } ] } diff --git a/.omc/state/subagent-tracking.json b/.omc/state/subagent-tracking.json index 8a8f379..cd46ede 100644 --- a/.omc/state/subagent-tracking.json +++ b/.omc/state/subagent-tracking.json @@ -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" } \ No newline at end of file diff --git a/gateway/scripts/bootstrap-lobechat-users.js b/gateway/scripts/bootstrap-lobechat-users.js new file mode 100755 index 0000000..b0a63be --- /dev/null +++ b/gateway/scripts/bootstrap-lobechat-users.js @@ -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/" 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://.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); +}); diff --git a/gateway/scripts/bootstrap-lobechat-users.sh b/gateway/scripts/bootstrap-lobechat-users.sh new file mode 100755 index 0000000..714a9d3 --- /dev/null +++ b/gateway/scripts/bootstrap-lobechat-users.sh @@ -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= 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." diff --git a/locales/zh-CN/welcome.json b/locales/zh-CN/welcome.json index 297ba12..0097224 100644 --- a/locales/zh-CN/welcome.json +++ b/locales/zh-CN/welcome.json @@ -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": "掌控节奏,从现在开始", @@ -309,4 +311,4 @@ "38": "继续保持好状态", "39": "记得休息一下" } -} +} \ No newline at end of file diff --git a/locales/zh-TW/welcome.json b/locales/zh-TW/welcome.json index fa6e69a..c31e426 100644 --- a/locales/zh-TW/welcome.json +++ b/locales/zh-TW/welcome.json @@ -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": "掌握節奏,從現在開始 🎵", @@ -309,4 +311,4 @@ "38": "繼續保持好狀態!", "39": "別忘了休息一下~ 💤" } -} +} \ No newline at end of file diff --git a/packages/database/src/models/agent.ts b/packages/database/src/models/agent.ts index cb9c4a4..bedca63 100644 --- a/packages/database/src/models/agent.ts +++ b/packages/database/src/models/agent.ts @@ -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), })); }; diff --git a/src/app/(backend)/webapi/enterprise/me/route.ts b/src/app/(backend)/webapi/enterprise/me/route.ts new file mode 100644 index 0000000..0cfa131 --- /dev/null +++ b/src/app/(backend)/webapi/enterprise/me/route.ts @@ -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 }, + ); + } +}; diff --git a/src/hooks/useEnterpriseRoles.ts b/src/hooks/useEnterpriseRoles.ts new file mode 100644 index 0000000..3eb7e1c --- /dev/null +++ b/src/hooks/useEnterpriseRoles.ts @@ -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 => { + 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('/webapi/enterprise/me', fetcher, { + dedupingInterval: 60_000, + revalidateOnFocus: false, + }); + return data ?? DEFAULT; +}; diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index 1f747f3..d635591 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -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.', diff --git a/src/locales/default/home.ts b/src/locales/default/home.ts index b8dfb80..d6ab795 100644 --- a/src/locales/default/home.ts +++ b/src/locales/default/home.ts @@ -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', }; diff --git a/src/routes/(main)/home/_layout/Body/InboxEntry.tsx b/src/routes/(main)/home/_layout/Body/InboxEntry.tsx index 6fb9e85..87eb820 100644 --- a/src/routes/(main)/home/_layout/Body/InboxEntry.tsx +++ b/src/routes/(main)/home/_layout/Body/InboxEntry.tsx @@ -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); diff --git a/src/routes/(main)/home/_layout/hooks/useCreateModal.tsx b/src/routes/(main)/home/_layout/hooks/useCreateModal.tsx index 490af7d..585c267 100644 --- a/src/routes/(main)/home/_layout/hooks/useCreateModal.tsx +++ b/src/routes/(main)/home/_layout/hooks/useCreateModal.tsx @@ -79,13 +79,14 @@ const Examples = memo(({ suggestMode, onExampleClick }) => { {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 ( ); diff --git a/src/routes/(main)/home/features/InputArea/StarterList.tsx b/src/routes/(main)/home/features/InputArea/StarterList.tsx index 1797079..2bbb595 100644 --- a/src/routes/(main)/home/features/InputArea/StarterList.tsx +++ b/src/routes/(main)/home/features/InputArea/StarterList.tsx @@ -82,7 +82,6 @@ const StarterList = memo(() => { titleKey: 'starter.imageGeneration', }, { - hot: true, icon: VideoIcon, key: 'video', titleKey: 'starter.videoGeneration', diff --git a/src/routes/(main)/home/features/SuggestQuestions/List.tsx b/src/routes/(main)/home/features/SuggestQuestions/List.tsx index e9677bc..76a360e 100644 --- a/src/routes/(main)/home/features/SuggestQuestions/List.tsx +++ b/src/routes/(main)/home/features/SuggestQuestions/List.tsx @@ -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(({ questions }) => { - const { t } = useTranslation('suggestQuestions'); - if (questions.length === 0) { return null; } @@ -21,14 +18,11 @@ const List = memo(({ questions }) => { return ( {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 ( - + ); })} diff --git a/src/routes/(main)/home/features/SuggestQuestions/useRandomQuestions.ts b/src/routes/(main)/home/features/SuggestQuestions/useRandomQuestions.ts index b698c15..fe3be99 100644 --- a/src/routes/(main)/home/features/SuggestQuestions/useRandomQuestions.ts +++ b/src/routes/(main)/home/features/SuggestQuestions/useRandomQuestions.ts @@ -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 = (array: T[]): T[] => { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { @@ -14,24 +47,26 @@ const shuffleArray = (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; } diff --git a/src/routes/(main)/home/features/WelcomeText/index.tsx b/src/routes/(main)/home/features/WelcomeText/index.tsx index 08a86ec..1742ae0 100644 --- a/src/routes/(main)/home/features/WelcomeText/index.tsx +++ b/src/routes/(main)/home/features/WelcomeText/index.tsx @@ -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; - return sample(Object.values(messages)); - }, [t]); - return (
{ marginBlock: '36px 24px', }} > - {sentence} + {'Enterprise AI Workspace'}
); }); diff --git a/src/routes/(main)/settings/hooks/useCategory.tsx b/src/routes/(main)/settings/hooks/useCategory.tsx index e94e126..8fe2ba7 100644 --- a/src/routes/(main)/settings/hooks/useCategory.tsx +++ b/src/routes/(main)/settings/hooks/useCategory.tsx @@ -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) && { - icon: Brain, - key: SettingsTabs.Provider, - label: t('tab.provider'), - }, - { + (!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, ]); diff --git a/src/server/routers/lambda/aiModel.ts b/src/server/routers/lambda/aiModel.ts index 468e3cb..b12b5ef 100644 --- a/src/server/routers/lambda/aiModel.ts +++ b/src/server/routers/lambda/aiModel.ts @@ -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(), diff --git a/src/server/routers/lambda/aiProvider.ts b/src/server/routers/lambda/aiProvider.ts index 1363d0b..8922242 100644 --- a/src/server/routers/lambda/aiProvider.ts +++ b/src/server/routers/lambda/aiProvider.ts @@ -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( diff --git a/src/server/services/enterpriseIdentity/guard.ts b/src/server/services/enterpriseIdentity/guard.ts new file mode 100644 index 0000000..3d5e4bc --- /dev/null +++ b/src/server/services/enterpriseIdentity/guard.ts @@ -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 => { + 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}`, + }); + } +}; diff --git a/src/server/services/enterpriseIdentity/index.ts b/src/server/services/enterpriseIdentity/index.ts new file mode 100644 index 0000000..ddacf2c --- /dev/null +++ b/src/server/services/enterpriseIdentity/index.ts @@ -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(); + +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 => { + 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));