Compare commits

..

27 Commits

Author SHA1 Message Date
RockChinQ 3d4a726cd8 chore: release v4.10.8 (#2450) 2026-08-21 01:47:39 +08:00
DongXiaoming e934f08adf fix(wecombot): deliver sandbox outbox media through full pipeline chain (#2328)
* fix(wecombot): align media upload protocol

* fix(wecombot): deliver outbox media in reply and fix tool call recording

- Integrate _send_media into reply_message and reply_message_chunk so
  sandbox outbox images/voices/files are uploaded and sent instead of
  being silently dropped.
- Add missing import base64 that caused _send_media to fail with a
  NameError swallowed by its except clause.
- Change yiri2target to return component dicts (text/image/voice/file)
  so callers can distinguish text from media.
- Fix _get_message_for_tool_context using result.first()/row[0] which
  returned a raw string instead of the ORM object, causing
  "'str' object has no attribute 'pipeline_id'" in tool call recording.
  Use result.scalars().first() per SQLAlchemy 2.0 convention.

* fix(pipeline): collect outbox attachments on final chunk with empty content

When the last streaming chunk has is_final=True but empty content
(e.g. the LLM sends all text in earlier chunks), the 'if result.content'
branch is skipped entirely, so _append_outbound_attachments never runs
and sandbox outbox images are silently dropped.

Add an elif branch for _is_final_assistant_message that creates an
empty MessageChain and still collects outbox attachments, so images
are delivered even when the final chunk carries no text.

* fix(box): bypass stdout truncation when reading outbox via exec

_read_outbox_via_exec used execute_tool which returns _serialize_result
where stdout is truncated to output_limit_chars (4000). A 7KB JPEG
encodes to ~9400 base64 chars, so the JSON payload was truncated and
json.loads failed silently, returning an empty list.

Call client.execute directly to get the raw BoxExecutionResult with
untruncated stdout, so base64 file data is preserved.

* fix(tests): adapt box and wrapper tests for client.execute and strict is_final check

- wrapper.py: restrict outbox collection on empty-content chunks to
  actual MessageChunk instances with is_final=True, not generic Mock
  objects that happen to have role='assistant'
- test_box_service.py: update _read_outbox_via_exec tests to mock
  client.execute (returning BoxExecutionResult) instead of
  execute_tool, matching the implementation change

* chore(wecombot): remove temporary upload log

* test(box): preserve direct outbox read and cleanup coverage

---------

Co-authored-by: fdc310 <2213070223@qq.com>
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-20 16:56:20 +08:00
Hyu 7803d56254 fix(plugin): keep pre-tenancy plugin storage readable (#2446)
* fix(plugin): adopt legacy scoped storage rows

* fix(plugin): delete adopted legacy storage rows

* test(plugin): cover legacy storage deletion

* fix(plugin): avoid mutating legacy storage on reads

* fix(plugin): make legacy storage adoption atomic

* fix(plugin): resolve concurrent legacy adoption

* fix(plugin): upsert concurrent storage writes

* fix(plugin): retry reads after legacy adoption

* fix(plugin): deduplicate migrated storage keys

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-17 11:49:26 +08:00
Hyu 54c96a18e1 test(migration): preserve legacy plugin storage payloads (#2444)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-17 10:31:03 +08:00
Hyu 579e3556e4 Merge pull request #2441 from langbot-app/fix/runtime-readonly-sdk-0.5.5
fix(runtime): adopt plugin SDK 0.5.5
2026-08-17 01:40:56 +08:00
dadachann a08a177a11 fix(runtime): adopt plugin SDK 0.5.5 2026-08-16 17:36:34 +00:00
Hyu c224f61c8c Merge pull request #2440 from langbot-app/fix/login-migration-20260817
fix(migrations): preserve legacy workspace ownership
2026-08-17 01:29:58 +08:00
dadachann b62cc9da45 fix(migrations): preserve legacy workspace ownership 2026-08-16 17:15:57 +00:00
Hyu 700104c015 Merge pull request #2439 from langbot-app/fix/recent-issues-20260817
fix recent migration and plugin command regressions
2026-08-17 01:04:41 +08:00
dadachann 717bd4b8bf fix(commands): pass trusted workspace scope to plugins 2026-08-16 17:01:02 +00:00
dadachann 0cc0e1b02d fix(migrations): retry backup reopen on bind mounts 2026-08-16 16:42:20 +00:00
Hyu 1f1a3aff55 fix(release): publish installable multi-arch artifacts (#2432)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-14 01:06:31 +08:00
Hyu de28b3160c docs(deploy): document optional runtime tokens (#2431)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-14 00:29:58 +08:00
Hyu 79773b669a fix(auth): allow callbacks from literally any origin (#2430)
* fix(auth): allow callbacks from any origin

* chore: retrigger repository checks

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-13 23:58:23 +08:00
Hyu 5084f2391d fix(auth): support dynamic OSS callbacks and LangBot Account copy (#2428)
* fix(auth): support dynamic OSS callbacks and LangBot Account copy

* style(web): format LangBot Account copy

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-13 23:18:01 +08:00
Hyu b121c66f18 Merge pull request #2425 from langbot-app/fix/pin-plugin-sdk-hotfix
fix: pin Plugin Runtime SDK 0.5.3
2026-08-13 18:44:49 +08:00
Chan 536534865e fix: pin plugin runtime SDK 0.5.3 2026-08-13 10:39:51 +00:00
Hyu ff528e8e0b Merge pull request #2424 from langbot-app/fix/pin-plugin-sdk-052
chore(runtime): pin plugin SDK 0.5.2
2026-08-13 17:36:18 +08:00
Chan f7914a8900 test: remove obsolete private deploy workflow assertion 2026-08-13 09:31:45 +00:00
Chan c18fc9dfe3 chore(runtime): pin plugin SDK 0.5.2 2026-08-13 09:27:50 +00:00
Hyu bc40418bda Merge pull request #2422 from langbot-app/fix/remove-cloud-v2-deploy
chore: remove private Cloud deployment configuration
2026-08-13 16:18:27 +08:00
Chan 45ed6efceb chore: remove private Cloud deployment configuration 2026-08-13 08:14:40 +00:00
Hyu c67a503532 feat(health): expose plugin runtime readiness (#2420)
Co-authored-by: Chan <dadachann@users.noreply.github.com>
2026-08-12 19:49:56 +08:00
Hyu 8b14d2ab8e fix(runtime): allow shared plugin reconcile to finish (#2419)
Co-authored-by: Chan <dadachann@users.noreply.github.com>
2026-08-12 19:31:23 +08:00
Hyu 97428310b9 fix(runtime): require Cloud control secrets (#2418)
Co-authored-by: Chan <dadachann@users.noreply.github.com>
2026-08-12 18:42:23 +08:00
Hyu 338ee733cb fix(deploy): preserve recovered Cloud adapter pin (#2417)
Co-authored-by: Chan <dadachann@users.noreply.github.com>
2026-08-12 18:06:34 +08:00
Hyu c3299fd1a6 fix(wizard): remove redundant Space CTA (#2416)
Co-authored-by: Chan <dadachann@users.noreply.github.com>
2026-08-12 16:56:25 +08:00
63 changed files with 1337 additions and 2208 deletions
-59
View File
@@ -1,59 +0,0 @@
name: Build and deploy production
on:
push:
branches: [deploy/prod]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: langbot-production
cancel-in-progress: false
env:
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
SPACE_REF: 58253c53933f95d81b035fbe2efedb55b6c1a82b
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build exact Core image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
${{ env.CORE_IMAGE }}:deploy-prod
cache-from: type=gha,scope=core-prod
cache-to: type=gha,mode=max,scope=core-prod
- name: Checkout production Cloud adapter
uses: actions/checkout@v4
with:
repository: langbot-app/langbot-space
ref: ${{ env.SPACE_REF }}
token: ${{ secrets.CLA_PAT }}
path: .space
- name: Build exact Cloud Core image
uses: docker/build-push-action@v6
with:
context: .space
file: .space/Dockerfile.cloud
push: true
build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
tags: |
${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }}
${{ env.CLOUD_IMAGE }}:deploy-prod
cache-from: type=gha,scope=cloud-core-prod
cache-to: type=gha,mode=max,scope=cloud-core-prod
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine AS node FROM --platform=$BUILDPLATFORM node:22-alpine AS node
WORKDIR /app WORKDIR /app
+1
View File
@@ -83,6 +83,7 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### One-Click Cloud Deploy ### One-Click Cloud Deploy
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
+1
View File
@@ -83,6 +83,7 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### 一键云部署 ### 一键云部署
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
+1
View File
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### Despliegue en la Nube con un Clic ### Despliegue en la Nube con un Clic
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
+1
View File
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### Déploiement Cloud en un Clic ### Déploiement Cloud en un Clic
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
+1
View File
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### ワンクリッククラウドデプロイ ### ワンクリッククラウドデプロイ
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
+1
View File
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### 원클릭 클라우드 배포 ### 원클릭 클라우드 배포
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
+1
View File
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### Облачное развертывание одним кликом ### Облачное развертывание одним кликом
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
+1
View File
@@ -84,6 +84,7 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### 一鍵雲端部署 ### 一鍵雲端部署
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
+1
View File
@@ -82,6 +82,7 @@ cd LangBot/docker
docker compose --profile all up -d docker compose --profile all up -d
``` ```
### Triển khai đám mây một cú nhấp ### Triển khai đám mây một cú nhấp
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd /opt/langbot-cloud-prod
TAG=${1:?usage: deploy.sh prod-<40-char-sha>}
[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; }
[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; }
rendered_compose=$(docker compose config)
grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || {
echo 'Cloud control-plane URL must be https://space.langbot.app' >&2
exit 4
}
grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || {
echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2
exit 5
}
grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || {
echo 'Cloud telemetry ingest token must be configured' >&2
exit 6
}
update_env() {
local key=$1 value=$2
python3 - "$key" "$value" <<'PY'
from pathlib import Path
import os
import sys
path = Path('.env')
key, value = sys.argv[1:]
lines = path.read_text().splitlines()
updated = False
for index, line in enumerate(lines):
if line.startswith(f'{key}='):
lines[index] = f'{key}={value}'
updated = True
break
if not updated:
lines.append(f'{key}={value}')
temporary = Path('.env.tmp')
temporary.write_text('\n'.join(lines) + '\n')
os.chmod(temporary, 0o600)
temporary.replace(path)
PY
}
update_env LANGBOT_IMAGE_TAG "$TAG"
set -a
. ./.env
set +a
: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}"
for attempt in 1 2 3 4 5; do
if docker compose pull postgres redis migrate plugin-runtime core; then
break
fi
if [ "$attempt" -eq 5 ]; then
echo "docker compose pull failed after $attempt attempts" >&2
exit 1
fi
delay=$((attempt * 10))
echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2
sleep "$delay"
done
docker compose up -d postgres redis
for _ in $(seq 1 60); do
if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi
sleep 2
done
docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \
-v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL'
SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec
ALTER ROLE langbot_runtime PASSWORD :'runtime_password';
GRANT CONNECT ON DATABASE langbot TO langbot_runtime;
REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime;
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime;
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime;
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime;
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime;
GRANT USAGE ON SCHEMA public TO langbot_runtime;
SQL
docker compose --profile tools run --rm migrate
docker compose up -d --remove-orphans plugin-runtime core
for _ in $(seq 1 90); do
if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then
docker compose ps
exit 0
fi
sleep 2
done
docker compose logs --tail=200 core plugin-runtime >&2
exit 1
-162
View File
@@ -1,162 +0,0 @@
services:
postgres:
image: pgvector/pgvector:pg17
container_name: langbot-cloud-postgres
restart: unless-stopped
environment:
POSTGRES_DB: langbot
POSTGRES_USER: langbot_operator
POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"]
interval: 5s
timeout: 5s
retries: 30
networks: [internal]
redis:
image: redis:7.4-alpine
container_name: langbot-cloud-redis
restart: unless-stopped
command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"]
volumes:
- redis-data:/data
healthcheck:
test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
interval: 5s
timeout: 5s
retries: 20
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
networks: [internal]
migrate:
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
profiles: [tools]
command: [uv, run, langbot, migrate, --cloud]
environment: &core-env
TZ: Asia/Shanghai
SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID}
SYSTEM__EDITION: cloud
SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY}
SYSTEM__JWT__SECRET: ${JWT_SECRET}
SYSTEM__LIMITATION__MAX_BOTS: "2"
SYSTEM__LIMITATION__MAX_PIPELINES: "3"
SYSTEM__LIMITATION__MAX_EXTENSIONS: "3"
SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2"
API__WEBHOOK_PREFIX: https://cloud.langbot.app
API__WEBUI_URL: https://cloud.langbot.app
WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app
DATABASE__USE: postgresql
DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot
DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN
LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot
VDB__USE: pgvector
VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true"
VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536"
PLUGIN__ENABLE: "true"
PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws
PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws
PLUGIN__WORKER__MAX_CPUS: "0.25"
PLUGIN__WORKER__MAX_MEMORY_MB: "256"
PLUGIN__WORKER__MAX_PIDS: "128"
PLUGIN__WORKER__MAX_WORKERS: "16"
PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0"
PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096"
PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true"
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
# Cloud v2 currently grants no managed Box capability. Keep the shared
# runtime deployed but disable Core integration until a hard-quota-capable
# backend can satisfy the fail-closed Cloud readiness contract.
BOX__ENABLED: "false"
BOX__BACKEND: nsjail
BOX__RUNTIME__ENDPOINT: ws://box:5410
BOX__ADMISSION__REQUIRED: "true"
BOX__ADMISSION__LOGICAL_SESSION_ID: global
BOX__ADMISSION__REQUIRED_BACKEND: nsjail
BOX__ADMISSION__MAX_SESSIONS: "1"
BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0"
BOX__ADMISSION__CPUS: "0.25"
BOX__ADMISSION__MEMORY_MB: "256"
BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256"
BOX__LOCAL__HOST_ROOT: /app/data/box
BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box
BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
MCP__STDIO__ENABLED: "false"
LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app
LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY}
LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID}
SPACE__URL: https://space.langbot.app
depends_on:
postgres: {condition: service_healthy}
networks: [internal]
plugin-runtime:
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-plugin-runtime
restart: unless-stopped
command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt]
environment:
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
volumes:
- plugin-data:/app/data
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroup: host
privileged: true
expose: ["5400"]
networks: [internal]
box:
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-box
restart: unless-stopped
command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"]
environment:
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
LANGBOT_BOX_ROOT: /app/data/box
volumes:
- box-data:/app/data/box
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroup: host
privileged: true
expose: ["5410"]
networks: [internal]
core:
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-core
restart: unless-stopped
environment: *core-env
volumes:
- core-data:/app/data
- box-data:/app/data/box
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
plugin-runtime: {condition: service_started}
box: {condition: service_started}
expose: ["5300"]
healthcheck:
test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
networks: [internal, shared-network]
networks:
internal:
shared-network:
external: true
volumes:
postgres-data:
redis-data:
plugin-data:
box-data:
core-data:
+7 -7
View File
@@ -47,11 +47,10 @@ services:
restart: on-failure restart: on-failure
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# Shared control-plane secret used to authenticate both the RPC socket # Optional shared control-plane secret used to authenticate both the RPC
# and managed-process relay. Generate once (for example with # socket and managed-process relay. Leave unset on both OSS services, or
# ``openssl rand -hex 32``) and export it before enabling this profile. # generate one with ``openssl rand -hex 32`` and set the same value on
# An empty value is accepted by Compose so Box can remain optional, but # both ends. Strongly recommended when the deployment is Internet-accessible.
# the Box runtime itself fails closed when the profile is started.
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-} - LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
# Box has its own process-wide blocking-work budget. # Box has its own process-wide blocking-work budget.
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8} - LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
@@ -79,8 +78,9 @@ services:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# Optional. Leave unset on both OSS services, or match plugin Runtime. # Optional. Leave unset on both OSS services, or match plugin Runtime.
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-} - LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
# Must match the value supplied to langbot_box. The token is sent only # When set, this must match langbot_box. If both ends leave it unset,
# in WebSocket handshake headers, never in URLs or action payloads. # OSS permits the connection without token authentication. The token is
# sent only in WebSocket handshake headers, never in URLs or payloads.
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-} - LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
# Core process-wide blocking-work admission. These are native config # Core process-wide blocking-work admission. These are native config
# overrides and are persisted with the effective data/config.yaml. # overrides and are persisted with the effective data/config.yaml.
+2 -3
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.7" version = "4.10.8"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -22,7 +22,6 @@ dependencies = [
"discord-py>=2.5.2", "discord-py>=2.5.2",
"pynacl>=1.5.0", # Required for Discord voice support "pynacl>=1.5.0", # Required for Discord voice support
"gewechat-client>=0.1.5", "gewechat-client>=0.1.5",
"itchat-uos>=1.5.0.dev",
"lark-oapi>=1.5.5", "lark-oapi>=1.5.5",
"mcp>=1.25.0,<2.0.0", "mcp>=1.25.0,<2.0.0",
"nakuru-project-idk>=0.0.2.1", "nakuru-project-idk>=0.0.2.1",
@@ -72,7 +71,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3", "pyseekdb==1.1.0.post3",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@7b559da430a50f80a7d30c9d3d66f088503ddbb3", "langbot-plugin==0.5.5",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2", "matrix-nio>=0.25.2",
@@ -46,6 +46,14 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg' CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg' CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
CMD_SEND_MSG = 'aibot_send_msg' CMD_SEND_MSG = 'aibot_send_msg'
# Media upload protocol (3 steps: init -> chunk * N -> finish). The
# command names below match the WeCom AI Bot long-connection protocol.
CMD_UPLOAD_INIT = 'aibot_upload_media_init'
CMD_UPLOAD_CHUNK = 'aibot_upload_media_chunk'
CMD_UPLOAD_FINISH = 'aibot_upload_media_finish'
# Default upload chunk size: 512 KB before base64 encoding.
_UPLOAD_CHUNK_SIZE = 512 * 1024
_DEDUP_CACHE_MAX = 4096 _DEDUP_CACHE_MAX = 4096
_STREAM_CACHE_MAX = 1024 _STREAM_CACHE_MAX = 1024
@@ -495,6 +503,145 @@ class WecomBotWsClient:
body['chatid'] = chat_id body['chatid'] = chat_id
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG) return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
# ------------------------------------------------------------------
# Media upload (image / voice / file)
# ------------------------------------------------------------------
async def upload_media(
self,
data: bytes,
filename: str = 'attachment',
media_type: str = 'file',
) -> Optional[dict]:
"""Upload *data* to the WeCom AI Bot CDN and return the parsed ACK.
Implements the three-step protocol documented for the WeCom
AI Bot:
1. ``aibot_upload_media_init`` — declare media type, file name,
size, MD5 and chunk count; receive ``upload_id``.
2. ``aibot_upload_media_chunk`` — send each chunk (base64-encoded
bytes) until done; receive per-chunk ACK.
3. ``aibot_upload_media_finish`` — finalize the upload; receive
``media_id``.
Returns a dict with the final ``media_id`` (and the raw
``finish`` ACK) on success, or ``None`` on any failure. The
caller is expected to ignore the result and continue
gracefully — the framework will keep working without media
delivery.
"""
import base64 as _b64
import hashlib as _hl
if not data:
return None
file_size = len(data)
file_md5 = _hl.md5(data).hexdigest()
total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE
if total_chunks == 0:
total_chunks = 1
# Step 1: init.
init_req_id = _generate_req_id(CMD_UPLOAD_INIT)
init_body = {
'type': media_type,
'filename': filename,
'total_size': file_size,
'total_chunks': total_chunks,
'md5': file_md5,
}
init_ack = await self._send_reply(
init_req_id,
init_body,
cmd=CMD_UPLOAD_INIT,
)
if not init_ack or init_ack.get('errcode', 0) != 0:
await self.logger.warning(f'upload_media init failed: ack={init_ack!r}')
return None
upload_id = (
init_ack.get('upload_id')
or init_ack.get('body', {}).get('upload_id')
or init_ack.get('data', {}).get('upload_id')
)
if not upload_id:
await self.logger.warning(f'upload_media init returned no upload_id: ack={init_ack!r}')
return None
# Step 2: chunks.
for index in range(total_chunks):
start = index * _UPLOAD_CHUNK_SIZE
end = min(start + _UPLOAD_CHUNK_SIZE, file_size)
chunk_bytes = data[start:end]
chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK)
chunk_body = {
'upload_id': upload_id,
'chunk_index': index,
'base64_data': _b64.b64encode(chunk_bytes).decode('ascii'),
}
chunk_ack = await self._send_reply(
chunk_req_id,
chunk_body,
cmd=CMD_UPLOAD_CHUNK,
)
if not chunk_ack or chunk_ack.get('errcode', 0) != 0:
await self.logger.warning(f'upload_media chunk {index} failed: ack={chunk_ack!r}')
return None
# Step 3: finish.
finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH)
finish_body = {'upload_id': upload_id}
finish_ack = await self._send_reply(
finish_req_id,
finish_body,
cmd=CMD_UPLOAD_FINISH,
)
if not finish_ack or finish_ack.get('errcode', 0) != 0:
await self.logger.warning(f'upload_media finish failed: ack={finish_ack!r}')
return None
media_id = (
finish_ack.get('media_id')
or finish_ack.get('body', {}).get('media_id')
or finish_ack.get('data', {}).get('media_id')
)
if not media_id:
await self.logger.warning(f'upload_media finish returned no media_id: ack={finish_ack!r}')
return None
return {'media_id': media_id, 'ack': finish_ack}
async def _reply_media(
self,
req_id: str,
media_id: str,
kind: str,
) -> Optional[dict]:
"""Send a media reply (image / voice / file) referencing *media_id*.
``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses
the standard ``aibot_respond_msg`` command with a per-kind
body key (matches the convention documented for the WeCom
AI Bot SDK).
"""
if kind not in {'image', 'voice', 'file'}:
await self.logger.warning(f'_reply_media called with unknown kind={kind!r}')
return None
body = {
'msgtype': kind,
kind: {'media_id': media_id},
}
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG)
async def reply_image(self, req_id: str, media_id: str) -> Optional[dict]:
return await self._reply_media(req_id, media_id, 'image')
async def reply_file(self, req_id: str, media_id: str) -> Optional[dict]:
return await self._reply_media(req_id, media_id, 'file')
async def reply_voice(self, req_id: str, media_id: str) -> Optional[dict]:
return await self._reply_media(req_id, media_id, 'voice')
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
"""Push a streaming chunk for a given message ID. """Push a streaming chunk for a given message ID.
@@ -1,7 +1,6 @@
import asyncio import asyncio
import dataclasses import dataclasses
import mimetypes import mimetypes
import os
import quart import quart
@@ -1134,224 +1133,3 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done(): if session and session.get('task') and not session['task'].done():
session['task'].cancel() session['task'].cancel()
return self.success(data={}) return self.success(data={})
# -----------------------------------------------------------------------
# Itchat WeChat QR Code Login
# -----------------------------------------------------------------------
_itchat_login_sessions: dict = {}
_ITCHAT_SESSION_TTL = 600 # 10 minutes (allows multiple QR regenerations)
def _cleanup_expired_itchat_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _itchat_login_sessions.items() if now - s.get('created_at', 0) > _ITCHAT_SESSION_TTL
]
for sid in expired:
session = _itchat_login_sessions.pop(sid, None)
if session:
core = session.get('core')
if core:
try:
core.alive = False
core.isLogging = False
except Exception:
pass
@self.route('/itchat/login', methods=['POST'])
async def _() -> str:
"""Start itchat WeChat QR code login. Returns session_id + QR code data URL."""
import uuid
import time
import base64
import threading
_cleanup_expired_itchat_sessions()
session_id = str(uuid.uuid4())
loop = asyncio.get_running_loop()
status_dir = os.path.join('data', 'itchat')
os.makedirs(status_dir, exist_ok=True)
qr_path = os.path.join(status_dir, f'{session_id}-QR.png')
session = {
'status': 'pending',
'qr_data_url': None,
'expire_at': None,
'nickname': None,
'error': None,
'created_at': time.time(),
'thread': None,
'logged_in': threading.Event(),
'core': None,
}
_itchat_login_sessions[session_id] = session
def _run_itchat_login():
try:
from itchat.core import Core
from itchat.content import TEXT as _TEXT
from langbot.pkg.platform.sources.itchat import ItchatAdapter
for f in (qr_path,):
try:
os.remove(f)
except OSError:
pass
_core = Core()
session['core'] = _core
def on_login():
try:
_core.get_friends(update=True)
user_info = _core.loginInfo.get('User', {})
nick = ItchatAdapter._get_obj_value(user_info, 'NickName', 'unknown')
wxid = ItchatAdapter._get_obj_value(user_info, 'UserName')
except Exception:
nick = 'unknown'
wxid = ''
session['nickname'] = nick
session['wxid'] = wxid
print(f'[itchat-login] Login success: {nick}', flush=True)
# Dump login status so the adapter can hot-reload it
try:
if not wxid:
raise ValueError('Unable to detect WeChat wxid after login')
account_status_path = ItchatAdapter.login_status_path_for_account(wxid)
_core.dump_login_status(account_status_path)
session['login_status_path'] = account_status_path
session['status'] = 'success'
print(f'[itchat-login] Session saved to {account_status_path}', flush=True)
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
print(f'[itchat-login] Failed to save session: {e}', flush=True)
finally:
session['logged_in'].set()
# Stop the message loop - we only needed the session for QR login
_core.alive = False
def on_qr(**kwargs):
qr_bytes = kwargs.get('qrcode', b'')
status = kwargs.get('status', '')
print(f'[itchat-login] QR callback: status={status}, bytes={len(qr_bytes)}', flush=True)
if status == '200':
return
# Only update QR image on new QR generation (status='0')
# or when status changes to '408' (timeout, QR may refresh)
if qr_bytes and status == '0':
b64 = base64.b64encode(qr_bytes).decode('utf-8')
def _update():
session['qr_data_url'] = f'data:image/png;base64,{b64}'
session['expire_at'] = time.time() + 120
session['status'] = 'waiting'
loop.call_soon_threadsafe(_update)
# Register a dummy text handler
@_core.msg_register([_TEXT])
def _dummy(msg):
pass
print('[itchat-login] Step 3: Calling auto_login...', flush=True)
_core.auto_login(
hotReload=False,
loginCallback=on_login,
qrCallback=on_qr,
)
print('[itchat-login] Step 4: auto_login returned, starting run...', flush=True)
_core.run(blockThread=True)
print('[itchat-login] Step 5: run() returned', flush=True)
except SystemExit as e:
print(f'[itchat-login] SystemExit: {e}', flush=True)
session['status'] = 'error'
session['error'] = f'itchat exited: {e}'
session['logged_in'].set()
except Exception as e:
import traceback
print(f'[itchat-login] Exception: {traceback.format_exc()}', flush=True)
session['status'] = 'error'
session['error'] = str(e)
session['logged_in'].set()
t = threading.Thread(target=_run_itchat_login, daemon=True)
t.start()
session['thread'] = t
# Wait for QR code to be ready (max 15 seconds)
for _ in range(30):
if session['qr_data_url'] or session['error'] or session['status'] == 'success':
break
await asyncio.sleep(0.5)
if session['error']:
return self.http_status(502, -1, session['error'])
if session['status'] == 'success':
return self.success(
data={
'session_id': session_id,
'status': 'success',
'nickname': session['nickname'],
'wxid': session.get('wxid', ''),
}
)
if not session['qr_data_url']:
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_data_url': session['qr_data_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/itchat/login/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll itchat login status."""
session = _itchat_login_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {
'status': session['status'],
'qr_data_url': session['qr_data_url'],
'expire_at': session['expire_at'],
}
if session['status'] == 'success':
data['nickname'] = session.get('nickname', '')
data['wxid'] = session.get('wxid', '')
_itchat_login_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_itchat_login_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/itchat/login/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up an itchat login session."""
session = _itchat_login_sessions.pop(session_id, None)
if session:
core = session.get('core')
if core:
try:
core.alive = False
core.isLogging = False
except Exception:
pass
thread = session.get('thread')
if thread and thread.is_alive():
# Thread is daemon, will die with the process
pass
return self.success(data={})
@@ -14,13 +14,6 @@ from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrati
@group.group_class('user', '/api/v1/user') @group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup): class UserRouterGroup(group.RouterGroup):
@staticmethod
def _origin(value: str) -> tuple[str, str, int | None] | None:
parsed = urlsplit(value)
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
return None
return parsed.scheme, parsed.hostname.casefold(), parsed.port
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str: def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
parsed = urlsplit(redirect_uri) parsed = urlsplit(redirect_uri)
if ( if (
@@ -38,17 +31,8 @@ class UserRouterGroup(group.RouterGroup):
if query != {'mode': ['bind']}: if query != {'mode': ['bind']}:
raise ValueError('Invalid Space binding redirect_uri') raise ValueError('Invalid Space binding redirect_uri')
elif query: elif query:
raise ValueError('Invalid Space login redirect_uri') raise ValueError('Invalid LangBot Account login redirect_uri')
redirect_origin = self._origin(redirect_uri)
api_config = self.ap.instance_config.data.get('api', {})
trusted_origins = {
self._origin(str(api_config.get(config_key, '') or '').strip())
for config_key in ('webui_url', 'webhook_prefix')
}
trusted_origins.discard(None)
if redirect_origin not in trusted_origins:
raise ValueError('Untrusted redirect_uri origin')
return redirect_uri return redirect_uri
async def initialize(self) -> None: async def initialize(self) -> None:
@@ -416,7 +400,7 @@ class UserRouterGroup(group.RouterGroup):
'Bind the LangBot Account with the same email as this local Account', 'Bind the LangBot Account with the same email as this local Account',
) )
except ValueError: except ValueError:
return self.http_status(400, -1, 'Space account binding failed') return self.http_status(400, -1, 'LangBot Account binding failed')
except Exception: except Exception:
raise raise
-2
View File
@@ -69,8 +69,6 @@ class BotService:
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid) runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is not None: if runtime_bot is not None:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
if hasattr(runtime_bot.adapter, 'get_runtime_status'):
adapter_runtime_values['runtime_status'] = runtime_bot.adapter.get_runtime_status()
# Webhook URL for unified webhook adapters (independent of bot running state) # Webhook URL for unified webhook adapters (independent of bot running state)
if persistence_bot['adapter'] in [ if persistence_bot['adapter'] in [
+4 -4
View File
@@ -114,7 +114,7 @@ class UserService:
if purpose == 'login' and account_uuid is not None: if purpose == 'login' and account_uuid is not None:
raise ValueError('Login state cannot be bound to an Account') raise ValueError('Login state cannot be bound to an Account')
if purpose != 'login' and launch_workspace_uuid is not None: if purpose != 'login' and launch_workspace_uuid is not None:
raise ValueError('Launch Workspace state is only valid for Space login') raise ValueError('Launch Workspace state is only valid for LangBot Account login')
if ttl_seconds <= 0: if ttl_seconds <= 0:
raise ValueError('OAuth state lifetime must be positive') raise ValueError('OAuth state lifetime must be positive')
@@ -327,7 +327,7 @@ class UserService:
normalized_email = normalize_email(user_email) normalized_email = normalize_email(user_email)
if self._uses_control_plane_directory(): if self._uses_control_plane_directory():
raise ControlPlaneDirectoryRequiredError( raise ControlPlaneDirectoryRequiredError(
'Cloud invitation registration must use a Space account to preserve control-plane identity' 'Cloud invitation registration must use a LangBot Account to preserve control-plane identity'
) )
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token) invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
if invitation.normalized_email != normalized_email: if invitation.normalized_email != normalized_email:
@@ -394,7 +394,7 @@ class UserService:
# Check if this user has a local password set # Check if this user has a local password set
if not user_obj.password: if not user_obj.password:
raise ValueError('请使用 Space登录') raise ValueError('请使用 LangBot登录')
await self._verify_password(user_obj.password, password) await self._verify_password(user_obj.password, password)
@@ -825,7 +825,7 @@ class UserService:
# Check if this Space account is already bound to another user # Check if this Space account is already bound to another user
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid) existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email): if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
raise ValueError('This Space account is already bound to another user') raise ValueError('This LangBot Account is already bound to another user')
# Update local account to Space account # Update local account to Space account
normalized_email = normalize_email(user_email) normalized_email = normalize_email(user_email)
+4
View File
@@ -368,6 +368,10 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
if not self._control_token and allow_generate: if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48) self._control_token = secrets.token_urlsafe(48)
if not self._control_token: if not self._control_token:
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
raise BoxRuntimeUnavailableError(
f'{BOX_CONTROL_TOKEN_ENV} must be configured with a strong shared secret for a Cloud Box runtime'
)
return '' return ''
try: try:
self._control_token = validate_control_token(self._control_token) self._control_token = validate_control_token(self._control_token)
+17 -8
View File
@@ -1210,8 +1210,9 @@ class BoxService:
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]: async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
"""Fallback: read the outbox over the exec channel (E2B / remote). """Fallback: read the outbox over the exec channel (E2B / remote).
Note: exec stdout is truncated by ``output_limit_chars``, so this path Uses ``client.execute`` directly (bypassing ``_serialize_result``)
only reliably transfers small files. The host path is preferred. so stdout is NOT truncated by ``output_limit_chars`` - the raw
base64 payload can be far larger than the 4000-char display limit.
""" """
import json as _json import json as _json
@@ -1265,14 +1266,22 @@ class BoxService:
' break\n' ' break\n'
'print(json.dumps(out))\n' 'print(json.dumps(out))\n'
) )
result = await self.execute_tool( spec_payload: dict = {
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120}, 'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
query, 'timeout_sec': 120,
) 'session_id': self.resolve_box_session_id(query),
if not result.get('ok'): }
if 'extra_mounts' not in spec_payload:
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
try:
spec = self.build_spec(spec_payload)
result = await self.client.execute(spec)
except Exception:
return []
if not result.ok:
return [] return []
try: try:
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1]) return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
except Exception: except Exception:
return [] return []
+9 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import typing import typing
import inspect import inspect
from ..api.http.context import ExecutionContext
from ..core import app from ..core import app
from . import operator from . import operator
from ..utils import importutil from ..utils import importutil
@@ -66,7 +67,14 @@ class CommandManager:
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None) require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
if require_context is not None: if require_context is not None:
result = require_context(context) result = require_context(
ExecutionContext(
instance_uuid=context.instance_uuid,
workspace_uuid=context.workspace_uuid,
placement_generation=context.placement_generation,
query_uuid=context.query_uuid,
)
)
if inspect.isawaitable(result): if inspect.isawaitable(result):
await result await result
+4
View File
@@ -249,6 +249,10 @@ class Application:
{}, {},
) )
), ),
'plugin_runtime_connected': bool(
self.plugin_connector is not None
and getattr(self.plugin_connector, '_runtime_available', lambda: False)()
),
} }
mcp_loader = getattr(self.tool_mgr, 'mcp_tool_loader', None) mcp_loader = getattr(self.tool_mgr, 'mcp_tool_loader', None)
runtime_stats.update( runtime_stats.update(
+1 -1
View File
@@ -17,4 +17,4 @@ class SpaceAccountBindingRequiredError(AccountEmailMismatchError):
code = 'space_account_binding_required' code = 'space_account_binding_required'
def __str__(self) -> str: def __str__(self) -> str:
return 'This local Account must bind Space from Account settings before Space login' return 'This local account must bind a LangBot Account from Account settings before LangBot Account login'
+12 -1
View File
@@ -177,7 +177,6 @@ class PersistenceManager:
await self._validate_cloud_runtime() await self._validate_cloud_runtime()
return return
self._enable_sqlite_foreign_keys()
if self.mode == PersistenceMode.RELEASE_MIGRATION: if self.mode == PersistenceMode.RELEASE_MIGRATION:
async with self._release_migration_lock(): async with self._release_migration_lock():
await self._initialize_managed_schema() await self._initialize_managed_schema()
@@ -185,6 +184,7 @@ class PersistenceManager:
return return
await self._initialize_managed_schema() await self._initialize_managed_schema()
await self._enable_sqlite_foreign_keys_after_migration()
if self.mode == PersistenceMode.OSS_COMPAT: if self.mode == PersistenceMode.OSS_COMPAT:
await self.write_space_model_providers() await self.write_space_model_providers()
@@ -373,6 +373,17 @@ class PersistenceManager:
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope) sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
self._oss_tenant_scope_listener_installed = True self._oss_tenant_scope_listener_installed = True
async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
"""Enable SQLite FK enforcement only after table-rebuilding migrations."""
engine = self.get_db_engine()
if engine.dialect.name != 'sqlite':
return
await engine.dispose()
self._enable_sqlite_foreign_keys()
# Dispose again so every runtime connection is opened through the new
# listener instead of reusing a pre-migration pooled connection.
await engine.dispose()
def _enable_sqlite_foreign_keys(self) -> None: def _enable_sqlite_foreign_keys(self) -> None:
"""Enable SQLite FK enforcement for every pooled runtime connection.""" """Enable SQLite FK enforcement for every pooled runtime connection."""
engine = self.get_db_engine() engine = self.get_db_engine()
@@ -12,6 +12,7 @@ import re
import secrets import secrets
import sqlite3 import sqlite3
import tempfile import tempfile
import time
import typing import typing
from sqlalchemy.ext.asyncio import AsyncEngine from sqlalchemy.ext.asyncio import AsyncEngine
@@ -117,8 +118,19 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
temporary_path.unlink(missing_ok=True) temporary_path.unlink(missing_ok=True)
def _fsync_file(path: pathlib.Path) -> None: def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
descriptor = os.open(path, os.O_RDONLY) """Sync a file, tolerating delayed visibility after replace on bind mounts."""
descriptor: int | None = None
for attempt in range(reopen_attempts):
try:
descriptor = os.open(path, os.O_RDONLY)
break
except FileNotFoundError:
if attempt + 1 >= reopen_attempts:
raise
time.sleep(0.05)
assert descriptor is not None
try: try:
os.fsync(descriptor) os.fsync(descriptor)
finally: finally:
@@ -158,6 +158,18 @@ class ResponseWrapper(stage.PipelineStage):
result_type=entities.ResultType.CONTINUE, result_type=entities.ResultType.CONTINUE,
new_query=query, new_query=query,
) )
elif (
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
):
# Final streaming chunk with no text content but
# possibly carrying sandbox outbox attachments.
reply_chain = platform_message.MessageChain([])
await self._append_outbound_attachments(query, reply_chain)
query.resp_message_chain.append(reply_chain)
yield entities.StageProcessResult(
result_type=entities.ResultType.CONTINUE,
new_query=query,
)
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用 if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
function_names = [tc.function.name for tc in result.tool_calls] function_names = [tc.function.name for tc in result.tool_calls]
-771
View File
@@ -1,771 +0,0 @@
"""itchat-uos adapter for LangBot.
Uses the itchat-uos WeChat Web library to integrate personal WeChat accounts
with LangBot via QR code login.
Reference: https://github.com/littlecodersh/ItChat
UOS fork: https://github.com/why2lyj/ItChat-uos
"""
from __future__ import annotations
import asyncio
import base64
import os
import re
import tempfile
import threading
import time
import traceback
import typing
from itchat.content import TEXT, PICTURE, RECORDING, VIDEO, SHARING
import pydantic
from itchat.core import Core as ItchatCore
try:
import queue
except ImportError:
import Queue as queue
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.logger import EventLogger
class ItchatMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Converts between LangBot MessageChain and itchat message dicts."""
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain,
) -> list[dict]:
"""LangBot MessageChain -> list of itchat-sendable items.
Each item is a dict with 'type' and the relevant content field.
The adapter's send_message() will call itchat.send() accordingly.
"""
items: list[dict] = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
if component.text:
items.append({'type': 'text', 'content': component.text})
elif isinstance(component, platform_message.Image):
if component.base64:
items.append({'type': 'image', 'base64': component.base64})
elif component.url:
items.append({'type': 'image', 'url': component.url})
elif isinstance(component, platform_message.Voice):
if component.base64:
items.append({'type': 'voice', 'base64': component.base64})
elif component.url:
items.append({'type': 'voice', 'url': component.url})
elif isinstance(component, platform_message.File):
if component.base64:
items.append({'type': 'file', 'base64': component.base64, 'name': component.name or 'file'})
elif component.url:
items.append({'type': 'file', 'url': component.url, 'name': component.name or 'file'})
elif isinstance(component, platform_message.At):
items.append({'type': 'text', 'content': f'@{component.target} '})
elif isinstance(component, platform_message.AtAll):
items.append({'type': 'text', 'content': '@所有人 '})
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
items.extend(await ItchatMessageConverter.yiri2target(node.message_chain))
elif isinstance(component, platform_message.Unknown):
pass # skip unknown outbound
return items
@staticmethod
def target2yiri(msg: dict) -> platform_message.MessageChain:
"""Convert an itchat msg dict to a LangBot MessageChain."""
components: list[platform_message.MessageComponent] = []
msg_type = msg.get('Type', '')
if msg_type == 'Text':
text = msg.get('Text', '')
if text:
components.append(platform_message.Plain(text=text))
elif msg_type == 'Picture':
try:
temp_dir = tempfile.gettempdir()
file_path = os.path.join(temp_dir, msg.get('FileName', 'image.jpg'))
msg.download(file_path)
if os.path.exists(file_path):
with open(file_path, 'rb') as f:
img_bytes = f.read()
b64 = base64.b64encode(img_bytes).decode('utf-8')
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
os.remove(file_path)
else:
components.append(platform_message.Unknown(text='[Image download failed]'))
except Exception:
components.append(platform_message.Unknown(text='[Image download failed]'))
elif msg_type == 'Recording':
try:
temp_dir = tempfile.gettempdir()
file_path = os.path.join(temp_dir, msg.get('FileName', 'voice.mp3'))
msg.download(file_path)
if os.path.exists(file_path):
with open(file_path, 'rb') as f:
voice_bytes = f.read()
b64 = base64.b64encode(voice_bytes).decode('utf-8')
components.append(platform_message.Voice(base64=b64))
os.remove(file_path)
else:
components.append(platform_message.Unknown(text='[Voice download failed]'))
except Exception:
components.append(platform_message.Unknown(text='[Voice download failed]'))
elif msg_type == 'Sharing':
text = msg.get('Text', '')
url = msg.get('Url', '')
content = text
if url and url not in text:
content = f'{text}\n{url}' if text else url
if content:
components.append(platform_message.Plain(text=content))
elif msg_type == 'Video':
components.append(platform_message.Unknown(text='[Video]'))
elif msg_type == 'Map':
components.append(platform_message.Unknown(text='[Location]'))
elif msg_type == 'Card':
components.append(platform_message.Unknown(text='[Contact Card]'))
elif msg_type == 'Note':
text = msg.get('Text', '')
if text:
components.append(platform_message.Unknown(text=f'[Note: {text}]'))
else:
text = msg.get('Text', '')
if text:
components.append(platform_message.Plain(text=text))
else:
components.append(platform_message.Unknown(text=f'[Unsupported message type: {msg_type}]'))
return platform_message.MessageChain(components)
class ItchatEventConverter(abstract_platform_adapter.AbstractEventConverter):
"""Converts itchat msg dicts to LangBot events."""
def __init__(self, adapter_ref: typing.Callable[[], typing.Any]):
"""adapter_ref is a callable returning the ItchatAdapter instance."""
self._get_adapter = adapter_ref
@staticmethod
async def yiri2target(event: platform_events.MessageEvent) -> dict:
return event.source_platform_object
def target2yiri(self, msg: dict) -> typing.Optional[platform_events.MessageEvent]:
"""Convert itchat msg to FriendMessage or GroupMessage."""
from_user = msg.get('FromUserName', '')
if not from_user:
return None
adapter = self._get_adapter()
bot_account_id = adapter.bot_account_id
bot_nickname = adapter._bot_nickname
# Ignore the bot's own messages to avoid reply loops
bot_user_name = getattr(adapter._core.storageClass, 'userName', '')
if from_user == bot_account_id or (bot_user_name and from_user == bot_user_name):
return None
message_chain = ItchatMessageConverter.target2yiri(msg)
if not message_chain:
return None
# Determine if this is a group message
# itchat uses '@@' prefix for chatroom IDs (not '@chatroom' suffix)
is_group = from_user.startswith('@@')
timestamp = msg.get('CreateTime', 0)
if is_group:
# Actual sender within the group
actual_user = msg.get('ActualUserName', '')
actual_nick = msg.get('ActualNickName', '') or actual_user
if not actual_user:
return None
# Prepend @bot if the bot was mentioned
# itchat uses 'IsAt' (capital I, capital A) in produce_group_chat
if msg.get('IsAt', False):
# Strip @bot_nickname from the text content to avoid LLM confusion
if bot_nickname:
at_re = re.compile(re.escape('@' + bot_nickname) + r'[ ]?')
for component in message_chain:
if isinstance(component, platform_message.Plain):
component.text = at_re.sub('', component.text, count=1)
break
message_chain = platform_message.MessageChain(
[platform_message.At(target=bot_account_id)] + list(message_chain)
)
# Try to get group display name
group_obj = msg.get('User', {})
group_name = ''
if hasattr(group_obj, 'NickName'):
group_name = group_obj.NickName
elif isinstance(group_obj, dict):
group_name = group_obj.get('NickName', '')
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=actual_user,
member_name=actual_nick,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=from_user,
name=group_name or from_user,
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=timestamp,
source_platform_object=msg,
)
else:
# Private / friend message
user_obj = msg.get('User', {})
sender_nick = adapter._get_obj_value(user_obj, 'NickName')
sender_remark = adapter._get_obj_value(user_obj, 'RemarkName')
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=from_user,
nickname=sender_nick or from_user,
remark=sender_remark,
),
message_chain=message_chain,
time=timestamp,
source_platform_object=msg,
)
class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""LangBot adapter for itchat-uos (WeChat Web)."""
name: str = 'itchat'
config: dict
logger: EventLogger
message_converter: ItchatMessageConverter
event_converter: ItchatEventConverter
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_loop: typing.Optional[asyncio.AbstractEventLoop] = pydantic.PrivateAttr(default=None)
_logged_in: typing.Optional[threading.Event] = pydantic.PrivateAttr(default=None)
_itchat_thread: typing.Optional[threading.Thread] = pydantic.PrivateAttr(default=None)
_core: typing.Optional[ItchatCore] = pydantic.PrivateAttr(default=None)
_bot_nickname: str = pydantic.PrivateAttr(default='')
_bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None)
_startup_error: typing.Optional[str] = pydantic.PrivateAttr(default=None)
_connection_status: str = pydantic.PrivateAttr(default='disconnected')
_connection_error: str = pydantic.PrivateAttr(default='')
_last_connected_at: typing.Optional[float] = pydantic.PrivateAttr(default=None)
_last_disconnected_at: typing.Optional[float] = pydantic.PrivateAttr(default=None)
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
message_converter = ItchatMessageConverter()
# Event converter needs a reference to self for bot_account_id + nickname
event_converter = ItchatEventConverter(adapter_ref=lambda: self)
super().__init__(
config=config,
logger=logger,
message_converter=message_converter,
event_converter=event_converter,
listeners={},
bot_account_id='',
)
# Initialize private attributes (can't be class-level defaults due to pickle)
self._loop = None
self._logged_in = threading.Event()
self._itchat_thread = None
self._core = ItchatCore()
self._startup_error = None
self._connection_status = 'disconnected'
self._connection_error = ''
self._last_connected_at = None
self._last_disconnected_at = None
@staticmethod
def _get_obj_value(obj: typing.Any, key: str, default: str = '') -> str:
if isinstance(obj, dict):
return obj.get(key, default) or default
return getattr(obj, key, default) or default
@staticmethod
def _safe_status_name(value: str) -> str:
cleaned = re.sub(r'[^A-Za-z0-9_.@-]+', '_', value.strip())
cleaned = cleaned.strip('._')
return cleaned
@staticmethod
def login_status_dir() -> str:
path = os.path.join('data', 'itchat')
os.makedirs(path, exist_ok=True)
return path
@classmethod
def login_status_path_for_account(cls, account_id: str) -> str:
safe_name = cls._safe_status_name(account_id)
if not safe_name:
raise ValueError('account_id is required for itchat login status')
filename = f'{safe_name}.pkl'
return os.path.join(cls.login_status_dir(), filename)
def _login_status_path(self) -> str:
configured_path = self.config.get('login_status_path', '').strip()
if configured_path:
return configured_path
account_id = self.config.get('account_id', '').strip()
if not account_id:
raise ValueError('account_id is required. Please scan the QR code and save this bot first.')
return self.login_status_path_for_account(account_id)
def set_bot_uuid(self, bot_uuid: str):
self._bot_uuid = bot_uuid
def _set_connection_status(self, status: str, error: str = ''):
self._connection_status = status
self._connection_error = error
now = time.time()
if status == 'connected':
self._last_connected_at = now
elif status in {'disconnected', 'error'}:
self._last_disconnected_at = now
def get_runtime_status(self) -> dict:
return {
'connection_status': self._connection_status,
'connection_error': self._connection_error,
'last_connected_at': self._last_connected_at,
'last_disconnected_at': self._last_disconnected_at,
}
def _on_login(self):
"""Called by itchat after successful QR code login."""
try:
# Refresh contacts
self._core.get_friends(update=True)
self._core.get_chatrooms(update=True)
# Get bot's own WeChat info from loginInfo['User']
user_info = self._core.loginInfo.get('User', {})
nick_name = self._get_obj_value(user_info, 'NickName')
user_name = self._get_obj_value(user_info, 'UserName')
# bot_account_id: config override or auto-detected wxid
# Used by AtBotRule for matching At.target
configured_id = self.config.get('account_id', '').strip()
self.bot_account_id = configured_id or user_name or nick_name or 'itchat-bot'
# _bot_nickname: config override or auto-detected nickname
configured_nick = self.config.get('nickname', '').strip()
self._bot_nickname = configured_nick or nick_name
self._set_connection_status('connected')
try:
chatrooms = self._core.search_chatrooms() or []
group_names = []
for c in chatrooms:
name = self._get_obj_value(c, 'NickName', str(c))
if name:
group_names.append(name)
if group_names:
self._log_sync(
f'itchat login as {nick_name} ({user_name}) | Groups ({len(group_names)}): {", ".join(group_names[:10])}{"..." if len(group_names) > 10 else ""}'
)
else:
self._log_sync(f'itchat login as {nick_name} ({user_name}) | No groups found')
except Exception as e:
self._log_sync(f'itchat login as {nick_name} ({user_name}) | Failed to list groups: {e}', 'warning')
except Exception as e:
self.bot_account_id = f'WeChat Bot (Error: {e})'
self._set_connection_status('error', str(e))
finally:
self._logged_in.set()
def _log_sync(self, msg: str, level: str = 'info'):
"""Thread-safe logging from itchat's sync thread."""
try:
if self._loop and not self._loop.is_closed():
log_fn = getattr(self.logger, level)
asyncio.run_coroutine_threadsafe(log_fn(msg), self._loop)
except Exception:
pass
def _drain_msglist(self):
"""Clear all stale messages from the msgList queue.
itchat's load_login_status fetches old messages via get_msg() and
pushes them into msgList. We drain them to avoid replaying history.
"""
try:
q = self._core.msgList
while True:
q.get_nowait()
except queue.Empty:
pass
def _on_qr_callback(self, **kwargs):
"""Called by itchat when QR code is generated or status changes.
Args:
uuid: QR code uuid
status: '200' = logged in, '201' = confirmed on phone, '408' = timeout
qrcode: raw bytes of the QR code PNG image
"""
status = kwargs.get('status', '')
qr_bytes = kwargs.get('qrcode', b'')
if status == '200':
# Login success, no need to show QR
return
# Only show QR on new QR generation (status='0') to avoid spamming
if not qr_bytes or status != '0':
return
try:
b64 = base64.b64encode(qr_bytes).decode('utf-8')
if self._loop and not self._loop.is_closed():
asyncio.run_coroutine_threadsafe(
self.logger.info(
'Please scan the QR code to login WeChat:',
images=[platform_message.Image(base64=f'data:image/png;base64,{b64}')],
),
self._loop,
)
except Exception:
pass
def _on_exit(self):
"""Called by itchat on exit."""
self._set_connection_status('disconnected')
self._log_sync('itchat session exited')
def _register_itchat_handlers(self):
"""Register itchat message decorators by re-registering handlers."""
@self._core.msg_register([TEXT])
def _on_text(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([TEXT], isGroupChat=True)
def _on_group_text(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([PICTURE])
def _on_picture(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([PICTURE], isGroupChat=True)
def _on_group_picture(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([RECORDING])
def _on_recording(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([RECORDING], isGroupChat=True)
def _on_group_recording(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([SHARING])
def _on_sharing(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([SHARING], isGroupChat=True)
def _on_group_sharing(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([VIDEO])
def _on_video(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([VIDEO], isGroupChat=True)
def _on_group_video(msg):
self._dispatch_itchat_message(msg)
def _dispatch_itchat_message(self, msg: dict):
"""Bridge itchat callback (sync, in itchat thread) to async listener."""
try:
event = self.event_converter.target2yiri(msg)
if event is None:
return
event_type = type(event)
if event_type in self.listeners and self._loop:
callback = self.listeners[event_type]
asyncio.run_coroutine_threadsafe(
callback(event, self),
self._loop,
)
except Exception:
self._log_sync(f'Error dispatching itchat message: {traceback.format_exc()}', 'error')
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
):
"""Send a message to a user or group via itchat."""
items = await self.message_converter.yiri2target(message)
loop = asyncio.get_event_loop()
# Merge consecutive text items to avoid splitting messages
merged = []
for item in items:
if item['type'] == 'text' and merged and merged[-1]['type'] == 'text':
merged[-1]['content'] += item['content']
else:
merged.append(item)
for item in merged:
try:
if item['type'] == 'text':
await loop.run_in_executor(None, self._core.send, item['content'], target_id)
elif item['type'] == 'image':
# Save to temp file then send
temp_path = self._save_to_temp(item, 'image')
if temp_path:
await loop.run_in_executor(None, self._core.send, f'@img@{temp_path}', target_id)
self._cleanup_temp(temp_path)
elif item['type'] == 'voice':
temp_path = self._save_to_temp(item, 'voice')
if temp_path:
await loop.run_in_executor(None, self._core.send, f'@fil@{temp_path}', target_id)
self._cleanup_temp(temp_path)
elif item['type'] == 'file':
temp_path = self._save_to_temp(item, 'file')
if temp_path:
await loop.run_in_executor(None, self._core.send, f'@fil@{temp_path}', target_id)
self._cleanup_temp(temp_path)
except Exception:
await self.logger.error(f'Failed to send itchat message: {traceback.format_exc()}')
def _save_to_temp(self, item: dict, prefix: str) -> typing.Optional[str]:
"""Save base64 or URL data to a temp file and return the path."""
try:
if 'base64' in item:
b64_data = item['base64']
# Strip data URI prefix if present
if ',' in b64_data:
b64_data = b64_data.split(',', 1)[1]
file_bytes = base64.b64decode(b64_data)
suffix = '.jpg' if prefix == 'image' else ('.mp3' if prefix == 'voice' else '.bin')
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix=f'itchat_{prefix}_')
with os.fdopen(fd, 'wb') as f:
f.write(file_bytes)
return temp_path
elif 'url' in item:
import requests
resp = requests.get(item['url'], timeout=30)
if resp.status_code == 200:
suffix = '.jpg' if prefix == 'image' else ('.mp3' if prefix == 'voice' else '.bin')
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix=f'itchat_{prefix}_')
with os.fdopen(fd, 'wb') as f:
f.write(resp.content)
return temp_path
except Exception:
self._log_sync(f'Failed to save temp file: {traceback.format_exc()}', 'error')
return None
def _cleanup_temp(self, path: str):
"""Remove a temp file."""
try:
if os.path.exists(path):
os.remove(path)
except OSError:
pass
def _prepare_reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
) -> platform_message.MessageChain:
"""Render group sender mentions with display names while keeping internal IDs stable."""
if not isinstance(message_source, platform_events.GroupMessage):
return message
source_msg = message_source.source_platform_object or {}
actual_user = source_msg.get('ActualUserName', '')
actual_nick = source_msg.get('ActualNickName', '')
if not actual_user or not actual_nick:
return message
components: list[platform_message.MessageComponent] = []
changed = False
for component in message:
if isinstance(component, platform_message.At) and str(component.target) == str(actual_user):
components.append(platform_message.Plain(text=f'@{actual_nick} '))
changed = True
else:
components.append(component)
if not changed:
return message
return platform_message.MessageChain(components)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
"""Reply to a received message."""
source_msg = message_source.source_platform_object
if not source_msg:
return
# For group messages, reply to the group; for private, reply to the sender
from_user = source_msg.get('FromUserName', '')
if not from_user:
return
await self.send_message('friend', from_user, self._prepare_reply_message(message_source, message))
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners.pop(event_type, None)
async def run_async(self):
"""Start the itchat adapter.
If an account-specific cached session file exists from a previous QR login,
itchat will reuse data/itchat/<account_id>.pkl without requiring a new QR scan.
"""
self._loop = asyncio.get_running_loop()
self._logged_in.clear()
self._startup_error = None
self._set_connection_status('connecting')
await self.logger.info('itchat adapter starting...')
# Register itchat message handlers BEFORE calling itchat.auto_login()
self._register_itchat_handlers()
# Run itchat in a daemon thread (it blocks)
def _run_itchat():
try:
status_path = self._login_status_path()
if not os.path.exists(status_path):
self._startup_error = (
f'No cached WeChat session found at {status_path}. '
'Please scan the QR code in the bot config page first.'
)
self._set_connection_status('error', self._startup_error)
self._log_sync(self._startup_error, 'error')
self._logged_in.set()
return
# Use hotReload to reuse the cached session from QR login
# If no cache exists, fail fast instead of triggering QR login
result = self._core.load_login_status(
status_path, loginCallback=self._on_login, exitCallback=self._on_exit
)
if result.get('BaseResponse', {}).get('Ret') != 0:
self._startup_error = (
f'Cached WeChat session at {status_path} is invalid. '
'Please scan the QR code in the bot config page again.'
)
self._set_connection_status('error', self._startup_error)
self._log_sync(self._startup_error, 'error')
self._logged_in.set()
return
# Session loaded, start message loop
self._log_sync(f'WeChat session loaded from cache: {status_path}')
# Clear stale messages that itchat fetched during hot-reload
self._drain_msglist()
self._core.run(blockThread=True)
self._set_connection_status('disconnected')
self._log_sync('itchat message loop stopped', 'error')
except Exception as e:
error = f'itchat run error: {e}'
self._set_connection_status('error', error)
self._log_sync(error, 'error')
self._logged_in.set()
self._itchat_thread = threading.Thread(target=_run_itchat, daemon=True, name='itchat-thread')
self._itchat_thread.start()
# Wait for login to complete (with timeout)
await asyncio.get_event_loop().run_in_executor(None, lambda: self._logged_in.wait(timeout=300))
if not self._logged_in.is_set():
raise RuntimeError('itchat login timed out (300s)')
if self._startup_error:
raise RuntimeError(self._startup_error)
await self.logger.info(f'itchat adapter running, bot: {self.bot_account_id}')
# Keep the adapter alive
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
async def kill(self) -> bool:
"""Stop the itchat adapter."""
try:
self._core.alive = False
self._core.isLogging = False
except Exception:
pass
self._set_connection_status('disconnected')
await self.logger.info('itchat adapter stopped')
return True
@@ -1,75 +0,0 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: itchat
label:
en_US: Itchat WeChat
zh_Hans: 个人微信 (itchat)
zh_Hant: 個人微信 (itchat)
ja_JP: 個人WeChat (itchat)
description:
en_US: Personal WeChat adapter via itchat-uos, supports QR code login and text/image/voice messages
zh_Hans: 基于 itchat-uos 的个人微信适配器,扫码登录,支持文本/图片/语音消息
zh_Hant: 基於 itchat-uos 的個人微信適配器,掃碼登入,支援文字/圖片/語音訊息
icon: wechat.png
spec:
categories:
- china
help_links:
zh: https://github.com/littlecodersh/ItChat
en: https://github.com/littlecodersh/ItChat
config:
- name: qr-login
label:
en_US: Scan QR Login
zh_Hans: 扫码登录
zh_Hant: 掃碼登入
description:
en_US: Scan QR code with WeChat to login. The session will be cached for the adapter to reuse.
zh_Hans: 使用微信扫码登录,登录状态将被缓存供适配器复用
zh_Hant: 使用微信掃碼登入,登入狀態將被快取供適配器復用
type: qr-code-login
login_platform: itchat
required: false
- name: account_id
label:
en_US: Bot Account ID
zh_Hans: 机器人账号标识
zh_Hant: 機器人帳號標識
ja_JP: ボットアカウントID
description:
en_US: Auto-filled after QR login with the WeChat wxid. Used for @-mention matching and to load data/itchat/<account_id>.pkl; do not change it to a nickname.
zh_Hans: 扫码登录后自动填入微信 wxid。用于群聊 @ 匹配,并加载 data/itchat/<account_id>.pkl;不要改成昵称。
zh_Hant: 掃碼登入後自動填入微信 wxid。用於群聊 @ 匹配,並載入 data/itchat/<account_id>.pkl;不要改成暱稱。
ja_JP: QRログイン後にWeChatのwxidが自動入力されます。@メンション判定と data/itchat/<account_id>.pkl の読み込みに使うため、ニックネームへ変更しないでください。
type: string
required: true
default: ""
- name: nickname
label:
en_US: Bot Nickname
zh_Hans: 机器人昵称
zh_Hant: 機器人暱稱
description:
en_US: The display nickname of the bot. Used to strip @nickname from incoming group messages. Auto-filled after QR login.
zh_Hans: 机器人的微信昵称。用于删除群聊消息中的 @昵称 前缀。扫码登录后自动填入
zh_Hant: 機器人的微信暱稱。用於刪除群聊訊息中的 @暱稱 前綴。掃碼登入後自動填入
type: string
required: false
default: ""
- name: hot_reload
label:
en_US: Hot Reload
zh_Hans: 登录缓存
zh_Hant: 登入快取
description:
en_US: Persist login session to avoid repeated QR code scans on restart
zh_Hans: 保存登录状态到本地,重启后无需重新扫码
zh_Hant: 儲存登入狀態到本機,重啟後無需重新掃碼
type: boolean
required: false
default: true
execution:
python:
path: ./itchat.py
attr: ItchatAdapter
+107 -19
View File
@@ -3,8 +3,10 @@ import typing
import asyncio import asyncio
import time import time
import traceback import traceback
import base64
import datetime import datetime
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.events as platform_events
@@ -24,11 +26,24 @@ from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod @staticmethod
async def yiri2target(message_chain: platform_message.MessageChain): async def yiri2target(message_chain: platform_message.MessageChain):
content = '' """Convert a MessageChain into a list of component dicts.
Each dict has a ``type`` key (``'text'``, ``'image'``,
``'voice'``, ``'file'``). Text items carry ``text``; media
items carry ``base64`` (may include a ``data:...;base64,``
prefix) and optionally ``name``.
"""
items: list[dict] = []
for msg in message_chain: for msg in message_chain:
if type(msg) is platform_message.Plain: if type(msg) is platform_message.Plain:
content += msg.text items.append({'type': 'text', 'text': msg.text})
return content elif type(msg) is platform_message.Image:
items.append({'type': 'image', 'base64': msg.base64 or ''})
elif type(msg) is platform_message.Voice:
items.append({'type': 'voice', 'base64': msg.base64 or ''})
elif type(msg) is platform_message.File:
items.append({'type': 'file', 'base64': msg.base64 or '', 'name': msg.name or ''})
return items
@staticmethod @staticmethod
async def target2yiri(event: WecomBotEvent, bot_name: str = ''): async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
@@ -362,13 +377,76 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
} }
) )
@staticmethod
def _join_text_components(items: list[dict]) -> str:
"""Concatenate ``text`` items in order, leaving media items alone."""
return ''.join(item['text'] for item in items if item.get('type') == 'text')
@staticmethod
def _iter_media_components(items: list[dict]):
"""Yield non-text items in order."""
for item in items:
if item.get('type') in {'image', 'voice', 'file'}:
yield item
@staticmethod
async def _send_media(
bot,
req_id: str,
item: dict,
) -> bool:
"""Upload *item* to the WeCom AI Bot CDN and send it as a media reply.
Returns True on success. Falls back to a no-op (with a warning log)
if the SDK does not yet implement ``upload_media`` /
``reply_image`` / ``reply_file`` / ``reply_voice`` the framework
will keep working, just without image delivery.
"""
kind = item.get('type')
upload = getattr(bot, 'upload_media', None)
if upload is None:
return False
b64_text = item.get('base64') or ''
if not b64_text:
return False
if b64_text.startswith('data:') and ',' in b64_text:
b64_text = b64_text.split(',', 1)[1]
try:
data = base64.b64decode(b64_text, validate=False)
except Exception:
return False
if not data:
return False
try:
upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind)
except Exception:
return False
media_id = getattr(upload_result, 'media_id', None) or (
isinstance(upload_result, dict) and upload_result.get('media_id')
)
if not media_id:
return False
reply_fn = {
'image': getattr(bot, 'reply_image', None),
'file': getattr(bot, 'reply_file', None),
'voice': getattr(bot, 'reply_voice', None),
}.get(kind)
if reply_fn is None:
return False
try:
await reply_fn(req_id, media_id)
return True
except Exception:
return False
async def reply_message( async def reply_message(
self, self,
message_source: platform_events.MessageEvent, message_source: platform_events.MessageEvent,
message: platform_message.MessageChain, message: platform_message.MessageChain,
quote_origin: bool = False, quote_origin: bool = False,
): ):
content = await self.message_converter.yiri2target(message) items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)
event = message_source.source_platform_object event = message_source.source_platform_object
@@ -382,7 +460,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else: else:
chat_id = str(message_source.sender.id) chat_id = str(message_source.sender.id)
try: try:
await self.bot.send_message(chat_id, content) await self.bot.send_message(chat_id, text)
except Exception: except Exception:
await self.logger.error( await self.logger.error(
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}' f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
@@ -396,12 +474,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if _ws_mode: if _ws_mode:
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '') req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
if req_id: if text:
await self.bot.reply_text(req_id, content) if req_id:
else: await self.bot.reply_text(req_id, text)
await self.bot.set_message(event.message_id, content) else:
await self.bot.set_message(event.message_id, text)
for item in self._iter_media_components(items):
await self._send_media(self.bot, req_id, item)
else: else:
await self.bot.set_message(event.message_id, content) await self.bot.set_message(event.message_id, text)
async def reply_message_chunk( async def reply_message_chunk(
self, self,
@@ -411,7 +492,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
quote_origin: bool = False, quote_origin: bool = False,
is_final: bool = False, is_final: bool = False,
): ):
content = await self.message_converter.yiri2target(message) items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)
# Synthetic events (e.g. button-click triggered form resume) have # Synthetic events (e.g. button-click triggered form resume) have
@@ -420,7 +502,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# of the stream/reply path. # of the stream/reply path.
spo = message_source.source_platform_object spo = message_source.source_platform_object
if spo is None: if spo is None:
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode) return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode)
msg_id = spo.message_id msg_id = spo.message_id
@@ -452,7 +534,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data.get('actions', []) or [], form_data.get('actions', []) or [],
) )
except Exception: except Exception:
fallback = content or '(人工输入)' fallback = text or '(人工输入)'
if _ws_mode: if _ws_mode:
event = message_source.source_platform_object event = message_source.source_platform_object
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '') req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
@@ -463,17 +545,22 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return {'stream': False, 'form': True, 'fallback': True} return {'stream': False, 'form': True, 'fallback': True}
if _ws_mode: if _ws_mode:
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
if not success and is_final: if not success and is_final:
event = message_source.source_platform_object event = message_source.source_platform_object
req_id = event.get('req_id', '') req_id = event.get('req_id', '')
if req_id: if req_id:
await self.bot.reply_text(req_id, content) await self.bot.reply_text(req_id, text)
if is_final:
event = message_source.source_platform_object
req_id = event.get('req_id', '')
for item in self._iter_media_components(items):
await self._send_media(self.bot, req_id, item)
return {'stream': success} return {'stream': success}
else: else:
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
if not success and is_final: if not success and is_final:
await self.bot.set_message(msg_id, content) await self.bot.set_message(msg_id, text)
return {'stream': success} return {'stream': success}
async def is_stream_output_supported(self) -> bool: async def is_stream_output_supported(self) -> bool:
@@ -627,8 +714,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def send_message(self, target_type, target_id, message): async def send_message(self, target_type, target_id, message):
_ws_mode = not self.config.get('enable-webhook', False) _ws_mode = not self.config.get('enable-webhook', False)
if _ws_mode: if _ws_mode:
content = await self.message_converter.yiri2target(message) items = await self.message_converter.yiri2target(message)
await self.bot.send_message(target_id, content) text = self._join_text_components(items)
await self.bot.send_message(target_id, text)
else: else:
pass pass
+5
View File
@@ -264,6 +264,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self._control_token and allow_generate: if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48) self._control_token = secrets.token_urlsafe(48)
if not self._control_token: if not self._control_token:
if self.runtime_profile == 'shared':
raise PluginRuntimeNotConnectedError(
f'{PLUGIN_RUNTIME_CONTROL_TOKEN_ENV} must be configured with a strong shared secret '
'for a Cloud Plugin Runtime'
)
return {} return {}
try: try:
self._control_token = validate_runtime_secret( self._control_token = validate_runtime_secret(
+118 -14
View File
@@ -11,6 +11,8 @@ import traceback
from dataclasses import dataclass from dataclasses import dataclass
import sqlalchemy import sqlalchemy
import sqlalchemy.dialects.postgresql
import sqlalchemy.dialects.sqlite
from langbot_plugin.runtime.io import handler from langbot_plugin.runtime.io import handler
from langbot_plugin.runtime.io.connection import Connection from langbot_plugin.runtime.io.connection import Connection
@@ -431,6 +433,19 @@ class RuntimeConnectionHandler(handler.Handler):
return f'{identity.plugin_author}/{identity.plugin_name}' return f'{identity.plugin_author}/{identity.plugin_name}'
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}') raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
@staticmethod
def _legacy_binary_storage_key(
action_context: ActionContext,
*,
owner_type: str,
owner: str,
key: str,
) -> str:
"""Return the pre-tenancy key shape for a row already scoped to this Workspace."""
legacy_owner = action_context.workspace_uuid if owner_type == 'workspace' else owner
return f'{owner_type}:{legacy_owner}:{key}'
@classmethod @classmethod
def _binary_storage_key( def _binary_storage_key(
cls, cls,
@@ -896,25 +911,82 @@ class RuntimeConnectionHandler(handler.Handler):
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key) .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
) )
storage = result.first()
if storage is None:
legacy_key = self._legacy_binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
storage = result.first()
if storage is not None:
update_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(unique_key=unique_key, value=value)
)
if update_result.rowcount:
return handler.ActionResponse.success(data={})
canonical_update = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(value=value)
)
if canonical_update.rowcount:
return handler.ActionResponse.success(data={})
storage = None
if result.first() is not None: if storage is not None:
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bstorage.BinaryStorage) sqlalchemy.update(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key) .where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
.values(value=value) .values(value=value)
) )
else: return handler.ActionResponse.success(data={})
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values( dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
workspace_uuid=action_context.workspace_uuid, insert = {
unique_key=unique_key, 'postgresql': sqlalchemy.dialects.postgresql.insert,
key=key, 'sqlite': sqlalchemy.dialects.sqlite.insert,
owner_type=owner_type, }.get(dialect_name)
owner=owner, if insert is None:
value=value, return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}')
) await self.ap.persistence_mgr.execute_async(
insert(persistence_bstorage.BinaryStorage)
.values(
workspace_uuid=action_context.workspace_uuid,
unique_key=unique_key,
key=key,
owner_type=owner_type,
owner=owner,
value=value,
) )
.on_conflict_do_update(
index_elements=['workspace_uuid', 'unique_key'],
set_={'value': value},
)
)
return handler.ActionResponse.success( return handler.ActionResponse.success(
data={}, data={},
@@ -946,6 +1018,29 @@ class RuntimeConnectionHandler(handler.Handler):
) )
storage = result.first() storage = result.first()
if storage is None:
legacy_key = self._legacy_binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
)
storage = result.first()
if storage is None:
retry_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
storage = retry_result.first()
if storage is None: if storage is None:
return handler.ActionResponse.error( return handler.ActionResponse.error(
message=f'Storage with key {key} not found', message=f'Storage with key {key} not found',
@@ -981,10 +1076,19 @@ class RuntimeConnectionHandler(handler.Handler):
message=str(e), message=str(e),
) )
legacy_key = self._legacy_binary_storage_key(
action_context,
owner_type=owner_type,
owner=owner,
key=key,
)
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bstorage.BinaryStorage) sqlalchemy.delete(persistence_bstorage.BinaryStorage)
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid) .where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key) .where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key)))
.where(persistence_bstorage.BinaryStorage.key == key)
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
.where(persistence_bstorage.BinaryStorage.owner == owner)
) )
return handler.ActionResponse.success( return handler.ActionResponse.success(
@@ -1012,7 +1116,7 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.success( return handler.ActionResponse.success(
data={ data={
'keys': result.scalars().all(), 'keys': list(dict.fromkeys(result.scalars().all())),
}, },
) )
@@ -1579,7 +1683,7 @@ class RuntimeConnectionHandler(handler.Handler):
return await self.call_action( return await self.call_action(
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS, LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
request.model_dump(), request.model_dump(),
timeout=120, timeout=300,
) )
async def apply_plugin_installation( async def apply_plugin_installation(
+42 -25
View File
@@ -165,34 +165,51 @@ async def test_bind_state_is_account_bound_and_requires_authentication(space_oau
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api): async def test_redirect_allows_any_http_or_https_origin(space_oauth_api):
_, client = space_oauth_api _, client = space_oauth_api
wrong_origin = await client.get( responses = [
'/api/v1/user/space/authorize-url', await client.get(
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'}, '/api/v1/user/space/authorize-url',
headers={'Origin': 'http://localhost'}, query_string={'redirect_uri': redirect_uri},
) headers={'Origin': 'https://irrelevant.example'},
wrong_path = await client.get( )
'/api/v1/user/space/authorize-url', for redirect_uri in (
query_string={'redirect_uri': 'http://localhost/arbitrary'}, 'https://langbot.example/auth/space/callback',
headers={'Origin': 'http://localhost'}, 'https://gateway.example:8443/auth/space/callback',
) 'https://192.0.2.10/auth/space/callback',
forged_origin = await client.get( 'http://localhost:5300/auth/space/callback',
'/api/v1/user/space/authorize-url', 'http://127.0.0.1:5300/auth/space/callback',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'}, 'http://[::1]:5300/auth/space/callback',
headers={'Origin': 'https://evil.example'}, 'http://langbot.example/auth/space/callback',
) 'http://192.0.2.10:5300/auth/space/callback',
forged_host = await client.get( )
'/api/v1/user/space/authorize-url', ]
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Host': 'evil.example'},
)
assert (await wrong_origin.get_json())['code'] == 1 assert all(response.status_code == 200 for response in responses)
assert (await wrong_path.get_json())['code'] == 1 payloads = [await response.get_json() for response in responses]
assert (await forged_origin.get_json())['code'] == 1 assert all(payload['code'] == 0 for payload in payloads)
assert (await forged_host.get_json())['code'] == 1
@pytest.mark.asyncio
async def test_redirect_rejects_invalid_callback_shape(space_oauth_api):
_, client = space_oauth_api
responses = [
await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': redirect_uri},
)
for redirect_uri in (
'https://langbot.example/arbitrary',
'https://langbot.example/auth/space/callback?next=https://evil.example',
'https://user@langbot.example/auth/space/callback',
'https://langbot.example/auth/space/callback#fragment',
)
]
payloads = [await response.get_json() for response in responses]
assert all(payload['code'] == 1 for payload in payloads)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -81,6 +81,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
sa.Column('key', sa.String(255), nullable=False), sa.Column('key', sa.String(255), nullable=False),
sa.Column('owner_type', sa.String(255), nullable=False), sa.Column('owner_type', sa.String(255), nullable=False),
sa.Column('owner', sa.String(255), nullable=False), sa.Column('owner', sa.String(255), nullable=False),
sa.Column('value', sa.LargeBinary, nullable=False),
) )
mcp_servers = _uuid_table( mcp_servers = _uuid_table(
metadata, metadata,
@@ -210,7 +211,13 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now)) await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner')) await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
await conn.execute( await conn.execute(
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo') binary_storages.insert().values(
unique_key='plugin:demo:key',
key='key',
owner_type='plugin',
owner='demo',
value=b'legacy-plugin-value',
)
) )
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now)) await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai')) await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
@@ -76,6 +76,26 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
) )
assert legacy_kb['collection_id'] == 'collection-1' assert legacy_kb['collection_id'] == 'collection-1'
assert legacy_kb['legacy_vector_collection'] == 1 assert legacy_kb['legacy_vector_collection'] == 1
legacy_binary_storage = (
(
await conn.execute(
sa.text(
'SELECT workspace_uuid, unique_key, key, owner_type, owner, value '
"FROM binary_storages WHERE owner_type = 'plugin' AND owner = 'demo'"
)
)
)
.mappings()
.one()
)
assert legacy_binary_storage == {
'workspace_uuid': workspace_uuid,
'unique_key': 'plugin:demo:key',
'key': 'key',
'owner_type': 'plugin',
'owner': 'demo',
'value': b'legacy-plugin-value',
}
assert ( assert (
await conn.scalar( await conn.scalar(
sa.text( sa.text(
@@ -209,8 +229,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
await conn.execute( await conn.execute(
sa.text( sa.text(
'INSERT INTO binary_storages ' 'INSERT INTO binary_storages '
'(workspace_uuid, unique_key, key, owner_type, owner) ' '(workspace_uuid, unique_key, key, owner_type, owner, value) '
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')" "VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
), ),
{'workspace_uuid': second_workspace_uuid}, {'workspace_uuid': second_workspace_uuid},
) )
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import logging import logging
import os
import pathlib import pathlib
import sqlite3 import sqlite3
@@ -9,7 +10,7 @@ import pytest
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.persistence import alembic_runner from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
from langbot.pkg.persistence.mgr import PersistenceManager from langbot.pkg.persistence.mgr import PersistenceManager
from .resource_migration_support import create_legacy_resource_schema from .resource_migration_support import create_legacy_resource_schema
@@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head() assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
finally: finally:
await engine.dispose() await engine.dispose()
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
database_path = tmp_path / 'legacy-bind-mount.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
real_open = os.open
transient_failures = 0
def transient_open(path, flags, *args, **kwargs):
nonlocal transient_failures
candidate = pathlib.Path(path)
if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0:
transient_failures += 1
raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate))
return real_open(path, flags, *args, **kwargs)
try:
await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount')
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open)
await _manager(engine)._run_alembic_migrations()
assert transient_failures == 1
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2
finally:
await engine.dispose()
@@ -179,13 +179,17 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
) )
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(schema.create_all) await conn.run_sync(schema.create_all)
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
await conn.execute( await conn.execute(
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"), sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
)
await conn.execute(
sa.text(
"INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"
),
{'uuid': old_workspace_uuid, 'instance': instance_id}, {'uuid': old_workspace_uuid, 'instance': instance_id},
) )
await conn.execute( await conn.execute(
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"), sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
{'uuid': old_workspace_uuid}, {'uuid': old_workspace_uuid},
) )
await run_alembic_stamp(engine, '0016_support_admin_sessions') await run_alembic_stamp(engine, '0016_support_admin_sessions')
@@ -193,8 +197,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
await run_alembic_upgrade(engine, 'head') await run_alembic_upgrade(engine, 'head')
async with engine.connect() as conn: async with engine.connect() as conn:
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
await engine.dispose() await engine.dispose()
@@ -411,6 +415,45 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
await engine.dispose() await engine.dispose()
async def test_persistence_startup_preserves_legacy_workspace_membership_with_foreign_keys(
tmp_path,
monkeypatch,
):
database_path = tmp_path / 'startup-foreign-keys.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
try:
await _create_legacy_schema(engine)
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
finally:
await engine.dispose()
monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test')
application = type('Application', (), {})()
application.logger = logging.getLogger('workspace-startup-foreign-keys-test')
application.instance_config = type(
'InstanceConfig',
(),
{'data': {'database': {'use': 'sqlite', 'sqlite': {'path': str(database_path)}}}},
)()
manager = PersistenceManager(application)
await manager.initialize()
try:
async with manager.get_db_engine().connect() as conn:
workspace = (
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
)
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
foreign_keys = await conn.scalar(sa.text('PRAGMA foreign_keys'))
assert workspace['created_by_account_uuid'] == membership['account_uuid']
assert membership['role'] == 'owner'
assert membership['status'] == 'active'
assert foreign_keys == 1
finally:
await manager.shutdown()
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path): async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}') engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
try: try:
@@ -425,7 +468,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
assert instance_uuid assert instance_uuid
await conn.execute( await conn.execute(
sa.text( sa.text(
"INSERT INTO workspace_metadata (workspace_uuid, key, value) " 'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
"VALUES (:workspace_uuid, 'migration_probe', 'present')" "VALUES (:workspace_uuid, 'migration_probe', 'present')"
), ),
{'workspace_uuid': old_uuid}, {'workspace_uuid': old_uuid},
@@ -433,7 +476,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
await conn.execute( await conn.execute(
sa.text( sa.text(
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) " "INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value" 'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
), ),
{'workspace_uuid': old_uuid}, {'workspace_uuid': old_uuid},
) )
@@ -442,12 +485,16 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
expected_uuid = workspace_uuid_from_instance_id(instance_uuid) expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
async with engine.connect() as conn: async with engine.connect() as conn:
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
assert await conn.scalar( assert (
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'") await conn.scalar(
) == expected_uuid sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
assert await conn.scalar( )
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'") == expected_uuid
) == expected_uuid )
assert (
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
== expected_uuid
)
finally: finally:
await engine.dispose() await engine.dispose()
@@ -377,7 +377,7 @@ class TestUserServiceAuthenticate:
service = UserService(ap) service = UserService(ap)
# Execute & Verify # Execute & Verify
with pytest.raises(ValueError, match='请使用 Space登录'): with pytest.raises(ValueError, match='请使用 LangBot登录'):
await service.authenticate('space@example.com', 'password') await service.authenticate('space@example.com', 'password')
@@ -726,7 +726,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
) )
service = UserService(ap) service = UserService(ap)
with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'): with pytest.raises(ControlPlaneDirectoryRequiredError, match='LangBot Account'):
await service.register_invited_account('invite-token', 'member@example.com', 'password') await service.register_invited_account('invite-token', 'member@example.com', 'password')
async def test_create_or_update_new_space_user_first_init(self): async def test_create_or_update_new_space_user_first_init(self):
+10 -1
View File
@@ -24,7 +24,7 @@ from langbot.pkg.box.connector import BoxRuntimeConnector
_CONTROL_TOKEN = 'box-control-token-that-is-longer-than-32-bytes' _CONTROL_TOKEN = 'box-control-token-that-is-longer-than-32-bytes'
def make_app(logger: Mock, runtime_endpoint: str = ''): def make_app(logger: Mock, runtime_endpoint: str = '', *, cloud: bool = False):
return SimpleNamespace( return SimpleNamespace(
logger=logger, logger=logger,
workspace_service=SimpleNamespace(instance_uuid='instance-a'), workspace_service=SimpleNamespace(instance_uuid='instance-a'),
@@ -42,6 +42,7 @@ def make_app(logger: Mock, runtime_endpoint: str = ''):
} }
} }
), ),
deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
) )
@@ -315,6 +316,14 @@ def test_external_box_runtime_control_headers_are_tokenless_when_secret_is_unset
assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'} assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'}
def test_cloud_box_runtime_rejects_missing_control_secret(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410', cloud=True))
with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
connector.get_control_headers()
def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypatch: pytest.MonkeyPatch): def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, 'too-short') monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, 'too-short')
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410')) connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
+48 -18
View File
@@ -2163,25 +2163,38 @@ class TestInboundOutboundRoundTrip:
calls = [] calls = []
async def fake_execute_tool(parameters, q): async def fake_client_execute(spec):
calls.append(parameters['command']) cmd = spec.cmd
if 'os.scandir' in parameters['command']: calls.append(cmd)
return { if 'os.scandir' in cmd:
'ok': True, return BoxExecutionResult(
'stdout': '[{"name": "out.png", "b64": "QUJD"}]', session_id='s',
'stderr': '', backend_name='test',
} status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='[{"name": "out.png", "b64": "QUJD"}]',
duration_ms=10,
)
# the rm -rf cleanup call # the rm -rf cleanup call
return {'ok': True, 'stdout': '', 'stderr': ''} return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='',
duration_ms=10,
)
service.execute_tool = AsyncMock(side_effect=fake_execute_tool) service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
attachments = await service.collect_outbound_attachments(query) attachments = await service.collect_outbound_attachments(query)
assert len(attachments) == 1 assert len(attachments) == 1
assert attachments[0]['type'] == 'Image' assert attachments[0]['type'] == 'Image'
assert attachments[0]['name'] == 'out.png' assert attachments[0]['name'] == 'out.png'
# cleanup (rm -rf) must have been issued after a successful collection # cleanup (rm -rf) must have been issued after a successful collection
assert any('rm -rf' in c for c in calls) service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_collect_outbound_empty_still_clears(self): async def test_collect_outbound_empty_still_clears(self):
@@ -2193,16 +2206,33 @@ class TestInboundOutboundRoundTrip:
calls = [] calls = []
async def fake_execute_tool(parameters, q): async def fake_client_execute(spec):
calls.append(parameters['command']) cmd = spec.cmd
if 'os.scandir' in parameters['command']: calls.append(cmd)
return {'ok': True, 'stdout': '[]', 'stderr': ''} if 'os.scandir' in cmd:
return {'ok': True, 'stdout': '', 'stderr': ''} return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='[]',
duration_ms=10,
)
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='',
duration_ms=10,
)
service.execute_tool = AsyncMock(side_effect=fake_execute_tool) service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
assert await service.collect_outbound_attachments(query) == [] assert await service.collect_outbound_attachments(query) == []
# cleanup (rm -rf) is issued unconditionally now # cleanup (rm -rf) is issued unconditionally now
assert any('rm -rf' in c for c in calls) service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_passthrough_noop_when_unavailable(self): async def test_passthrough_noop_when_unavailable(self):
+27
View File
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock
from langbot.pkg.command import operator from langbot.pkg.command import operator
from langbot.pkg.command.cmdmgr import CommandManager from langbot.pkg.command.cmdmgr import CommandManager
from langbot.pkg.api.http.context import ExecutionContext
from tests.factories import FakeApp, command_query from tests.factories import FakeApp, command_query
import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.session as provider_session
@@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute:
assert len(results) == 1 assert len(results) == 1
assert results[0].text == 'plugin response' assert results[0].text == 'plugin response'
@pytest.mark.asyncio
async def test_execute_selects_workspace_with_trusted_context(self):
"""Plugin command discovery receives the typed runtime scope."""
fake_app = FakeApp()
mgr = CommandManager(fake_app)
mgr.cmd_list = []
fake_app.plugin_connector.require_workspace_context = AsyncMock()
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
ctx = self._create_context(command='help')
ctx.instance_uuid = 'instance-a'
ctx.workspace_uuid = 'workspace-a'
ctx.placement_generation = 4
ctx.query_uuid = 'query-a'
async for _ in mgr._execute(ctx, mgr.cmd_list):
pass
selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0]
assert isinstance(selected, ExecutionContext)
assert selected.instance_uuid == 'instance-a'
assert selected.workspace_uuid == 'workspace-a'
assert selected.placement_generation == 4
assert selected.query_uuid == 'query-a'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_with_bound_plugins(self): async def test_execute_with_bound_plugins(self):
"""_execute passes bound_plugins to plugin connector.""" """_execute passes bound_plugins to plugin connector."""
+5 -1
View File
@@ -87,7 +87,10 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
app.platform_mgr = SimpleNamespace(_bots_by_key={}) app.platform_mgr = SimpleNamespace(_bots_by_key={})
app.pipeline_mgr = SimpleNamespace(_pipelines_by_key={}) app.pipeline_mgr = SimpleNamespace(_pipelines_by_key={})
app.rag_mgr = SimpleNamespace(knowledge_bases={}) app.rag_mgr = SimpleNamespace(knowledge_bases={})
app.plugin_connector = SimpleNamespace(_known_desired_states={'installation': object()}) app.plugin_connector = SimpleNamespace(
_known_desired_states={'installation': object()},
_runtime_available=lambda: True,
)
app.persistence_mgr = SimpleNamespace( app.persistence_mgr = SimpleNamespace(
get_resource_stats=lambda: { get_resource_stats=lambda: {
'configured_capacity': 20, 'configured_capacity': 20,
@@ -140,3 +143,4 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
} }
assert stats['models']['providers'] == 1 assert stats['models']['providers'] == 1
assert stats['runtimes']['plugin_installations'] == 1 assert stats['runtimes']['plugin_installations'] == 1
assert stats['runtimes']['plugin_runtime_connected'] is True
@@ -1,130 +0,0 @@
"""Tests for itchat adapter group/private message conversion."""
from __future__ import annotations
from types import SimpleNamespace
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources.itchat import ItchatAdapter, ItchatEventConverter
def _make_adapter(bot_account_id: str = '@bot_wxid', bot_nickname: str = 'MyBot'):
adapter = SimpleNamespace(
bot_account_id=bot_account_id,
_bot_nickname=bot_nickname,
_core=SimpleNamespace(storageClass=SimpleNamespace(userName=bot_account_id)),
_get_obj_value=ItchatAdapter._get_obj_value,
)
return adapter
def _make_converter(adapter) -> ItchatEventConverter:
return ItchatEventConverter(adapter_ref=lambda: adapter)
def test_group_text_becomes_group_message():
converter = _make_converter(_make_adapter())
msg = {
'FromUserName': '@@group_wxid',
'Type': 'Text',
'Text': 'hello',
'ActualUserName': '@member_wxid',
'ActualNickName': 'MemberNick',
'IsAt': False,
'CreateTime': 123456,
'User': SimpleNamespace(NickName='Group Name'),
}
event = converter.target2yiri(msg)
assert isinstance(event, platform_events.GroupMessage)
assert isinstance(event.sender, platform_entities.GroupMember)
assert event.sender.id == '@member_wxid'
assert event.sender.member_name == 'MemberNick'
assert event.sender.group.id == '@@group_wxid'
assert event.sender.group.name == 'Group Name'
components = list(event.message_chain)
assert len(components) == 1
assert isinstance(components[0], platform_message.Plain)
assert components[0].text == 'hello'
def test_private_text_becomes_friend_message():
converter = _make_converter(_make_adapter())
msg = {
'FromUserName': '@friend_wxid',
'Type': 'Text',
'Text': 'hi',
'CreateTime': 123456,
'User': SimpleNamespace(NickName='FriendNick', RemarkName='FriendRemark'),
}
event = converter.target2yiri(msg)
assert isinstance(event, platform_events.FriendMessage)
assert isinstance(event.sender, platform_entities.Friend)
assert event.sender.id == '@friend_wxid'
assert event.sender.nickname == 'FriendNick'
assert event.sender.remark == 'FriendRemark'
def test_bot_own_message_is_ignored():
converter = _make_converter(_make_adapter(bot_account_id='@bot_wxid'))
msg = {
'FromUserName': '@bot_wxid',
'Type': 'Text',
'Text': 'self echo',
}
assert converter.target2yiri(msg) is None
def test_group_at_bot_strips_prefix_and_adds_at():
converter = _make_converter(_make_adapter(bot_account_id='@bot_wxid', bot_nickname='MyBot'))
msg = {
'FromUserName': '@@group_wxid',
'Type': 'Text',
'Text': '@MyBothello world',
'ActualUserName': '@member_wxid',
'ActualNickName': 'MemberNick',
'IsAt': True,
'CreateTime': 123456,
'User': SimpleNamespace(NickName='Group Name'),
}
event = converter.target2yiri(msg)
assert isinstance(event, platform_events.GroupMessage)
components = list(event.message_chain)
assert len(components) == 2
assert isinstance(components[0], platform_message.At)
assert components[0].target == '@bot_wxid'
assert isinstance(components[1], platform_message.Plain)
assert components[1].text == 'hello world'
def test_group_message_without_sender_is_ignored():
converter = _make_converter(_make_adapter())
msg = {
'FromUserName': '@@group_wxid',
'Type': 'Text',
'Text': 'system note',
'ActualUserName': '',
'ActualNickName': '',
'IsAt': False,
'CreateTime': 123456,
'User': SimpleNamespace(NickName='Group Name'),
}
assert converter.target2yiri(msg) is None
@@ -0,0 +1,127 @@
import base64
import pytest
import langbot.pkg.core.app # noqa: F401
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.libs.wecom_ai_bot_api.ws_client import _UPLOAD_CHUNK_SIZE, WecomBotWsClient
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter, WecomBotMessageConverter
class Logger:
def __init__(self):
self.warnings = []
self.errors = []
async def warning(self, message):
self.warnings.append(message)
async def error(self, message):
self.errors.append(message)
async def info(self, message):
return None
class UploadClient(WecomBotWsClient):
def __init__(self):
super().__init__(bot_id='bot', secret='secret', logger=Logger())
self.frames = []
async def _send_reply(self, req_id: str, body: dict, cmd: str = 'aibot_respond_msg'):
self.frames.append((cmd, body))
if cmd == 'aibot_upload_media_init':
return {'errcode': 0, 'body': {'upload_id': 'upload-1'}}
if cmd == 'aibot_upload_media_finish':
return {'errcode': 0, 'body': {'media_id': 'media-1'}}
return {'errcode': 0}
class Bot:
def __init__(self):
self.calls = []
async def upload_media(self, data, filename='attachment', media_type='file'):
self.calls.append(('upload_media', media_type, filename, data))
return {'media_id': 'media-1'}
async def reply_text(self, req_id, content):
self.calls.append(('reply_text', req_id, content))
async def reply_image(self, req_id, media_id):
self.calls.append(('reply_image', req_id, media_id))
async def send_message(self, target_id, content):
self.calls.append(('send_message', target_id, content))
def make_adapter(bot):
return WecomBotAdapter.model_construct(
bot=bot,
config={'enable-webhook': False},
logger=Logger(),
message_converter=WecomBotMessageConverter(),
)
@pytest.mark.asyncio
async def test_ws_client_upload_media_uses_chunk_protocol():
client = UploadClient()
data = b'a' * (_UPLOAD_CHUNK_SIZE + 1)
upload_result = await client.upload_media(data, 'image.png', media_type='image')
assert upload_result['media_id'] == 'media-1'
assert [cmd for cmd, _ in client.frames] == [
'aibot_upload_media_init',
'aibot_upload_media_chunk',
'aibot_upload_media_chunk',
'aibot_upload_media_finish',
]
init_body = client.frames[0][1]
assert init_body['type'] == 'image'
assert init_body['filename'] == 'image.png'
assert init_body['total_size'] == len(data)
assert init_body['total_chunks'] == 2
assert client.frames[1][1]['chunk_index'] == 0
assert base64.b64decode(client.frames[1][1]['base64_data']) == b'a' * _UPLOAD_CHUNK_SIZE
assert client.frames[2][1]['chunk_index'] == 1
assert base64.b64decode(client.frames[2][1]['base64_data']) == b'a'
@pytest.mark.asyncio
async def test_reply_message_uploads_and_replies_image_media():
bot = Bot()
adapter = make_adapter(bot)
png_data = b'\x89PNG\r\n\x1a\nimage'
image_b64 = base64.b64encode(png_data).decode('utf-8')
chain = platform_message.MessageChain([platform_message.Image(base64=f'data:image/png;base64,{image_b64}')])
items = await WecomBotMessageConverter.yiri2target(chain)
await adapter._send_media(bot, 'req-1', items[0])
assert bot.calls == [
('upload_media', 'image', 'attachment.image', png_data),
('reply_image', 'req-1', 'media-1'),
]
@pytest.mark.asyncio
async def test_send_message_sends_text_and_skips_proactive_image():
bot = Bot()
adapter = make_adapter(bot)
jpg_data = b'\xff\xd8\xffimage'
image_b64 = base64.b64encode(jpg_data).decode('utf-8')
chain = platform_message.MessageChain(
[
platform_message.Plain(text='before'),
platform_message.Image(base64=f'data:image/jpeg;base64,{image_b64}'),
platform_message.Plain(text='after'),
]
)
await adapter.send_message('group', 'chat-1', chain)
assert bot.calls == [
('send_message', 'chat-1', 'beforeafter'),
]
+10 -1
View File
@@ -15,7 +15,7 @@ from langbot_plugin.runtime.security import (
) )
def make_connector() -> PluginRuntimeConnector: def make_connector(*, cloud: bool = False) -> PluginRuntimeConnector:
app = SimpleNamespace( app = SimpleNamespace(
logger=Mock(), logger=Mock(),
instance_config=SimpleNamespace( instance_config=SimpleNamespace(
@@ -34,6 +34,7 @@ def make_connector() -> PluginRuntimeConnector:
'space': {'url': ''}, 'space': {'url': ''},
} }
), ),
deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
) )
return PluginRuntimeConnector(app, AsyncMock()) return PluginRuntimeConnector(app, AsyncMock())
@@ -332,6 +333,14 @@ def test_external_runtime_control_headers_are_empty_when_secret_is_unset(monkeyp
assert connector._control_headers(allow_generate=False) == {} assert connector._control_headers(allow_generate=False) == {}
def test_cloud_runtime_rejects_missing_control_secret(monkeypatch):
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
connector = make_connector(cloud=True)
with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
connector._control_headers(allow_generate=False)
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch): def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False) monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
connector = make_connector() connector = make_connector()
+16 -2
View File
@@ -9,8 +9,8 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, Mock from unittest.mock import AsyncMock, MagicMock, Mock
import pytest import pytest
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction from langbot_plugin.entities.io.actions.enums import LangBotToRuntimeAction, PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginInstallationDesiredState
def make_handler(app): def make_handler(app):
@@ -67,6 +67,20 @@ def make_handler(app):
return runtime_handler return runtime_handler
@pytest.mark.asyncio
async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish():
app = SimpleNamespace()
runtime_handler = make_handler(app)
runtime_handler.call_action = AsyncMock(return_value={})
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
await runtime_handler.reconcile_plugin_installations((desired,))
assert runtime_handler.call_action.await_args.args[0] == LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
class TestHandlerQueryVariables: class TestHandlerQueryVariables:
"""Tests for handler query variable logic.""" """Tests for handler query variable logic."""
+136 -6
View File
@@ -234,6 +234,7 @@ class TestSetBinaryStorage:
}, },
} }
mock_app.persistence_mgr = Mock() mock_app.persistence_mgr = Mock()
mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite'))
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result()) mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
mock_app.logger = Mock() mock_app.logger = Mock()
return mock_app return mock_app
@@ -270,8 +271,8 @@ class TestSetBinaryStorage:
) )
assert response.code == 0 assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 2 assert app.persistence_mgr.execute_async.await_count == 3
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0]) insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
assert insert_params['workspace_uuid'] == 'workspace-a' assert insert_params['workspace_uuid'] == 'workspace-a'
assert insert_params['unique_key'] == canonical_binary_key( assert insert_params['unique_key'] == canonical_binary_key(
'plugin', 'plugin',
@@ -301,6 +302,69 @@ class TestSetBinaryStorage:
assert expected_key in update_params.values() assert expected_key in update_params.values()
assert update_params['value'] == b'new' assert update_params['value'] == b'new'
@pytest.mark.asyncio
async def test_adopts_legacy_storage_before_updating(self, app):
"""A migrated pre-tenancy row is updated in place rather than duplicated."""
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
adopted = SimpleNamespace(rowcount=1)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
adopted,
]
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 3
adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
assert expected_key in adoption_params.values()
assert adoption_params['value'] == b'new'
@pytest.mark.asyncio
async def test_legacy_adoption_race_updates_winning_canonical_row(self, app):
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
lost_race = SimpleNamespace(rowcount=0)
canonical_winner = SimpleNamespace(rowcount=1)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
lost_race,
canonical_winner,
]
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 4
winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0])
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values()
assert winner_update['value'] == b'new'
@pytest.mark.asyncio
async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app):
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
lost_race = SimpleNamespace(rowcount=0)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
lost_race,
SimpleNamespace(rowcount=0),
make_result(),
]
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
assert response.code == 0
assert app.persistence_mgr.execute_async.await_count == 5
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0])
assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
assert insert_params['value'] == b'new'
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app): async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
"""Invalid max_value_bytes uses the 10MB default limit.""" """Invalid max_value_bytes uses the 10MB default limit."""
@@ -525,6 +589,46 @@ class TestGetBinaryStorage:
in statement_params.values() in statement_params.values()
) )
@pytest.mark.asyncio
async def test_reads_legacy_storage_without_mutating_key(self, app):
runtime_handler = make_handler(app)
legacy_storage = SimpleNamespace(
unique_key='plugin:test-author/test-plugin:test-key',
value=b'legacy bytes',
)
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(legacy_storage),
]
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
)
assert response.code == 0
assert base64.b64decode(response.data['value_base64']) == b'legacy bytes'
assert app.persistence_mgr.execute_async.await_count == 2
@pytest.mark.asyncio
async def test_retries_canonical_after_concurrent_legacy_adoption(self, app):
runtime_handler = make_handler(app)
canonical_storage = SimpleNamespace(value=b'adopted bytes')
app.persistence_mgr.execute_async.side_effect = [
make_result(),
make_result(),
make_result(canonical_storage),
]
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
)
assert response.code == 0
assert base64.b64decode(response.data['value_base64']) == b'adopted bytes'
assert app.persistence_mgr.execute_async.await_count == 3
retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_error_when_not_found(self, app): async def test_returns_error_when_not_found(self, app):
"""Missing binary storage rows return an error response.""" """Missing binary storage rows return an error response."""
@@ -567,21 +671,47 @@ class TestDeleteAndListBinaryStorage:
assert response.code == 0 assert response.code == 0
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0]) statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert 'workspace-a' in statement_params.values() flat_values = [
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
]
assert 'workspace-a' in flat_values
assert ( assert (
canonical_binary_key( canonical_binary_key(
'plugin', 'plugin',
'test-author/test-plugin', 'test-author/test-plugin',
'test-key', 'test-key',
) )
in statement_params.values() in flat_values
) )
assert 'forged-owner' not in statement_params.values() assert 'forged-owner' not in flat_values
@pytest.mark.asyncio
async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app):
runtime_handler = make_handler(app)
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
{
'key': 'test-key',
'owner_type': 'plugin',
'owner': 'forged-owner',
}
)
assert response.code == 0
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
values = [
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
]
assert 'workspace-a' in values
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values
assert 'plugin:test-author/test-plugin:test-key' in values
assert 'test-author/test-plugin' in values
assert 'forged-owner' not in values
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_keys_uses_trusted_plugin_owner(self, app): async def test_list_keys_uses_trusted_plugin_owner(self, app):
result = Mock() result = Mock()
result.scalars.return_value.all.return_value = ['first', 'second'] result.scalars.return_value.all.return_value = ['first', 'second', 'first']
app.persistence_mgr.execute_async.return_value = result app.persistence_mgr.execute_async.return_value = result
runtime_handler = make_handler(app) runtime_handler = make_handler(app)
Generated
+67 -84
View File
@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.11, <4.0" requires-python = ">=3.11, <4.0"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -1018,7 +1018,7 @@ name = "cuda-bindings"
version = "13.3.1" version = "13.3.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "cuda-pathfinder" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
@@ -1051,34 +1051,34 @@ wheels = [
[package.optional-dependencies] [package.optional-dependencies]
cudart = [ cudart = [
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cuda-runtime" },
] ]
cufft = [ cufft = [
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cufft" },
] ]
cufile = [ cufile = [
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cufile" },
] ]
cupti = [ cupti = [
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cuda-cupti" },
] ]
curand = [ curand = [
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-curand" },
] ]
cusolver = [ cusolver = [
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cusolver" },
] ]
cusparse = [ cusparse = [
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cusparse" },
] ]
nvjitlink = [ nvjitlink = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-nvjitlink" },
] ]
nvrtc = [ nvrtc = [
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cuda-nvrtc" },
] ]
nvtx = [ nvtx = [
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-nvtx" },
] ]
[[package]] [[package]]
@@ -1814,19 +1814,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "itchat-uos"
version = "1.5.0.dev0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pypng" },
{ name = "pyqrcode" },
{ name = "requests" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/2b/0be7e46195dc3c461518b046a6fb9c34c97cd2fd44847b840a0011686575/itchat_uos-1.5.0.dev0-py3-none-any.whl", hash = "sha256:0293b77cab31fa8c9c2144ea8b5636d43836f47855beba0c603ad3fb1ff89625", size = 52507, upload-time = "2022-07-14T02:38:32.507Z" },
]
[[package]] [[package]]
name = "itsdangerous" name = "itsdangerous"
version = "2.2.0" version = "2.2.0"
@@ -2021,7 +2008,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.10.7" version = "4.10.8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
@@ -2048,7 +2035,6 @@ dependencies = [
{ name = "ebooklib" }, { name = "ebooklib" },
{ name = "gewechat-client" }, { name = "gewechat-client" },
{ name = "html2text" }, { name = "html2text" },
{ name = "itchat-uos" },
{ name = "langbot-plugin" }, { name = "langbot-plugin" },
{ name = "langchain" }, { name = "langchain" },
{ name = "langchain-core" }, { name = "langchain-core" },
@@ -2139,8 +2125,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "itchat-uos", specifier = ">=1.5.0.dev0" }, { name = "langbot-plugin", specifier = "==0.5.5" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3" },
{ name = "langchain", specifier = ">=1.3.9" }, { name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2206,8 +2191,8 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.5.1" version = "0.5.5"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3#7b559da430a50f80a7d30c9d3d66f088503ddbb3" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "aiohttp" }, { name = "aiohttp" },
@@ -2227,6 +2212,10 @@ dependencies = [
{ name = "watchdog" }, { name = "watchdog" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" },
]
[[package]] [[package]]
name = "langchain" name = "langchain"
@@ -3253,7 +3242,7 @@ name = "nvidia-cublas"
version = "13.1.1.3" version = "13.1.1.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "nvidia-cuda-nvrtc" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
@@ -3292,7 +3281,7 @@ name = "nvidia-cudnn-cu13"
version = "9.20.0.48" version = "9.20.0.48"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "nvidia-cublas" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
@@ -3304,7 +3293,7 @@ name = "nvidia-cufft"
version = "12.0.0.61" version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "nvidia-nvjitlink" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
@@ -3334,9 +3323,9 @@ name = "nvidia-cusolver"
version = "12.0.4.66" version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "nvidia-cublas" },
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "nvidia-cusparse" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "nvidia-nvjitlink" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
@@ -3348,7 +3337,7 @@ name = "nvidia-cusparse"
version = "12.6.3.3" version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "nvidia-nvjitlink" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
@@ -4532,12 +4521,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
] ]
[[package]]
name = "pyqrcode"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/37/61/f07226075c347897937d4086ef8e55f0a62ae535e28069884ac68d979316/PyQRCode-1.2.1.tar.gz", hash = "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5", size = 36989, upload-time = "2016-06-20T03:28:03.411Z" }
[[package]] [[package]]
name = "pyreadline3" name = "pyreadline3"
version = "3.5.4" version = "3.5.4"
@@ -5184,10 +5167,10 @@ name = "scikit-learn"
version = "1.8.0" version = "1.8.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "joblib", marker = "python_full_version >= '3.14'" }, { name = "joblib" },
{ name = "numpy", marker = "python_full_version >= '3.14'" }, { name = "numpy" },
{ name = "scipy", marker = "python_full_version >= '3.14'" }, { name = "scipy" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" }, { name = "threadpoolctl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [ wheels = [
@@ -5234,7 +5217,7 @@ name = "scipy"
version = "1.17.1" version = "1.17.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.14'" }, { name = "numpy" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [ wheels = [
@@ -5305,14 +5288,14 @@ name = "sentence-transformers"
version = "5.2.3" version = "5.2.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, { name = "huggingface-hub" },
{ name = "numpy", marker = "python_full_version >= '3.14'" }, { name = "numpy" },
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" }, { name = "scikit-learn" },
{ name = "scipy", marker = "python_full_version >= '3.14'" }, { name = "scipy" },
{ name = "torch", marker = "python_full_version >= '3.14'" }, { name = "torch" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" }, { name = "tqdm" },
{ name = "transformers", marker = "python_full_version >= '3.14'" }, { name = "transformers" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" } sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
wheels = [ wheels = [
@@ -5685,21 +5668,21 @@ name = "torch"
version = "2.12.1" version = "2.12.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "cuda-bindings", marker = "sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
{ name = "filelock", marker = "python_full_version >= '3.14'" }, { name = "filelock" },
{ name = "fsspec", marker = "python_full_version >= '3.14'" }, { name = "fsspec" },
{ name = "jinja2", marker = "python_full_version >= '3.14'" }, { name = "jinja2" },
{ name = "networkx", marker = "python_full_version >= '3.14'" }, { name = "networkx" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
{ name = "setuptools", marker = "python_full_version >= '3.14'" }, { name = "setuptools" },
{ name = "sympy", marker = "python_full_version >= '3.14'" }, { name = "sympy" },
{ name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" }, { name = "triton", marker = "sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" }, { name = "typing-extensions" },
] ]
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" }, { url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
@@ -5741,15 +5724,15 @@ name = "transformers"
version = "5.3.0" version = "5.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" }, { name = "huggingface-hub" },
{ name = "numpy", marker = "python_full_version >= '3.14'" }, { name = "numpy" },
{ name = "packaging", marker = "python_full_version >= '3.14'" }, { name = "packaging" },
{ name = "pyyaml", marker = "python_full_version >= '3.14'" }, { name = "pyyaml" },
{ name = "regex", marker = "python_full_version >= '3.14'" }, { name = "regex" },
{ name = "safetensors", marker = "python_full_version >= '3.14'" }, { name = "safetensors" },
{ name = "tokenizers", marker = "python_full_version >= '3.14'" }, { name = "tokenizers" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" }, { name = "tqdm" },
{ name = "typer", marker = "python_full_version >= '3.14'" }, { name = "typer" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" } sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
wheels = [ wheels = [
@@ -6010,9 +5993,9 @@ name = "valkey-glide"
version = "2.4.1" version = "2.4.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio", marker = "sys_platform != 'win32'" }, { name = "anyio" },
{ name = "protobuf", marker = "sys_platform != 'win32'" }, { name = "protobuf" },
{ name = "sniffio", marker = "sys_platform != 'win32'" }, { name = "sniffio" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" } sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
wheels = [ wheels = [
+9 -87
View File
@@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
@@ -27,79 +26,9 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
Settings,
FileText,
Users,
RefreshCw,
Trash2,
CircleCheck,
CircleAlert,
Loader2,
CircleOff,
} from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Bot, BotAdapterRuntimeStatus } from '@/app/infra/entities/api';
function getBotRuntimeStatus(bot: Bot | null): BotAdapterRuntimeStatus | null {
return bot?.adapter_runtime_values?.runtime_status ?? null;
}
function RuntimeStatusBadge({
status,
}: {
status: BotAdapterRuntimeStatus | null;
}) {
const { t } = useTranslation();
if (!status) return null;
const value = status?.connection_status ?? 'disconnected';
const config = {
connected: {
label: t('bots.runtimeConnected'),
className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700',
icon: CircleCheck,
},
connecting: {
label: t('bots.runtimeConnecting'),
className: 'border-amber-500/30 bg-amber-500/10 text-amber-700',
icon: Loader2,
},
disconnected: {
label: t('bots.runtimeDisconnected'),
className: 'border-muted-foreground/20 bg-muted text-muted-foreground',
icon: CircleOff,
},
error: {
label: t('bots.runtimeError'),
className: 'border-destructive/30 bg-destructive/10 text-destructive',
icon: CircleAlert,
},
}[value];
const Icon = config.icon;
return (
<div className="flex min-w-0 items-center gap-2">
<Badge
variant="outline"
className={cn('h-6 gap-1.5 px-2 text-xs', config.className)}
>
<Icon
className={cn('size-3.5', value === 'connecting' && 'animate-spin')}
/>
{config.label}
</Badge>
{status?.connection_error && (
<span className="max-w-[360px] truncate text-xs text-destructive">
{status.connection_error}
</span>
)}
</div>
);
}
import { useCurrentWorkspace } from '@/app/infra/http'; import { useCurrentWorkspace } from '@/app/infra/http';
export default function BotDetailContent({ id }: { id: string }) { export default function BotDetailContent({ id }: { id: string }) {
@@ -135,24 +64,16 @@ export default function BotDetailContent({ id }: { id: string }) {
// Enable state managed here so the header switch works // Enable state managed here so the header switch works
const [botEnabled, setBotEnabled] = useState(true); const [botEnabled, setBotEnabled] = useState(true);
const [enableLoaded, setEnableLoaded] = useState(false); const [enableLoaded, setEnableLoaded] = useState(false);
const [botDetail, setBotDetail] = useState<Bot | null>(null);
const fetchBotDetail = useCallback(async () => {
if (isCreateMode) return;
const res = await httpClient.getBot(id);
setBotDetail(res.bot);
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
}, [id, isCreateMode]);
// Fetch bot enable state // Fetch bot enable state
useEffect(() => { useEffect(() => {
if (!isCreateMode) { if (!isCreateMode) {
fetchBotDetail(); httpClient.getBot(id).then((res) => {
const timer = window.setInterval(fetchBotDetail, 5000); setBotEnabled(res.bot.enable ?? true);
return () => window.clearInterval(timer); setEnableLoaded(true);
});
} }
}, [fetchBotDetail, isCreateMode]); }, [id, isCreateMode]);
const handleEnableToggle = useCallback( const handleEnableToggle = useCallback(
async (checked: boolean) => { async (checked: boolean) => {
@@ -180,7 +101,9 @@ export default function BotDetailContent({ id }: { id: string }) {
function handleFormSubmit() { function handleFormSubmit() {
// Re-sync enable state after form save (form may update enable too) // Re-sync enable state after form save (form may update enable too)
fetchBotDetail(); httpClient.getBot(id).then((res) => {
setBotEnabled(res.bot.enable ?? true);
});
refreshBots(); refreshBots();
} }
@@ -261,7 +184,6 @@ export default function BotDetailContent({ id }: { id: string }) {
</Label> </Label>
</div> </div>
)} )}
<RuntimeStatusBadge status={getBotRuntimeStatus(botDetail)} />
</div> </div>
{canManage && ( {canManage && (
<Button <Button
@@ -688,10 +688,7 @@ export default function DynamicFormComponent({
onSuccess={(credentials) => { onSuccess={(credentials) => {
for (const [key, value] of Object.entries(credentials)) { for (const [key, value] of Object.entries(credentials)) {
if (value) { if (value) {
form.setValue(key as keyof FormValues, value as never, { form.setValue(key as keyof FormValues, value as never);
shouldDirty: true,
shouldValidate: true,
});
} }
} }
}} }}
@@ -22,7 +22,6 @@ export type QrLoginPlatform =
| 'weixin' | 'weixin'
| 'dingtalk' | 'dingtalk'
| 'wecombot' | 'wecombot'
| 'itchat'
| 'qqofficial'; | 'qqofficial';
interface PlatformConfig { interface PlatformConfig {
@@ -100,20 +99,6 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
}), }),
successNoteKey: 'wecombot.robotNameNote', successNoteKey: 'wecombot.robotNameNote',
}, },
itchat: {
titleKey: 'itchat.scanLogin',
connectingKey: 'itchat.connecting',
scanQRCodeKey: 'itchat.scanQRCode',
waitingKey: 'itchat.waitingForScan',
successKey: 'itchat.loginSuccess',
failedKey: 'itchat.loginFailed',
retryKey: 'itchat.retry',
apiBase: '/api/v1/platform/adapters/itchat/login',
extractSuccess: (data) => ({
account_id: data.wxid || '',
nickname: data.nickname || '',
}),
},
qqofficial: { qqofficial: {
titleKey: 'qqofficial.createBinding', titleKey: 'qqofficial.createBinding',
connectingKey: 'qqofficial.connecting', connectingKey: 'qqofficial.connecting',
@@ -154,7 +139,6 @@ export default function QrCodeLoginDialog({
const [state, setState] = useState<DialogState>('connecting'); const [state, setState] = useState<DialogState>('connecting');
const [qrDataUrl, setQrDataUrl] = useState(''); const [qrDataUrl, setQrDataUrl] = useState('');
const qrDataUrlRef = useRef('');
const [expireIn, setExpireIn] = useState(0); const [expireIn, setExpireIn] = useState(0);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [successMeta, setSuccessMeta] = useState(''); const [successMeta, setSuccessMeta] = useState('');
@@ -223,7 +207,6 @@ export default function QrCodeLoginDialog({
cleanedRef.current = false; cleanedRef.current = false;
setState('connecting'); setState('connecting');
setQrDataUrl(''); setQrDataUrl('');
qrDataUrlRef.current = '';
setExpireIn(0); setExpireIn(0);
setErrorMessage(''); setErrorMessage('');
setSuccessMeta(''); setSuccessMeta('');
@@ -259,14 +242,12 @@ export default function QrCodeLoginDialog({
if (qr_data_url) { if (qr_data_url) {
setQrDataUrl(qr_data_url); setQrDataUrl(qr_data_url);
qrDataUrlRef.current = qr_data_url;
} else if (qr_url) { } else if (qr_url) {
const dataUrl = await QRCode.toDataURL(qr_url, { const dataUrl = await QRCode.toDataURL(qr_url, {
width: 224, width: 224,
margin: 2, margin: 2,
}); });
setQrDataUrl(dataUrl); setQrDataUrl(dataUrl);
qrDataUrlRef.current = dataUrl;
} }
setState('waiting'); setState('waiting');
@@ -366,19 +347,6 @@ export default function QrCodeLoginDialog({
cleanup(); cleanup();
setExpireIn(0); setExpireIn(0);
setState('expired'); setState('expired');
} else if (status === 'waiting') {
// Update QR data URL if regenerated (e.g. itchat QR expiry)
if (rest.qr_data_url && rest.qr_data_url !== qrDataUrlRef.current) {
setQrDataUrl(rest.qr_data_url);
qrDataUrlRef.current = rest.qr_data_url;
}
if (rest.expire_at) {
const remaining = Math.max(
0,
Math.floor(rest.expire_at - Date.now() / 1000),
);
setExpireIn(remaining);
}
} }
} catch { } catch {
// ignore poll errors // ignore poll errors
+1 -23
View File
@@ -203,28 +203,6 @@ export interface ApiRespPlatformBot {
bot: Bot; bot: Bot;
} }
export type BotAdapterConnectionStatus =
| 'connecting'
| 'connected'
| 'disconnected'
| 'error';
export interface BotAdapterRuntimeStatus {
connection_status?: BotAdapterConnectionStatus;
connection_error?: string;
last_connected_at?: number | null;
last_disconnected_at?: number | null;
}
export interface BotAdapterRuntimeValues {
bot_account_id?: string;
webhook_url?: string | null;
webhook_full_url?: string | null;
extra_webhook_full_url?: string | null;
runtime_status?: BotAdapterRuntimeStatus;
[key: string]: unknown;
}
export interface Bot { export interface Bot {
uuid?: string; uuid?: string;
name: string; name: string;
@@ -237,7 +215,7 @@ export interface Bot {
pipeline_routing_rules?: PipelineRoutingRule[]; pipeline_routing_rules?: PipelineRoutingRule[];
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
adapter_runtime_values?: BotAdapterRuntimeValues; adapter_runtime_values?: object;
} }
export type RoutingRuleOperator = export type RoutingRuleOperator =
-47
View File
@@ -16,7 +16,6 @@ import {
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { import {
userInfo,
systemInfo, systemInfo,
bootstrapWorkspaceSession, bootstrapWorkspaceSession,
initializeSystemInfo, initializeSystemInfo,
@@ -490,24 +489,6 @@ export default function WizardPage() {
t, t,
]); ]);
// ---- Space auth redirect ----
const handleSpaceAuth = useCallback(async () => {
try {
const callbackUrl = `${window.location.origin}/auth/space/callback`;
const resp = await httpClient.getSpaceAuthorizeUrl(callbackUrl);
window.location.href = resp.authorize_url;
} catch (err) {
console.error('Failed to get space authorize URL', err);
toast.error(t('wizard.spaceAuthError'));
}
}, [t]);
// ---- Check if local account ----
// Re-evaluated after remote data fetch (when userInfo is populated)
const isLocalAccount =
!isLoading && (!userInfo || userInfo.account_type === 'local');
// ---- Skip handler ---- // ---- Skip handler ----
const [showSkipConfirm, setShowSkipConfirm] = useState(false); const [showSkipConfirm, setShowSkipConfirm] = useState(false);
const [isSkipping, setIsSkipping] = useState(false); const [isSkipping, setIsSkipping] = useState(false);
@@ -662,8 +643,6 @@ export default function WizardPage() {
runnerOptions={runnerOptions} runnerOptions={runnerOptions}
selected={selectedRunner} selected={selectedRunner}
onSelect={handleSelectRunner} onSelect={handleSelectRunner}
isLocalAccount={isLocalAccount}
onSpaceAuth={handleSpaceAuth}
runnerConfigItems={selectedRunnerConfigItems} runnerConfigItems={selectedRunnerConfigItems}
runnerConfigValues={runnerConfig} runnerConfigValues={runnerConfig}
onRunnerConfigChange={setRunnerConfig} onRunnerConfigChange={setRunnerConfig}
@@ -1006,8 +985,6 @@ function StepAIEngine({
runnerOptions, runnerOptions,
selected, selected,
onSelect, onSelect,
isLocalAccount,
onSpaceAuth,
runnerConfigItems, runnerConfigItems,
runnerConfigValues, runnerConfigValues,
onRunnerConfigChange, onRunnerConfigChange,
@@ -1015,8 +992,6 @@ function StepAIEngine({
runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[]; runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[];
selected: string | null; selected: string | null;
onSelect: (name: string) => void; onSelect: (name: string) => void;
isLocalAccount: boolean;
onSpaceAuth: () => void;
runnerConfigItems: IDynamicFormItemSchema[]; runnerConfigItems: IDynamicFormItemSchema[];
runnerConfigValues: Record<string, unknown>; runnerConfigValues: Record<string, unknown>;
onRunnerConfigChange: (v: Record<string, unknown>) => void; onRunnerConfigChange: (v: Record<string, unknown>) => void;
@@ -1127,28 +1102,6 @@ function StepAIEngine({
</Card> </Card>
); );
})} })}
{/* Space promotion banner */}
{selected === 'local-agent' && isLocalAccount && (
<div className="animate-in fade-in slide-in-from-left-2 duration-300">
<div className="relative rounded-lg p-[2px] bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500">
<div className="rounded-[calc(0.5rem-2px)] bg-background p-3 flex flex-col items-center gap-2 text-center">
<Sparkles className="w-6 h-6 text-purple-500 shrink-0" />
<p className="text-xs font-medium">
{t('wizard.spaceBanner.message')}
</p>
<Button
variant="outline"
size="sm"
onClick={onSpaceAuth}
className="w-full"
>
{t('wizard.spaceBanner.action')}
</Button>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
+24 -43
View File
@@ -85,18 +85,18 @@ const enUS = {
'Recommended: Use official stable model APIs and cloud services', 'Recommended: Use official stable model APIs and cloud services',
loginLocal: 'Login with local account', loginLocal: 'Login with local account',
loginWithPassword: 'Login with password', loginWithPassword: 'Login with password',
spaceLoginTitle: 'Login with Space', spaceLoginTitle: 'Login with LangBot Account',
spaceLoginDescription: spaceLoginDescription:
'Scan the QR code or visit the link below to authorize', 'Scan the QR code or visit the link below to authorize',
spaceLoginUserCode: 'Your code', spaceLoginUserCode: 'Your code',
spaceLoginExpires: 'Code expires in {{seconds}} seconds', spaceLoginExpires: 'Code expires in {{seconds}} seconds',
spaceLoginWaiting: 'Waiting for authorization...', spaceLoginWaiting: 'Waiting for authorization...',
spaceLoginSuccess: 'Authorization successful', spaceLoginSuccess: 'Authorization successful',
spaceLoginFailed: 'Space login failed', spaceLoginFailed: 'LangBot Account login failed',
spaceLoginExpired: 'Authorization code expired, please try again', spaceLoginExpired: 'Authorization code expired, please try again',
spaceLoginCancel: 'Cancel', spaceLoginCancel: 'Cancel',
spaceLoginVisitLink: 'Visit link', spaceLoginVisitLink: 'Visit link',
spaceLoginProcessing: 'Logging in with Space', spaceLoginProcessing: 'Logging in with LangBot Account',
spaceLoginProcessingDescription: spaceLoginProcessingDescription:
'Please wait while we complete your login...', 'Please wait while we complete your login...',
spaceLoginSuccessDescription: 'Redirecting to LangBot...', spaceLoginSuccessDescription: 'Redirecting to LangBot...',
@@ -105,7 +105,7 @@ const enUS = {
backToLogin: 'Back to Login', backToLogin: 'Back to Login',
backToHome: 'Back to Home', backToHome: 'Back to Home',
spaceAccountCannotChangePassword: spaceAccountCannotChangePassword:
'Space accounts cannot change password here', 'LangBot Accounts cannot change password here',
theme: 'Theme', theme: 'Theme',
changePassword: 'Change Password', changePassword: 'Change Password',
currentPassword: 'Current Password', currentPassword: 'Current Password',
@@ -254,8 +254,9 @@ const enUS = {
llmModels: 'LLM Models', llmModels: 'LLM Models',
localProvider: 'Local', localProvider: 'Local',
localProviderDescription: 'Models configured and managed locally', localProviderDescription: 'Models configured and managed locally',
spaceProviderDescription: 'Models synced from your Space account', spaceProviderDescription: 'Models synced from your LangBot Account',
spaceDisabledForLocalAccount: 'Login with Space to use cloud models', spaceDisabledForLocalAccount:
'Login with LangBot Account to use cloud models',
syncModels: 'Sync', syncModels: 'Sync',
syncSuccess: 'Sync complete: {{created}} created, {{updated}} updated', syncSuccess: 'Sync complete: {{created}} created, {{updated}} updated',
syncError: 'Sync failed: ', syncError: 'Sync failed: ',
@@ -291,15 +292,15 @@ const enUS = {
langbotModelsDescription: 'Cloud models powered by LangBot Space', langbotModelsDescription: 'Cloud models powered by LangBot Space',
credits: 'Credits', credits: 'Credits',
loginWithSpace: 'Login with LangBot Account', loginWithSpace: 'Login with LangBot Account',
loginToUseModels: 'Login with Space to use cloud models', loginToUseModels: 'Login with LangBot Account to use cloud models',
ownerMustBindSpace: ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.', 'The Workspace owner must connect a LangBot Account for LangBot Models.',
usesOwnerSpaceBilling: usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
noModels: 'No models configured', noModels: 'No models configured',
langbotModels: 'LangBot Models', langbotModels: 'LangBot Models',
spaceTrialTooltip: spaceTrialTooltip:
'Free trial credits available! Login with Space to access cloud models with zero configuration.', 'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
unlockModels: 'Login to use', unlockModels: 'Login to use',
editProvider: 'Edit Provider', editProvider: 'Edit Provider',
addProvider: 'Add Provider', addProvider: 'Add Provider',
@@ -372,10 +373,6 @@ const enUS = {
log: 'Log', log: 'Log',
configuration: 'Configuration', configuration: 'Configuration',
logs: 'Logs', logs: 'Logs',
runtimeConnected: 'Connected',
runtimeConnecting: 'Connecting',
runtimeDisconnected: 'Disconnected',
runtimeError: 'Connection error',
basicInfo: 'Basic Information', basicInfo: 'Basic Information',
basicInfoDescription: 'Set the bot name and description', basicInfoDescription: 'Set the bot name and description',
routingConnection: 'Routing & Connection', routingConnection: 'Routing & Connection',
@@ -1222,13 +1219,13 @@ const enUS = {
adminAccountNote: adminAccountNote:
'The account you use here will be set as the administrator account', 'The account you use here will be set as the administrator account',
register: 'Register', register: 'Register',
initWithSpace: 'Initialize with Space', initWithSpace: 'Initialize with LangBot Account',
spaceRecommended: spaceRecommended:
'Recommended: Use official stable model APIs and cloud services', 'Recommended: Use official stable model APIs and cloud services',
spaceInfoTip1: spaceInfoTip1:
'Space provides unified account authentication services without uploading any of your sensitive information.', 'Space provides unified account authentication services without uploading any of your sensitive information.',
spaceInfoTip2: spaceInfoTip2:
'Logging in with a Space account gives you access to LangBot Models and other cloud services, including free model call credits to help you get started quickly.', 'Logging in with a LangBot Account gives you access to LangBot Models and other cloud services, including free model call credits to help you get started quickly.',
spaceInfoTip3: spaceInfoTip3:
'Your login method does not affect other features. You can configure and use models from other sources at any time.', 'Your login method does not affect other features. You can configure and use models from other sources at any time.',
registerLocal: 'Register local account', registerLocal: 'Register local account',
@@ -1285,32 +1282,32 @@ const enUS = {
passwordNotSet: 'Not Set', passwordNotSet: 'Not Set',
passwordSetDescription: passwordSetDescription:
'Password is set, you can login with email and password', 'Password is set, you can login with email and password',
spaceStatus: 'Space Account', spaceStatus: 'LangBot Account',
spaceBound: 'Bound', spaceBound: 'Bound',
spaceNotBound: 'Not Bound', spaceNotBound: 'Not Bound',
spaceBoundDescription: spaceBoundDescription:
'Space account bound, official model APIs and cloud services available', 'LangBot Account bound, official model APIs and cloud services available',
bindSpace: 'Bind Space Account', bindSpace: 'Bind LangBot Account',
bindSpaceDescription: 'Bind to use official model APIs and cloud services', bindSpaceDescription: 'Bind to use official model APIs and cloud services',
bindSpaceButton: 'Bind', bindSpaceButton: 'Bind',
bindSpaceConfirmTitle: 'Confirm Binding', bindSpaceConfirmTitle: 'Confirm Binding',
bindSpaceConfirmDescription: bindSpaceConfirmDescription:
'You are about to bind your local instance to a Space account', 'You are about to bind your local instance to a LangBot Account',
bindSpaceWarning: bindSpaceWarning:
'After binding, your login email will be changed from {{localEmail}} to the Space account email.', 'After binding, your login email will be changed from {{localEmail}} to the LangBot Account email.',
bindSpaceSuccess: 'Space account bound successfully', bindSpaceSuccess: 'LangBot Account bound successfully',
bindSpaceFailed: 'Failed to bind Space account', bindSpaceFailed: 'Failed to bind LangBot Account',
bindSpaceInvalidState: bindSpaceInvalidState:
'Invalid bind request. Please try again from account settings.', 'Invalid bind request. Please try again from account settings.',
setPasswordHint: 'Set a password to login with email and password', setPasswordHint: 'Set a password to login with email and password',
spaceEmailMismatch: spaceEmailMismatch:
'The Space login email does not match the local account email.', 'The LangBot Account login email does not match the local account email.',
space_account_not_registeredTitle: 'Account not registered', space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered: space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required', space_account_binding_requiredTitle: 'LangBot Account connection required',
space_account_binding_required: space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.', 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
}, },
workspace: { workspace: {
title: 'Workspace', title: 'Workspace',
@@ -1811,7 +1808,6 @@ const enUS = {
botCreateSuccess: 'Bot created successfully!', botCreateSuccess: 'Bot created successfully!',
botSaveSuccess: 'Bot configuration saved and enabled!', botSaveSuccess: 'Bot configuration saved and enabled!',
createError: 'Failed to create resources', createError: 'Failed to create resources',
spaceAuthError: 'Failed to initiate Space authorization',
skipSaveError: 'Failed to save skip status. Please try again.', skipSaveError: 'Failed to save skip status. Please try again.',
completeSaveError: 'Failed to save completion status. Please try again.', completeSaveError: 'Failed to save completion status. Please try again.',
step: { step: {
@@ -1840,11 +1836,6 @@ const enUS = {
description: description:
"Choose the AI engine that will power your bot's intelligence.", "Choose the AI engine that will power your bot's intelligence.",
}, },
spaceBanner: {
message:
'Connect to LangBot Space for free trial model credits and zero-config instant setup!',
action: 'Authorize with Space',
},
config: { config: {
botInfo: 'Bot Information', botInfo: 'Bot Information',
botNamePlaceholder: 'Enter bot name', botNamePlaceholder: 'Enter bot name',
@@ -1929,16 +1920,6 @@ const enUS = {
waitingForScan: 'Waiting for scan', waitingForScan: 'Waiting for scan',
retry: 'Retry', retry: 'Retry',
}, },
itchat: {
scanLogin: 'Scan QR Login',
scanQRCode: 'Scan the QR code below with WeChat to login',
loginSuccess:
'Login successful! Session cached. Save config to start using.',
loginFailed: 'Login failed',
connecting: 'Starting WeChat login...',
waitingForScan: 'Waiting for scan',
retry: 'Retry',
},
dingtalk: { dingtalk: {
createApp: 'One-Click Create DingTalk App', createApp: 'One-Click Create DingTalk App',
scanQRCode: scanQRCode:
+25 -29
View File
@@ -88,19 +88,19 @@ const esES = {
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube', 'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
loginLocal: 'Iniciar sesión con cuenta local', loginLocal: 'Iniciar sesión con cuenta local',
loginWithPassword: 'Iniciar sesión con contraseña', loginWithPassword: 'Iniciar sesión con contraseña',
spaceLoginTitle: 'Iniciar sesión con Space', spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot',
spaceLoginDescription: spaceLoginDescription:
'Escanea el código QR o visita el enlace para autorizar', 'Escanea el código QR o visita el enlace para autorizar',
spaceLoginUserCode: 'Tu código', spaceLoginUserCode: 'Tu código',
spaceLoginExpires: 'El código expira en {{seconds}} segundos', spaceLoginExpires: 'El código expira en {{seconds}} segundos',
spaceLoginWaiting: 'Esperando autorización...', spaceLoginWaiting: 'Esperando autorización...',
spaceLoginSuccess: 'Autorización exitosa', spaceLoginSuccess: 'Autorización exitosa',
spaceLoginFailed: 'Error de inicio de sesión con Space', spaceLoginFailed: 'Error de inicio de sesión con una cuenta de LangBot',
spaceLoginExpired: spaceLoginExpired:
'El código de autorización ha expirado, por favor inténtalo de nuevo', 'El código de autorización ha expirado, por favor inténtalo de nuevo',
spaceLoginCancel: 'Cancelar', spaceLoginCancel: 'Cancelar',
spaceLoginVisitLink: 'Visitar enlace', spaceLoginVisitLink: 'Visitar enlace',
spaceLoginProcessing: 'Iniciando sesión con Space', spaceLoginProcessing: 'Iniciando sesión con una cuenta de LangBot',
spaceLoginProcessingDescription: spaceLoginProcessingDescription:
'Por favor espera mientras completamos tu inicio de sesión...', 'Por favor espera mientras completamos tu inicio de sesión...',
spaceLoginSuccessDescription: 'Redirigiendo a LangBot...', spaceLoginSuccessDescription: 'Redirigiendo a LangBot...',
@@ -109,7 +109,7 @@ const esES = {
backToLogin: 'Volver al inicio de sesión', backToLogin: 'Volver al inicio de sesión',
backToHome: 'Volver al inicio', backToHome: 'Volver al inicio',
spaceAccountCannotChangePassword: spaceAccountCannotChangePassword:
'Las cuentas de Space no pueden cambiar la contraseña aquí', 'Las cuentas de LangBot no pueden cambiar la contraseña aquí',
theme: 'Tema', theme: 'Tema',
changePassword: 'Cambiar contraseña', changePassword: 'Cambiar contraseña',
currentPassword: 'Contraseña actual', currentPassword: 'Contraseña actual',
@@ -261,9 +261,10 @@ const esES = {
llmModels: 'Modelos LLM', llmModels: 'Modelos LLM',
localProvider: 'Local', localProvider: 'Local',
localProviderDescription: 'Modelos configurados y gestionados localmente', localProviderDescription: 'Modelos configurados y gestionados localmente',
spaceProviderDescription: 'Modelos sincronizados desde tu cuenta de Space', spaceProviderDescription:
'Modelos sincronizados desde tu cuenta de LangBot',
spaceDisabledForLocalAccount: spaceDisabledForLocalAccount:
'Inicia sesión con Space para usar modelos en la nube', 'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
syncModels: 'Sincronizar', syncModels: 'Sincronizar',
syncSuccess: syncSuccess:
'Sincronización completa: {{created}} creados, {{updated}} actualizados', 'Sincronización completa: {{created}} creados, {{updated}} actualizados',
@@ -302,11 +303,12 @@ const esES = {
langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space', langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space',
credits: 'Créditos', credits: 'Créditos',
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot', loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
loginToUseModels: 'Inicia sesión con Space para usar modelos en la nube', loginToUseModels:
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
noModels: 'No hay modelos configurados', noModels: 'No hay modelos configurados',
langbotModels: 'Modelos LangBot', langbotModels: 'Modelos LangBot',
spaceTrialTooltip: spaceTrialTooltip:
'¡Créditos de prueba gratuitos disponibles! Inicia sesión con Space para acceder a modelos en la nube sin configuración.', '¡Créditos de prueba gratuitos disponibles! Inicia sesión con una cuenta de LangBot para acceder a modelos en la nube sin configuración.',
unlockModels: 'Inicia sesión para usar', unlockModels: 'Inicia sesión para usar',
editProvider: 'Editar proveedor', editProvider: 'Editar proveedor',
addProvider: 'Añadir proveedor', addProvider: 'Añadir proveedor',
@@ -341,9 +343,9 @@ const esES = {
}, },
ownerMustBindSpace: ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.', 'The Workspace owner must connect a LangBot Account for LangBot Models.',
usesOwnerSpaceBilling: usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
title: 'Bots', title: 'Bots',
@@ -1251,13 +1253,13 @@ const esES = {
adminAccountNote: adminAccountNote:
'La cuenta que uses aquí se establecerá como cuenta de administrador', 'La cuenta que uses aquí se establecerá como cuenta de administrador',
register: 'Registrarse', register: 'Registrarse',
initWithSpace: 'Inicializar con Space', initWithSpace: 'Inicializar con una cuenta de LangBot',
spaceRecommended: spaceRecommended:
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube', 'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
spaceInfoTip1: spaceInfoTip1:
'Space proporciona servicios de autenticación unificada de cuentas sin subir ninguna de tu información sensible.', 'Space proporciona servicios de autenticación unificada de cuentas sin subir ninguna de tu información sensible.',
spaceInfoTip2: spaceInfoTip2:
'Iniciar sesión con una cuenta de Space te da acceso a los modelos de LangBot y otros servicios en la nube, incluyendo créditos gratuitos de llamadas a modelos para ayudarte a comenzar rápidamente.', 'Iniciar sesión con una cuenta de LangBot te da acceso a los modelos de LangBot y otros servicios en la nube, incluyendo créditos gratuitos de llamadas a modelos para ayudarte a comenzar rápidamente.',
spaceInfoTip3: spaceInfoTip3:
'Tu método de inicio de sesión no afecta otras funciones. Puedes configurar y usar modelos de otras fuentes en cualquier momento.', 'Tu método de inicio de sesión no afecta otras funciones. Puedes configurar y usar modelos de otras fuentes en cualquier momento.',
registerLocal: 'Registrar cuenta local', registerLocal: 'Registrar cuenta local',
@@ -1315,35 +1317,35 @@ const esES = {
passwordNotSet: 'No establecida', passwordNotSet: 'No establecida',
passwordSetDescription: passwordSetDescription:
'La contraseña está establecida, puedes iniciar sesión con correo y contraseña', 'La contraseña está establecida, puedes iniciar sesión con correo y contraseña',
spaceStatus: 'Cuenta de Space', spaceStatus: 'Cuenta de LangBot',
spaceBound: 'Vinculada', spaceBound: 'Vinculada',
spaceNotBound: 'No vinculada', spaceNotBound: 'No vinculada',
spaceBoundDescription: spaceBoundDescription:
'Cuenta de Space vinculada, API de modelos oficiales y servicios en la nube disponibles', 'Cuenta de LangBot vinculada, API de modelos oficiales y servicios en la nube disponibles',
bindSpace: 'Vincular cuenta de Space', bindSpace: 'Vincular cuenta de LangBot',
bindSpaceDescription: bindSpaceDescription:
'Vincular para usar API de modelos oficiales y servicios en la nube', 'Vincular para usar API de modelos oficiales y servicios en la nube',
bindSpaceButton: 'Vincular', bindSpaceButton: 'Vincular',
bindSpaceConfirmTitle: 'Confirmar vinculación', bindSpaceConfirmTitle: 'Confirmar vinculación',
bindSpaceConfirmDescription: bindSpaceConfirmDescription:
'Estás a punto de vincular tu instancia local a una cuenta de Space', 'Estás a punto de vincular tu instancia local a una cuenta de LangBot',
bindSpaceWarning: bindSpaceWarning:
'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de Space.', 'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de LangBot.',
bindSpaceSuccess: 'Cuenta de Space vinculada correctamente', bindSpaceSuccess: 'Cuenta de LangBot vinculada correctamente',
bindSpaceFailed: 'Error al vincular la cuenta de Space', bindSpaceFailed: 'Error al vincular la cuenta de LangBot',
bindSpaceInvalidState: bindSpaceInvalidState:
'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.', 'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.',
setPasswordHint: setPasswordHint:
'Establece una contraseña para iniciar sesión con correo y contraseña', 'Establece una contraseña para iniciar sesión con correo y contraseña',
spaceEmailMismatch: spaceEmailMismatch:
'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local', 'El correo de la cuenta de LangBot no coincide con el correo de la cuenta local',
space_account_not_registeredTitle: 'Account not registered', space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered: space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required', space_account_binding_requiredTitle: 'LangBot Account connection required',
space_account_binding_required: space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.', 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
}, },
monitoring: { monitoring: {
title: 'Panel de control', title: 'Panel de control',
@@ -1672,7 +1674,6 @@ const esES = {
botCreateSuccess: '¡Bot creado correctamente!', botCreateSuccess: '¡Bot creado correctamente!',
botSaveSuccess: '¡Configuración del Bot guardada y activada!', botSaveSuccess: '¡Configuración del Bot guardada y activada!',
createError: 'Error al crear los recursos', createError: 'Error al crear los recursos',
spaceAuthError: 'Error al iniciar la autorización de Space',
skipSaveError: skipSaveError:
'Error al guardar el estado de omisión. Por favor, inténtalo de nuevo.', 'Error al guardar el estado de omisión. Por favor, inténtalo de nuevo.',
completeSaveError: completeSaveError:
@@ -1705,11 +1706,6 @@ const esES = {
description: description:
'Elige el motor de IA que impulsará la inteligencia de tu Bot.', 'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
}, },
spaceBanner: {
message:
'¡Conéctate a LangBot Space para obtener créditos de prueba gratuitos y configuración instantánea sin esfuerzo!',
action: 'Autorizar con Space',
},
config: { config: {
botInfo: 'Información del Bot', botInfo: 'Información del Bot',
botNamePlaceholder: 'Introduce el nombre del Bot', botNamePlaceholder: 'Introduce el nombre del Bot',
+24 -33
View File
@@ -86,19 +86,19 @@ const jaJP = {
'おすすめ:公式の安定したモデル API とクラウドサービスを利用', 'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
loginLocal: 'ローカルアカウントでログイン', loginLocal: 'ローカルアカウントでログイン',
loginWithPassword: 'パスワードでログイン', loginWithPassword: 'パスワードでログイン',
spaceLoginTitle: 'Space でログイン', spaceLoginTitle: 'LangBot アカウントでログイン',
spaceLoginDescription: spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください', 'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
spaceLoginUserCode: '認証コード', spaceLoginUserCode: '認証コード',
spaceLoginExpires: 'コードは {{seconds}} 秒後に期限切れになります', spaceLoginExpires: 'コードは {{seconds}} 秒後に期限切れになります',
spaceLoginWaiting: '認証を待っています...', spaceLoginWaiting: '認証を待っています...',
spaceLoginSuccess: '認証に成功しました', spaceLoginSuccess: '認証に成功しました',
spaceLoginFailed: 'Space ログインに失敗しました', spaceLoginFailed: 'LangBot アカウントログインに失敗しました',
spaceLoginExpired: spaceLoginExpired:
'認証コードの有効期限が切れました。もう一度お試しください', '認証コードの有効期限が切れました。もう一度お試しください',
spaceLoginCancel: 'キャンセル', spaceLoginCancel: 'キャンセル',
spaceLoginVisitLink: 'リンクにアクセス', spaceLoginVisitLink: 'リンクにアクセス',
spaceLoginProcessing: 'Space でログイン中', spaceLoginProcessing: 'LangBot アカウントでログイン中',
spaceLoginProcessingDescription: spaceLoginProcessingDescription:
'ログインを完了しています。しばらくお待ちください...', 'ログインを完了しています。しばらくお待ちください...',
spaceLoginSuccessDescription: 'LangBot にリダイレクト中...', spaceLoginSuccessDescription: 'LangBot にリダイレクト中...',
@@ -107,7 +107,7 @@ const jaJP = {
backToLogin: 'ログインに戻る', backToLogin: 'ログインに戻る',
backToHome: 'ホームに戻る', backToHome: 'ホームに戻る',
spaceAccountCannotChangePassword: spaceAccountCannotChangePassword:
'Space アカウントはここでパスワードを変更できません', 'LangBot アカウントはここでパスワードを変更できません',
theme: 'テーマ', theme: 'テーマ',
changePassword: 'パスワードを変更', changePassword: 'パスワードを変更',
currentPassword: '現在のパスワード', currentPassword: '現在のパスワード',
@@ -257,8 +257,9 @@ const jaJP = {
llmModels: 'LLM モデル', llmModels: 'LLM モデル',
localProvider: 'ローカル', localProvider: 'ローカル',
localProviderDescription: 'ローカルで設定・管理されているモデル', localProviderDescription: 'ローカルで設定・管理されているモデル',
spaceProviderDescription: 'Space アカウントから同期されたモデル', spaceProviderDescription: 'LangBot アカウントから同期されたモデル',
spaceDisabledForLocalAccount: 'Space でログインしてクラウドモデルを使用', spaceDisabledForLocalAccount:
'LangBot アカウントでログインしてクラウドモデルを使用',
syncModels: '同期', syncModels: '同期',
syncSuccess: '同期完了:{{created}} 件作成、{{updated}} 件更新', syncSuccess: '同期完了:{{created}} 件作成、{{updated}} 件更新',
syncError: '同期に失敗しました:', syncError: '同期に失敗しました:',
@@ -296,15 +297,15 @@ const jaJP = {
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル', langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
credits: 'クレジット', credits: 'クレジット',
loginWithSpace: 'LangBot アカウントでログイン', loginWithSpace: 'LangBot アカウントでログイン',
loginToUseModels: 'Space でログインしてクラウドモデルを使用', loginToUseModels: 'LangBot アカウントでログインしてクラウドモデルを使用',
ownerMustBindSpace: ownerMustBindSpace:
'LangBot モデルを使うにはワークスペース所有者が Space を連携する必要があります。', 'LangBot モデルを使うにはワークスペース所有者が LangBot アカウントを連携する必要があります。',
usesOwnerSpaceBilling: usesOwnerSpaceBilling:
'ワークスペース所有者の Space 課金とクレジットを使用します。', 'ワークスペース所有者の LangBot アカウント課金とクレジットを使用します。',
noModels: 'モデルがありません', noModels: 'モデルがありません',
langbotModels: 'LangBot モデル', langbotModels: 'LangBot モデル',
spaceTrialTooltip: spaceTrialTooltip:
'無料トライアルクレジットが利用可能!Space でログインして、設定不要でクラウドモデルを使用できます。', '無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。',
unlockModels: 'ログインして使用', unlockModels: 'ログインして使用',
editProvider: 'プロバイダーを編集', editProvider: 'プロバイダーを編集',
addProvider: 'プロバイダーを追加', addProvider: 'プロバイダーを追加',
@@ -378,10 +379,6 @@ const jaJP = {
log: 'ログ', log: 'ログ',
configuration: '設定', configuration: '設定',
logs: 'ログ', logs: 'ログ',
runtimeConnected: '接続済み',
runtimeConnecting: '接続中',
runtimeDisconnected: '切断済み',
runtimeError: '接続エラー',
basicInfo: '基本情報', basicInfo: '基本情報',
basicInfoDescription: 'ボットの名前と説明を設定', basicInfoDescription: 'ボットの名前と説明を設定',
routingConnection: 'ルーティングと接続', routingConnection: 'ルーティングと接続',
@@ -1227,13 +1224,13 @@ const jaJP = {
adminAccountNote: adminAccountNote:
'ここで初期化されたアカウントは管理者アカウントとして使用されます', 'ここで初期化されたアカウントは管理者アカウントとして使用されます',
register: '登録', register: '登録',
initWithSpace: 'Space で初期化', initWithSpace: 'LangBot アカウントで初期化',
spaceRecommended: spaceRecommended:
'おすすめ:公式の安定したモデル API とクラウドサービスを利用', 'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
spaceInfoTip1: spaceInfoTip1:
'Space は統一されたアカウント認証サービスを提供し、機密情報をアップロードすることはありません。', 'Space は統一されたアカウント認証サービスを提供し、機密情報をアップロードすることはありません。',
spaceInfoTip2: spaceInfoTip2:
'Space アカウントでログインすると、LangBot Models などのクラウドサービスを利用でき、無料のモデル呼び出しクレジットで迅速に開始できます。', 'LangBot アカウントでログインすると、LangBot Models などのクラウドサービスを利用でき、無料のモデル呼び出しクレジットで迅速に開始できます。',
spaceInfoTip3: spaceInfoTip3:
'ログイン方法は他の機能に影響しません。いつでも他のソースからモデルを設定して使用できます。', 'ログイン方法は他の機能に影響しません。いつでも他のソースからモデルを設定して使用できます。',
registerLocal: 'ローカルアカウントを登録', registerLocal: 'ローカルアカウントを登録',
@@ -1290,33 +1287,33 @@ const jaJP = {
passwordNotSet: '未設定', passwordNotSet: '未設定',
passwordSetDescription: passwordSetDescription:
'パスワードが設定されています。メールとパスワードでログインできます', 'パスワードが設定されています。メールとパスワードでログインできます',
spaceStatus: 'Space アカウント', spaceStatus: 'LangBot アカウント',
spaceBound: '連携済み', spaceBound: '連携済み',
spaceNotBound: '未連携', spaceNotBound: '未連携',
spaceBoundDescription: spaceBoundDescription:
'Space アカウントと連携済み、公式モデル API とクラウドサービスが利用可能', 'LangBot アカウントと連携済み、公式モデル API とクラウドサービスが利用可能',
bindSpace: 'Space アカウントを連携', bindSpace: 'LangBot アカウントを連携',
bindSpaceDescription: '連携して公式モデル API とクラウドサービスを利用', bindSpaceDescription: '連携して公式モデル API とクラウドサービスを利用',
bindSpaceButton: '連携', bindSpaceButton: '連携',
bindSpaceConfirmTitle: '連携を確認', bindSpaceConfirmTitle: '連携を確認',
bindSpaceConfirmDescription: bindSpaceConfirmDescription:
'ローカルインスタンスを Space アカウントに連携しようとしています', 'ローカルインスタンスを LangBot アカウントに連携しようとしています',
bindSpaceWarning: bindSpaceWarning:
'連携後、ログインメールアドレスは {{localEmail}} から Space アカウントのメールアドレスに変更されます。', '連携後、ログインメールアドレスは {{localEmail}} から LangBot アカウントのメールアドレスに変更されます。',
bindSpaceSuccess: 'Space アカウントの連携に成功しました', bindSpaceSuccess: 'LangBot アカウントの連携に成功しました',
bindSpaceFailed: 'Space アカウントの連携に失敗しました', bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
bindSpaceInvalidState: bindSpaceInvalidState:
'無効な連携リクエストです。アカウント設定から再度お試しください。', '無効な連携リクエストです。アカウント設定から再度お試しください。',
setPasswordHint: setPasswordHint:
'パスワードを設定するとメールとパスワードでログインできます', 'パスワードを設定するとメールとパスワードでログインできます',
spaceEmailMismatch: spaceEmailMismatch:
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません', 'LangBot アカウントのメールアドレスがローカルアカウントのメールアドレスと一致しません',
space_account_not_registeredTitle: 'アカウントが登録されていません', space_account_not_registeredTitle: 'アカウントが登録されていません',
space_account_not_registered: space_account_not_registered:
'この Space メールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。', 'この LangBot アカウントのメールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。',
space_account_binding_requiredTitle: 'Space の連携が必要です', space_account_binding_requiredTitle: 'LangBot アカウントの連携が必要です',
space_account_binding_required: space_account_binding_required:
'Space ログインを使用する前に、アカウント設定でこのローカルアカウントを Space に連携してください。', 'LangBot アカウントログインを使用する前に、アカウント設定でこのローカルアカウントを LangBot アカウントに連携してください。',
}, },
workspace: { workspace: {
title: 'ワークスペース', title: 'ワークスペース',
@@ -1726,7 +1723,6 @@ const jaJP = {
botCreateSuccess: 'ボットが正常に作成されました!', botCreateSuccess: 'ボットが正常に作成されました!',
botSaveSuccess: 'ボット設定が保存され、有効になりました!', botSaveSuccess: 'ボット設定が保存され、有効になりました!',
createError: 'リソースの作成に失敗しました', createError: 'リソースの作成に失敗しました',
spaceAuthError: 'Space 認証の開始に失敗しました',
skipSaveError: 'スキップ状態の保存に失敗しました。もう一度お試しください。', skipSaveError: 'スキップ状態の保存に失敗しました。もう一度お試しください。',
completeSaveError: '完了状態の保存に失敗しました。もう一度お試しください。', completeSaveError: '完了状態の保存に失敗しました。もう一度お試しください。',
step: { step: {
@@ -1757,11 +1753,6 @@ const jaJP = {
description: description:
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。', 'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
}, },
spaceBanner: {
message:
'LangBot Spaceに接続して、無料トライアルモデルクレジットとゼロ設定の即時セットアップを入手!',
action: 'Spaceで認証',
},
config: { config: {
botInfo: 'ボット情報', botInfo: 'ボット情報',
botNamePlaceholder: 'ボット名を入力', botNamePlaceholder: 'ボット名を入力',
+24 -29
View File
@@ -85,18 +85,18 @@ const ruRU = {
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы', 'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
loginLocal: 'Войти с локальной учётной записью', loginLocal: 'Войти с локальной учётной записью',
loginWithPassword: 'Войти с паролем', loginWithPassword: 'Войти с паролем',
spaceLoginTitle: 'Войти через Space', spaceLoginTitle: 'Войти с аккаунтом LangBot',
spaceLoginDescription: spaceLoginDescription:
'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации', 'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации',
spaceLoginUserCode: 'Ваш код', spaceLoginUserCode: 'Ваш код',
spaceLoginExpires: 'Код истекает через {{seconds}} секунд', spaceLoginExpires: 'Код истекает через {{seconds}} секунд',
spaceLoginWaiting: 'Ожидание авторизации...', spaceLoginWaiting: 'Ожидание авторизации...',
spaceLoginSuccess: 'Авторизация успешна', spaceLoginSuccess: 'Авторизация успешна',
spaceLoginFailed: 'Ошибка входа через Space', spaceLoginFailed: 'Ошибка входа с аккаунтом LangBot',
spaceLoginExpired: 'Код авторизации истёк, попробуйте снова', spaceLoginExpired: 'Код авторизации истёк, попробуйте снова',
spaceLoginCancel: 'Отмена', spaceLoginCancel: 'Отмена',
spaceLoginVisitLink: 'Перейти по ссылке', spaceLoginVisitLink: 'Перейти по ссылке',
spaceLoginProcessing: 'Вход через Space', spaceLoginProcessing: 'Вход с аккаунтом LangBot',
spaceLoginProcessingDescription: spaceLoginProcessingDescription:
'Пожалуйста, подождите, пока мы завершим вход...', 'Пожалуйста, подождите, пока мы завершим вход...',
spaceLoginSuccessDescription: 'Перенаправление в LangBot...', spaceLoginSuccessDescription: 'Перенаправление в LangBot...',
@@ -105,7 +105,7 @@ const ruRU = {
backToLogin: 'Вернуться к входу', backToLogin: 'Вернуться к входу',
backToHome: 'На главную', backToHome: 'На главную',
spaceAccountCannotChangePassword: spaceAccountCannotChangePassword:
'Для аккаунтов Space невозможно изменить пароль здесь', 'Для аккаунтов LangBot невозможно изменить пароль здесь',
theme: 'Тема', theme: 'Тема',
changePassword: 'Изменить пароль', changePassword: 'Изменить пароль',
currentPassword: 'Текущий пароль', currentPassword: 'Текущий пароль',
@@ -259,9 +259,9 @@ const ruRU = {
localProvider: 'Локальный', localProvider: 'Локальный',
localProviderDescription: 'Модели, настроенные и управляемые локально', localProviderDescription: 'Модели, настроенные и управляемые локально',
spaceProviderDescription: spaceProviderDescription:
'Модели, синхронизированные из вашего аккаунта Space', 'Модели, синхронизированные из вашего аккаунта LangBot',
spaceDisabledForLocalAccount: spaceDisabledForLocalAccount:
'Войдите через Space, чтобы использовать облачные модели', 'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
syncModels: 'Синхронизировать', syncModels: 'Синхронизировать',
syncSuccess: syncSuccess:
'Синхронизация завершена: {{created}} создано, {{updated}} обновлено', 'Синхронизация завершена: {{created}} создано, {{updated}} обновлено',
@@ -300,11 +300,12 @@ const ruRU = {
langbotModelsDescription: 'Облачные модели на базе LangBot Space', langbotModelsDescription: 'Облачные модели на базе LangBot Space',
credits: 'Кредиты', credits: 'Кредиты',
loginWithSpace: 'Войти с аккаунтом LangBot', loginWithSpace: 'Войти с аккаунтом LangBot',
loginToUseModels: 'Войдите через Space, чтобы использовать облачные модели', loginToUseModels:
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
noModels: 'Модели не настроены', noModels: 'Модели не настроены',
langbotModels: 'Модели LangBot', langbotModels: 'Модели LangBot',
spaceTrialTooltip: spaceTrialTooltip:
'Доступны бесплатные пробные кредиты! Войдите через Space, чтобы получить доступ к облачным моделям без настройки.', 'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.',
unlockModels: 'Войдите для использования', unlockModels: 'Войдите для использования',
editProvider: 'Редактировать провайдера', editProvider: 'Редактировать провайдера',
addProvider: 'Добавить провайдера', addProvider: 'Добавить провайдера',
@@ -340,9 +341,9 @@ const ruRU = {
}, },
ownerMustBindSpace: ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.', 'The Workspace owner must connect a LangBot Account for LangBot Models.',
usesOwnerSpaceBilling: usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
title: 'Боты', title: 'Боты',
@@ -1229,13 +1230,13 @@ const ruRU = {
adminAccountNote: adminAccountNote:
'Указанная учётная запись будет настроена как администратор', 'Указанная учётная запись будет настроена как администратор',
register: 'Регистрация', register: 'Регистрация',
initWithSpace: 'Инициализация через Space', initWithSpace: 'Инициализация с аккаунтом LangBot',
spaceRecommended: spaceRecommended:
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы', 'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
spaceInfoTip1: spaceInfoTip1:
'Space предоставляет единую службу аутентификации без загрузки конфиденциальной информации.', 'Space предоставляет единую службу аутентификации без загрузки конфиденциальной информации.',
spaceInfoTip2: spaceInfoTip2:
'Вход через Space даёт доступ к моделям LangBot и облачным сервисам, включая бесплатные кредиты для быстрого старта.', 'Вход с аккаунтом LangBot даёт доступ к моделям LangBot и облачным сервисам, включая бесплатные кредиты для быстрого старта.',
spaceInfoTip3: spaceInfoTip3:
'Способ входа не влияет на другие функции. Вы можете настроить модели из других источников в любое время.', 'Способ входа не влияет на другие функции. Вы можете настроить модели из других источников в любое время.',
registerLocal: 'Зарегистрировать локальную учётную запись', registerLocal: 'Зарегистрировать локальную учётную запись',
@@ -1291,34 +1292,34 @@ const ruRU = {
passwordNotSet: 'Не установлен', passwordNotSet: 'Не установлен',
passwordSetDescription: passwordSetDescription:
'Пароль установлен, вы можете входить с email и паролем', 'Пароль установлен, вы можете входить с email и паролем',
spaceStatus: 'Аккаунт Space', spaceStatus: 'Аккаунт LangBot',
spaceBound: 'Привязан', spaceBound: 'Привязан',
spaceNotBound: 'Не привязан', spaceNotBound: 'Не привязан',
spaceBoundDescription: spaceBoundDescription:
'Аккаунт Space привязан, доступны официальные API моделей и облачные сервисы', 'Аккаунт LangBot привязан, доступны официальные API моделей и облачные сервисы',
bindSpace: 'Привязать аккаунт Space', bindSpace: 'Привязать аккаунт LangBot',
bindSpaceDescription: bindSpaceDescription:
'Привяжите для использования официальных API моделей и облачных сервисов', 'Привяжите для использования официальных API моделей и облачных сервисов',
bindSpaceButton: 'Привязать', bindSpaceButton: 'Привязать',
bindSpaceConfirmTitle: 'Подтверждение привязки', bindSpaceConfirmTitle: 'Подтверждение привязки',
bindSpaceConfirmDescription: bindSpaceConfirmDescription:
'Вы собираетесь привязать локальный экземпляр к аккаунту Space', 'Вы собираетесь привязать локальный экземпляр к аккаунту LangBot',
bindSpaceWarning: bindSpaceWarning:
'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта Space.', 'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта LangBot.',
bindSpaceSuccess: 'Аккаунт Space успешно привязан', bindSpaceSuccess: 'Аккаунт LangBot успешно привязан',
bindSpaceFailed: 'Не удалось привязать аккаунт Space', bindSpaceFailed: 'Не удалось привязать аккаунт LangBot',
bindSpaceInvalidState: bindSpaceInvalidState:
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.', 'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
setPasswordHint: 'Установите пароль для входа с email и паролем', setPasswordHint: 'Установите пароль для входа с email и паролем',
spaceEmailMismatch: spaceEmailMismatch:
'Email входа через Space не совпадает с email локальной учётной записи', 'Email входа с аккаунтом LangBot не совпадает с email локальной учётной записи',
space_account_not_registeredTitle: 'Account not registered', space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered: space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required', space_account_binding_requiredTitle: 'LangBot Account connection required',
space_account_binding_required: space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.', 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
}, },
monitoring: { monitoring: {
title: 'Мониторинг', title: 'Мониторинг',
@@ -1645,7 +1646,6 @@ const ruRU = {
botCreateSuccess: 'Бот успешно создан!', botCreateSuccess: 'Бот успешно создан!',
botSaveSuccess: 'Конфигурация бота сохранена и включена!', botSaveSuccess: 'Конфигурация бота сохранена и включена!',
createError: 'Не удалось создать ресурсы', createError: 'Не удалось создать ресурсы',
spaceAuthError: 'Не удалось инициировать авторизацию через Space',
skipSaveError: 'Не удалось сохранить статус пропуска. Повторите попытку.', skipSaveError: 'Не удалось сохранить статус пропуска. Повторите попытку.',
completeSaveError: completeSaveError:
'Не удалось сохранить статус завершения. Повторите попытку.', 'Не удалось сохранить статус завершения. Повторите попытку.',
@@ -1675,11 +1675,6 @@ const ruRU = {
description: description:
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.', 'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
}, },
spaceBanner: {
message:
'Подключитесь к LangBot Space для бесплатных пробных кредитов и мгновенной настройки!',
action: 'Авторизация через Space',
},
config: { config: {
botInfo: 'Информация о боте', botInfo: 'Информация о боте',
botNamePlaceholder: 'Введите имя бота', botNamePlaceholder: 'Введите имя бота',
+26 -29
View File
@@ -85,18 +85,18 @@ const thTH = {
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์', 'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น', loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน', loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน',
spaceLoginTitle: 'เข้าสู่ระบบด้วย Space', spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot',
spaceLoginDescription: spaceLoginDescription:
'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์', 'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์',
spaceLoginUserCode: 'รหัสของคุณ', spaceLoginUserCode: 'รหัสของคุณ',
spaceLoginExpires: 'รหัสจะหมดอายุใน {{seconds}} วินาที', spaceLoginExpires: 'รหัสจะหมดอายุใน {{seconds}} วินาที',
spaceLoginWaiting: 'กำลังรอการยืนยันสิทธิ์...', spaceLoginWaiting: 'กำลังรอการยืนยันสิทธิ์...',
spaceLoginSuccess: 'ยืนยันสิทธิ์สำเร็จ', spaceLoginSuccess: 'ยืนยันสิทธิ์สำเร็จ',
spaceLoginFailed: 'เข้าสู่ระบบ Space ล้มเหลว', spaceLoginFailed: 'เข้าสู่ระบบด้วยบัญชี LangBot ล้มเหลว',
spaceLoginExpired: 'รหัสยืนยันหมดอายุแล้ว กรุณาลองใหม่', spaceLoginExpired: 'รหัสยืนยันหมดอายุแล้ว กรุณาลองใหม่',
spaceLoginCancel: 'ยกเลิก', spaceLoginCancel: 'ยกเลิก',
spaceLoginVisitLink: 'เข้าชมลิงก์', spaceLoginVisitLink: 'เข้าชมลิงก์',
spaceLoginProcessing: 'กำลังเข้าสู่ระบบด้วย Space', spaceLoginProcessing: 'กำลังเข้าสู่ระบบด้วยบัญชี LangBot',
spaceLoginProcessingDescription: 'กรุณารอสักครู่ขณะดำเนินการเข้าสู่ระบบ...', spaceLoginProcessingDescription: 'กรุณารอสักครู่ขณะดำเนินการเข้าสู่ระบบ...',
spaceLoginSuccessDescription: 'กำลังเปลี่ยนเส้นทางไปยัง LangBot...', spaceLoginSuccessDescription: 'กำลังเปลี่ยนเส้นทางไปยัง LangBot...',
spaceLoginError: 'เข้าสู่ระบบล้มเหลว', spaceLoginError: 'เข้าสู่ระบบล้มเหลว',
@@ -104,7 +104,7 @@ const thTH = {
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ', backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
backToHome: 'กลับไปหน้าแรก', backToHome: 'กลับไปหน้าแรก',
spaceAccountCannotChangePassword: spaceAccountCannotChangePassword:
'บัญชี Space ไม่สามารถเปลี่ยนรหัสผ่านได้ที่นี่', 'บัญชี LangBot ไม่สามารถเปลี่ยนรหัสผ่านได้ที่นี่',
theme: 'ธีม', theme: 'ธีม',
changePassword: 'เปลี่ยนรหัสผ่าน', changePassword: 'เปลี่ยนรหัสผ่าน',
currentPassword: 'รหัสผ่านปัจจุบัน', currentPassword: 'รหัสผ่านปัจจุบัน',
@@ -252,8 +252,9 @@ const thTH = {
llmModels: 'โมเดล LLM', llmModels: 'โมเดล LLM',
localProvider: 'ท้องถิ่น', localProvider: 'ท้องถิ่น',
localProviderDescription: 'โมเดลที่กำหนดค่าและจัดการในเครื่อง', localProviderDescription: 'โมเดลที่กำหนดค่าและจัดการในเครื่อง',
spaceProviderDescription: 'โมเดลที่ซิงค์จากบัญชี Space ของคุณ', spaceProviderDescription: 'โมเดลที่ซิงค์จากบัญชี LangBot ของคุณ',
spaceDisabledForLocalAccount: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์', spaceDisabledForLocalAccount:
'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
syncModels: 'ซิงค์', syncModels: 'ซิงค์',
syncSuccess: syncSuccess:
'ซิงค์เสร็จสมบูรณ์: สร้าง {{created}} รายการ, อัปเดต {{updated}} รายการ', 'ซิงค์เสร็จสมบูรณ์: สร้าง {{created}} รายการ, อัปเดต {{updated}} รายการ',
@@ -289,11 +290,11 @@ const thTH = {
langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space', langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space',
credits: 'เครดิต', credits: 'เครดิต',
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot', loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
loginToUseModels: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์', loginToUseModels: 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า', noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
langbotModels: 'โมเดล LangBot', langbotModels: 'โมเดล LangBot',
spaceTrialTooltip: spaceTrialTooltip:
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วย Space เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า', 'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
unlockModels: 'เข้าสู่ระบบเพื่อใช้งาน', unlockModels: 'เข้าสู่ระบบเพื่อใช้งาน',
editProvider: 'แก้ไขผู้ให้บริการ', editProvider: 'แก้ไขผู้ให้บริการ',
addProvider: 'เพิ่มผู้ให้บริการ', addProvider: 'เพิ่มผู้ให้บริการ',
@@ -327,9 +328,9 @@ const thTH = {
}, },
ownerMustBindSpace: ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.', 'The Workspace owner must connect a LangBot Account for LangBot Models.',
usesOwnerSpaceBilling: usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
title: 'บอท', title: 'บอท',
@@ -1202,13 +1203,13 @@ const thTH = {
description: 'นี่เป็นครั้งแรกที่คุณเริ่มใช้งาน LangBot', description: 'นี่เป็นครั้งแรกที่คุณเริ่มใช้งาน LangBot',
adminAccountNote: 'บัญชีที่คุณใช้ที่นี่จะถูกตั้งเป็นบัญชีผู้ดูแลระบบ', adminAccountNote: 'บัญชีที่คุณใช้ที่นี่จะถูกตั้งเป็นบัญชีผู้ดูแลระบบ',
register: 'ลงทะเบียน', register: 'ลงทะเบียน',
initWithSpace: 'เริ่มต้นด้วย Space', initWithSpace: 'เริ่มต้นด้วยบัญชี LangBot',
spaceRecommended: spaceRecommended:
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์', 'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
spaceInfoTip1: spaceInfoTip1:
'Space ให้บริการยืนยันตัวตนแบบรวมโดยไม่อัปโหลดข้อมูลสำคัญใดๆ ของคุณ', 'Space ให้บริการยืนยันตัวตนแบบรวมโดยไม่อัปโหลดข้อมูลสำคัญใดๆ ของคุณ',
spaceInfoTip2: spaceInfoTip2:
'การเข้าสู่ระบบด้วยบัญชี Space ช่วยให้คุณเข้าถึงโมเดล LangBot และบริการคลาวด์อื่นๆ รวมถึงเครดิตเรียกใช้โมเดลฟรีเพื่อช่วยให้คุณเริ่มต้นได้อย่างรวดเร็ว', 'การเข้าสู่ระบบด้วยบัญชี LangBot ช่วยให้คุณเข้าถึงโมเดล LangBot และบริการคลาวด์อื่นๆ รวมถึงเครดิตเรียกใช้โมเดลฟรีเพื่อช่วยให้คุณเริ่มต้นได้อย่างรวดเร็ว',
spaceInfoTip3: spaceInfoTip3:
'วิธีการเข้าสู่ระบบของคุณไม่มีผลต่อฟีเจอร์อื่นๆ คุณสามารถกำหนดค่าและใช้โมเดลจากแหล่งอื่นได้ตลอดเวลา', 'วิธีการเข้าสู่ระบบของคุณไม่มีผลต่อฟีเจอร์อื่นๆ คุณสามารถกำหนดค่าและใช้โมเดลจากแหล่งอื่นได้ตลอดเวลา',
registerLocal: 'ลงทะเบียนบัญชีท้องถิ่น', registerLocal: 'ลงทะเบียนบัญชีท้องถิ่น',
@@ -1263,30 +1264,32 @@ const thTH = {
passwordNotSet: 'ยังไม่ได้ตั้งค่า', passwordNotSet: 'ยังไม่ได้ตั้งค่า',
passwordSetDescription: passwordSetDescription:
'ตั้งรหัสผ่านแล้ว คุณสามารถเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน', 'ตั้งรหัสผ่านแล้ว คุณสามารถเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
spaceStatus: 'บัญชี Space', spaceStatus: 'บัญชี LangBot',
spaceBound: 'ผูกแล้ว', spaceBound: 'ผูกแล้ว',
spaceNotBound: 'ยังไม่ผูก', spaceNotBound: 'ยังไม่ผูก',
spaceBoundDescription: spaceBoundDescription:
'ผูกบัญชี Space แล้ว สามารถใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์ได้', 'ผูกบัญชี LangBot แล้ว สามารถใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์ได้',
bindSpace: 'ผูกบัญชี Space', bindSpace: 'ผูกบัญชี LangBot',
bindSpaceDescription: 'ผูกเพื่อใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์', bindSpaceDescription: 'ผูกเพื่อใช้ API โมเดลอย่างเป็นทางการและบริการคลาวด์',
bindSpaceButton: 'ผูก', bindSpaceButton: 'ผูก',
bindSpaceConfirmTitle: 'ยืนยันการผูก', bindSpaceConfirmTitle: 'ยืนยันการผูก',
bindSpaceConfirmDescription: 'คุณกำลังจะผูกอินสแตนซ์ท้องถิ่นกับบัญชี Space', bindSpaceConfirmDescription:
'คุณกำลังจะผูกอินสแตนซ์ท้องถิ่นกับบัญชี LangBot',
bindSpaceWarning: bindSpaceWarning:
'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี Space', 'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี LangBot',
bindSpaceSuccess: 'ผูกบัญชี Space สำเร็จ', bindSpaceSuccess: 'ผูกบัญชี LangBot สำเร็จ',
bindSpaceFailed: 'ผูกบัญชี Space ล้มเหลว', bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว',
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี', bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน', setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
spaceEmailMismatch: 'อีเมลเข้าสู่ระบบ Space ไม่ตรงกับอีเมลบัญชีท้องถิ่น', spaceEmailMismatch:
'อีเมลเข้าสู่ระบบด้วยบัญชี LangBot ไม่ตรงกับอีเมลบัญชีท้องถิ่น',
space_account_not_registeredTitle: 'Account not registered', space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered: space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required', space_account_binding_requiredTitle: 'LangBot Account connection required',
space_account_binding_required: space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.', 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
}, },
monitoring: { monitoring: {
title: 'แดชบอร์ด', title: 'แดชบอร์ด',
@@ -1612,7 +1615,6 @@ const thTH = {
botCreateSuccess: 'สร้าง Bot สำเร็จ!', botCreateSuccess: 'สร้าง Bot สำเร็จ!',
botSaveSuccess: 'บันทึกและเปิดใช้งาน Bot สำเร็จ!', botSaveSuccess: 'บันทึกและเปิดใช้งาน Bot สำเร็จ!',
createError: 'ไม่สามารถสร้างทรัพยากรได้', createError: 'ไม่สามารถสร้างทรัพยากรได้',
spaceAuthError: 'ไม่สามารถเริ่มต้นการยืนยันสิทธิ์ Space ได้',
skipSaveError: 'ไม่สามารถบันทึกสถานะการข้ามได้ กรุณาลองใหม่', skipSaveError: 'ไม่สามารถบันทึกสถานะการข้ามได้ กรุณาลองใหม่',
completeSaveError: 'ไม่สามารถบันทึกสถานะการเสร็จสิ้นได้ กรุณาลองใหม่', completeSaveError: 'ไม่สามารถบันทึกสถานะการเสร็จสิ้นได้ กรุณาลองใหม่',
step: { step: {
@@ -1640,11 +1642,6 @@ const thTH = {
title: 'เลือกเครื่องมือ AI', title: 'เลือกเครื่องมือ AI',
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot', description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
}, },
spaceBanner: {
message:
'เชื่อมต่อกับ LangBot Space เพื่อรับเครดิตทดลองใช้โมเดลฟรีและตั้งค่าทันทีโดยไม่ต้องกำหนดค่า!',
action: 'ยืนยันสิทธิ์กับ Space',
},
config: { config: {
botInfo: 'ข้อมูล Bot', botInfo: 'ข้อมูล Bot',
botNamePlaceholder: 'กรอกชื่อ Bot', botNamePlaceholder: 'กรอกชื่อ Bot',
+24 -29
View File
@@ -86,18 +86,18 @@ const viVN = {
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây', 'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
loginLocal: 'Đăng nhập với tài khoản cục bộ', loginLocal: 'Đăng nhập với tài khoản cục bộ',
loginWithPassword: 'Đăng nhập bằng mật khẩu', loginWithPassword: 'Đăng nhập bằng mật khẩu',
spaceLoginTitle: 'Đăng nhập với Space', spaceLoginTitle: 'Đăng nhập bằng tài khoản LangBot',
spaceLoginDescription: spaceLoginDescription:
'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền', 'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền',
spaceLoginUserCode: 'Mã của bạn', spaceLoginUserCode: 'Mã của bạn',
spaceLoginExpires: 'Mã hết hạn sau {{seconds}} giây', spaceLoginExpires: 'Mã hết hạn sau {{seconds}} giây',
spaceLoginWaiting: 'Đang chờ ủy quyền...', spaceLoginWaiting: 'Đang chờ ủy quyền...',
spaceLoginSuccess: 'Ủy quyền thành công', spaceLoginSuccess: 'Ủy quyền thành công',
spaceLoginFailed: 'Đăng nhập Space thất bại', spaceLoginFailed: 'Đăng nhập bằng tài khoản LangBot thất bại',
spaceLoginExpired: 'Mã ủy quyền đã hết hạn, vui lòng thử lại', spaceLoginExpired: 'Mã ủy quyền đã hết hạn, vui lòng thử lại',
spaceLoginCancel: 'Hủy', spaceLoginCancel: 'Hủy',
spaceLoginVisitLink: 'Truy cập liên kết', spaceLoginVisitLink: 'Truy cập liên kết',
spaceLoginProcessing: 'Đang đăng nhập với Space', spaceLoginProcessing: 'Đang đăng nhập bằng tài khoản LangBot',
spaceLoginProcessingDescription: spaceLoginProcessingDescription:
'Vui lòng chờ trong khi chúng tôi hoàn tất đăng nhập...', 'Vui lòng chờ trong khi chúng tôi hoàn tất đăng nhập...',
spaceLoginSuccessDescription: 'Đang chuyển hướng đến LangBot...', spaceLoginSuccessDescription: 'Đang chuyển hướng đến LangBot...',
@@ -106,7 +106,7 @@ const viVN = {
backToLogin: 'Quay lại đăng nhập', backToLogin: 'Quay lại đăng nhập',
backToHome: 'Quay lại trang chủ', backToHome: 'Quay lại trang chủ',
spaceAccountCannotChangePassword: spaceAccountCannotChangePassword:
'Tài khoản Space không thể đổi mật khẩu tại đây', 'Tài khoản LangBot không thể đổi mật khẩu tại đây',
theme: 'Giao diện', theme: 'Giao diện',
changePassword: 'Đổi mật khẩu', changePassword: 'Đổi mật khẩu',
currentPassword: 'Mật khẩu hiện tại', currentPassword: 'Mật khẩu hiện tại',
@@ -258,9 +258,9 @@ const viVN = {
localProvider: 'Cục bộ', localProvider: 'Cục bộ',
localProviderDescription: 'Các mô hình được cấu hình và quản lý cục bộ', localProviderDescription: 'Các mô hình được cấu hình và quản lý cục bộ',
spaceProviderDescription: spaceProviderDescription:
'Các mô hình được đồng bộ từ tài khoản Space của bạn', 'Các mô hình được đồng bộ từ tài khoản LangBot của bạn',
spaceDisabledForLocalAccount: spaceDisabledForLocalAccount:
'Đăng nhập với Space để sử dụng mô hình đám mây', 'Đăng nhập bằng tài khoản LangBot để sử dụng mô hình đám mây',
syncModels: 'Đồng bộ', syncModels: 'Đồng bộ',
syncSuccess: syncSuccess:
'Đồng bộ hoàn tất: {{created}} đã tạo, {{updated}} đã cập nhật', 'Đồng bộ hoàn tất: {{created}} đã tạo, {{updated}} đã cập nhật',
@@ -297,11 +297,12 @@ const viVN = {
langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space', langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space',
credits: 'Tín dụng', credits: 'Tín dụng',
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot', loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
loginToUseModels: 'Đăng nhập với Space để sử dụng mô hình đám mây', loginToUseModels:
'Đăng nhập bằng tài khoản LangBot để sử dụng mô hình đám mây',
noModels: 'Chưa cấu hình mô hình nào', noModels: 'Chưa cấu hình mô hình nào',
langbotModels: 'Mô hình LangBot', langbotModels: 'Mô hình LangBot',
spaceTrialTooltip: spaceTrialTooltip:
'Có tín dụng dùng thử miễn phí! Đăng nhập với Space để truy cập mô hình đám mây không cần cấu hình.', 'Có tín dụng dùng thử miễn phí! Đăng nhập bằng tài khoản LangBot để truy cập mô hình đám mây không cần cấu hình.',
unlockModels: 'Đăng nhập để sử dụng', unlockModels: 'Đăng nhập để sử dụng',
editProvider: 'Chỉnh sửa nhà cung cấp', editProvider: 'Chỉnh sửa nhà cung cấp',
addProvider: 'Thêm nhà cung cấp', addProvider: 'Thêm nhà cung cấp',
@@ -336,9 +337,9 @@ const viVN = {
}, },
ownerMustBindSpace: ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.', 'The Workspace owner must connect a LangBot Account for LangBot Models.',
usesOwnerSpaceBilling: usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
title: 'Bot', title: 'Bot',
@@ -1222,13 +1223,13 @@ const viVN = {
adminAccountNote: adminAccountNote:
'Tài khoản bạn sử dụng ở đây sẽ được đặt làm tài khoản quản trị viên', 'Tài khoản bạn sử dụng ở đây sẽ được đặt làm tài khoản quản trị viên',
register: 'Đăng ký', register: 'Đăng ký',
initWithSpace: 'Khởi tạo với Space', initWithSpace: 'Khởi tạo bằng tài khoản LangBot',
spaceRecommended: spaceRecommended:
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây', 'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
spaceInfoTip1: spaceInfoTip1:
'Space cung cấp dịch vụ xác thực tài khoản thống nhất mà không tải lên bất kỳ thông tin nhạy cảm nào của bạn.', 'Space cung cấp dịch vụ xác thực tài khoản thống nhất mà không tải lên bất kỳ thông tin nhạy cảm nào của bạn.',
spaceInfoTip2: spaceInfoTip2:
'Đăng nhập bằng tài khoản Space cho phép bạn truy cập Mô hình LangBot và các dịch vụ đám mây khác, bao gồm tín dụng gọi mô hình miễn phí để giúp bạn bắt đầu nhanh chóng.', 'Đăng nhập bằng tài khoản LangBot cho phép bạn truy cập Mô hình LangBot và các dịch vụ đám mây khác, bao gồm tín dụng gọi mô hình miễn phí để giúp bạn bắt đầu nhanh chóng.',
spaceInfoTip3: spaceInfoTip3:
'Phương thức đăng nhập của bạn không ảnh hưởng đến các tính năng khác. Bạn có thể cấu hình và sử dụng mô hình từ các nguồn khác bất cứ lúc nào.', 'Phương thức đăng nhập của bạn không ảnh hưởng đến các tính năng khác. Bạn có thể cấu hình và sử dụng mô hình từ các nguồn khác bất cứ lúc nào.',
registerLocal: 'Đăng ký tài khoản cục bộ', registerLocal: 'Đăng ký tài khoản cục bộ',
@@ -1285,34 +1286,34 @@ const viVN = {
passwordNotSet: 'Chưa đặt', passwordNotSet: 'Chưa đặt',
passwordSetDescription: passwordSetDescription:
'Mật khẩu đã được đặt, bạn có thể đăng nhập bằng email và mật khẩu', 'Mật khẩu đã được đặt, bạn có thể đăng nhập bằng email và mật khẩu',
spaceStatus: 'Tài khoản Space', spaceStatus: 'Tài khoản LangBot',
spaceBound: 'Đã liên kết', spaceBound: 'Đã liên kết',
spaceNotBound: 'Chưa liên kết', spaceNotBound: 'Chưa liên kết',
spaceBoundDescription: spaceBoundDescription:
'Tài khoản Space đã liên kết, có thể sử dụng API mô hình chính thức và dịch vụ đám mây', 'Tài khoản LangBot đã liên kết, có thể sử dụng API mô hình chính thức và dịch vụ đám mây',
bindSpace: 'Liên kết tài khoản Space', bindSpace: 'Liên kết tài khoản LangBot',
bindSpaceDescription: bindSpaceDescription:
'Liên kết để sử dụng API mô hình chính thức và dịch vụ đám mây', 'Liên kết để sử dụng API mô hình chính thức và dịch vụ đám mây',
bindSpaceButton: 'Liên kết', bindSpaceButton: 'Liên kết',
bindSpaceConfirmTitle: 'Xác nhận liên kết', bindSpaceConfirmTitle: 'Xác nhận liên kết',
bindSpaceConfirmDescription: bindSpaceConfirmDescription:
'Bạn sắp liên kết phiên bản cục bộ với tài khoản Space', 'Bạn sắp liên kết phiên bản cục bộ với tài khoản LangBot',
bindSpaceWarning: bindSpaceWarning:
'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản Space.', 'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản LangBot.',
bindSpaceSuccess: 'Liên kết tài khoản Space thành công', bindSpaceSuccess: 'Liên kết tài khoản LangBot thành công',
bindSpaceFailed: 'Liên kết tài khoản Space thất bại', bindSpaceFailed: 'Liên kết tài khoản LangBot thất bại',
bindSpaceInvalidState: bindSpaceInvalidState:
'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.', 'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.',
setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu', setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu',
spaceEmailMismatch: spaceEmailMismatch:
'Email đăng nhập Space không khớp với email tài khoản cục bộ', 'Email tài khoản LangBot không khớp với email tài khoản cục bộ',
space_account_not_registeredTitle: 'Account not registered', space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered: space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required', space_account_binding_requiredTitle: 'LangBot Account connection required',
space_account_binding_required: space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.', 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
}, },
monitoring: { monitoring: {
title: 'Bảng điều khiển', title: 'Bảng điều khiển',
@@ -1638,7 +1639,6 @@ const viVN = {
botCreateSuccess: 'Tạo Bot thành công!', botCreateSuccess: 'Tạo Bot thành công!',
botSaveSuccess: 'Cấu hình Bot đã lưu và bật!', botSaveSuccess: 'Cấu hình Bot đã lưu và bật!',
createError: 'Tạo tài nguyên thất bại', createError: 'Tạo tài nguyên thất bại',
spaceAuthError: 'Khởi tạo ủy quyền Space thất bại',
skipSaveError: 'Lưu trạng thái bỏ qua thất bại. Vui lòng thử lại.', skipSaveError: 'Lưu trạng thái bỏ qua thất bại. Vui lòng thử lại.',
completeSaveError: 'Lưu trạng thái hoàn tất thất bại. Vui lòng thử lại.', completeSaveError: 'Lưu trạng thái hoàn tất thất bại. Vui lòng thử lại.',
step: { step: {
@@ -1666,11 +1666,6 @@ const viVN = {
title: 'Chọn công cụ AI', title: 'Chọn công cụ AI',
description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.', description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.',
}, },
spaceBanner: {
message:
'Kết nối với LangBot Space để nhận tín dụng dùng thử mô hình miễn phí và thiết lập tức thì không cần cấu hình!',
action: 'Ủy quyền với Space',
},
config: { config: {
botInfo: 'Thông tin Bot', botInfo: 'Thông tin Bot',
botNamePlaceholder: 'Nhập tên Bot', botNamePlaceholder: 'Nhập tên Bot',
+24 -41
View File
@@ -83,24 +83,24 @@ const zhHans = {
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务', spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
loginLocal: '使用本地账号登录', loginLocal: '使用本地账号登录',
loginWithPassword: '通过密码登录', loginWithPassword: '通过密码登录',
spaceLoginTitle: '通过 Space 登录', spaceLoginTitle: '通过 LangBot 账号登录',
spaceLoginDescription: '扫描二维码或访问下方链接进行授权', spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
spaceLoginUserCode: '您的验证码', spaceLoginUserCode: '您的验证码',
spaceLoginExpires: '验证码将在 {{seconds}} 秒后过期', spaceLoginExpires: '验证码将在 {{seconds}} 秒后过期',
spaceLoginWaiting: '等待授权中...', spaceLoginWaiting: '等待授权中...',
spaceLoginSuccess: '授权成功', spaceLoginSuccess: '授权成功',
spaceLoginFailed: 'Space 登录失败', spaceLoginFailed: 'LangBot 账号登录失败',
spaceLoginExpired: '验证码已过期,请重试', spaceLoginExpired: '验证码已过期,请重试',
spaceLoginCancel: '取消', spaceLoginCancel: '取消',
spaceLoginVisitLink: '访问链接', spaceLoginVisitLink: '访问链接',
spaceLoginProcessing: '正在通过 Space 登录', spaceLoginProcessing: '正在通过 LangBot 账号登录',
spaceLoginProcessingDescription: '请稍候,正在完成登录...', spaceLoginProcessingDescription: '请稍候,正在完成登录...',
spaceLoginSuccessDescription: '正在跳转到 LangBot...', spaceLoginSuccessDescription: '正在跳转到 LangBot...',
spaceLoginError: '登录失败', spaceLoginError: '登录失败',
spaceLoginNoCode: '缺少授权码', spaceLoginNoCode: '缺少授权码',
backToLogin: '返回登录', backToLogin: '返回登录',
backToHome: '返回首页', backToHome: '返回首页',
spaceAccountCannotChangePassword: 'Space无法在此修改密码', spaceAccountCannotChangePassword: 'LangBot无法在此修改密码',
theme: '主题', theme: '主题',
changePassword: '修改密码', changePassword: '修改密码',
currentPassword: '当前密码', currentPassword: '当前密码',
@@ -243,8 +243,8 @@ const zhHans = {
llmModels: '对话模型', llmModels: '对话模型',
localProvider: '本地', localProvider: '本地',
localProviderDescription: '在本地配置和管理的模型', localProviderDescription: '在本地配置和管理的模型',
spaceProviderDescription: '从您的 Space同步的模型', spaceProviderDescription: '从您的 LangBot同步的模型',
spaceDisabledForLocalAccount: '使用 Space 登录以使用云端模型', spaceDisabledForLocalAccount: '使用 LangBot 账号登录以使用云端模型',
syncModels: '同步', syncModels: '同步',
syncSuccess: '同步完成:创建 {{created}} 个,更新 {{updated}} 个', syncSuccess: '同步完成:创建 {{created}} 个,更新 {{updated}} 个',
syncError: '同步失败:', syncError: '同步失败:',
@@ -279,13 +279,14 @@ const zhHans = {
langbotModelsDescription: 'LangBot Space 提供的云端模型', langbotModelsDescription: 'LangBot Space 提供的云端模型',
credits: '积分', credits: '积分',
loginWithSpace: '使用 LangBot 账号登录', loginWithSpace: '使用 LangBot 账号登录',
loginToUseModels: '通过 Space 登录以使用云端模型', loginToUseModels: '通过 LangBot 账号登录以使用云端模型',
ownerMustBindSpace: '工作区所有者需要绑定 Space 才能使用 LangBot 模型。', ownerMustBindSpace:
usesOwnerSpaceBilling: '使用工作区所有者的 Space 计费与积分。', '工作区所有者需要绑定 LangBot 账号才能使用 LangBot 模型。',
usesOwnerSpaceBilling: '使用工作区所有者的 LangBot 账号计费与积分。',
noModels: '暂无模型', noModels: '暂无模型',
langbotModels: 'LangBot 模型', langbotModels: 'LangBot 模型',
spaceTrialTooltip: spaceTrialTooltip:
'免费试用积分已就绪!通过 Space 登录即可零配置使用云端模型。', '免费试用积分已就绪!通过 LangBot 账号登录即可零配置使用云端模型。',
unlockModels: '登录以使用', unlockModels: '登录以使用',
editProvider: '编辑供应商', editProvider: '编辑供应商',
addProvider: '添加供应商', addProvider: '添加供应商',
@@ -355,10 +356,6 @@ const zhHans = {
log: '日志', log: '日志',
configuration: '配置', configuration: '配置',
logs: '日志', logs: '日志',
runtimeConnected: '已连接',
runtimeConnecting: '连接中',
runtimeDisconnected: '已掉线',
runtimeError: '连接错误',
basicInfo: '基础信息', basicInfo: '基础信息',
basicInfoDescription: '设置机器人名称和描述', basicInfoDescription: '设置机器人名称和描述',
routingConnection: '路由与连接', routingConnection: '路由与连接',
@@ -1165,11 +1162,11 @@ const zhHans = {
description: '这是您首次启动 LangBot', description: '这是您首次启动 LangBot',
adminAccountNote: '您在此处初始化使用的账号将作为管理员账号', adminAccountNote: '您在此处初始化使用的账号将作为管理员账号',
register: '注册', register: '注册',
initWithSpace: '通过 Space 初始化', initWithSpace: '通过 LangBot 账号初始化',
spaceRecommended: '推荐:使用官方提供的稳定模型 API 和云服务', spaceRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
spaceInfoTip1: 'Space 提供统一的账户鉴权服务,不会上传您的任何敏感信息。', spaceInfoTip1: 'Space 提供统一的账户鉴权服务,不会上传您的任何敏感信息。',
spaceInfoTip2: spaceInfoTip2:
'使用 Space登录可使用 LangBot Models 等云服务,您将会获得一定的免费模型调用额度帮助您快速起步。', '使用 LangBot登录可使用 LangBot Models 等云服务,您将会获得一定的免费模型调用额度帮助您快速起步。',
spaceInfoTip3: spaceInfoTip3:
'登录方式不会影响其他功能,您在任何情况下都可以配置使用其他来源的模型。', '登录方式不会影响其他功能,您在任何情况下都可以配置使用其他来源的模型。',
registerLocal: '注册本地账号', registerLocal: '注册本地账号',
@@ -1223,28 +1220,28 @@ const zhHans = {
passwordSet: '已设置', passwordSet: '已设置',
passwordNotSet: '未设置', passwordNotSet: '未设置',
passwordSetDescription: '您已设置本地密码,可使用邮箱密码登录', passwordSetDescription: '您已设置本地密码,可使用邮箱密码登录',
spaceStatus: 'Space', spaceStatus: 'LangBot',
spaceBound: '已绑定', spaceBound: '已绑定',
spaceNotBound: '未绑定', spaceNotBound: '未绑定',
spaceBoundDescription: '已绑定 Space,可使用官方模型 API 和云服务', spaceBoundDescription: '已绑定 LangBot,可使用官方模型 API 和云服务',
bindSpace: '绑定 Space', bindSpace: '绑定 LangBot',
bindSpaceDescription: '绑定后可使用官方模型 API 和云服务', bindSpaceDescription: '绑定后可使用官方模型 API 和云服务',
bindSpaceButton: '绑定', bindSpaceButton: '绑定',
bindSpaceConfirmTitle: '确认绑定', bindSpaceConfirmTitle: '确认绑定',
bindSpaceConfirmDescription: '您即将把本地实例绑定到 Space', bindSpaceConfirmDescription: '您即将把本地实例绑定到 LangBot',
bindSpaceWarning: bindSpaceWarning:
'绑定后,您的登录邮箱将从 {{localEmail}} 更改为 Space的邮箱。', '绑定后,您的登录邮箱将从 {{localEmail}} 更改为 LangBot的邮箱。',
bindSpaceSuccess: 'Space绑定成功', bindSpaceSuccess: 'LangBot绑定成功',
bindSpaceFailed: '绑定 Space失败', bindSpaceFailed: '绑定 LangBot失败',
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起', bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后可使用邮箱密码登录', setPasswordHint: '设置密码后可使用邮箱密码登录',
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配', spaceEmailMismatch: 'LangBot 账号邮箱与本实例账号邮箱不匹配',
space_account_not_registeredTitle: '账户尚未注册', space_account_not_registeredTitle: '账户尚未注册',
space_account_not_registered: space_account_not_registered:
'此 Space 邮箱尚无本地账户,请联系工作区所有者获取邀请。', '此 LangBot 账号邮箱尚无本地账户,请联系工作区所有者获取邀请。',
space_account_binding_requiredTitle: '需要绑定 Space', space_account_binding_requiredTitle: '需要绑定 LangBot 账号',
space_account_binding_required: space_account_binding_required:
'此本地账户必须先在账户设置中绑定 Space,才能使用 Space 登录。', '此本地账户必须先在账户设置中绑定 LangBot 账号,才能使用 LangBot 账号登录。',
}, },
workspace: { workspace: {
title: '工作区', title: '工作区',
@@ -1734,7 +1731,6 @@ const zhHans = {
botCreateSuccess: '机器人创建成功!', botCreateSuccess: '机器人创建成功!',
botSaveSuccess: '机器人配置已保存并启用!', botSaveSuccess: '机器人配置已保存并启用!',
createError: '创建资源失败', createError: '创建资源失败',
spaceAuthError: '无法发起 Space 授权',
skipSaveError: '保存跳过状态失败,请重试。', skipSaveError: '保存跳过状态失败,请重试。',
completeSaveError: '保存完成状态失败,请重试。', completeSaveError: '保存完成状态失败,请重试。',
step: { step: {
@@ -1760,10 +1756,6 @@ const zhHans = {
title: '选择 AI 引擎', title: '选择 AI 引擎',
description: '选择驱动机器人智能的 AI 引擎。', description: '选择驱动机器人智能的 AI 引擎。',
}, },
spaceBanner: {
message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!',
action: '前往授权登录',
},
config: { config: {
botInfo: '机器人信息', botInfo: '机器人信息',
botNamePlaceholder: '请输入机器人名称', botNamePlaceholder: '请输入机器人名称',
@@ -1842,15 +1834,6 @@ const zhHans = {
waitingForScan: '等待扫码中', waitingForScan: '等待扫码中',
retry: '重试', retry: '重试',
}, },
itchat: {
scanLogin: '扫码登录微信',
scanQRCode: '请使用微信扫描以下二维码登录',
loginSuccess: '登录成功!会话已缓存,保存配置后即可使用',
loginFailed: '登录失败',
connecting: '正在启动微信登录...',
waitingForScan: '等待扫码中',
retry: '重试',
},
dingtalk: { dingtalk: {
createApp: '一键创建钉钉应用', createApp: '一键创建钉钉应用',
scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据', scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',
+23 -28
View File
@@ -83,24 +83,24 @@ const zhHant = {
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務', spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
loginLocal: '使用本地帳號登入', loginLocal: '使用本地帳號登入',
loginWithPassword: '透過密碼登入', loginWithPassword: '透過密碼登入',
spaceLoginTitle: '透過 Space 登入', spaceLoginTitle: '透過 LangBot 帳號登入',
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權', spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
spaceLoginUserCode: '您的驗證碼', spaceLoginUserCode: '您的驗證碼',
spaceLoginExpires: '驗證碼將在 {{seconds}} 秒後過期', spaceLoginExpires: '驗證碼將在 {{seconds}} 秒後過期',
spaceLoginWaiting: '等待授權中...', spaceLoginWaiting: '等待授權中...',
spaceLoginSuccess: '授權成功', spaceLoginSuccess: '授權成功',
spaceLoginFailed: 'Space 登入失敗', spaceLoginFailed: 'LangBot 帳號登入失敗',
spaceLoginExpired: '驗證碼已過期,請重試', spaceLoginExpired: '驗證碼已過期,請重試',
spaceLoginCancel: '取消', spaceLoginCancel: '取消',
spaceLoginVisitLink: '訪問連結', spaceLoginVisitLink: '訪問連結',
spaceLoginProcessing: '正在透過 Space 登入', spaceLoginProcessing: '正在透過 LangBot 帳號登入',
spaceLoginProcessingDescription: '請稍候,正在完成登入...', spaceLoginProcessingDescription: '請稍候,正在完成登入...',
spaceLoginSuccessDescription: '正在跳轉到 LangBot...', spaceLoginSuccessDescription: '正在跳轉到 LangBot...',
spaceLoginError: '登入失敗', spaceLoginError: '登入失敗',
spaceLoginNoCode: '缺少授權碼', spaceLoginNoCode: '缺少授權碼',
backToLogin: '返回登入', backToLogin: '返回登入',
backToHome: '返回首頁', backToHome: '返回首頁',
spaceAccountCannotChangePassword: 'Space無法在此修改密碼', spaceAccountCannotChangePassword: 'LangBot無法在此修改密碼',
theme: '主題', theme: '主題',
changePassword: '修改密碼', changePassword: '修改密碼',
currentPassword: '當前密碼', currentPassword: '當前密碼',
@@ -244,8 +244,8 @@ const zhHant = {
llmModels: '對話模型', llmModels: '對話模型',
localProvider: '本地', localProvider: '本地',
localProviderDescription: '在本地設定和管理的模型', localProviderDescription: '在本地設定和管理的模型',
spaceProviderDescription: '從您的 Space同步的模型', spaceProviderDescription: '從您的 LangBot同步的模型',
spaceDisabledForLocalAccount: '使用 Space 登入以使用雲端模型', spaceDisabledForLocalAccount: '使用 LangBot 帳號登入以使用雲端模型',
syncModels: '同步', syncModels: '同步',
syncSuccess: '同步完成:建立 {{created}} 個,更新 {{updated}} 個', syncSuccess: '同步完成:建立 {{created}} 個,更新 {{updated}} 個',
syncError: '同步失敗:', syncError: '同步失敗:',
@@ -279,11 +279,11 @@ const zhHant = {
langbotModelsDescription: '由 LangBot Space 提供的雲端模型', langbotModelsDescription: '由 LangBot Space 提供的雲端模型',
credits: '積分', credits: '積分',
loginWithSpace: '使用 LangBot 帳號登入', loginWithSpace: '使用 LangBot 帳號登入',
loginToUseModels: '使用 Space 登入以使用雲端模型', loginToUseModels: '使用 LangBot 帳號登入以使用雲端模型',
noModels: '暫無模型', noModels: '暫無模型',
langbotModels: 'LangBot 模型', langbotModels: 'LangBot 模型',
spaceTrialTooltip: spaceTrialTooltip:
'免費試用積分已就緒!使用 Space 登入即可零設定使用雲端模型。', '免費試用積分已就緒!使用 LangBot 帳號登入即可零設定使用雲端模型。',
unlockModels: '登入以使用', unlockModels: '登入以使用',
editProvider: '編輯供應商', editProvider: '編輯供應商',
addProvider: '新增供應商', addProvider: '新增供應商',
@@ -317,9 +317,9 @@ const zhHant = {
}, },
ownerMustBindSpace: ownerMustBindSpace:
'The Workspace owner must connect Space for LangBot Models.', 'The Workspace owner must connect a LangBot Account for LangBot Models.',
usesOwnerSpaceBilling: usesOwnerSpaceBilling:
"Uses the Workspace owner's Space billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
title: '機器人', title: '機器人',
@@ -1163,11 +1163,11 @@ const zhHant = {
description: '這是您首次啟動 LangBot', description: '這是您首次啟動 LangBot',
adminAccountNote: '您在此處初始化使用的帳號將作為管理員帳號', adminAccountNote: '您在此處初始化使用的帳號將作為管理員帳號',
register: '註冊', register: '註冊',
initWithSpace: '透過 Space 初始化', initWithSpace: '透過 LangBot 帳號初始化',
spaceRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務', spaceRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
spaceInfoTip1: 'Space 提供統一的帳戶鑑權服務,不會上傳您的任何敏感資訊。', spaceInfoTip1: 'Space 提供統一的帳戶鑑權服務,不會上傳您的任何敏感資訊。',
spaceInfoTip2: spaceInfoTip2:
'使用 Space登入可使用 LangBot Models 等雲服務,您將會獲得一定的免費模型調用額度幫助您快速起步。', '使用 LangBot登入可使用 LangBot Models 等雲服務,您將會獲得一定的免費模型調用額度幫助您快速起步。',
spaceInfoTip3: spaceInfoTip3:
'登入方式不會影響其他功能,您在任何情況下都可以配置使用其他來源的模型。', '登入方式不會影響其他功能,您在任何情況下都可以配置使用其他來源的模型。',
registerLocal: '註冊本地帳號', registerLocal: '註冊本地帳號',
@@ -1221,29 +1221,29 @@ const zhHant = {
passwordSet: '已設定', passwordSet: '已設定',
passwordNotSet: '未設定', passwordNotSet: '未設定',
passwordSetDescription: '您已設定本地密碼,可使用電子郵件密碼登入', passwordSetDescription: '您已設定本地密碼,可使用電子郵件密碼登入',
spaceStatus: 'Space', spaceStatus: 'LangBot',
spaceBound: '已綁定', spaceBound: '已綁定',
spaceNotBound: '未綁定', spaceNotBound: '未綁定',
spaceBoundDescription: '已綁定 Space,可使用官方模型 API 和雲服務', spaceBoundDescription: '已綁定 LangBot,可使用官方模型 API 和雲服務',
bindSpace: '綁定 Space', bindSpace: '綁定 LangBot',
bindSpaceDescription: '綁定後可使用官方模型 API 和雲服務', bindSpaceDescription: '綁定後可使用官方模型 API 和雲服務',
bindSpaceButton: '綁定', bindSpaceButton: '綁定',
bindSpaceConfirmTitle: '確認綁定', bindSpaceConfirmTitle: '確認綁定',
bindSpaceConfirmDescription: '您即將把本地實例綁定到 Space', bindSpaceConfirmDescription: '您即將把本地實例綁定到 LangBot',
bindSpaceWarning: bindSpaceWarning:
'綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 Space的電子郵件。', '綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 LangBot的電子郵件。',
bindSpaceSuccess: 'Space綁定成功', bindSpaceSuccess: 'LangBot綁定成功',
bindSpaceFailed: '綁定 Space失敗', bindSpaceFailed: '綁定 LangBot失敗',
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起', bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
setPasswordHint: '設定密碼後可使用電子郵件密碼登入', setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配', spaceEmailMismatch: 'LangBot 帳號電子郵件與本實例帳號電子郵件不匹配',
space_account_not_registeredTitle: 'Account not registered', space_account_not_registeredTitle: 'Account not registered',
space_account_not_registered: space_account_not_registered:
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.', 'No local account is registered for this LangBot Account email. Ask the Workspace owner for an invitation.',
space_account_binding_requiredTitle: 'Space connection required', space_account_binding_requiredTitle: 'LangBot Account connection required',
space_account_binding_required: space_account_binding_required:
'This local account must connect Space from Account settings before using Space login.', 'This local account must connect a LangBot Account from Account settings before using LangBot Account login.',
}, },
monitoring: { monitoring: {
title: '儀表盤', title: '儀表盤',
@@ -1566,7 +1566,6 @@ const zhHant = {
botCreateSuccess: '機器人建立成功!', botCreateSuccess: '機器人建立成功!',
botSaveSuccess: '機器人配置已儲存並啟用!', botSaveSuccess: '機器人配置已儲存並啟用!',
createError: '建立資源失敗', createError: '建立資源失敗',
spaceAuthError: '無法發起 Space 授權',
skipSaveError: '儲存跳過狀態失敗,請重試。', skipSaveError: '儲存跳過狀態失敗,請重試。',
completeSaveError: '儲存完成狀態失敗,請重試。', completeSaveError: '儲存完成狀態失敗,請重試。',
step: { step: {
@@ -1592,10 +1591,6 @@ const zhHant = {
title: '選擇 AI 引擎', title: '選擇 AI 引擎',
description: '選擇驅動機器人智慧的 AI 引擎。', description: '選擇驅動機器人智慧的 AI 引擎。',
}, },
spaceBanner: {
message: '接入 LangBot Space,取得免費試用模型額度,零配置極速開箱!',
action: '前往授權登入',
},
config: { config: {
botInfo: '機器人資訊', botInfo: '機器人資訊',
botNamePlaceholder: '請輸入機器人名稱', botNamePlaceholder: '請輸入機器人名稱',
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import test from 'node:test';
const localeDir = new URL('../../src/i18n/locales/', import.meta.url);
const localeFiles = readdirSync(localeDir).filter((name) =>
name.endsWith('.ts'),
);
const deprecatedAccountCopy = [
/Initialize with Space/i,
/Login with Space/i,
/Logging in with Space/i,
/Space login/i,
/Space accounts?/i,
/Bind Space Account/i,
/Authorize with Space/i,
/通过 Space 登录/,
/使用 Space 登录/,
/Space 登录/,
/Space 账户/,
/Space 帳戶/,
/绑定 Space/,
/綁定 Space/,
/Space アカウント/,
/Space でログイン/,
/cuenta de Space/i,
/cuentas de Space/i,
/cuenta Space/i,
/tài khoản Space/i,
/บัญชี Space/,
/аккаунт(?:ов|а)? Space/i,
/аккаунт Space/i,
];
test('user-facing account authentication copy uses LangBot Account terminology', () => {
const violations = [];
for (const file of localeFiles) {
const source = readFileSync(new URL(file, localeDir), 'utf8');
for (const pattern of deprecatedAccountCopy) {
if (pattern.test(source)) violations.push(`${file}: ${pattern}`);
}
}
assert.deepEqual(violations, []);
});