feat: add local model gateway and console tabs
This commit is contained in:
@@ -3,3 +3,5 @@ dist/
|
||||
node_modules/
|
||||
*.png
|
||||
data/
|
||||
.env*
|
||||
/docs/references/
|
||||
|
||||
@@ -4,7 +4,8 @@ This repository contains the first runnable ShareServer project. It is an
|
||||
interactive control-console prototype based on the
|
||||
[ShareServer PRD](docs/features/share-server/prd.md), not a production
|
||||
ShareServer implementation. It also includes a persistent local sharing metadata
|
||||
service connected to GoodBuddy's Device Sharing application.
|
||||
service connected to GoodBuddy's Device Sharing application and a local
|
||||
OpenAI-compatible model gateway that forwards requests to configured upstreams.
|
||||
|
||||
## Repository and documentation
|
||||
|
||||
@@ -36,20 +37,17 @@ Source and feature documentation were extracted from GoodBuddy commit
|
||||
action endpoints.
|
||||
- Same-origin React, Vite, TypeScript, Tailwind CSS, and shadcn/ui-style Web
|
||||
console.
|
||||
- Administrator views for overview, members, devices, capabilities, policies,
|
||||
approvals, releases, federation, tasks, audit, and settings.
|
||||
- Member self-service views for personal devices, grants, and requests.
|
||||
- Sharing center at the preserved `/capabilities` route: capability and knowledge
|
||||
catalogs, publisher/device views, URL filters and linked metadata details.
|
||||
Knowledge fixtures distinguish synced copies from remote retrieval and display
|
||||
separate search/read/download permissions; no content operations are connected.
|
||||
- GoodBuddy service demos: `/model-gateway`, `/documents`, and `/distribution`.
|
||||
They simulate upstream routing policies and call audit, document ownership and
|
||||
recycle-bin permissions, OnlyOffice instance checks, and per-group configuration
|
||||
releases and rollback. State survives navigation between these routes but resets
|
||||
on refresh; these actions use browser memory only, with no network requests or
|
||||
local storage. Offline devices retain their applied version and remain pending.
|
||||
- Responsive navigation, dark and light themes, search, filters, details,
|
||||
- Overview with service links; planned modules show pending-service notices.
|
||||
- Sharing center at `/capabilities`: real device and publication metadata,
|
||||
filters, source declarations and separate search/read/download flags; no content
|
||||
operations or organization ownership checks are connected.
|
||||
- Local model gateway at `/model-gateway`: persistent upstreams and model aliases,
|
||||
encrypted upstream credentials, scoped client access keys, real model discovery,
|
||||
request testing, JSON/SSE forwarding, and metadata audits.
|
||||
- `/documents` and `/distribution` are placeholders without document operations,
|
||||
OnlyOffice checks, drafts, releases or rollback; saving gateway models does not
|
||||
deliver configuration to GoodBuddy.
|
||||
- Responsive navigation, dark and light themes, filters, details,
|
||||
forms, confirmations, and keyboard-accessible dialogs.
|
||||
- Production build and a non-root container image.
|
||||
|
||||
@@ -61,17 +59,18 @@ business pages.
|
||||
|
||||
## Not included yet
|
||||
|
||||
The existing snapshot records are read-only fixtures; the service demos mutate
|
||||
only in-memory examples. The action endpoint returns an acknowledgement but does
|
||||
not persist changes. PostgreSQL,
|
||||
initialization and login, organization isolation, real authorization, device
|
||||
Legacy snapshot and acknowledgement endpoints remain in the backend but are no
|
||||
longer used by the console. PostgreSQL,
|
||||
initialization and login, organization isolation, organization authorization, device
|
||||
WebSocket connections, task routing, relay, package storage, federation, and
|
||||
auditable mutations remain production implementation work. The planned Office
|
||||
editing integration also has no current implementation: ONLYOFFICE Docs,
|
||||
object storage, document sessions, save callbacks, checkpoints, and Desktop
|
||||
document tabs remain design work. Model inference, actual gateway forwarding,
|
||||
document saving, and device delivery are not implemented. The OnlyOffice checks
|
||||
use preset results and do not establish an editing session. See the
|
||||
document tabs remain design work. Model hosting and inference, document saving,
|
||||
and device delivery are not implemented. B1 gateway code is implemented with
|
||||
Bearer key authentication and model scopes, but acceptance is partial; GoodBuddy
|
||||
Desktop, browser UI and a full tool-result roundtrip remain unverified for B1.
|
||||
Organization authentication and quotas remain unimplemented. See the
|
||||
[ShareServer feature documentation](docs/features/share-server/README.md).
|
||||
|
||||
Daily work belongs in GoodBuddy; ShareServer provides the organization backend.
|
||||
@@ -95,32 +94,113 @@ The readiness endpoint reports this explicitly:
|
||||
|
||||
## Run locally
|
||||
|
||||
```text
|
||||
cd goodbuddy-shareserver
|
||||
Run from the repository root. The development launcher creates and reuses a
|
||||
persistent master key under the current user's home directory. For first-time
|
||||
Windows PowerShell setup:
|
||||
|
||||
```powershell
|
||||
npm ci
|
||||
$env:SHARESERVER_GATEWAY_BASE_URL = 'http://127.0.0.1:8787/api/v1/gateway/v1'
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The automatic key is outside `SHARESERVER_DATA_DIR` (default `./data`) and is
|
||||
reused on later starts. To manage the key explicitly, create one with
|
||||
`npm run gateway:init-key -- <file>` and set `SHARESERVER_MASTER_KEY_FILE`
|
||||
before starting the service. Back up the key separately from `data/`; restoring
|
||||
encrypted credentials requires the same master key.
|
||||
|
||||
Open `http://127.0.0.1:5174`. Vite proxies `/api` to the Hono service on port
|
||||
`8787`.
|
||||
`8787` by default. When the launcher loads `SHARESERVER_PORT`, it passes the
|
||||
derived API port to Vite so the proxy follows the API child. Vite accepts `/api`
|
||||
requests only from loopback sockets, even if its listen address is changed.
|
||||
|
||||
## Local GoodBuddy integration
|
||||
|
||||
With `npm run dev` running here, run `npm run dev` in the adjacent GoodBuddy
|
||||
checkout. Open Device Sharing from its application center, save the server URL
|
||||
(`http://127.0.0.1:8787`), register the device, and publish a metadata entry.
|
||||
Open `http://127.0.0.1:5174/capabilities?view=local` to view actual records separately
|
||||
from the organization fixtures. Revoke in GoodBuddy and refresh the Web directory.
|
||||
Open `http://127.0.0.1:5174/capabilities?view=local` to view actual records.
|
||||
Organization fixture tabs have been removed. Revoke in GoodBuddy and refresh the Web directory.
|
||||
|
||||
Records persist in `data/sharing.json`; override the directory with
|
||||
`SHARESERVER_DATA_DIR`. Use one process per data directory. This API is a local,
|
||||
unauthenticated development service: device IDs identify records, not owners.
|
||||
`SHARESERVER_DATA_DIR`. Use one process per data directory. This API is a local
|
||||
development service without device authentication, restricted to direct loopback
|
||||
administration: device IDs identify records, not owners.
|
||||
Source modes and permission flags are declarations only; no knowledge content,
|
||||
file synchronization, remote execution, or organization authorization is provided.
|
||||
The [technical design](docs/features/share-server/technical-design.md#41-本地共享目录契约已实现)
|
||||
defines the actual HTTP contract. The readiness response additionally reports this
|
||||
local-service boundary and keeps `productionReady: false`.
|
||||
|
||||
## Local model gateway
|
||||
|
||||
The gateway runs independently of GoodBuddy and has no Desktop source dependency.
|
||||
Open `/model-gateway` in the Web console. A new store starts with no upstreams,
|
||||
models or audits; mutations persist in `data/gateway.json` under
|
||||
`SHARESERVER_DATA_DIR` (default `./data`). Use one process and one store per data
|
||||
directory. It retains the newest 200 chat audits, containing metadata only, never
|
||||
prompts, response content or credentials. Missing token usage remains unknown.
|
||||
|
||||
Add an upstream with its name, HTTP/HTTPS Base URL, OpenAI Chat Completions
|
||||
protocol and API Key (or no authentication). Credentials are encrypted with
|
||||
AES-256-GCM and never returned by the API. Editing defaults to `keep`; `replace`
|
||||
sets a new secret and `clear` removes it. Selecting no authentication does not
|
||||
delete a saved credential. `credentialConfigured` reports stored configuration,
|
||||
not connectivity or successful decryption.
|
||||
|
||||
The master key is supplied by `SHARESERVER_MASTER_KEY_FILE`, or alternatively
|
||||
`SHARESERVER_MASTER_KEY` containing 64 hexadecimal characters; do not set both.
|
||||
Missing or incorrect keys report credential unavailability and never regenerate
|
||||
the key over existing ciphertext.
|
||||
|
||||
Persisted legacy `apiKeyEnv` references remain readable and callable. Replace
|
||||
them explicitly through the upstream editor to use managed credentials. The
|
||||
variable-name/prefix UI and `/import-env` endpoint have been removed. For legacy
|
||||
references or authorized development testing only, an env file can be loaded:
|
||||
|
||||
```text
|
||||
npm run dev -- --env-file=../goodbuddy/.env.deepseek.local
|
||||
```
|
||||
|
||||
`scripts/dev.mjs` passes values loaded from this file only to the API child, not
|
||||
Vite; existing process variables take precedence. Restart the API after environment
|
||||
changes. The adjacent path is optional test input, not a repository dependency.
|
||||
Use preexisting personal configurations only for explicitly authorized environment
|
||||
testing; the gateway does not automatically read or migrate GoodBuddy settings.
|
||||
|
||||
Use **Discover models** to request the upstream model list, add or edit an alias
|
||||
mapping, and explicitly send a real test request. Discovery and testing contact the upstream; chat may incur
|
||||
charges. Manual mapping is also available.
|
||||
|
||||
For an OpenAI-compatible client, use Base URL
|
||||
`http://127.0.0.1:8787/api/v1/gateway/v1`, protocol `openai-chat-completions`,
|
||||
a configured model alias and a newly created gateway access key as the client's
|
||||
API Key. The console shows `SHARESERVER_GATEWAY_BASE_URL`; it does not infer a
|
||||
public address from Vite or request Host headers. Supported
|
||||
client endpoints are `/models` and `/chat/completions`; chat supports JSON or SSE,
|
||||
tool-call data and upstream usage. Responses and Anthropic APIs are not supported, and the
|
||||
gateway does not execute tools. Requests are limited to 1 MiB, upstream responses
|
||||
to 16 MiB, and the default request timeout is 120 seconds. Exact stream limits and
|
||||
API contracts are in [technical design section 4.2](docs/features/share-server/technical-design.md#42-本地模型网关契约已实现).
|
||||
|
||||
Both client endpoints require a Bearer access key. Create a key for selected
|
||||
model record IDs; renaming an alias preserves its scope. The full key is shown
|
||||
only at creation, and only its SHA-256 digest is stored. Missing, invalid or
|
||||
revoked keys return 401; unauthorized models return 403. Model lists include only
|
||||
authorized, enabled models with enabled upstreams and usable credentials. Audits
|
||||
link the access key ID, which identifies a calling configuration, not a user.
|
||||
|
||||
Management and the legacy sharing API require a direct loopback socket. For these
|
||||
requests, the API rejects `Forwarded`, `X-Forwarded-*` and `X-Real-IP` headers and checks Origin;
|
||||
Origin alone is not authorization. Web tests use local-only `/api/v1/gateway/test`,
|
||||
not a Bearer bypass on client endpoints. Reverse-proxy and container administration
|
||||
are unsupported until B2 login exists; a proxy's loopback connection must not be
|
||||
treated as an administrator. Nonlocal clients must reach the API inference
|
||||
endpoints directly with a key, not through Vite. Use HTTPS for external deployment;
|
||||
HTTP carries keys without transport encryption. Organization isolation and quotas
|
||||
remain pending. Dated verification is in [progress](docs/features/share-server/progress.md).
|
||||
|
||||
## Run the production build
|
||||
|
||||
```text
|
||||
@@ -131,20 +211,26 @@ npm start
|
||||
|
||||
Open `http://127.0.0.1:8787`.
|
||||
|
||||
Use the same master-key and gateway Base URL environment settings as development
|
||||
when starting the build. A successful build does not imply production readiness.
|
||||
|
||||
Build output is `dist/web/` for the console and `dist/server/` for the backend.
|
||||
`npm start` runs `dist/server/server/src/index.js` from the repository root and
|
||||
serves `dist/web/`. The backend preserves the source directory structure under
|
||||
its output directory so compiled imports can resolve root `shared/` modules.
|
||||
|
||||
Set `SHARESERVER_PORT` to use another listen port. Local runs bind to
|
||||
`127.0.0.1` by default; set `SHARESERVER_HOST` explicitly when a trusted reverse
|
||||
proxy or container needs another interface. Do not expose the prototype as a
|
||||
production service.
|
||||
`127.0.0.1` by default; `SHARESERVER_HOST` changes the listen interface, not the
|
||||
management boundary. Do not expose the prototype as a production service.
|
||||
|
||||
## Container
|
||||
|
||||
Container configuration lives in `docker/`. Run these commands from the repository
|
||||
root to build and start the Web console and API together:
|
||||
Container configuration lives in `docker/`. The existing commands below build and
|
||||
start the image, but B1 does not support container/proxy administration: published
|
||||
ports usually reach the API from a non-loopback socket and management returns 403.
|
||||
The supplied Compose file does not configure the master-key file. Managed
|
||||
credentials require a separate secret mount outside `/app/data` and its environment
|
||||
setting; container management must wait for B2. Run from the repository root:
|
||||
|
||||
```text
|
||||
mkdir -p data
|
||||
@@ -155,11 +241,12 @@ docker compose -f docker/compose.yaml logs -f shareserver
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8787`. The container runs as the non-root `node` user
|
||||
and stores sharing metadata in `/app/data`, bind-mounted from the project's
|
||||
and stores sharing and gateway metadata in `/app/data`, bind-mounted from the project's
|
||||
`./data` directory. No named volume or additional database container is required.
|
||||
Existing local `data/sharing.json` is used directly and must be readable by UID 1000.
|
||||
Only sharing metadata is
|
||||
persisted; the console's other demos retain their prototype behavior.
|
||||
Gateway configuration and its bounded audit metadata use `data/gateway.json` in
|
||||
the same mount; upstream credentials are encrypted there and access keys are
|
||||
stored as digests. The master key must be supplied and backed up separately.
|
||||
|
||||
Stop with `docker compose -f docker/compose.yaml down`; the host directory survives
|
||||
container recreation and removal with `down -v`. Run only one process per data directory, including
|
||||
@@ -176,7 +263,7 @@ docker run --rm -p 127.0.0.1:8787:8787 --mount type=bind,source="$(pwd)/data",ta
|
||||
|
||||
Compose publishes port 8787 on all host interfaces; the standalone example
|
||||
publishes only on the host loopback interface. Containerization
|
||||
does not add authentication or authorization; this remains a local prototype.
|
||||
does not add organization login or relax B1's socket restrictions.
|
||||
The image health check verifies HTTP availability, not storage writability or
|
||||
production readiness.
|
||||
|
||||
|
||||
@@ -23,34 +23,43 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [产品功能列表](./feature-list.md) | 按模块、页面和 Tab 汇总功能范围、状态和当前交付边界 |
|
||||
| [PRD](./prd.md) | 管理角色、产品范围、功能需求和验收 |
|
||||
| [用户故事](./user-stories.md) | 工作空间、实时消息、资料同步及数字分身处理的验收场景与逻辑审查 |
|
||||
| [功能逻辑](./logic-design.md) | 组织隔离、设备、发布、策略、任务和联邦状态规则 |
|
||||
| [管理界面设计](./ui-design.md) | ShareServer Web 控制台的信息架构与交互 |
|
||||
| [技术设计](./technical-design.md) | 服务模块、API、数据、部署、运维和验证 |
|
||||
| [后端基础设计](./backend-foundation-design.md) | GoodBuddy 适配依据;B1 已实现、验收部分完成,B2 至 B6 待实现 |
|
||||
| [实施进度](./progress.md) | 已验证实现、剩余生产工作与验证证据 |
|
||||
| [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威(GoodBuddy 仓库) |
|
||||
| [Desktop Office 协同编辑](../../../../goodbuddy/docs/features/office-document-editing/README.md) | 文档 Tab、源文件回写、冲突和 AI 选区编辑(GoodBuddy 仓库) |
|
||||
|
||||
工作空间协作场景由用户故事追踪;管理员、能力发布者、审计员等既有场景继续由 PRD 与逻辑
|
||||
设计覆盖。仓库已有全控制台交互原型及本地共享目录服务,后者持久化 GoodBuddy 注册和发布的元数据。
|
||||
设计覆盖。仓库已有控制台交互原型、本地共享目录服务和真实转发的本地模型网关;目录持久化
|
||||
GoodBuddy 注册和发布的元数据,网关独立保存上游、模型映射和有界调用元数据。
|
||||
组织生产服务尚未实现。当前边界和运行方法见[实施进度](./progress.md)与
|
||||
[`README.md`](../../../README.md)。跨仓库文档链接要求将 `goodbuddy` 与
|
||||
[`README.md`](../../../README.md)。历史演示与测试记录保留其原有范围,当前实现说明不追溯扩大验证结论。
|
||||
跨仓库文档链接要求将 `goodbuddy` 与
|
||||
`goodbuddy-shareserver` 克隆到同一级目录;服务构建和运行不依赖桌面端仓库。
|
||||
|
||||
当前源码按 `web/src/`、`server/src/` 和根 `shared/` 组织,构建、测试与依赖仍由根目录统一管理。
|
||||
目录和产物路径见[技术设计第 1 节](./technical-design.md#1-仓库与运行边界)。
|
||||
|
||||
## GoodBuddy 服务演示
|
||||
## GoodBuddy 服务入口
|
||||
|
||||
当前新增 `/model-gateway`(模型网关)、`/documents`(文档与协作)、`/distribution`(配置下发)
|
||||
三个路由,演示策略判断、文档归属与回收站、OnlyOffice 实例配置和检查、按组发布与回退。
|
||||
三页导航共享会话内存,刷新重置;服务演示操作不发网络请求、不写本地存储。角色开关、检查
|
||||
结果和在线设备应用状态均为模拟,未实现推理、真实转发、Office 编辑保存或设备交付。
|
||||
具体交互见 [UI 设计第 16.1 节](./ui-design.md#161-goodbuddy-服务演示)。
|
||||
`/model-gateway` 提供上游与模型映射保存、服务端加密凭据、模型访问 Key、真实模型发现、请求测试
|
||||
和调用元数据审计。管理仅限直连回环,客户端通过 Bearer Key 访问获授权的模型;组织认证、组织
|
||||
隔离及配额控制仍待实现。旧环境引用保留存储兼容,前缀导入界面和接口已移除。
|
||||
网关独立于 GoodBuddy 运行,无桌面源码依赖;个人既有配置仅可用于明确授权的环境测试,不自动读取或迁移。
|
||||
启动方法及客户端 Base URL 见[根 README](../../../README.md#local-model-gateway),接口、存储和限制见
|
||||
[技术设计第 4.2 节](./technical-design.md#42-本地模型网关契约已实现)。
|
||||
|
||||
共享中心沿用 `/capabilities`。能力目录、共享知识库、按用户查看和按设备查看使用示例数据;
|
||||
“本地服务目录”页签读取真实本地记录。来源模式、权限展示与跳转约定见
|
||||
`/documents` 显示待接入说明,没有示例文档或 OnlyOffice 模拟检查。`/distribution` 同样为待接入占位,
|
||||
无配置草稿、设备回执、发布或回滚;保存网关模型
|
||||
不会自动下发到 GoodBuddy。交互见 [UI 设计第 16.1 节](./ui-design.md#161-goodbuddy-服务入口)。
|
||||
|
||||
`/devices` 和共享中心沿用真实本地 API:前者展示登记设备及其发布数量,后者直接读取设备与发布记录,支持筛选、设备跳转和撤销已发布元数据;已移除组织示例页签。
|
||||
来源模式与知识权限仅为声明,尚无正文服务。交互约定见
|
||||
[UI 设计](./ui-design.md),本地 API 见[技术设计第 4.1 节](./technical-design.md#41-本地共享目录契约已实现)。
|
||||
|
||||
本地体验:在本仓库运行 `npm run dev`,在 GoodBuddy 仓库运行 `npm run dev`,从桌面应用中心
|
||||
@@ -58,6 +67,10 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不
|
||||
`http://127.0.0.1:5174/capabilities?view=local` 查看。服务数据默认保存到 `data/sharing.json`。
|
||||
完整桌面范围与验证见[设备共享文档](../../../../goodbuddy/docs/features/device-sharing/README.md)。
|
||||
|
||||
[后端基础设计 B1](./backend-foundation-design.md#4-b1正常模型网关接入)代码已实现,验收部分完成:
|
||||
已记录 103 项自动化测试及部分真实 API 验证;完整工具结果回传、GoodBuddy Desktop 和浏览器界面
|
||||
验收仍未完成。Vite `/api` 只允许回环 socket,容器/反向代理管理须等待 B2 登录。
|
||||
|
||||
## 角色
|
||||
|
||||
- **系统管理员**:维护实例、身份源、域名、证书、存储和全局限制。
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# 基于 GoodBuddy 的后端基础设计
|
||||
|
||||
状态:B1 已实现、验收部分完成,2026-10-05;B2 至 B6 待实现。原始设计日期为 2026-10-04。
|
||||
本文是[技术设计](./technical-design.md)的基础服务实施分册,维护客户端适配依据、接口和实施顺序。
|
||||
产品范围由 [PRD](./prd.md)维护,业务状态由[功能逻辑](./logic-design.md)维护,已验证事实见[实施进度](./progress.md)。
|
||||
|
||||
## 1. 设计结论
|
||||
|
||||
GoodBuddy 承担模型选择、对话、工具执行、知识检索入口和文档日常操作。ShareServer 独立运行,
|
||||
负责模型请求转发、组织身份与授权、共享目录和服务端文档存储。ONLYOFFICE 是独立编辑服务。
|
||||
不把 Electron、客户端 SQLite、Agent Runtime 或客户端源码引入服务端构建。
|
||||
|
||||
基础功能优先利用客户端已有调用路径:先完成正常的模型网关接入,再增加组织与设备身份,
|
||||
随后实现组织模型导入和真实资源发布。知识检索、文档编辑各自按实际用户动作验收。
|
||||
完整组织服务仍以 PostgreSQL 为目标;本地网关阶段继续单进程文件存储,不先建设消息系统、联邦或通用任务平台。
|
||||
|
||||
## 2. 客户端依据与差距
|
||||
|
||||
下表链接指向相邻 `goodbuddy` 仓库,要求两个仓库位于同一级目录。只引用契约,不建立源码依赖。
|
||||
|
||||
| 客户端实现 | 已核对行为 | 后端设计影响 |
|
||||
| --- | --- | --- |
|
||||
| [模型配置 schema](../../../../goodbuddy/src/shared/contracts.ts) | profile 含 `baseUrl`、`modelName`、显式 `protocol`、认证方式及能力设置;模型 ID 最长 128 字符 | 开放模型 ID 按客户端限制保存;目录携带协议,不能只返回显示名称 |
|
||||
| [OpenAI 地址拼接](../../../../goodbuddy/src/main/agent/openai-endpoint.ts) | 在 Base URL 后追加 `chat/completions` 或 `responses`;不自动增加 `/v1` | 接入信息必须给出包含 `/v1` 的完整 Base URL |
|
||||
| [模型 Runtime](../../../../goodbuddy/src/main/agent/model-runtime.ts) | 连接测试发非流式生成请求;对话走 SSE,包含工具、工具结果和用量字段 | 获取模型成功不能代替连接测试;必须验收 JSON、SSE 与工具往返 |
|
||||
| [配置存储](../../../../goodbuddy/src/main/runtime-settings-store.ts)、[凭据加密](../../../../goodbuddy/src/main/settings-credential-cipher.ts) | Main 保管模型 Key,Renderer 只看到是否已配置 | Desktop 保存网关访问 Key,上游 Key 留在 ShareServer |
|
||||
| [模型选择](../../../../goodbuddy/src/shared/runtime-selection-contracts.ts) | 会话、项目、全局逐层解析模型 profile | 导入组织模型不覆盖个人模型,也不自动更换已有会话选择 |
|
||||
| [设备共享服务](../../../../goodbuddy/src/main/device-sharing-service.ts) | 单服务 URL,稳定设备 ID,HTTP 注册、列表、发布和撤销;没有认证或心跳 | 现有 ID 不能当作用户身份;注册记录不能标为在线 |
|
||||
| [发布契约](../../../../goodbuddy/src/shared/device-sharing-contracts.ts) | 仅名称、描述、类型、来源模式和权限声明,没有实际资源 ID | 不能从目录记录直接派发执行或检索请求 |
|
||||
| [能力目录](../../../../goodbuddy/src/main/capabilities/capability-service.ts) | 可枚举本机 Skill、MCP 等配置 | 后续发布选择器可复用枚举;枚举结果不自动授权共享 |
|
||||
| [知识服务](../../../../goodbuddy/src/main/knowledge/knowledge-service.ts) | `retrieve`、`retrieveMany` 是现有检索入口 | 提供端经授权后直接调用知识服务,不用 Agent 提示词代替检索协议 |
|
||||
| [外部知识契约](../../../../goodbuddy/src/shared/external-knowledge-contracts.ts) | 当前提供者是 Dify、FastGPT、RAGFlow | ShareServer 共享知识需要新增适配,不能假设现有外部连接已支持 |
|
||||
| [Office 功能文档](../../../../goodbuddy/docs/features/office-document-editing/README.md) | Office 编辑尚在设计;文件解析与外部打开已有实现 | 服务端 callback 和客户端文档 Tab 都要实现,不能把解析当作编辑 |
|
||||
|
||||
GoodBuddy 目前没有自动获取网关模型并创建 profile 的通用流程。现有设备共享页面也不会自动
|
||||
启用模型连接。设备共享应用仍默认关闭,名称保留“设备共享(技术预览)”。
|
||||
|
||||
模型协议分别验收:本期只支持 `openai-chat-completions`。Responses、Anthropic、图像生成和
|
||||
Embedding 均不因为上游声称“OpenAI 兼容”而自动支持。现有网关也没有这些转发端点。
|
||||
远程 Agent 使用模型时,发请求的 Host 必须能访问网关;桌面本机的 `127.0.0.1` 地址不能直接用于该场景。
|
||||
|
||||
## 3. 服务划分与存储
|
||||
|
||||
| 服务 | 第一项可用行为 | 数据归属 |
|
||||
| --- | --- | --- |
|
||||
| 模型网关 | 保存上游、开放模型、验证访问 Key、转发并记录调用 | 上游配置及凭据、映射、访问 Key 摘要、调用元数据 |
|
||||
| 身份与组织 | 管理员初始化、成员登录、加入组织 | 用户、组织、成员关系、会话 |
|
||||
| 设备与目录 | 认证设备登记、撤销、发布者和资源绑定 | 设备归属、发布记录、动作授权;设备连接状态单独管理 |
|
||||
| 客户端配置 | 显式获取并导入组织模型,回报应用结果 | 组织配置版本与每设备接收/应用结果 |
|
||||
| 知识共享 | 对指定发布条目检索并返回引用 | 服务端保存发布元数据;远程模式的正文仍在提供设备 |
|
||||
| 文档服务 | 上传、读取、编辑后保存、按归属删除或转交 | 主文档、归属、版本、回收记录;编辑会话单独保存 |
|
||||
|
||||
继续沿用 Hono 路由、业务 Store 和根 `shared/` DTO。业务增长后按上述职责拆文件,不预先建立
|
||||
空模块树或通用插件框架。HTTP 边界校验输入与输出投影,控制台只使用 API,不直接访问存储。
|
||||
|
||||
现有 `sharing.json` 与 `gateway.json` 保留。网关新增秘密使用服务端加密密文,组织阶段再将
|
||||
用户、组织、设备和授权纳入 PostgreSQL 事务。迁移要保留现有 ID 和撤销事实;没有身份的旧目录
|
||||
只能作为未绑定记录,不能按设备名称自动认领。迁移命令、备份和重复执行行为随数据库实现交付。
|
||||
|
||||
## 4. B1:正常模型网关接入
|
||||
|
||||
这一实施单元完成本地可用路径,不依赖组织数据库。支持一个 ShareServer 实例、多个上游和
|
||||
多个模型映射;使用本机管理入口,GoodBuddy 可手工配置网关。组织分组、费用结算、自动切换上游另行实现。
|
||||
|
||||
### 4.1 上游与凭据
|
||||
|
||||
新建上游输入 `name`、`baseUrl`、`protocol=openai-chat-completions`、`authentication=api-key|none`、
|
||||
`enabled`。使用 API Key 时输入真实密钥。更新通过 `credentialAction=keep|replace|clear` 区分
|
||||
保留、替换和清除,空输入不解释为清除。响应只返回 `credentialConfigured`,不返回密钥或密文。
|
||||
|
||||
使用 Node 标准加密实现 AES-256-GCM;主密钥来自部署配置或数据目录外的秘密文件,首次配置
|
||||
命令为 `npm run gateway:init-key -- <文件路径>`,父目录须已存在,拒绝覆盖已有文件。
|
||||
通过 `SHARESERVER_MASTER_KEY_FILE` 指定文件,或用 `SHARESERVER_MASTER_KEY` 提供 64 位十六进制值,不能同时设置。
|
||||
密文带随机 nonce 与认证标签,以稳定上游 ID 为 AAD,随上游记录原子保存,避免独立文件半提交。
|
||||
缺少或错误的主密钥应报告凭据不可用,不能生成新密钥覆盖既有密文。备份说明要求单独保管主密钥。
|
||||
主密钥不进入 Web、普通数据备份和日志;HTTP 请求正文不记录密钥。
|
||||
|
||||
现有环境变量引用是已持久化的数据,需要继续可读、可调用;编辑时可以显式替换为托管凭据。
|
||||
移除产品界面的前缀导入和变量名输入,环境文件加载仅保留为开发测试方式,不自动扫描 GoodBuddy 配置。
|
||||
|
||||
Base URL 接受 HTTP/HTTPS,保留合法服务路径,拒绝内嵌凭据、query 和 fragment,去掉尾斜杠。
|
||||
可信内网 HTTP 上游已属于支持范围。访问 Key 经非加密 HTTP
|
||||
传输的部署限制在接入说明中说明,对外部署使用 HTTPS;不为此加入额外网络发现流程。
|
||||
|
||||
### 4.2 模型与转发
|
||||
|
||||
发现模型调用上游 `/models`,发现失败不删除已保存配置,也不阻止手动填写模型 ID。
|
||||
用户选择模型并指定对外 ID 后才创建映射。新对外 ID 最长 128 字符,首字符为 ASCII 字母或数字,
|
||||
其余允许字母、数字、下划线、点、冒号、斜杠、连字符;显示名称单独保存。现有更长别名保留调用,
|
||||
在导入 GoodBuddy 前要求管理员显式改名,不静默截断已有标识。
|
||||
|
||||
每个对外 ID 明确绑定一个上游和远端模型。转发只替换模型名与上游认证,不执行工具,不自动重试
|
||||
有可能已经执行的请求。工具参数、工具结果和用量按 Chat Completions 协议传递;取消传播至上游。
|
||||
保留现有超时、输出大小边界和终态审计。上游缺失用量记录为未知,不显示为零消耗。
|
||||
|
||||
### 4.3 网关访问与管理边界
|
||||
|
||||
客户端访问 Key 与上游 API Key 分开。服务端生成随机访问 Key,只在创建成功时返回完整值,
|
||||
持久化 SHA-256 摘要、ID、名称、创建/撤销时间和允许的模型记录 UUID;别名改名不改变授权,列表不返回原值。可撤销后重新创建,
|
||||
不提供读取旧 Key。初期 Key 代表一个调用配置,审计可关联 Key ID,但不能把备注名解释为已认证用户。
|
||||
|
||||
`/v1/models` 与 `/v1/chat/completions` 使用 Bearer Key;未知或撤销的 Key 返回 401,
|
||||
无权调用该模型返回 403。列表只返回该 Key 允许且可用的模型。停用或撤销阻止后续请求,
|
||||
已开始的流按原请求结束或由调用方取消;本期不承诺撤销自动追回已输出内容。
|
||||
|
||||
本机管理接口不能仅依靠 Origin 检查授权。B1 默认管理请求限直连回环来源,校验实际连接地址,
|
||||
拒绝携带 `Forwarded`、`X-Forwarded-*` 或 `X-Real-IP` 的管理请求,并检查 Origin。
|
||||
Vite 在代理 `/api` 前也检查原始 socket,只允许回环。除健康和就绪 GET 外,非回环 API 请求
|
||||
仅能访问已带 Key 的推理接口;无认证旧目录 API 也保持
|
||||
本机访问。容器/反向代理管理需要 B2 登录接入,不能将代理地址当作管理员。Web 网关测试仍通过
|
||||
网关转发逻辑,由本机管理入口调用,不在浏览器长期保存管理秘密或把豁免开放给推理接口。
|
||||
|
||||
### 4.4 已实现接口
|
||||
|
||||
下表记录 B1 已实现接口,字段、错误及资源限制以技术设计第 4.2 节为准。前缀导入接口已移除,旧环境引用仅保留存储兼容。
|
||||
|
||||
| 路径 | 已实现行为 |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/gateway/config` | 上游与模型公开投影、调用元数据、部署指定 `gatewayBaseUrl`;不携带任何密文或完整 Key |
|
||||
| `POST /api/v1/gateway/upstreams`、`PUT /api/v1/gateway/upstreams/:id` | 接收地址、协议、认证方式和凭据操作,替代新表单的 `apiKeyEnv` |
|
||||
| `POST /api/v1/gateway/upstreams/:id/discover` | 真实模型发现,保留手工映射入口 |
|
||||
| `POST /api/v1/gateway/models`、`PUT /api/v1/gateway/models/:id` | 保存对外 ID、显示名、远端模型和启停 |
|
||||
| `GET/POST /api/v1/gateway/access-keys` | 列表或创建模型访问 Key |
|
||||
| `POST /api/v1/gateway/access-keys/:id/revoke` | 撤销 Key,重复撤销返回同一撤销事实 |
|
||||
| `POST /api/v1/gateway/test` | 本机管理端非流式测试;复用真实转发及审计,不接受自定义上游 URL |
|
||||
| `GET /api/v1/gateway/v1/models` | Bearer 认证后的 OpenAI 模型列表 |
|
||||
| `POST /api/v1/gateway/v1/chat/completions` | Bearer 认证、模型授权、JSON/SSE 转发 |
|
||||
|
||||
Key 无效返回 401,其余错误沿用 `{error:{code,message},requestId}`,请求 ID 与调用
|
||||
审计关联。不能把上游含密钥的错误正文原样返回;保留可定位的上游状态和错误类别。
|
||||
|
||||
### 4.5 GoodBuddy 使用与验收
|
||||
|
||||
管理员保存上游并开放模型后,在网关接入区取得 Base URL、协议和对外模型 ID,创建访问 Key。
|
||||
GoodBuddy 在现有模型连接页面手动新增配置:
|
||||
|
||||
```text
|
||||
Base URL: http://127.0.0.1:8787/api/v1/gateway/v1
|
||||
Protocol: openai-chat-completions
|
||||
Model: 管理员开放的模型 ID
|
||||
API Key: ShareServer 签发的访问 Key
|
||||
```
|
||||
|
||||
该操作不依赖设备共享开关,不读取供应商 Key,不修改个人已有 profile。异机调用用客户端可达
|
||||
的 ShareServer 地址;界面显示配置的公开地址,不能把 Vite 端口或请求中的任意 Host 当作权威地址。
|
||||
|
||||
以下为完整验收清单,尚未全部通过。2026-10-05 已完成自动化检查和部分真实 API 验证,
|
||||
包括发现、JSON/SSE、工具选择及本地执行、重启持久化与撤销拒绝;工具结果未回传上游,
|
||||
未验证 GoodBuddy Desktop;浏览器仅核对页面快照,交互与视觉验收未完成。详细证据和剩余项目见[实施进度](./progress.md)。
|
||||
|
||||
验收依次检查:
|
||||
|
||||
1. 从空目录经 Web 保存真实上游,发现或手动添加模型;刷新、重启仍保留配置,密钥不回显。
|
||||
2. GoodBuddy 现有模型连接测试成功,真实会话收到流式回答,审计可以关联模型和访问 Key。
|
||||
3. 用无副作用工具完成一次真实工具调用与结果回传;停止生成时上游请求被取消。
|
||||
4. 错误 Key、撤销 Key、未授权模型和停用上游均拒绝新调用,错误不伪装成空成功响应。
|
||||
5. 上游超时、SSE 中断、凭据缺失、主密钥错误均给出准确状态;检查持久化及日志不含明文秘密或对话正文。
|
||||
6. 验证 HTTP 内网上游及管理接口来源限制。单元测试不能替代上述桌面与真实服务验证。
|
||||
|
||||
这组验证只覆盖本机模型 Runtime。OpenCode、Continue、Harness 和远程 Agent 另行记录结果,
|
||||
不从同一个 profile 推断全部 Runtime 可用。真实模型验证记录调用次数,不将配置下发标记为已实现。
|
||||
|
||||
## 5. 后续基础服务实施顺序
|
||||
|
||||
| 单元 | 交付内容 | 前置条件与完成标准 |
|
||||
| --- | --- | --- |
|
||||
| B2 身份与组织 | PostgreSQL 迁移、首个管理员、登录退出、单组织成员、设备绑定与撤销;Web 使用会话,Desktop 凭据在 Main 保存 | 从可信本机管理扩展到组织部署;两个不同成员不能改动对方设备或私人资源;退出或撤销后新请求被拒 |
|
||||
| B3 组织模型导入 | 认证目录、用户显式导入、组织模型来源标记、配置版本与应用回报 | 依赖 B2;客户端创建独立 profile,保留个人配置;离线为待接收,收到后保存成功才为已应用 |
|
||||
| B4 真实资源发布 | 从本机能力/知识库选择资源,登记稳定来源 ID、所有者、提供设备及独立动作权限 | 依赖 B2;客户端增加资源绑定和提供端通道;无心跳不显示在线,旧手工目录不自动成为可调用资源 |
|
||||
| B5 共享知识检索 | 消费者选共享库,服务端授权并路由,提供端调用 `retrieveMany`,返回有界引用 | 依赖 B4;两台 GoodBuddy 真实检索,撤销及提供端离线有准确失败;正文读取、下载各自另验收 |
|
||||
| B6 服务端文档 | 主文档上传、读取、个人删除、转交组织、回收;再接 ONLYOFFICE 编辑保存与 GoodBuddy 文档入口 | 归属功能依赖 B2;保存后重开及重启可见,转交后个人删除权失效,管理员元数据可见不赋予正文权限 |
|
||||
|
||||
Office 私有 DOCX 试验仍可按技术设计第 10.1 节独立验证部署与保存。该试验没有用户归属,
|
||||
不能替代 B6。后续服务端主存文档保存不要求 Desktop 回写确认;显式本地/SSH 来源模式另走既有源文件契约。
|
||||
|
||||
B3 先实现主动获取和显式导入,不以推送、离线队列或回滚系统为第一步。组织配置关联服务端稳定
|
||||
模型 ID,个人 profile 不由组织配置覆盖。组织撤销后网关直接拒绝访问,客户端缓存不能维持授权。
|
||||
|
||||
B4 的提供端连接必须单独实现。已有 Agent 执行通道没有发布身份、共享授权和知识正文协议,不能
|
||||
直接暴露为共享 API。远程知识模式不上传整个库;服务器副本模式须实际完成文件与索引写入才可标为可检索。
|
||||
|
||||
## 6. 文档与实施记录
|
||||
|
||||
2026-10-04 设计轮只完成设计与源码核对,不更改两仓运行代码、不读取 `.env*`、不调用上游模型。
|
||||
现有模拟接口仍留在服务端源码但已退出控制台路由,后续清理应单独验证调用方,不作为基础服务使用。
|
||||
2026-10-05 按 B1 代码同步技术设计第 4.2 节、逻辑规则、UI 设计、运行文档和实施进度,
|
||||
将实现状态与部分验收结果分开记录。B2 至 B6 和 B1 剩余验收继续按上述顺序推进。
|
||||
|
||||
跨产品网络与 Office 契约继续引用 GoodBuddy 原文;其总体架构仍有“没有真实 Desktop 集成”的旧描述,
|
||||
应在后续 GoodBuddy 文档同步时改为“已有元数据接线,完整共享网络待实现”。本轮不修改相邻仓库。
|
||||
@@ -0,0 +1,259 @@
|
||||
# ShareServer 产品功能列表
|
||||
|
||||
## 文档用途
|
||||
|
||||
本文用于确定 ShareServer 的产品信息架构、页面范围和功能状态。功能按“模块、页面、Tab、功能”组织,页面结构先于功能细节确定。
|
||||
|
||||
状态含义如下:
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| 已实现 | 代码和自动化验证已覆盖,范围以实施进度中的证据为准 |
|
||||
| 部分实现 | 已有可运行功能,但生产验收或关键依赖尚未完成 |
|
||||
| 设计中 | 已形成产品和技术设计,尚未作为生产功能交付 |
|
||||
| 待规划 | 已进入产品方向,页面和接口仍需进一步确定 |
|
||||
|
||||
ShareServer 是 GoodBuddy 的独立组织后台。成员的日常操作主要在 GoodBuddy 中完成,ShareServer 负责设备目录、能力发布、授权、任务、模型网关、文档和审计等组织能力。当前仓库已经包含本地共享目录和真实转发的本地模型网关,生产身份、组织隔离、设备控制连接和工具执行尚未完成。
|
||||
|
||||
## 页面组织规则
|
||||
|
||||
- 一级导航按业务对象分组,页面只负责一个相对完整的管理对象。
|
||||
- 页面内 Tab 按查看范围或操作阶段划分,不能把配置、运行记录和统计混在同一个 Tab。
|
||||
- 列表、详情和创建表单属于同一 Tab 的交互,不单独扩成一级导航。
|
||||
- Tab 名称使用产品对象或用户任务命名,避免使用“其他”“更多功能”等收纳式名称。
|
||||
- 当前未接入的 Tab 可以进入,但必须显示待接入边界,不生成示例数据,也不提供不会生效的操作。
|
||||
|
||||
## 信息架构总览
|
||||
|
||||
| 一级模块 | 页面 | Tab | 当前状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 工作台 | 运行概览 | 服务状态、待处理事项、最近活动 | 部分实现 |
|
||||
| GoodBuddy 服务 | 模型网关 | 概览、上游服务、模型目录、访问密钥、请求测试、调用审计、客户端接入 | 部分实现 |
|
||||
| GoodBuddy 服务 | 文档与协作 | 文档库、编辑会话、Office 服务、保存记录 | 待接入 |
|
||||
| GoodBuddy 服务 | 配置下发 | 配置草稿、设备生效、发布记录、回滚记录 | 待接入 |
|
||||
| 共享资源 | GoodBuddy 接入 | 设备列表、设备详情、连接记录、撤销记录 | 部分实现 |
|
||||
| 共享资源 | 共享中心 | 能力目录、共享知识库、按成员查看、按设备查看 | 部分实现 |
|
||||
| 共享资源 | 数字人 | 数字人目录、我的数字人、调用权限、发布记录 | 设计中 |
|
||||
| 组织管理 | 组织与成员 | 成员、用户组、角色、服务账号 | 设计中 |
|
||||
| 组织管理 | 权限策略 | 强制策略、Grant、策略模拟、变更记录 | 设计中 |
|
||||
| 组织管理 | 审批 | 待处理、已处理、我的申请、审批规则 | 设计中 |
|
||||
| 组织管理 | 发布与更新 | 能力包、发布通道、更新策略、撤销记录 | 设计中 |
|
||||
| 组织管理 | 网关联邦 | 合作组织、待确认、开放范围、事件记录 | 设计中 |
|
||||
| 运营与审计 | 任务与中继 | 任务列表、活动任务、中继连接、运行统计 | 设计中 |
|
||||
| 运营与审计 | 审计 | 事件查询、调用链、导出任务、保留策略 | 部分实现 |
|
||||
| 系统 | 系统设置 | 实例信息、身份与登录、基础设施、中继与联邦、通知、保留与审计 | 设计中 |
|
||||
| 成员自助 | 我的设备与授权 | 我的设备、我的授权、我的申请、我的数字人 | 设计中 |
|
||||
|
||||
## 1. 工作台
|
||||
|
||||
### 1.1 运行概览
|
||||
|
||||
| Tab | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 服务状态 | ShareServer、GoodBuddy 设备、模型上游和共享目录的关系;设备登记数、启用上游数和启用模型数 | 部分实现 |
|
||||
| 待处理事项 | 待审批、失败任务、保存失败、连接异常和紧急撤销 | 设计中 |
|
||||
| 最近活动 | 最近的注册、发布、网关调用、授权和任务事件 | 设计中 |
|
||||
|
||||
当前页面只展示真实 API 返回的服务拓扑和入口,不生成预设指标或活动记录。
|
||||
|
||||
## 2. GoodBuddy 服务
|
||||
|
||||
### 2.1 模型网关
|
||||
|
||||
模型网关为 GoodBuddy 和兼容客户端提供统一的模型请求入口。网关保存上游配置和模型映射,转发请求并记录有界调用元数据,不托管模型,也不在网关执行模型返回的工具调用。
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 概览 | 网关地址、启用上游数、启用模型数、访问密钥数、最近调用状态和管理边界 | 部分实现 |
|
||||
| 上游服务 | 添加、编辑、启停上游;保存 Base URL、协议和认证方式;凭据留空则保留,填写则覆盖;发现远端模型 | 部分实现 |
|
||||
| 模型目录 | 查看模型别名、远端模型名、所属上游和启用状态;手工添加或编辑映射;启停映射 | 部分实现 |
|
||||
| 访问密钥 | 创建客户端 Bearer Key;按模型绑定授权范围;查看名称和状态;撤销密钥 | 部分实现 |
|
||||
| 请求测试 | 选择可用模型发送真实非流式请求;显示结果;取消请求;提示数据去向和费用风险 | 部分实现 |
|
||||
| 调用审计 | 查看时间、请求编号、模型、上游、访问密钥、流式方式、结果、HTTP 状态、耗时、Token 用量和错误码 | 部分实现 |
|
||||
| 客户端接入 | 查看客户端 Base URL、协议、模型 ID 和配置步骤;说明客户端只使用网关访问 Key | 部分实现 |
|
||||
|
||||
模型网关的页面关系如下:
|
||||
|
||||
```text
|
||||
概览
|
||||
├─ 上游服务 管理外部模型服务连接
|
||||
├─ 模型目录 管理客户端可见的模型映射
|
||||
├─ 访问密钥 管理客户端调用凭据和模型授权
|
||||
├─ 请求测试 验证模型请求转发
|
||||
├─ 调用审计 查看调用结果和运行元数据
|
||||
└─ 客户端接入 将已配置模型交给 GoodBuddy 或兼容客户端使用
|
||||
```
|
||||
|
||||
网关后续扩展仍放在对应 Tab:
|
||||
|
||||
- 上游服务增加健康状态、延迟和错误率。
|
||||
- 模型目录增加能力标签、路由规则和备用上游。
|
||||
- 访问密钥增加主体、组织、有效期、配额和撤销原因。
|
||||
- 请求测试增加工具调用和流式场景的受控验证。
|
||||
- 调用审计增加 `traceId`、父子请求、决策、失败阶段和链路详情。
|
||||
- 客户端接入增加按组织或用户组分发配置的能力。
|
||||
|
||||
组织身份、工具级权限、三态策略、供应商切换、配额、成本统计和工具执行属于后续网关能力,不能写成当前 Tab 已提供的操作。
|
||||
|
||||
### 2.2 文档与协作
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 文档库 | 主存文档、归属、版本、保留规则和检索 | 待接入 |
|
||||
| 编辑会话 | 活动会话、访问模式、checkpoint、保存状态和过期处理 | 待接入 |
|
||||
| Office 服务 | Document Server、回调、存储和健康检查 | 待接入 |
|
||||
| 保存记录 | 服务端保存、结果未知、失败重试和本地源文件回写状态 | 设计中 |
|
||||
|
||||
首期 Office 范围为浏览器上传 DOCX、编辑、保存和重新打开。XLSX、PPTX、Desktop 文档 Tab 和本地回写属于后续范围。
|
||||
|
||||
### 2.3 配置下发
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 配置草稿 | 按客户端、设备或成员组编排模型和服务配置 | 待接入 |
|
||||
| 设备生效 | 查看目标设备、应用版本、生效状态和失败原因 | 待接入 |
|
||||
| 发布记录 | 查看配置版本、发布时间、发布者和影响范围 | 待接入 |
|
||||
| 回滚记录 | 记录回滚请求、目标版本和设备确认结果 | 待接入 |
|
||||
|
||||
保存模型网关配置不会自动创建配置下发记录。
|
||||
|
||||
## 3. 共享资源
|
||||
|
||||
### 3.1 GoodBuddy 接入
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 设备列表 | 查询设备名称、平台、应用版本、登记时间和发布数量 | 部分实现 |
|
||||
| 设备详情 | 查看登记信息、发布记录和来源声明 | 部分实现 |
|
||||
| 连接记录 | 查看在线状态、出站控制连接和最近心跳 | 设计中 |
|
||||
| 撤销记录 | 撤销设备、停止新任务和查看历史撤销原因 | 设计中 |
|
||||
|
||||
当前实现支持本地设备登记元数据和真实发布目录查询;生产设备认证、在线状态、出站 WebSocket 和任务控制待实现。
|
||||
|
||||
### 3.2 共享中心
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 能力目录 | 能力名称、版本、来源设备、发布状态和可见范围 | 部分实现 |
|
||||
| 共享知识库 | 知识来源模式,以及搜索、读取原文、下载三项权限声明 | 部分实现 |
|
||||
| 按成员查看 | 按成员查看可发现、可检查和可调用的资源 | 设计中 |
|
||||
| 按设备查看 | 按提供设备查看已发布能力、知识和撤销记录 | 部分实现 |
|
||||
|
||||
当前共享中心只处理发布元数据,不提供知识正文、能力执行或真实授权。
|
||||
|
||||
### 3.3 数字人
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 数字人目录 | 按 `@handle`、所有者、Runtime、版本和可用范围查询 | 设计中 |
|
||||
| 我的数字人 | 创建发布草稿、选择 Agent、设置开放范围、提交审批和撤销 | 设计中 |
|
||||
| 调用权限 | 查看当前成员的发现、检查、调用权限和审批要求 | 设计中 |
|
||||
| 发布记录 | 查看版本、状态、权限变化和撤销原因 | 设计中 |
|
||||
|
||||
## 4. 组织管理
|
||||
|
||||
### 4.1 组织与成员
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 成员 | 邀请、停用、移除和查看成员设备、授权与申请 | 设计中 |
|
||||
| 用户组 | 管理成员组、设备组和组内授权范围 | 设计中 |
|
||||
| 角色 | 管理组织管理员、发布者、审批人和审计员 | 设计中 |
|
||||
| 服务账号 | 管理自动化调用主体、凭据、权限和有效期 | 设计中 |
|
||||
|
||||
### 4.2 权限策略
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 强制策略 | 组织级禁止规则、数据范围、出站地址和风险限制 | 设计中 |
|
||||
| Grant | 将主体、资源、动作、有效期和预算绑定为授权 | 设计中 |
|
||||
| 策略模拟 | 输入主体、资源和动作,查看命中规则与最终决策 | 设计中 |
|
||||
| 变更记录 | 查看策略版本、变更者、影响范围和撤销结果 | 设计中 |
|
||||
|
||||
策略决策使用三种结果:自动允许、人工确认、直接拒绝。工具权限应落到具体动作,例如查询允许、修改需确认、删除拒绝。
|
||||
|
||||
### 4.3 审批
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 待处理 | 按调用、发布、更新和联邦申请筛选待审批项 | 设计中 |
|
||||
| 已处理 | 查看批准、拒绝、过期和撤销的申请 | 设计中 |
|
||||
| 我的申请 | 查看本人提交的申请和当前状态 | 设计中 |
|
||||
| 审批规则 | 设置审批人、风险条件和批量审批范围 | 设计中 |
|
||||
|
||||
### 4.4 发布与更新
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 能力包 | 上传、验证、查看 manifest、签名、版本和兼容范围 | 设计中 |
|
||||
| 发布通道 | 按设备组、组织和发布范围安排版本 | 设计中 |
|
||||
| 更新策略 | 设置审批、自动更新、灰度和失败处理规则 | 设计中 |
|
||||
| 撤销记录 | 紧急撤销版本、查看影响设备和后续任务处理 | 设计中 |
|
||||
|
||||
### 4.5 网关联邦
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 合作组织 | 查看对端地址、指纹、连接状态和有效期 | 设计中 |
|
||||
| 待确认 | 创建、接受或拒绝联邦 offer | 设计中 |
|
||||
| 开放范围 | 分别查看我方开放、对方开放和最终可用的资源集合 | 设计中 |
|
||||
| 事件记录 | 查看建立、暂停、恢复、撤销和调用事件 | 设计中 |
|
||||
|
||||
## 5. 运营与审计
|
||||
|
||||
### 5.1 任务与中继
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 任务列表 | 按调用方、能力、提供者、路由、状态和时间查询任务 | 设计中 |
|
||||
| 活动任务 | 查看排队、运行、等待确认、取消和结果未知的任务 | 设计中 |
|
||||
| 中继连接 | 查看活动连接、吞吐、容量和拒绝原因 | 设计中 |
|
||||
| 运行统计 | 查看延迟、失败率、取消率和提供者状态 | 设计中 |
|
||||
|
||||
### 5.2 审计
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 事件查询 | 按操作者、动作、对象、组织、结果、时间和关联 ID 查询 | 设计中 |
|
||||
| 调用链 | 查看模型、工具、任务、路由和最终结果的父子事件 | 设计中 |
|
||||
| 导出任务 | 创建、查看和下载有时效的审计导出 | 设计中 |
|
||||
| 保留策略 | 设置事件、正文、工具参数和模型输出的保留范围 | 设计中 |
|
||||
|
||||
当前模型网关已经保存有界调用元数据。完整组织审计、调用链、导出和保留策略仍未实现。
|
||||
|
||||
## 6. 系统
|
||||
|
||||
### 6.1 系统设置
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 实例信息 | 实例名称、公开地址、协议版本和部署信息 | 设计中 |
|
||||
| 身份与登录 | 本地账号、OIDC、Web 会话、CSRF 和会话撤销 | 设计中 |
|
||||
| 基础设施 | 数据库、对象存储、证书、版本和服务状态 | 设计中 |
|
||||
| 中继与联邦 | 中继开关、容量限制、联邦总开关和证书 | 设计中 |
|
||||
| 通知 | 邮件、审批提醒、失败告警和通知渠道 | 设计中 |
|
||||
| 保留与审计 | 全局保留规则、归档和审计导出限制 | 设计中 |
|
||||
|
||||
运行时秘密、数据库连接串、主密钥和容器挂载路径由部署环境提供,系统设置只显示状态或执行替换与测试。
|
||||
|
||||
## 7. 成员自助
|
||||
|
||||
### 7.1 我的设备与授权
|
||||
|
||||
| Tab | 主要功能 | 当前状态 |
|
||||
| --- | --- | --- |
|
||||
| 我的设备 | 管理本人设备、连接状态和设备撤销 | 设计中 |
|
||||
| 我的授权 | 查看本人获得的能力、模型、工具、有效期和限制 | 设计中 |
|
||||
| 我的申请 | 创建调用、发布和权限申请,查看处理结果 | 设计中 |
|
||||
| 我的数字人 | 管理本人创建的数字人及其发布范围 | 设计中 |
|
||||
|
||||
成员的日常协作、消息、资料同步和能力调用主要在 GoodBuddy 中完成,Web 端提供授权、申请和发布管理入口。
|
||||
|
||||
## 当前交付边界
|
||||
|
||||
当前仓库可以运行的功能集中在两个页面域:
|
||||
|
||||
1. GoodBuddy 接入和共享中心:保存并读取本地设备登记、能力发布和知识发布元数据。
|
||||
2. 模型网关:保存上游、加密凭据、模型映射和访问 Key,支持真实模型发现、请求转发、请求测试和有界调用审计。
|
||||
|
||||
组织认证、组织隔离、生产设备认证、设备控制连接、工具执行、任务路由、文档保存、Office 编辑、实时协作、成本控制和联邦仍属于设计或后续实现范围。接口、状态规则和验证证据分别由 [PRD](./prd.md)、[功能逻辑](./logic-design.md)、[技术设计](./technical-design.md) 和 [实施进度](./progress.md) 维护。
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
## 本地目录首期状态规则
|
||||
|
||||
当前本地联调仅实现设备与发布元数据,使用[技术设计第 4.1 节](./technical-design.md#41-本地共享目录契约已实现)
|
||||
的独立接口。下文组织身份、Grant、任务和文档归属规则属于目标设计。
|
||||
本地目录实现设备与发布元数据,使用[技术设计第 4.1 节](./technical-design.md#41-本地共享目录契约已实现)
|
||||
的独立接口;本地模型网关规则见第 6.1 节。下文组织身份、Grant、任务和文档归属规则属于目标设计。
|
||||
Office 私有部署第一阶段的限定状态规则见第 8.2 节,不声称具备 INV-1 的组织隔离或 INV-12 的多类凭据体系。
|
||||
|
||||
- 注册相同设备 ID 更新名称、平台及版本,保留首次注册时间;相同内容重试不改变时间。
|
||||
@@ -23,7 +23,7 @@ Office 私有部署第一阶段的限定状态规则见第 8.2 节,不声称
|
||||
- 撤销将状态变为 `revoked` 并保留目录记录。重复撤销不改变结果;重试旧发布请求不恢复已撤销记录。
|
||||
- 能力与知识按 `kind` 区分。知识的搜索、原文、下载标志分别存储,不相互推导;
|
||||
`server` 来源也只是发布者声明,不表示文件已经上传或同步。
|
||||
- 当前没有用户主体或组织所有权,因此真实目录按设备查看。按用户查看仍使用组织示例,
|
||||
- 当前没有用户主体或组织所有权,因此真实目录按设备查看。组织示例已移除,
|
||||
不把设备名称推定为用户身份。
|
||||
|
||||
## 1. 核心不变量
|
||||
@@ -314,7 +314,7 @@ Publication/Grant,本人草稿确认也不能绕过当前访问权限。
|
||||
共享知识区分服务器同步副本和远程设备取用。已就绪的服务器副本不因来源设备离线而失效,
|
||||
但更新需等待设备连接;远程取用在设备离线时不可用。发布暂停或设备撤销仍阻止使用,不能
|
||||
以已有副本绕过撤销。搜索、读取原文、下载分别判断权限;可用状态、共享对象和管理员元数据
|
||||
可见性均不替代动作授权。当前共享中心只演示这些状态,无真实内容请求。
|
||||
可见性均不替代动作授权。当前共享中心仅展示真实目录声明,无真实内容请求。
|
||||
|
||||
可用内容为来源授权、当前入口受众、动作、空间权限、组织策略及 Provider 本地权限的交集。
|
||||
个人外部入口与组织空间独立授权;同一分身身份复用不复制其所有来源权限。
|
||||
@@ -342,19 +342,37 @@ Publication/Grant,本人草稿确认也不能绕过当前访问权限。
|
||||
任何权限扩大都从自动路径转为 `require_approval`。降级只有在目标版本仍被签名目录允许且
|
||||
策略明确指定时才提供,不自动视为故障回滚。
|
||||
|
||||
### 6.1 网关配置演示
|
||||
### 6.1 本地模型网关
|
||||
|
||||
模型网关负责向其他服务转发,不托管模型。原型仅在上游与映射均启用、目标组被允许时判定
|
||||
模型可用;模拟调用记录允许/拒绝及路由元数据,不发送请求或记录正文。
|
||||
本节描述已实现的 [B1 网关](./backend-foundation-design.md#4-b1正常模型网关接入);完整验收尚未完成。
|
||||
|
||||
配置演示与上述签名能力包更新分开。草稿选择目标组可用模型,发布时重新检查策略;每组
|
||||
独立记录发布历史。回退仅选择该组上一条记录,并重新检查其模型可用性,恢复版本及默认
|
||||
模型后移除当前历史末项。发布和回退仅更新目标组在线示例设备;离线设备保留已应用版本,
|
||||
目标版本随发布变化并显示待下发,未模拟重连交付。
|
||||
模型网关向其他服务真实转发,不托管模型。新存储无上游、映射和审计。别名全局唯一并绑定
|
||||
一个上游与远端模型。客户端必须提供有效 Bearer 访问 Key;列表只返回 Key 授权、上游及映射
|
||||
均启用、凭据可用的模型,无需认证的上游不要求凭据。“凭据已配置”仅表示保存了密文或有效
|
||||
环境引用,不表示解密或连接已验证。
|
||||
|
||||
这些状态仅保存在三页共享的会话内存中,刷新重置;角色开关不构成实际授权,发布状态不构成
|
||||
设备确认。文档演示中仅本人可删除/转移个人文档,组织删除/恢复要求显式模拟组织文档管理员
|
||||
角色,其他成员个人文档仅显示元数据,回收站不提供永久删除。
|
||||
上游凭据按 `keep|replace|clear` 显式保留、替换或清除;空值不代表清除,无需认证不会自动删除
|
||||
已有凭据。新秘密加密保存;主密钥缺失或错误时报告不可用,不覆盖旧密文。旧环境变量引用可
|
||||
继续调用,显式替换后改为托管凭据;前缀导入界面及接口已移除。
|
||||
|
||||
Key 仅创建时返回原文,存储摘要;授权绑定模型记录 UUID,修改别名不改变范围。未知或撤销
|
||||
Key 返回 401,未授权模型返回 403。重复撤销保持原撤销时间,撤销阻止新请求,不主动取消已开始
|
||||
的流。管理与旧目录接口限制实际 socket 为直连回环,不能靠 Origin 或转发头建立管理身份。
|
||||
|
||||
发现模型真实访问上游列表,但不自动保存发现结果;失败仍可手工映射。用户选择或手动填写映射,
|
||||
再通过本机管理测试入口显式测试。调用按配置将别名
|
||||
换为远端模型,响应恢复别名,不允许请求覆盖上游地址或凭据,也不自动重试或切换上游。
|
||||
|
||||
调用终态为成功、失败或取消。JSON 响应通过格式检查后记录成功;SSE 必须收到 `[DONE]`,提前
|
||||
结束记为失败。取消或超时中止上游请求,流已开始时的 HTTP 200 不等于最终成功。审计只记录
|
||||
调用元数据,缺失用量保留未知;存储保留最新 200 条,不保存提示词、工具参数或响应正文。
|
||||
配置和审计写盘成功后才更新内存,坏文件或写入失败报错,不清空已有记录。
|
||||
|
||||
本地预览已有 Key 级模型授权,尚无组织认证、组织隔离、用户组策略或配额。配置下发为占位,
|
||||
没有发布、回退或设备确认;保存网关模型不会自动下发。接口和资源限制见技术设计第 4.2 节。
|
||||
历史网关/配置演示记录保持原有证据范围,不能据此认定真实转发或交付已验证。
|
||||
|
||||
文档页显示待接入说明,已移除会话内存演示及模拟组织角色;文档归属与回收规则仍待服务端实现。
|
||||
|
||||
## 7. 联邦可见性
|
||||
|
||||
|
||||
@@ -38,9 +38,35 @@
|
||||
相同记录,服务重启后记录仍保留。此阶段不自动枚举本地能力或上传知识文件。
|
||||
|
||||
验收要求:注册不自动发布,撤销后刷新仍为已撤销,网络失败有可重试反馈,知识权限独立保存;
|
||||
真实目录与组织样例分别展示。API、状态和验证证据分别由技术设计、逻辑设计和实施进度维护。
|
||||
界面只展示真实目录,空目录不填充样例。API、状态和验证证据分别由技术设计、逻辑设计和实施进度维护。
|
||||
身份认证、组织授权、内容同步与远程调用仍按以下完整产品范围继续实现。
|
||||
|
||||
### 本地模型网关技术预览
|
||||
|
||||
当前实现允许在可信本地环境保存上游、模型别名,发现上游模型并发送真实测试请求。网关向
|
||||
其他模型服务转发 OpenAI 兼容 Chat Completions,支持 JSON、SSE、工具调用数据和上游用量,
|
||||
提供模型列表;不运行模型或工具,不支持 Responses 或 Anthropic API。
|
||||
|
||||
此阶段已有按模型授权的 Bearer 访问 Key,尚无组织认证、组织隔离、用户组策略或配额控制。
|
||||
服务从空配置开始,持久化配置、加密上游凭据、访问 Key 摘要和最近 200 条调用元数据,不保存
|
||||
提示词、响应正文或明文秘密。页面接收上游密钥,编辑时留空保留、填写覆盖;旧环境引用保留存储
|
||||
兼容,前缀导入界面和接口已移除。管理限直连回环,发现模型和请求测试才访问上游。
|
||||
ShareServer 可独立构建和运行,不依赖 GoodBuddy 源码;个人既有配置仅用于明确授权的环境测试。
|
||||
|
||||
配置下发当前为占位,保存模型不会自动配置 GoodBuddy。生产访问策略和按用户组分配仍为目标
|
||||
范围。接口与限制由技术设计第 4.2 节维护,实际测试结果由实施进度维护;本节不改写历史验收证据。
|
||||
|
||||
### 正常模型网关接入(B1 已实现,验收部分完成)
|
||||
|
||||
管理员填写上游名称、协议、API 地址和 API Key,保存后发现或手动添加模型,选择对 GoodBuddy
|
||||
开放的模型 ID。界面不要求用户理解环境变量前缀。ShareServer 保管上游凭据,另外签发可撤销的
|
||||
网关访问 Key;GoodBuddy 使用网关地址和该 Key,通过现有模型连接入口发起实际对话。
|
||||
|
||||
首期完成本地网关管理、真实转发与调用记录;不依赖组织数据库或自动配置下发。只将实际支持的
|
||||
Chat Completions 协议提供给客户端。当前已完成部分真实 API 验证,GoodBuddy Desktop 连接测试、
|
||||
对话、完整工具往返、取消及浏览器界面仍需验收,不能用自动化或脚本调用代替。
|
||||
接口与凭据设计、后续组织及知识服务顺序见[后端基础设计](./backend-foundation-design.md)。
|
||||
|
||||
### Office 私有部署第一阶段(待实现)
|
||||
|
||||
在可信私有网络内,先验证浏览器上传 DOCX、打开、编辑、保存到 ShareServer、关闭后重新打开,
|
||||
@@ -77,7 +103,7 @@ JWT 用于编辑器配置和服务回调校验,不代表浏览器用户已认
|
||||
|
||||
- 远程管理设备桌面、任意 Shell 或任意文件系统。
|
||||
- 发现未注册的局域网设备。
|
||||
- 收集或分发用户个人 API Key、Cookie 和桌面登录状态。模型网关所需的组织上游凭据由管理员单独配置、服务端保管,不下发给客户端;当前原型不接收凭据。
|
||||
- 自动收集或分发用户个人 API Key、Cookie 和桌面登录状态。管理员显式提供的网关上游凭据由服务端加密保管,不分发给客户端;客户端使用单独签发的访问 Key,见后端基础设计。
|
||||
- 默认查看任务正文、附件和模型输出。
|
||||
- 自动上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆;用户明确发布的知识与选定回顾按 FR-18 单独管理。
|
||||
- 因为成员加入组织而自动开放其数字人、记忆、Runtime、工作区或工具。
|
||||
@@ -175,11 +201,9 @@ JWT 用于编辑器配置和服务回调校验,不代表浏览器用户已认
|
||||
按设备查看四个页签。内容展示发布者、提供设备、版本、在线与发布状态、共享对象;用户和
|
||||
设备视图列出关联内容并可进入详情,既有设备详情提供对应共享视图入口。
|
||||
|
||||
当前“GoodBuddy 服务”原型提供 `/model-gateway`、`/documents`、`/distribution` 三页,覆盖
|
||||
网关策略判断、文档归属与回收站、OnlyOffice 实例检查、配置发布和回退。操作仅修改会话内存,
|
||||
跨页保留、刷新重置,不发送服务请求或写入本地存储;模拟角色不构成授权,模拟成功不构成
|
||||
推理、转发、Office 保存或设备交付的验收。离线设备保持待下发与原已应用版本;发布和回退
|
||||
都检查目标组当前可用模型,回退同时恢复版本及默认模型。细节见 UI 设计第 16.1 节。
|
||||
当前“GoodBuddy 服务”提供 `/model-gateway` 本地技术预览;`/documents`、`/distribution`
|
||||
及其他未实现模块显示待接入说明。网关和共享目录使用真实 API,界面已移除模拟角色、文档、
|
||||
审批及配置发布操作。上述完整共享中心页签仍为目标范围。细节见 UI 设计第 16.1 节。
|
||||
|
||||
### FR-12 可部署性
|
||||
|
||||
|
||||
@@ -2,8 +2,102 @@
|
||||
|
||||
## 当前状态
|
||||
|
||||
ShareServer 包含控制台交互原型和可持久化的本地共享目录服务。GoodBuddy 已能注册设备、发布与
|
||||
撤销元数据,Web 能查看真实目录;生产身份、组织隔离、设备控制连接、授权及任务路由仍未实现。
|
||||
ShareServer 包含 Web 控制台、可持久化的本地共享目录和真实模型网关。GoodBuddy 已能注册
|
||||
设备、发布与撤销元数据,Web 能查看真实目录。模型网关支持 OpenAI 兼容上游的发现、映射与
|
||||
Chat Completions 转发,以及 B1 加密凭据、访问 Key 和模型授权。B1 代码已实现、验收部分完成;
|
||||
生产身份、组织隔离、设备控制连接、组织授权及任务路由仍未实现。
|
||||
|
||||
### 2026-10-07:前端真实目录完善
|
||||
|
||||
- `GoodBuddy 接入` 已接入本地共享 API,显示真实登记设备、登记时间、版本和关联发布数量;支持搜索、详情和跳转到该设备的共享内容。页面明确登记记录不代表在线、所有权或身份认证。
|
||||
- `共享中心` 新增真实发布记录的撤销确认和刷新流程。撤销调用 `POST /api/v1/sharing/publications/:id/revoke`,保留服务端撤销记录,不提供能力执行、知识正文读取或下载。
|
||||
- 其他规划页面改为按模块展示专属待接入边界和可用入口,不显示示例数据或不会生效的创建、审批、导出和配置操作。模型网关调用元数据仍从模型网关页面查看。
|
||||
- 新增共享 API 请求封装,统一错误信息和请求取消处理。前端自动化测试覆盖设备目录、待接入边界、共享筛选和撤销入口;本轮尚未进行浏览器视觉验收。
|
||||
- 顶部移除“技术预览 · 非生产服务”常驻文字;侧栏按业务重排为工作台、GoodBuddy 服务、共享资源、组织管理、运营与审计、系统和成员自助。各页面内部仍保留必要的实现边界说明。
|
||||
- 工作台增加服务拓扑,读取真实设备、发布记录和模型网关配置,展示 GoodBuddy 设备、ShareServer 与外部模型上游的关系及数量。空数据仅显示零接入状态,不生成示例节点;当前尚未进行浏览器视觉验收。
|
||||
|
||||
### 2026-10-05:B1 实现与部分验收
|
||||
|
||||
- 上游表单接收真实 API Key,支持保留、替换和清除;AES-256-GCM 密文随配置保存,主密钥位于
|
||||
数据目录外并单独备份。旧环境变量引用继续可读、可调用,前缀导入界面与 `/import-env` 已移除。
|
||||
- 客户端模型列表和聊天要求 Bearer Key,Key 原文仅创建时返回,持久化 SHA-256 摘要。
|
||||
授权绑定模型记录 UUID,修改别名不改变范围;审计关联 Key ID,不将其名称当作用户身份。
|
||||
管理与旧目录 API 校验实际回环 socket,并拒绝转发头;Vite `/api` 也只允许回环来源。
|
||||
容器/反向代理管理尚不支持,等待 B2 登录。
|
||||
- 实现轮验证记录:103 项测试通过,`npm run typecheck`、`npm run lint`、前后端构建通过。
|
||||
自动化覆盖不等于 GoodBuddy Desktop 或浏览器端到端验收。
|
||||
- 实现轮真实供应商验证共 5 次上游请求:1 次模型发现;1 次非流式请求使用 16 Token 上限,
|
||||
输出过短,未形成有效结论;1 次 SSE 请求成功,包含 reasoning 内容和 Token 用量;1 次非流式
|
||||
请求使用 1024 Token 上限并成功;1 次工具选择返回有效调用参数并在本地执行。工具结果未再次
|
||||
发送给上游,因此未完成完整工具往返。
|
||||
- 实际 API 验证确认重启后配置与访问 Key 保留,撤销后拒绝调用;存储含加密凭据而无明文秘密。
|
||||
上述真实验证由实现轮提供,本次文档同步没有新增供应商请求或读取环境秘密。
|
||||
- 浏览器已读取独立服务的网关页快照,确认空目录、访问密钥和客户端设置区域;点击被工具判定为超出视口,截图超时,未完成交互与视觉验收。独立服务已停止,默认数据未改动。
|
||||
- 本轮没有验证 GoodBuddy Desktop。B1 剩余验收包括从空存储经浏览器配置、刷新
|
||||
与重启,Desktop 连接测试和真实 SSE 会话,完整工具结果回传、用户停止时上游取消,以及真实
|
||||
环境下超时、流中断、错误主密钥、内网 HTTP 和部署来源限制。后端基础设计第 4.5 节继续保留完整
|
||||
清单;历史浏览器和旧网关结果不追溯成为 B1 验收。
|
||||
- 文档同步轮重新运行 `npm test`,9 个文件、103 项测试通过;lint、含类型检查的前后端构建
|
||||
通过。已阅读 deai-writing 并扫描相关文档,阻断项为 0;本次修改段落的字段枚举和验收限制
|
||||
按技术含义保留,未借审校改写历史记录。`git diff --check` 通过,仅编辑 README 与文档。
|
||||
|
||||
以下按原日期保留历史记录,其中“当前”“本轮”均指对应日期的实现,不描述 2026-10-05 的状态。
|
||||
|
||||
### 2026-10-04:基于 GoodBuddy 源码的后端基础设计
|
||||
|
||||
- 新增[后端基础设计](./backend-foundation-design.md),核对模型 profile、协议 URL、Main 凭据保存、
|
||||
设备共享 HTTP、资源发布字段、统一知识检索入口和 Office 设计状态。按实际客户端契约安排 B1 至 B6。
|
||||
- 首个实施单元为正常模型网关接入:密钥录入与服务端保存、开放模型、独立访问 Key、手工配置
|
||||
GoodBuddy 和真实对话验证。自动组织模型导入、身份权限、资源绑定、知识检索及文档归属另列依赖。
|
||||
- 修正功能入口、PRD、逻辑和技术文档中仍将模拟页面描述为当前实现的段落;保留历史验证记录。
|
||||
新接口与凭据方案标记待实现,未把当前无认证网关描述成已有访问 Key。
|
||||
- 本轮仅改 ShareServer 文档,未修改 GoodBuddy 或服务代码,未读取环境秘密、启动服务或调用模型。
|
||||
相邻仓库链接目标及新增章节锚点已核对,`git diff --check` 通过。新增设计的 deai-writing 扫描
|
||||
为 0 个阻断项;复核项涉及字段枚举和实施边界,按技术含义保留。未运行业务测试或进行运行时验收。
|
||||
|
||||
### 2026-10-02:移除界面模拟数据
|
||||
|
||||
- 控制台不再加载原型快照,移除虚构身份、组织、统计数量、活动以及模拟操作。
|
||||
概览改为服务入口,共享中心直接读取本地目录;未接入模块保留菜单并显示待实现说明。
|
||||
- 模型网关及共享目录继续使用真实 API,保留加载、空状态、失败重试和筛选。
|
||||
未删除本地持久化数据,未改动 GoodBuddy 仓库。旧原型源码及接口未在本轮删除,已退出控制台路由。
|
||||
- `npm test`:7 个文件、87 项测试通过;类型检查、lint 和前后端构建通过。
|
||||
浏览器访问本地开发地址返回 `Request blocked`,本轮未完成视觉复查。
|
||||
|
||||
### 2026-10-01:本地模型网关与真实上游联调
|
||||
|
||||
- `/model-gateway` 改为真实 API 页面,移除网关样例上游、模型和模拟调用。新增配置持久化、
|
||||
环境引用导入、模型发现、映射编辑与启停、网页请求测试及调用元数据。配置下发移除虚构模型、
|
||||
用户组与设备回执,改为待接入说明;文档及其他示例模块仍保留明确的演示边界。
|
||||
- API 支持模型列表、非流式 JSON 和流式 SSE 转发,透传工具调用数据和上游用量,不执行工具。
|
||||
具体路径、资源限制和存储契约见[技术设计第 4.2 节](./technical-design.md#42-本地模型网关契约已实现)。
|
||||
凭据只引用服务端环境变量,`scripts/dev.mjs` 支持显式 `--env-file`,加载值仅传给 API 子进程。
|
||||
- 测试使用 `../goodbuddy/.env.deepseek.local`,经用户授权加载;未修改 GoodBuddy 仓库。
|
||||
在独立端口 8792 和临时数据目录完成联调,保留原有 8787/5174 开发服务及项目数据。
|
||||
浏览器从空目录导入配置,发现接口返回 2 个模型,包含已配置模型 `deepseek-flash`。
|
||||
- 网页非流式请求返回预期内容,上游报告输入 41、输出 26 Token;另一条 SSE 请求收到 `[DONE]`
|
||||
和预期内容,用量为输入 37、输出 20 Token。停用映射后请求返回 403,错误请求 ID 与审计一致,
|
||||
被拒请求的未知用量未计为零。真实工具调用与 GoodBuddy 桌面端模型调用未在本轮验收。
|
||||
- Playwright/Edge 验证模型发现、网页请求、停用与启用、刷新后保留配置并清除测试正文;
|
||||
1440px 和 390px 的深浅主题未见横向溢出或页面脚本错误,并复核截图。重启独立 API 后,
|
||||
1 个上游、1 个模型和 3 条审计与重启前一致;持久化文件不含测试凭据、提示词或响应正文。
|
||||
独立测试服务已停止,测试配置未导入项目默认数据目录。
|
||||
- `npm test`:7 个文件、77 项测试通过;`npm run lint`、`npm run build`(含类型检查)通过。
|
||||
测试覆盖坏存储、上游错误、超限、流中断、超时、取消、审计写入失败和前端失败重试。
|
||||
设计文档已按真实接口更新并使用 deai-writing 复核;历史演示验证记录保留原有时间与范围。
|
||||
|
||||
### 2026-10-01:本地仓库同步与开发入口
|
||||
|
||||
- 已逐项核对本地未提交代码和未跟踪源码:另一台机器提交的 `c9d5e63` 已包含这些功能与测试,
|
||||
代码差异仅为目录迁移后的引用和构建输出路径调整;`9fa8f3e` 进一步补充 Office 私有部署设计。
|
||||
- 按用户要求将本地 `main` 对齐 `origin/main` 的 `9fa8f3e`,清理旧路径重复源码,未产生合并提交。
|
||||
同步后工作区干净;本地数据、依赖和忽略文件保留,GoodBuddy 仓库单独处理,本轮未操作。
|
||||
- 本地开发在仓库根目录运行 `npm run dev`,入口为 `scripts/dev.mjs`,同时启动 `dev:api` 与
|
||||
`dev:web`。API 使用 Node watch,前端使用 Vite 热更新;默认 Web 地址为 `http://127.0.0.1:5174`,
|
||||
GoodBuddy 连接 API 地址 `http://127.0.0.1:8787`,Web 的 `/api` 请求代理到该端口。
|
||||
- 首次安装依赖用 `npm ci`,终端按 `Ctrl+C` 停止开发服务。开发时先停止占用同一 API 端口或
|
||||
数据目录的旧预览/容器实例;共享记录默认保存在 `data/sharing.json`。
|
||||
- 本轮核对 Git 差异与启动配置,未重新启动服务或运行测试、构建。
|
||||
|
||||
### 2026-10-01:Office 私有部署第一阶段设计
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 共享协议 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) |
|
||||
|
||||
基于 GoodBuddy 当前源码的适配依据、正常网关接入和基础服务实施顺序见
|
||||
[后端基础设计](./backend-foundation-design.md)。B1 已实现、验收部分完成,B2 至 B6 待实现;第 4.1、4.2 节记录
|
||||
当前已实现 API,组织与 Office 章节仍是目标设计,不代表现有菜单已有对应服务。
|
||||
|
||||
## 1. 仓库与运行边界
|
||||
|
||||
ShareServer 在独立的 `goodbuddy-shareserver` 仓库根目录维护。当前可运行结构为:
|
||||
@@ -22,7 +26,7 @@ goodbuddy-shareserver/
|
||||
server/
|
||||
src/ # Hono API、测试与同源静态资源服务
|
||||
tsconfig.json
|
||||
shared/ # 原型 fixture 与类型
|
||||
shared/ # 共享目录、网关 DTO 与保留的旧原型类型
|
||||
web/
|
||||
src/ # React Web 控制台与测试
|
||||
index.html
|
||||
@@ -69,9 +73,9 @@ goodbuddy-shareserver/
|
||||
`npm start` 或容器启动后,Hono 同源托管 `dist/web/`。Docker 配置仍在 `docker/`,
|
||||
Compose 的 `../data` 仍映射仓库根 `data/` 到容器 `/app/data`。
|
||||
|
||||
仓库根目录包含全控制台交互原型和本地共享目录服务。Hono 提供健康、就绪、
|
||||
界面快照、原型操作确认与真实元数据 API,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的
|
||||
`src/shared`。本地目录使用 JSON 文件持久化,契约见第 4.1 节。当前尚未建立生产数据库、认证、设备控制连接,因此也不提前创建空的
|
||||
仓库根目录包含控制台交互原型、本地共享目录和模型网关。Hono 提供健康、就绪、
|
||||
界面快照、原型操作确认、真实元数据 API 和网关转发,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的
|
||||
`src/shared`。本地目录与网关使用独立 JSON 文件持久化,契约见第 4.1、4.2 节。当前尚未建立生产数据库、组织认证、设备控制连接,因此也不提前创建空的
|
||||
`packages/share-protocol`。进入真实 Desktop 协议实现时,服务端和 Desktop 共同依赖
|
||||
独立发布的 `share-protocol` 协议包,具体归属在真实协议实现时确定;
|
||||
ShareServer 仍不得导入 Electron 的 `src/shared`,也不得依赖相邻桌面仓库构建。
|
||||
@@ -79,9 +83,9 @@ ShareServer 仍不得导入 Electron 的 `src/shared`,也不得依赖相邻桌
|
||||
## 2. 技术选型约束
|
||||
|
||||
本节及下文组织身份、数据库事务、Web session 安全与生产部署要求属于完整组织服务目标。
|
||||
本地目录按第 4.1 节实现;Office 私有部署第一阶段按第 10.1 节单独推进,不以前述能力为前置条件。
|
||||
本地目录和网关按第 4.1、4.2 节实现;Office 私有部署第一阶段按第 10.1 节单独推进,不以前述能力为前置条件。
|
||||
|
||||
- 使用与仓库一致的 TypeScript/Node.js 工具链,具体 HTTP 框架在实现前根据维护性选择。
|
||||
- 使用与仓库一致的 TypeScript/Node.js 工具链,沿用当前 Hono 框架。
|
||||
- PostgreSQL 是组织、策略、任务元数据和审计的权威数据库;工作空间消息、处理状态及可靠投递记录
|
||||
也以数据库为准,具体业务表与受控正文存储在协作模块实现时增加。
|
||||
- 完整组织服务计划使用 S3 兼容对象存储保存 Package、可选中继暂存和 Office 工作副本。
|
||||
@@ -135,7 +139,7 @@ audience,并绑定单个 `DocumentEditSession`。
|
||||
### 4.1 本地共享目录契约(已实现)
|
||||
|
||||
此接口供同机 GoodBuddy 与 Web Demo 联调,默认监听 `127.0.0.1:8787`。没有登录、组织隔离
|
||||
或设备认证;可访问接口的客户端均可修改目录。设备 ID 仅标识记录,客户端只显示本机撤销按钮
|
||||
或设备认证;B1 起只允许直连回环管理请求,符合此限制的客户端均可修改目录。设备 ID 仅标识记录,客户端只显示本机撤销按钮
|
||||
不构成服务端所有权校验。`productionReady` 保持 `false`。上文 audience、组织授权及 WebSocket
|
||||
规则是后续组织服务目标,不由这些本地接口实现。
|
||||
|
||||
@@ -171,9 +175,110 @@ GoodBuddy 通过 `页面 → preload → 可信 IPC → Main HTTP 服务` 调用
|
||||
服务地址与设备名称,并设置请求超时。Web 同源读取目录,不保存桌面设备凭据。
|
||||
两仓独立构建;桌面实现与验证见[设备共享文档](../../../../goodbuddy/docs/features/device-sharing/README.md)。
|
||||
|
||||
### 4.2 本地模型网关契约(已实现)
|
||||
|
||||
实现位于 `server/src/gateway-routes.ts`、`gateway-store.ts` 和 `shared/gateway.ts`,挂载到
|
||||
`/api/v1/gateway`。B1 已实现本机管理、托管凭据和按模型授权的 Bearer 访问 Key,没有组织隔离、
|
||||
用户组策略、配额或速率/并发控制。客户端 Key 不转发给上游。就绪接口报告网关边界,保持
|
||||
`productionReady=false`、`storageChecked=false`。网关独立构建运行,不导入 GoodBuddy 源码。
|
||||
|
||||
下表路径均相对于 `/api/v1/gateway`,成功返回 200:
|
||||
|
||||
| 方法与路径 | 输入与行为 | 响应 |
|
||||
| --- | --- | --- |
|
||||
| `GET /config` | 读取配置、调用元数据和部署指定地址 | `{upstreams,models,audits,gatewayBaseUrl}` |
|
||||
| `POST /upstreams`、`PUT /upstreams/:id` | `name,baseUrl,protocol,authentication,enabled,credentialAction`;仅 `replace` 带 `apiKey` | 上游公开字段及 `credentialConfigured`,无秘密或密文 |
|
||||
| `POST /models`、`PUT /models/:id` | `alias,upstreamId,remoteModel,enabled`,可选 `displayName` | 模型映射 |
|
||||
| `GET /access-keys` | 列出 Key 元数据 | `{accessKeys}`,不含摘要或原文 |
|
||||
| `POST /access-keys` | `{name,modelIds}`,至少一个不重复的模型记录 ID | `{accessKey,key}`,完整 Key 仅此次返回 |
|
||||
| `POST /access-keys/:id/revoke` | 撤销 Key | Key 元数据,重复撤销保留原 `revokedAt` |
|
||||
| `POST /upstreams/:id/discover` | 无请求体;向已启用上游的 `/models` 发 GET | 去重模型 ID 列表 `{models: string[]}`,不自动保存映射 |
|
||||
| `POST /test` | 本机管理测试,使用 Chat 字段,仅非流式 | 真实 JSON 响应,复用转发和审计,`accessKeyId=null` |
|
||||
| `GET /v1/models` | Bearer Key;仅列授权、启用且上游启用、凭据可用的模型 | OpenAI 模型列表,模型 ID 为别名 |
|
||||
| `POST /v1/chat/completions` | Bearer Key 与模型授权;按别名转发 | JSON 或 SSE,响应 `model` 改回别名 |
|
||||
|
||||
除两个 `/v1` 客户端端点外,网关管理和旧共享目录 API 均通过 `admin-boundary.ts` 校验实际
|
||||
`incoming.socket.remoteAddress`,只接受 `::1`、`127.*` IPv4 及其 IPv4-mapped 地址;没有 socket
|
||||
信息也拒绝。含 `Forwarded`、`X-Forwarded-*` 或 `X-Real-IP` 的管理请求返回 403,不采信其来源声明。
|
||||
Origin 允许无值、同源、`http://127.0.0.1:5174` 和 `http://localhost:5174`;来源不符同样拒绝。
|
||||
Vite 在代理前对所有 `/api` 请求检查原始 socket,仅限回环。容器和任意反向代理管理不受支持,
|
||||
须等待 B2 登录;无转发头的本机代理不能被当作管理员身份。健康与就绪 GET 不受管理限制。
|
||||
|
||||
Key 使用 `ss_` 加 32 个随机字节的 base64url 编码,存储 SHA-256 摘要。`modelIds` 绑定映射
|
||||
记录的 UUID,修改别名不改变授权。Key 名称不代表用户身份;缺失、格式错误、未知或已撤销的 Key
|
||||
返回 401,未授权或不存在的请求模型返回 403。撤销阻止后续请求,不主动终止已经开始的流。
|
||||
|
||||
客户端 Base URL 为 `http://127.0.0.1:8787/api/v1/gateway/v1`。仅支持上述模型列表与 Chat
|
||||
Completions,不实现 Responses、Anthropic 或其他推理端点,不执行工具。Chat 顶层字段采用
|
||||
`CHAT_FIELDS` 白名单,包含消息、流式选项、采样参数、`tools/tool_choice/parallel_tool_calls`、
|
||||
兼容的 `functions/function_call`、`response_format`、`reasoning_effort` 等;未知字段及地址/凭据
|
||||
覆盖报错。消息角色允许 `system/developer/user/assistant/tool/function`;具体参数语义由上游处理,
|
||||
不保证每个上游支持全部字段。工具调用数据和 usage 随响应透传。
|
||||
|
||||
配置输入严格匹配字段。名称、显示名和远端模型名最多 200 字符,新建或改名的别名最多 128 字符;
|
||||
已有最长 200 字符的别名可继续使用和原名编辑,接入 GoodBuddy 前需显式改为其支持的长度。
|
||||
别名以 ASCII 字母或数字开头,其余可含字母、数字、点、下划线、冒号、斜杠和连字符。
|
||||
上游、模型记录和 Key ID 为服务端生成的 UUID v4。Base URL 最多 2048 字符,允许 HTTP/HTTPS
|
||||
及合法路径,去掉尾斜杠,禁止用户信息、查询和片段;请求不跟随重定向。支持可信内网 HTTP
|
||||
上游,但 HTTP 不加密传输凭据,对外部署使用 HTTPS;该校验不构成生产出站访问策略。
|
||||
|
||||
`protocol` 仅支持 `openai-chat-completions`,`authentication=api-key|none`。
|
||||
`credentialAction=keep|replace|clear` 显式保留、替换或清除凭据;空输入不代表清除,选择 `none`
|
||||
不自动删除已有凭据。替换时 `apiKey` 最多 16384 字符,使用 AES-256-GCM、随机 nonce、认证标签,
|
||||
并以稳定上游 ID 作为 AAD;密文随上游一起原子保存。`credentialConfigured` 只反映密文或有效
|
||||
环境引用是否存在,不保证解密或连接成功。
|
||||
|
||||
`SHARESERVER_MASTER_KEY_FILE` 指向数据目录外的秘密文件,也可用 `SHARESERVER_MASTER_KEY`
|
||||
提供 64 位十六进制主密钥,两者不能同时设置。文件路径及真实路径均须在数据目录外。
|
||||
初始化命令为 `npm run gateway:init-key -- <文件路径>`,父目录须已存在,拒绝覆盖已有文件;
|
||||
Windows 可运行示例见根 README。主密钥与普通数据分开备份;缺失、错误或无法解密时返回
|
||||
`CREDENTIAL_UNAVAILABLE`,不重新生成或覆盖。替换任一凭据前先检查既有密文能否解密。
|
||||
|
||||
旧版本 `apiKeyEnv` 记录仍可读取并在调用时解析 API 环境,显式替换/清除后移除引用;
|
||||
新表单及 API 不接受变量名,`/import-env` 已移除。环境文件仅供旧引用及授权开发测试加载:
|
||||
|
||||
```text
|
||||
npm run dev -- --env-file=../goodbuddy/.env.deepseek.local
|
||||
```
|
||||
|
||||
`scripts/dev.mjs` 用 `parseEnv` 加载指定文件,仅将文件中的变量传给 API 子进程,Vite 不接收
|
||||
这份加载结果,仅接收派生的 `SHARESERVER_API_PORT` 以同步代理目标;已有进程环境优先。
|
||||
环境变更需重启 API。该路径仅是可选的授权测试输入,
|
||||
不构成 GoodBuddy 依赖,不自动读取或迁移个人配置;使用既有个人配置须有明确的环境测试授权。
|
||||
|
||||
`SHARESERVER_GATEWAY_BASE_URL` 设置完整客户端 Base URL,`/config` 将其返回为 `gatewayBaseUrl`;
|
||||
未配置时返回 `null`,界面提示配置,不从请求 Host 或 Vite 地址推断。自定义端口或异机访问须
|
||||
设置客户端可达的地址并保留 `/api/v1/gateway/v1`。
|
||||
|
||||
请求体要求 JSON,读取上限为 1 MiB;上游 JSON 响应、发现结果及 SSE 总字节上限为 16 MiB。
|
||||
SSE 单事件及未分帧缓冲的字符串长度上限为 1,048,576 个 JavaScript UTF-16 码元,不能视为
|
||||
1 MiB 字节上限。默认超时 120 秒,聊天从读取入站请求到完成流共用该期限;取消会中止上游。
|
||||
SSE 必须有 `[DONE]`,中途 EOF 记 `INCOMPLETE_STREAM`;聊天 JSON 结果/SSE JSON 数据事件须有
|
||||
`choices` 数组且无 `error`。流已发出后,失败通过错误事件或流中断报告,HTTP 200 不代表调用成功。
|
||||
|
||||
错误包为 `{error:{code,message},requestId}`,不回传上游错误正文。输入无效 400,Key 无效 401,停用、未授权模型或 Origin
|
||||
拒绝 403,不存在 404,别名冲突 409,请求超限 413,非 JSON 415,上游故障或响应无效 502,
|
||||
缺凭据/存储故障 503,超时 504。聊天审计的取消状态记 499;这不是承诺客户端能收到的 HTTP 状态。
|
||||
管理请求按上述 socket 和 Origin 规则校验;聊天端点也检查 Origin。未识别路径在通过管理边界后返回 JSON 404。
|
||||
|
||||
存储默认 `data/gateway.json`,跟随 `SHARESERVER_DATA_DIR`。文件缺失时从空上游、映射和审计
|
||||
开始,首次写入创建文件,无预置服务或调用;每个数据目录只允许单进程中的一个 GatewayStore。
|
||||
写入排队,经临时文件、文件同步和 rename 后更新内存;坏文件返回存储错误,不自动重置。
|
||||
配置无删除 API,可编辑或停用;当前不提供配置修订冲突控制或 PostgreSQL 迁移。
|
||||
|
||||
存储版本 2 包含上游密文、映射、Key 摘要及元数据与最新写入的 200 条聊天终态审计;版本 1
|
||||
可读取并在下次写入时保存为版本 2,保留原 ID 和旧环境引用。审计新记录在前,仅含 ID、时间、
|
||||
已识别模型别名、上游 ID、访问 Key ID、是否流式、成功/失败/取消、HTTP 状态、耗时、输入/输出 Token 和
|
||||
稳定错误码;未识别别名不保存用户原始值。用量只取上游有效非负整数,缺失为 `null`,不估算为零。
|
||||
审计不保存提示词、工具参数、响应正文或凭据;配置变更与模型发现不生成聊天审计,不具备完整管理审计。
|
||||
写入失败不能据此确认审计已保存,流中已发送的数据也不能撤回。
|
||||
|
||||
本节按当前源码描述契约。`gateway.test.ts` 中的测试用例不等同于本次运行证据,实测结果与日期
|
||||
由 `progress.md` 维护;历史模拟网关的通过记录不追溯作为真实转发验收。
|
||||
|
||||
## 5. 数据模型
|
||||
|
||||
下列数据库模型属于组织服务目标设计。本地目录首期使用第 4.1 节的文件存储,不将本地设备 ID 当作组织身份。
|
||||
下列数据库模型属于组织服务目标设计。本地目录和网关使用第 4.1、4.2 节的文件存储,不将本地设备 ID 当作组织身份。
|
||||
|
||||
核心表建议为:
|
||||
|
||||
@@ -287,7 +392,7 @@ Release catalog 使用独立签名域。服务器不能重打包已签名 payloa
|
||||
### 10.1 私有部署第一阶段(待实现)
|
||||
|
||||
本节是首期容器、配置、接口和持久化的权威设计,范围以 PRD 第 3 节为准。当前 `/documents`
|
||||
仍为演示,仓库只有 ShareServer 的 `docker/compose.yaml`,没有 Office overlay 或真实编辑链路。
|
||||
显示待接入说明,仓库只有 ShareServer 的 `docker/compose.yaml`,没有 Office overlay 或真实编辑链路。
|
||||
第一阶段采用单个 ShareServer 进程、独立 Document Server 和本地文件系统,不新增身份源、数据库、
|
||||
S3/MinIO、独立 callback worker 或启动 code/安全 Cookie 交换。仅适用于可信私有网络;当前无认证,
|
||||
可访问服务者可操作试验文档,随机文档 ID 和 JWT 均不能据此宣称用户授权或组织隔离。
|
||||
@@ -523,46 +628,25 @@ Office 第一阶段配置与独立往返验收见第 10.1 节;后续组织部
|
||||
|
||||
设计完成不代表服务已实现;只有独立部署和真实 Desktop 端到端路径通过后才能更新实施状态。
|
||||
|
||||
## 16. 当前原型实现
|
||||
## 16. 当前控制台实现
|
||||
|
||||
2026-09-05 建立的交互原型固定以下技术基线:
|
||||
Hono 与 `@hono/node-server` 提供 API 和构建后的同源 Web 托管。Web 沿用 React、Vite、
|
||||
TypeScript、Tailwind CSS 和 Radix。部署已有非 root 多阶段 Docker 镜像。
|
||||
|
||||
- API:Hono + `@hono/node-server`,保留 `/api/v1/public/*` 与
|
||||
`/api/v1/web/*` audience 路径。
|
||||
- Web:React、Vite、TypeScript、Tailwind CSS 和 Radix primitives,按 shadcn/ui 组件模式
|
||||
维护本地可修改组件。
|
||||
- 路由与范围:管理员和成员自助使用不同可见导航;当前组织持续显示。
|
||||
- 部署:生产构建由同源 Hono 服务托管,并提供非 root 多阶段 Docker 镜像。
|
||||
`web/src/App.tsx` 不再请求原型快照,不显示虚构身份或角色切换。概览显示服务入口,
|
||||
`/capabilities` 通过 `sharing-center.tsx` 与 `local-sharing.tsx` 读取真实设备和发布记录;
|
||||
来源与知识权限只是声明,不代表正文、检索或调用已可用。
|
||||
|
||||
`/api/v1/public/ready` 返回 `mode=interactive-prototype` 和
|
||||
`productionReady=false`。原型 fixture、角色切换和操作确认只用于评审信息架构,不得被
|
||||
Desktop 或部署脚本当作真实身份、策略、任务或审计 API。组织生产实施须从 FR-1
|
||||
初始化、本地身份、PostgreSQL 组织作用域和事务审计开始;第 10.1 节私有 Office 试验可独立推进,
|
||||
使用真实文件和独立元数据,不在 fixture 上叠加权威状态。
|
||||
当前 `/documents` 已有 OnlyOffice 实例配置与预设检查模拟;Office 会话 API、对象存储、
|
||||
ONLYOFFICE 编辑、callback、文档保存和 Desktop 文档 Tab 集成仍未实现。
|
||||
`/model-gateway` 的 `gateway-page.tsx` 使用第 4.2 节真实 API 保存配置、发现模型、发起非流式
|
||||
测试和读取审计;测试正文仅在组件内存,卸载取消在途请求。`/distribution` 只显示待接入说明
|
||||
与网关链接。文档和其他未实现模块显示待接入状态,没有示例列表或模拟操作。
|
||||
|
||||
`web/src/app-config.ts` 将 `/model-gateway`、`/documents`、`/distribution` 放入管理员的
|
||||
“GoodBuddy 服务”分组。`web/src/components/services-demo.tsx` 使用 `ServicesDemo` 的 React
|
||||
Context 与 `useState` 共享演示状态,跨路由导航保留、刷新重置。服务演示操作不使用 Hono
|
||||
操作确认 API、不发网络请求、不写本地存储,也不保存凭据;页面角色与权限判断均为内存模拟。
|
||||
控制台启动仍通过既有快照 API 加载示例数据,此边界仅指三个服务页的模拟操作。
|
||||
服务端仍保留旧 `/api/v1/web/prototype/*` 接口及 fixture,但它们已退出控制台调用路径,不能
|
||||
作为组织业务 API。`/api/v1/public/ready` 仍报告 `mode=interactive-prototype`、
|
||||
`productionReady=false` 和存储未检查。历史模拟测试不作为真实业务验收。
|
||||
|
||||
模型网关只模拟上游/映射启用与用户组策略判断及最近 50 条有界调用审计;产品职责是转发
|
||||
其他模型服务,不托管模型或执行推理。文档仅保存示例名称、归属及回收站标记;转移清除个人
|
||||
删除权,组织删除/恢复依赖显式模拟角色,恢复保留归属。OnlyOffice 检查使用预设结果。
|
||||
|
||||
配置演示保存单个草稿和按组发布历史,发布与回退均重新检查模型可用性;回退取上一条记录并
|
||||
移除当前末项,恢复版本及默认模型。仅目标组在线示例设备更新 `applied`,离线设备保持原值与
|
||||
待下发状态。这里没有设备连接、重连投递或持久化版本记录,不能作为真实配置交付证据。
|
||||
|
||||
共享中心原型由 `web/src/components/sharing-center.tsx` 和 `web/src/lib/sharing-fixtures.ts` 提供。
|
||||
条目使用现有快照中的成员、设备 ID 建立关联,本地 fixture 仅含元数据和显式权限标记。
|
||||
既有 `/capabilities` 路由不变;查询参数 `view=catalog|knowledge|users|devices` 选择页签,
|
||||
`user`/`device` 定位主体,`item` 打开详情,`q`、`status=ready|unavailable`、
|
||||
`source=synced|remote`、`audience` 保存筛选。刷新恢复 URL 状态;内容链接保留筛选,
|
||||
跨主体链接清除旧条件,无效条目链接显示可关闭的错误详情。除启动快照外不新增 API 请求,
|
||||
不保存正文、不模拟下载;真实授权和同步仍属后续实现。
|
||||
B1 正常网关接入代码已实现,真实验收部分完成,范围见实施进度。组织服务需补身份、PostgreSQL 与授权;Office 私有
|
||||
试验可按第 10.1 节独立推进。保存网关模型不会自动下发给 GoodBuddy,配置交付仍未实现。
|
||||
|
||||
## 17. 工作空间协作的待实现契约
|
||||
|
||||
|
||||
@@ -16,8 +16,14 @@ ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现
|
||||
|
||||
## 1. 使用对象
|
||||
|
||||
当前本地首期在 `/capabilities?view=local` 提供“本地服务目录”页签,读取真实设备和发布数据。
|
||||
其余四个共享中心页签保留组织演示。真实页不使用示例组织、用户归属或在线状态解释本地记录。
|
||||
当前控制台移除模拟数据与操作。`/capabilities` 直接读取本地服务的设备和发布数据,原
|
||||
`/capabilities?view=local` 地址仍可访问。共享中心不再提供组织示例页签,不使用示例组织、
|
||||
用户归属或在线状态解释本地记录。
|
||||
|
||||
概览只提供已接入服务的入口,不显示预设指标和活动。导航保留规划模块;未接入的页面显示
|
||||
待实现说明,不提供虚假的保存、审批或健康检查。模型网关、共享目录分别加载自己的 API,
|
||||
控制台不再依赖原型快照。移除预设身份、组织切换、角色切换、通知数量和未实现的全局搜索。
|
||||
下文中的组织身份、文档协作及其他管理流程属于目标设计,尚未实现的部分不作为当前可用功能展示。
|
||||
|
||||
GoodBuddy 从应用菜单或应用中心进入“设备共享”,完成地址与名称保存、注册、发布、撤销和刷新。
|
||||
Web 提供设备、类型、状态、关键词筛选,显示注册时间、来源声明及独立知识权限;提供加载、空目录、
|
||||
@@ -56,6 +62,34 @@ GoodBuddy 接入
|
||||
当前组织始终显示。切换组织会重新加载整个数据范围,不保留上一组织的选择、筛选或详情。
|
||||
系统管理使用“实例范围”标识,不能伪装成某个组织。
|
||||
|
||||
### 2.1 页面 Tab 结构
|
||||
|
||||
每个管理页面围绕一个业务对象组织,页面内通过 Tab 切换查看范围或操作阶段。列表、详情和
|
||||
编辑表单属于当前 Tab 的交互,不单独增加一级导航。未接入的页面保留 Tab 结构,但每个 Tab
|
||||
只显示对应的待接入说明,不生成示例数据。
|
||||
|
||||
| 页面 | Tab |
|
||||
| --- | --- |
|
||||
| 运行概览 | 服务状态、待处理事项、最近活动 |
|
||||
| 模型网关 | 概览、上游服务、模型目录、访问密钥、请求测试、调用审计、客户端接入 |
|
||||
| 文档与协作 | 文档库、编辑会话、Office 服务、保存记录 |
|
||||
| 配置下发 | 配置草稿、设备生效、发布记录、回滚记录 |
|
||||
| GoodBuddy 接入 | 设备列表、设备详情、连接记录、撤销记录 |
|
||||
| 共享中心 | 能力目录、共享知识库、按成员查看、按设备查看 |
|
||||
| 数字人 | 数字人目录、我的数字人、调用权限、发布记录 |
|
||||
| 组织与成员 | 成员、用户组、角色、服务账号 |
|
||||
| 权限策略 | 强制策略、Grant、策略模拟、变更记录 |
|
||||
| 审批 | 待处理、已处理、我的申请、审批规则 |
|
||||
| 发布与更新 | 能力包、发布通道、更新策略、撤销记录 |
|
||||
| 网关联邦 | 合作组织、待确认、开放范围、事件记录 |
|
||||
| 任务与中继 | 任务列表、活动任务、中继连接、运行统计 |
|
||||
| 审计 | 事件查询、调用链、导出任务、保留策略 |
|
||||
| 系统设置 | 实例信息、身份与登录、基础设施、中继与联邦、通知、保留与审计 |
|
||||
| 我的设备与授权 | 我的设备、我的授权、我的申请、我的数字人 |
|
||||
|
||||
Tab 的状态和功能范围以[产品功能列表](./feature-list.md)为准。当前原型已经为组织与成员
|
||||
提供通用 Tab 组件;模型网关使用独立的真实 API 页面,其余尚未接入服务的页面显示占位说明。
|
||||
|
||||
## 3. 概览
|
||||
|
||||
概览只显示需要行动和运行事实:
|
||||
@@ -192,7 +226,7 @@ GPU-Worker-02 AI Team Linux 离线 8 2 小时前
|
||||
## 13. Office 编辑目标设计
|
||||
|
||||
Office 编辑管理归入“文档与协作”,属于系统管理员的服务配置和组织管理员的策略管理,不提供
|
||||
文档正文浏览器。以下是生产目标,当前实例配置与预设检查演示见第 16.1 节。
|
||||
文档正文浏览器。以下是生产目标,当前待接入页面见第 16.1 节。
|
||||
|
||||
日常文档操作在 GoodBuddy 中完成。文档以 ShareServer 为主存储;显式选择本地来源导入/回写
|
||||
时,另行显示检查点与源文件回写状态。关闭会话不表示删除主文档,检查点可用不表示本地源文件已保存。
|
||||
@@ -217,7 +251,7 @@ Office 编辑管理归入“文档与协作”,属于系统管理员的服务
|
||||
|
||||
### 13.1 私有部署第一阶段入口(待实现)
|
||||
|
||||
在 `/documents` 提供明确区分于现有演示的“私有文档试验”入口,展示无用户授权的使用范围。
|
||||
后续在 `/documents` 提供“私有文档试验”入口,展示无用户授权的使用范围。
|
||||
提供 DOCX 上传、真实文档列表、打开编辑器、保存状态与关闭后重新打开;不使用模拟个人/组织
|
||||
归属或角色开关解释真实文件。列表显示名称、大小、最近持久化版本与保存时间,不显示服务器路径。
|
||||
|
||||
@@ -250,34 +284,56 @@ Office 编辑管理归入“文档与协作”,属于系统管理员的服务
|
||||
shadcn/ui 的本地组件模式和 Radix 可访问 primitives,布局参考成熟后台的侧栏、顶栏、数据
|
||||
表格和详情对话框,但不复制第三方示例业务页面。
|
||||
|
||||
原型已覆盖数字人需求加入前的一级栏目、管理员与成员自助视图切换、组织范围、搜索筛选、
|
||||
页签、详情、创建表单、危险确认和 390px 窄屏导航。动态发光、网格和网络脉冲只用于运行状态
|
||||
与品牌氛围;业务表格仍保持克制,状态继续同时使用图标与文字。既有 fixture 页面操作会调用无持久化确认
|
||||
API,并持续标明不会写入生产数据,不能据此宣称真实审批、撤销、策略或任务操作已实现。
|
||||
早期原型覆盖一级栏目、角色切换、搜索筛选、详情、创建表单与窄屏导航。
|
||||
2026-10-02 起,控制台移除示例业务页面及其操作入口,保留侧栏、主题和窄屏导航。
|
||||
`FR-13` 新增的数字人目录、“我的数字人”和 `@` 选择器尚未进入当前原型。
|
||||
“文档与协作”已提供 OnlyOffice 实例配置和预设健康检查模拟;真实 Office 编辑与会话管理尚未实现。
|
||||
“文档与协作”显示待接入状态;Office 编辑与会话管理尚未实现。
|
||||
|
||||
### 16.1 GoodBuddy 服务演示
|
||||
### 16.1 GoodBuddy 服务入口
|
||||
|
||||
侧栏“GoodBuddy 服务”分组提供三个管理员路由。页首持续标注模拟边界:三页共享当前会话
|
||||
内存,导航保留状态、刷新重置;操作不调用网络或写本地存储。角色选择仅用于评审,不构成
|
||||
真实授权;未接入模型推理、网关转发、OnlyOffice 编辑保存或设备交付。
|
||||
本节记录已实现的 [B1 网关界面](./backend-foundation-design.md#4-b1正常模型网关接入),本轮未完成浏览器验收。
|
||||
|
||||
工作台顶部显示服务拓扑,按真实 API 汇总已登记 GoodBuddy 设备、ShareServer 发布目录和模型网关上游。拓扑表达服务职责和配置关系,登记设备不显示为在线,发布目录不显示为可执行内容;没有数据时显示零接入状态。
|
||||
页面按 Tab 组织为“概览”“上游服务”“模型目录”“访问密钥”“请求测试”“调用审计”“客户端接入”。概览只展示网关地址、资源数量和最近状态;
|
||||
上游服务管理外部连接,模型目录管理客户端可见的映射,访问密钥管理客户端授权,请求测试验证真实转发,调用审计查看有界元数据,客户端接入展示
|
||||
GoodBuddy 所需的地址和模型配置。不同 Tab 不混合配置表单、运行结果和审计记录。上游表单输入 API 地址和密钥,移除前缀与变量名;
|
||||
接入区展示完整 Base URL、协议、模型 ID,并支持创建与撤销访问 Key。凭据只显示配置状态,
|
||||
编辑上游时,凭据输入框留空表示保留当前值,填写新值表示覆盖;页面不要求用户选择额外的凭据操作。新建访问 Key 仅当次显示完整值,离开后只保留名称及状态。
|
||||
获取模型失败仍可手动添加,空状态引导接入,不填示例。访问 Key 的授权绑定模型记录 ID,修改别名不改变授权。
|
||||
|
||||
侧栏按业务流程分为“工作台”“GoodBuddy 服务”“共享资源”“组织管理”“运营与审计”“系统”和“成员自助”。
|
||||
其中“GoodBuddy 服务”分组保留模型网关、文档与协作、配置下发三个管理路由;“共享资源”集中设备接入和共享中心。
|
||||
各页分别说明实现范围。网关是无组织认证、
|
||||
组织隔离和配额的本地技术预览,已实现客户端 Key 认证及模型授权;文档与配置下发显示待接入说明。
|
||||
|
||||
| 路由 | 交互与结果 |
|
||||
| --- | --- |
|
||||
| `/model-gateway` 模型网关 | “上游服务”“模型映射 / 访问策略”“调用审计”页签;可添加和启停上游,切换映射上游、允许组及启停状态,选择调用组模拟允许/拒绝。最近 50 条审计仅含路由与策略结果,可查看详情,不含请求/响应正文 |
|
||||
| `/documents` 文档与协作 | “组织文档”“归属元数据”“OnlyOffice 服务器”“回收站”页签;展示名称与归属,无个人正文预览。OnlyOffice 可添加实例、预设健康/失败并累计模拟检查次数,不访问实例或建立编辑会话 |
|
||||
| `/distribution` 配置下发 | 创建一个配置草稿,选择目标组、当前可用默认模型和三段数字版本;发布前再次检查模型与组策略。按组展示当前/上一版本、设备已应用/目标版本,并可回退到上一条发布记录 |
|
||||
| `/model-gateway` 模型网关 | 按“概览/上游服务/模型目录/访问密钥/请求测试/调用审计/客户端接入”组织;配置保存到服务器,可添加、编辑、启停上游与映射,发现模型、创建和撤销访问 Key、测试及刷新审计,无用户组策略控件 |
|
||||
| `/documents` 文档与协作 | 显示尚未接入服务,无示例文档、实例或操作按钮 |
|
||||
| `/distribution` 配置下发 | 显示“尚无配置下发服务”及模型网关链接;无草稿、设备生效回执或发布回滚记录,保存网关模型不会自动下发到 GoodBuddy |
|
||||
|
||||
“打开模拟 GoodBuddy 客户端”仅打开对话框,个人创建者可删除自己的示例文档或确认转移给
|
||||
当前组织,不启动真实客户端或上传文件。转移后从个人列表移除,原个人删除权失效;显式
|
||||
勾选模拟“组织文档管理员”才可删除/恢复组织文档。其他成员个人文档不可操作,恢复保留
|
||||
原归属,演示不提供永久删除。
|
||||
网关初始展示空上游、空映射、空访问 Key 与空审计。上游表单填写名称、Base URL、协议、认证
|
||||
方式和启用状态;API Key 留空时保留已有凭据,填写真实 API Key 时覆盖旧值,提交后清空密钥输入。保存失败保留
|
||||
普通字段,密钥重试需重新输入。编辑默认保留;无需认证不会自动清除已有凭据。界面仅显示
|
||||
凭据配置状态,不返回旧值或密文。旧环境引用保留存储兼容,当前界面没有导入入口。
|
||||
|
||||
配置发布与回退仅更新目标组在线示例设备;离线设备保持原已应用版本并标记待下发,无重连
|
||||
交付模拟。回退同时恢复版本和默认模型;历史模型停用或当前组策略不允许时禁止回退,不能
|
||||
仅改版本标签。上游及 OnlyOffice 地址表单只接受不含账号、密码、查询参数或片段的 HTTPS
|
||||
地址,不提供密钥字段,也不验证连接。
|
||||
“发现模型”访问真实上游并列出结果,选择结果打开映射表单,也可手动填写。测试只列出上游、
|
||||
映射均启用且无需认证或已配置凭据的别名,用户填写提示词后通过本机管理 `/test` 发送非流式请求;发送前提示数据去向及
|
||||
可能费用。测试支持取消、清除结果,提示词与结果仅在组件内存中,离开页面清除。页面显示客户端
|
||||
Base URL 来自 `SHARESERVER_GATEWAY_BASE_URL`,本机示例为 `http://127.0.0.1:8787/api/v1/gateway/v1`;
|
||||
未配置时提示管理员设置,不从 Vite 端口或浏览器 Host 推断。客户端使用访问 Key,不使用上游 API Key。
|
||||
|
||||
配置加载失败可重试;已有快照时保留并标注上次数据。保存失败保留普通字段,错误显示 HTTP
|
||||
状态、错误码及请求 ID。取消测试后提示审计可能稍后更新。审计显示服务器保留的最新 200 条
|
||||
调用元数据:时间、编号、模型、上游、访问 Key ID、流式方式、结果、HTTP 状态、耗时、Token 用量与错误码;
|
||||
不展示正文,缺失用量显示“未知”。窄屏审计表可横向滚动。
|
||||
|
||||
文档页已移除“打开模拟 GoodBuddy 客户端”、示例文档、模拟角色和回收操作;归属操作待后端实现。
|
||||
|
||||
网关上游接受 HTTP/HTTPS,包括可信内网 HTTP,不允许账号、密码、查询参数或片段;保存不验证连接,
|
||||
发现与测试才发真实请求。OnlyOffice 实例表单与预设检查已退出当前界面。
|
||||
管理 API 与 Vite `/api` 均限制实际 socket 来源为回环;容器/反向代理管理待 B2 登录后支持。
|
||||
既有网关策略与配置发布演示记录属于历史交互证据,不作为当前真实网关或设备交付的测试结论。
|
||||
|
||||
## 17. Desktop 工作空间协作目标交互
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:api": "node --watch --import tsx server/src/index.ts",
|
||||
"dev:web": "vite --config web/vite.config.ts",
|
||||
"gateway:init-key": "node scripts/init-master-key.mjs",
|
||||
"build": "npm run typecheck && vite build --config web/vite.config.ts && npm run build:server",
|
||||
"build:server": "node -e \"require('node:fs').rmSync('dist/server',{recursive:true,force:true})\" && tsc -p server/tsconfig.json",
|
||||
"start": "node dist/server/server/src/index.js",
|
||||
|
||||
+46
-2
@@ -1,10 +1,54 @@
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { parseEnv } from 'node:util'
|
||||
import { concurrently } from 'concurrently'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length > 1 || (args.length === 1 && !args[0].startsWith('--env-file='))) {
|
||||
console.error('Usage: npm run dev -- --env-file=<path>')
|
||||
process.exit(1)
|
||||
}
|
||||
let apiEnvironment = process.env
|
||||
if (args.length) {
|
||||
try {
|
||||
// Provider credentials belong only to the API child, not the Vite process.
|
||||
apiEnvironment = { ...parseEnv(readFileSync(args[0].slice('--env-file='.length), 'utf8')), ...process.env }
|
||||
} catch {
|
||||
console.error('Unable to load API environment file. Check the supplied path and permissions.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
function developmentMasterKey(environment) {
|
||||
if (environment.SHARESERVER_MASTER_KEY || environment.SHARESERVER_MASTER_KEY_FILE) return environment
|
||||
const file = join(homedir(), '.goodbuddy-shareserver', 'master-key')
|
||||
try {
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
try {
|
||||
const existing = readFileSync(file, 'utf8').trim()
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(existing)) throw new Error('The development gateway master key is invalid.')
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
writeFileSync(file, `${randomBytes(32).toString('hex')}\n`, { mode: 0o600, flag: 'wx' })
|
||||
}
|
||||
return { ...environment, SHARESERVER_MASTER_KEY_FILE: file }
|
||||
} catch (error) {
|
||||
if (error?.code === 'EEXIST') return { ...environment, SHARESERVER_MASTER_KEY_FILE: file }
|
||||
console.error(`Unable to prepare the development gateway master key: ${error instanceof Error ? error.message : 'unknown error'}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
apiEnvironment = developmentMasterKey(apiEnvironment)
|
||||
|
||||
// Avoid CLI argv detection treating Electron's Node mode as a bundled app.
|
||||
const { result } = concurrently([
|
||||
{ command: 'npm run dev:api', name: 'api', prefixColor: 'cyan' },
|
||||
{ command: 'npm run dev:web -- --host 127.0.0.1', name: 'web', prefixColor: 'magenta' }
|
||||
{ command: 'npm run dev:api', name: 'api', prefixColor: 'cyan', env: apiEnvironment },
|
||||
{ command: 'npm run dev:web -- --host 127.0.0.1', name: 'web', prefixColor: 'magenta', env: { SHARESERVER_API_PORT: apiEnvironment.SHARESERVER_PORT ?? '8787' } }
|
||||
], { killOthersOn: ['success', 'failure'] })
|
||||
|
||||
result.then(() => { process.exitCode = 0 }, () => { process.exitCode = 1 })
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
import { open, realpath } from 'node:fs/promises'
|
||||
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
|
||||
// Keep this file outside the data directory and back it up separately.
|
||||
const target = process.argv[2]
|
||||
if (!target || process.argv.length !== 3) {
|
||||
console.error('Usage: npm run gateway:init-key -- <secret-file-outside-data-directory>')
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
try {
|
||||
const directory = resolve(process.env.SHARESERVER_DATA_DIR ?? './data')
|
||||
const destination = resolve(target)
|
||||
const actualParent = await realpath(dirname(destination))
|
||||
const actualDirectory = await realpath(directory).catch(error => {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
return directory
|
||||
})
|
||||
for (const [root, file] of [[directory, destination], [actualDirectory, resolve(actualParent, basename(destination))]]) {
|
||||
const path = relative(root, file)
|
||||
if (!(path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path))) throw new Error('Secret file must be outside the data directory.')
|
||||
}
|
||||
const file = await open(destination, 'wx', 0o600)
|
||||
try { await file.writeFile(`${randomBytes(32).toString('hex')}\n`); await file.sync() }
|
||||
finally { await file.close() }
|
||||
console.log('Master key created. Set SHARESERVER_MASTER_KEY_FILE to this file. Back it up separately from data; never publish it. On Windows, restrict its ACL to the service account.')
|
||||
} catch {
|
||||
console.error('Master key initialization failed. Use a new file in an existing directory outside SHARESERVER_DATA_DIR; existing files are never replaced.')
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Context, Next } from 'hono'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import { isIP } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export type SocketBindings = { incoming?: IncomingMessage }
|
||||
|
||||
export async function localAdmin<E extends { Bindings: SocketBindings }>(c: Context<E>, next: Next) {
|
||||
const address = c.env?.incoming?.socket?.remoteAddress?.toLowerCase()
|
||||
const ipv4 = address?.startsWith('::ffff:') ? address.slice(7) : address
|
||||
const loopback = address === '::1' || (ipv4 !== undefined && isIP(ipv4) === 4 && ipv4.startsWith('127.'))
|
||||
const forwarded = [...c.req.raw.headers.keys()].some(key => key === 'forwarded' || key.startsWith('x-forwarded-') || key === 'x-real-ip')
|
||||
const origin = c.req.header('origin')
|
||||
const allowedOrigin = !origin || origin === new URL(c.req.url).origin || ['http://127.0.0.1:5174', 'http://localhost:5174'].includes(origin)
|
||||
if (!loopback || forwarded || !allowedOrigin) {
|
||||
return c.json({ error: { code: 'ADMIN_ACCESS_DENIED', message: 'Direct loopback administration required.' }, requestId: randomUUID() }, 403)
|
||||
}
|
||||
await next()
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { app } from './app.js'
|
||||
import { createApp } from './app.js'
|
||||
import { withLocalSocket } from './test-socket.js'
|
||||
|
||||
const app = withLocalSocket(createApp())
|
||||
|
||||
describe('ShareServer prototype API', () => {
|
||||
it('reports prototype readiness without claiming production readiness', async () => {
|
||||
|
||||
+21
-2
@@ -5,11 +5,19 @@ import { secureHeaders } from 'hono/secure-headers'
|
||||
import { prototypeSnapshot } from '../../shared/prototype-data.js'
|
||||
import { sharingRoutes } from './sharing-routes.js'
|
||||
import { SharingStore } from './sharing-store.js'
|
||||
import { GatewayStore } from './gateway-store.js'
|
||||
import { gatewayRoutes } from './gateway-routes.js'
|
||||
import { localAdmin, type SocketBindings } from './admin-boundary.js'
|
||||
|
||||
export function createApp(store = new SharingStore()) {
|
||||
const app = new Hono()
|
||||
export function createApp(store = new SharingStore(), gateway = new GatewayStore()) {
|
||||
const app = new Hono<{ Bindings: SocketBindings }>()
|
||||
|
||||
app.use('*', logger())
|
||||
app.use('/api/*', async (c, next) => {
|
||||
if (c.req.path.startsWith('/api/v1/gateway/') || (c.req.method === 'GET' && ['/api/v1/public/health', '/api/v1/public/ready'].includes(c.req.path))) await next()
|
||||
else return localAdmin(c, next)
|
||||
})
|
||||
app.use('/api', localAdmin)
|
||||
app.use(
|
||||
'*',
|
||||
secureHeaders({
|
||||
@@ -37,6 +45,16 @@ export function createApp(store = new SharingStore()) {
|
||||
status: 'ready',
|
||||
mode: 'interactive-prototype',
|
||||
productionReady: false,
|
||||
gateway: {
|
||||
mode: 'local-openai-compatible',
|
||||
authentication: 'scoped-bearer-key',
|
||||
persistence: 'local-json',
|
||||
credentials: 'aes-256-gcm-and-legacy-environment-references',
|
||||
writerScope: 'single-process-single-store-per-data-directory',
|
||||
networkBoundary: 'direct-loopback-admin-authenticated-inference',
|
||||
organizationAclEnforced: false,
|
||||
storageChecked: false
|
||||
},
|
||||
sharing: {
|
||||
mode: 'local-metadata-only',
|
||||
authentication: 'none',
|
||||
@@ -83,6 +101,7 @@ export function createApp(store = new SharingStore()) {
|
||||
})
|
||||
|
||||
app.route('/api/v1/sharing', sharingRoutes(store))
|
||||
app.route('/api/v1/gateway', gatewayRoutes(gateway))
|
||||
|
||||
app.all('/api/*', (context) => context.json({
|
||||
code: 'NOT_FOUND', message: 'API route not found.', requestId: crypto.randomUUID()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
import { readFile, realpath } from 'node:fs/promises'
|
||||
import { isAbsolute, relative, resolve } from 'node:path'
|
||||
|
||||
export type EncryptedCredential = { nonce: string; tag: string; ciphertext: string }
|
||||
|
||||
export function outsideDirectory(directory: string, file: string) {
|
||||
const path = relative(resolve(directory), resolve(file))
|
||||
return path !== '' && (path === '..' || path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(path))
|
||||
}
|
||||
|
||||
async function masterKey(directory: string): Promise<Buffer> {
|
||||
let value = process.env.SHARESERVER_MASTER_KEY
|
||||
const file = process.env.SHARESERVER_MASTER_KEY_FILE
|
||||
if (value && file) throw new Error('Ambiguous master key configuration')
|
||||
if (file) {
|
||||
const actualFile = await realpath(file)
|
||||
const actualDirectory = await realpath(directory).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
return resolve(directory)
|
||||
})
|
||||
if (!outsideDirectory(directory, file) || !outsideDirectory(actualDirectory, actualFile)) throw new Error('Master key must be outside data directory')
|
||||
value = (await readFile(actualFile, 'utf8')).trim()
|
||||
}
|
||||
if (!value || !/^[0-9a-fA-F]{64}$/.test(value)) throw new Error('Master key unavailable')
|
||||
return Buffer.from(value, 'hex')
|
||||
}
|
||||
|
||||
export async function encryptCredential(directory: string, id: string, secret: string): Promise<EncryptedCredential> {
|
||||
const nonce = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', await masterKey(directory), nonce)
|
||||
cipher.setAAD(Buffer.from(id))
|
||||
const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()])
|
||||
return { nonce: nonce.toString('hex'), tag: cipher.getAuthTag().toString('hex'), ciphertext: ciphertext.toString('hex') }
|
||||
}
|
||||
|
||||
export async function decryptCredential(directory: string, id: string, value: EncryptedCredential): Promise<string> {
|
||||
const decipher = createDecipheriv('aes-256-gcm', await masterKey(directory), Buffer.from(value.nonce, 'hex'))
|
||||
decipher.setAAD(Buffer.from(id))
|
||||
decipher.setAuthTag(Buffer.from(value.tag, 'hex'))
|
||||
return Buffer.concat([decipher.update(Buffer.from(value.ciphertext, 'hex')), decipher.final()]).toString('utf8')
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from './app.js'
|
||||
import { GatewayStore } from './gateway-store.js'
|
||||
import { SharingStore } from './sharing-store.js'
|
||||
import { socketBinding } from './test-socket.js'
|
||||
|
||||
const prefix = '/api/v1/gateway'
|
||||
const upstreamInput = { name: 'Provider', baseUrl: 'http://10.0.0.2:8000/service/v1/', protocol: 'openai-chat-completions', authentication: 'api-key', enabled: true, credentialAction: 'replace', apiKey: 'provider-secret' }
|
||||
const post = (value: unknown, key?: string): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) }, body: JSON.stringify(value) })
|
||||
const exec = promisify(execFile)
|
||||
|
||||
describe('B1 gateway security and persistence contracts', () => {
|
||||
let directory: string
|
||||
let store: GatewayStore
|
||||
let app: ReturnType<typeof createApp>
|
||||
const chat = { model: 'public-model', messages: [{ role: 'user', content: 'hello' }] }
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'b1-gateway-'))
|
||||
store = new GatewayStore(join(directory, 'data'))
|
||||
app = createApp(new SharingStore(join(directory, 'data')), store)
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '12'.repeat(32))
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', '')
|
||||
vi.stubEnv('SHARESERVER_GATEWAY_BASE_URL', '')
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
async function configured() {
|
||||
const upstream = await store.upstream(upstreamInput)
|
||||
const model = await store.model({ alias: 'public-model', upstreamId: upstream.id, remoteModel: 'remote-model', enabled: true, displayName: 'Friendly model' })
|
||||
const created = await store.createAccessKey({ name: 'Desktop', modelIds: [model.id] })
|
||||
return { upstream, model, ...created }
|
||||
}
|
||||
function mockCompletion() {
|
||||
return vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ choices: [{ message: { role: 'assistant', content: 'answer' } }] }))
|
||||
}
|
||||
it('encrypts with fresh nonces, projects no secrets, and keeps/replaces/clears credentials atomically', async () => {
|
||||
const { upstream } = await configured()
|
||||
const filename = join(directory, 'data', 'gateway.json')
|
||||
const original = JSON.parse(await readFile(filename, 'utf8'))
|
||||
expect(original.upstreams[0].credential).toMatchObject({ nonce: expect.stringMatching(/^[a-f0-9]{24}$/), tag: expect.stringMatching(/^[a-f0-9]{32}$/) })
|
||||
expect(JSON.stringify(original)).not.toContain('provider-secret')
|
||||
expect(upstream).toEqual({ id: upstream.id, name: 'Provider', baseUrl: 'http://10.0.0.2:8000/service/v1', protocol: 'openai-chat-completions', authentication: 'api-key', enabled: true, credentialConfigured: true })
|
||||
const { apiKey, ...input } = upstreamInput
|
||||
expect(apiKey).toBe('provider-secret')
|
||||
await store.upstream({ ...input, credentialAction: 'keep' }, upstream.id)
|
||||
expect(JSON.parse(await readFile(filename, 'utf8')).upstreams[0].credential).toEqual(original.upstreams[0].credential)
|
||||
expect(() => store.upstream({ ...input, credentialAction: 'replace', apiKey: '' }, upstream.id)).toThrow()
|
||||
await store.upstream(upstreamInput, upstream.id)
|
||||
expect(JSON.parse(await readFile(filename, 'utf8')).upstreams[0].credential.nonce).not.toBe(original.upstreams[0].credential.nonce)
|
||||
expect(await new GatewayStore(join(directory, 'data')).headers(upstream.id)).toMatchObject({ authorization: 'Bearer provider-secret' })
|
||||
await store.upstream({ ...input, credentialAction: 'clear' }, upstream.id)
|
||||
expect((await store.snapshot()).upstreams[0].credentialConfigured).toBe(false)
|
||||
await expect(store.headers(upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_NOT_CONFIGURED' })
|
||||
await store.upstream({ ...input, authentication: 'none', credentialAction: 'keep' }, upstream.id)
|
||||
expect(await store.headers(upstream.id)).toEqual({ 'content-type': 'application/json' })
|
||||
})
|
||||
it('fails on missing/wrong master keys and tampered ciphertext without replacing existing secrets', async () => {
|
||||
const { upstream, key } = await configured()
|
||||
const filename = join(directory, 'data', 'gateway.json')
|
||||
const saved = await readFile(filename, 'utf8')
|
||||
const fetch = mockCompletion()
|
||||
for (const master of ['', '34'.repeat(32), 'invalid']) {
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', master)
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post(chat, key))
|
||||
expect(response.status).toBe(503)
|
||||
expect(await response.json()).toMatchObject({ error: { code: 'CREDENTIAL_UNAVAILABLE' } })
|
||||
expect((await store.snapshot()).upstreams[0].credentialConfigured).toBe(true)
|
||||
await expect(store.upstream(upstreamInput, upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' })
|
||||
expect(JSON.parse(await readFile(filename, 'utf8')).upstreams).toEqual(JSON.parse(saved).upstreams)
|
||||
}
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '12'.repeat(32))
|
||||
const corrupted = JSON.parse(saved)
|
||||
corrupted.upstreams[0].credential.tag = '00'.repeat(16)
|
||||
await writeFile(filename, JSON.stringify(corrupted))
|
||||
await expect(new GatewayStore(join(directory, 'data')).headers(upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' })
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '')
|
||||
await expect(store.upstream(upstreamInput, upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' })
|
||||
expect(await readFile(filename, 'utf8')).toBe(JSON.stringify(corrupted))
|
||||
})
|
||||
it('reads legacy environment records and long aliases, then migrates explicitly without changing IDs', async () => {
|
||||
const upstreamId = randomUUID(), modelId = randomUUID()
|
||||
const alias = 'a'.repeat(180)
|
||||
await mkdir(join(directory, 'data'))
|
||||
await writeFile(join(directory, 'data', 'gateway.json'), JSON.stringify({ version: 1,
|
||||
upstreams: [{ id: upstreamId, name: 'Old', baseUrl: upstreamInput.baseUrl, apiKeyEnv: 'B1_LEGACY_KEY', enabled: true }],
|
||||
models: [{ id: modelId, alias, upstreamId, remoteModel: 'remote', enabled: true }], audits: [] }))
|
||||
vi.stubEnv('B1_LEGACY_KEY', 'legacy-secret')
|
||||
const snapshot = await store.snapshot()
|
||||
expect(JSON.stringify(snapshot)).not.toContain('B1_LEGACY_KEY')
|
||||
const { key } = await store.createAccessKey({ name: 'Migrated', modelIds: [modelId] })
|
||||
const fetch = mockCompletion()
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, model: alias }, key))).status).toBe(200)
|
||||
expect(fetch.mock.calls[0][1]?.headers).toMatchObject({ authorization: 'Bearer legacy-secret' })
|
||||
await store.model({ alias, upstreamId, remoteModel: 'remote', enabled: false }, modelId)
|
||||
await expect(store.model({ alias: 'b'.repeat(129), upstreamId, remoteModel: 'remote', enabled: true })).rejects.toMatchObject({ status: 400 })
|
||||
await store.upstream(upstreamInput, upstreamId)
|
||||
expect((await new GatewayStore(join(directory, 'data')).snapshot()).models[0].id).toBe(modelId)
|
||||
expect(await store.headers(upstreamId)).toMatchObject({ authorization: 'Bearer provider-secret' })
|
||||
const saved = await readFile(join(directory, 'data', 'gateway.json'), 'utf8')
|
||||
expect(saved).not.toContain('apiKeyEnv')
|
||||
expect(saved).not.toContain('legacy-secret')
|
||||
})
|
||||
it('returns keys only at creation, enforces stable model scopes, and preserves revocation across restarts', async () => {
|
||||
const { upstream, model, key, accessKey } = await configured()
|
||||
const fetch = mockCompletion()
|
||||
const second = await store.model({ alias: 'other-model', upstreamId: upstream.id, remoteModel: 'other', enabled: true })
|
||||
for (const invalid of [undefined, 'bad-key']) {
|
||||
expect((await app.request(`${prefix}/v1/models`, { headers: invalid ? { authorization: `Bearer ${invalid}` } : {} })).status).toBe(401)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, invalid))).status).toBe(401)
|
||||
}
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, model: second.alias }, key))).status).toBe(403)
|
||||
const list = await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${key}` } })
|
||||
expect(await list.json()).toMatchObject({ data: [{ id: 'public-model' }] })
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key))).status).toBe(200)
|
||||
expect((await store.snapshot()).audits[0]).toMatchObject({ accessKeyId: accessKey.id, inputTokens: null, outputTokens: null })
|
||||
const response = await app.request(`${prefix}/access-keys`, post({ name: 'Web-created', modelIds: [model.id] }), socketBinding())
|
||||
const created = await response.json()
|
||||
expect(Object.keys(created).sort()).toEqual(['accessKey', 'key'])
|
||||
expect(Object.keys(created.accessKey).sort()).toEqual(['createdAt', 'id', 'modelIds', 'name', 'revokedAt'])
|
||||
const publicList = await (await app.request(`${prefix}/access-keys`, undefined, socketBinding())).json()
|
||||
expect(publicList.accessKeys).toHaveLength(2)
|
||||
expect(JSON.stringify(publicList)).not.toContain(created.key)
|
||||
expect(await readFile(join(directory, 'data', 'gateway.json'), 'utf8')).not.toContain(key)
|
||||
const revoked = await (await app.request(`${prefix}/access-keys/${accessKey.id}/revoke`, { method: 'POST' }, socketBinding())).json()
|
||||
expect(revoked.revokedAt).toEqual(expect.any(String))
|
||||
const restarted = new GatewayStore(join(directory, 'data'))
|
||||
expect(await restarted.revokeAccessKey(accessKey.id)).toEqual(revoked)
|
||||
await expect(restarted.authenticate(`Bearer ${key}`)).rejects.toMatchObject({ status: 401 })
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key))).status).toBe(401)
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
for (const modelIds of [[], ['public-model'], [model.id, model.id], [randomUUID()]]) {
|
||||
expect((await app.request(`${prefix}/access-keys`, post({ name: 'Invalid', modelIds }), socketBinding())).status).toBe(400)
|
||||
}
|
||||
})
|
||||
it('denies admin requests without an actual loopback binding, including legacy and prototype APIs', async () => {
|
||||
const { key } = await configured()
|
||||
for (const path of [`${prefix}/config`, `${prefix}/access-keys`, `${prefix}/test`, '/api/v1/sharing/devices', '/api/v1/web/prototype/snapshot', '/api/v1/web/prototype/actions']) {
|
||||
const init = path.endsWith('/test') || path.endsWith('/actions') ? post(chat, key) : { headers: { authorization: `Bearer ${key}` } }
|
||||
for (const binding of [undefined, socketBinding('192.168.1.8'), socketBinding('::ffff:192.168.1.8')]) {
|
||||
expect((await app.request(path, init, binding)).status).toBe(403)
|
||||
}
|
||||
for (const headers of [{ 'x-forwarded-for': '127.0.0.1' }, { forwarded: 'for=127.0.0.1' }, { 'x-real-ip': '127.0.0.1' }]) {
|
||||
expect((await app.request(path, { ...init, headers: { ...init.headers, ...headers } }, socketBinding())).status).toBe(403)
|
||||
}
|
||||
}
|
||||
for (const address of ['127.0.0.1', '127.0.0.2', '::1', '::ffff:127.0.0.1']) {
|
||||
expect((await app.request(`${prefix}/config`, undefined, socketBinding(address))).status).toBe(200)
|
||||
}
|
||||
mockCompletion()
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key), socketBinding('192.168.1.8'))).status).toBe(200)
|
||||
})
|
||||
it('uses the same forwarding and auditing for local nonstream tests without bypassing inference authentication', async () => {
|
||||
const { upstream } = await configured()
|
||||
const fetch = mockCompletion()
|
||||
const toolMessages = [{ role: 'assistant', tool_calls: [{ id: 'call', type: 'function', function: { name: 'lookup', arguments: '{}' } }] }, { role: 'tool', tool_call_id: 'call', content: 'result' }]
|
||||
const response = await app.request(`${prefix}/test`, post({ ...chat, messages: toolMessages }), socketBinding())
|
||||
expect(response.status).toBe(200)
|
||||
expect(JSON.parse(fetch.mock.calls[0][1]?.body as string)).toMatchObject({ model: 'remote-model', messages: toolMessages })
|
||||
expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'success', accessKeyId: null, upstreamId: upstream.id })
|
||||
expect((await app.request(`${prefix}/test`, post({ ...chat, stream: true }), socketBinding())).status).toBe(400)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat), socketBinding())).status).toBe(401)
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('uses only the configured gateway URL and omits unavailable models from client discovery', async () => {
|
||||
const { upstream, key } = await configured()
|
||||
expect((await store.snapshot()).gatewayBaseUrl).toBeNull()
|
||||
vi.stubEnv('SHARESERVER_GATEWAY_BASE_URL', 'https://gateway.example/service/api/v1/gateway/v1/')
|
||||
const response = await app.request(`${prefix}/config`, { headers: { host: 'attacker.example' } }, socketBinding())
|
||||
expect((await response.json()).gatewayBaseUrl).toBe('https://gateway.example/service/api/v1/gateway/v1')
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '')
|
||||
expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${key}` } })).json()).toMatchObject({ data: [] })
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '12'.repeat(32))
|
||||
await store.upstream({ ...upstreamInput, enabled: false }, upstream.id)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key))).status).toBe(403)
|
||||
})
|
||||
it('preserves mapping and credentials after failed discovery and sends no auth to unauthenticated upstreams', async () => {
|
||||
const { upstream, model } = await configured()
|
||||
const before = await store.snapshot()
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('provider-secret sensitive failure', { status: 401 }))
|
||||
const response = await app.request(`${prefix}/upstreams/${upstream.id}/discover`, { method: 'POST' }, socketBinding())
|
||||
expect(response.status).toBe(502)
|
||||
expect(await response.json()).toMatchObject({ error: { code: 'UPSTREAM_HTTP_ERROR', message: 'Upstream returned HTTP 401.' } })
|
||||
expect(await store.snapshot()).toEqual(before)
|
||||
await store.upstream({ name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: 'none', enabled: true, credentialAction: 'keep' }, upstream.id)
|
||||
fetch.mockResolvedValueOnce(Response.json({ choices: [] }))
|
||||
expect((await app.request(`${prefix}/test`, post(chat), socketBinding())).status).toBe(200)
|
||||
expect(fetch.mock.calls[1][1]?.headers).toEqual({ 'content-type': 'application/json' })
|
||||
const renamed = await store.model({ alias: 'renamed', upstreamId: upstream.id, remoteModel: model.remoteModel, enabled: true }, model.id)
|
||||
expect(renamed.id).toBe(model.id)
|
||||
const { key } = await store.createAccessKey({ name: 'Renamed client', modelIds: [model.id] })
|
||||
expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${key}` } })).json()).toMatchObject({ data: [{ id: 'renamed' }] })
|
||||
})
|
||||
it('initializes an external master key explicitly, refuses overwrite/data paths, and supports file-backed credentials', async () => {
|
||||
const file = join(directory, 'master.key')
|
||||
const data = join(directory, 'data')
|
||||
await mkdir(data)
|
||||
const command = resolve('scripts/init-master-key.mjs')
|
||||
const env = { ...process.env, SHARESERVER_DATA_DIR: data }
|
||||
const result = await exec(process.execPath, [command, file], { env })
|
||||
const key = (await readFile(file, 'utf8')).trim()
|
||||
expect(key).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(result.stdout).not.toContain(key)
|
||||
await expect(exec(process.execPath, [command, file], { env })).rejects.toMatchObject({ code: 1 })
|
||||
await expect(exec(process.execPath, [command, join(data, 'bad.key')], { env })).rejects.toMatchObject({ code: 1 })
|
||||
expect((await readFile(file, 'utf8')).trim()).toBe(key)
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '')
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', file)
|
||||
const upstream = await store.upstream(upstreamInput)
|
||||
expect(await new GatewayStore(data).headers(upstream.id)).toMatchObject({ authorization: 'Bearer provider-secret' })
|
||||
await writeFile(join(data, 'bad.key'), `${key}\n`)
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', join(data, 'bad.key'))
|
||||
await expect(store.headers(upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Hono } from 'hono'
|
||||
import type { GatewayAudit } from '../../shared/gateway.js'
|
||||
import { GatewayError, GatewayStore, record } from './gateway-store.js'
|
||||
import { localAdmin, type SocketBindings } from './admin-boundary.js'
|
||||
|
||||
const REQUEST_LIMIT = 1024 * 1024
|
||||
const RESPONSE_LIMIT = 16 * 1024 * 1024
|
||||
const EVENT_LIMIT = 1024 * 1024
|
||||
const CHAT_FIELDS = new Set(('model messages stream stream_options temperature top_p n stop max_tokens max_completion_tokens presence_penalty frequency_penalty logit_bias logprobs top_logprobs seed tools tool_choice parallel_tool_calls functions function_call response_format reasoning_effort verbosity user').split(' '))
|
||||
async function readBounded(body: ReadableStream<Uint8Array> | null, limit: number, signal: AbortSignal): Promise<string> {
|
||||
if (!body) throw new GatewayError(400, 'EMPTY_BODY')
|
||||
const reader = body.getReader()
|
||||
const abort = () => { void reader.cancel().catch(() => undefined) }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
let size = 0
|
||||
let result = ''
|
||||
const decoder = new TextDecoder()
|
||||
try {
|
||||
while (true) {
|
||||
if (signal.aborted) throw new GatewayError(504, 'REQUEST_TIMEOUT')
|
||||
const { done, value } = await reader.read()
|
||||
if (signal.aborted) throw new GatewayError(504, 'REQUEST_TIMEOUT')
|
||||
if (done) break
|
||||
size += value.byteLength
|
||||
if (size > limit) throw new GatewayError(413, 'BODY_TOO_LARGE')
|
||||
result += decoder.decode(value, { stream: true })
|
||||
}
|
||||
return result + decoder.decode()
|
||||
} finally { signal.removeEventListener('abort', abort); await reader.cancel().catch(() => undefined); reader.releaseLock() }
|
||||
}
|
||||
function json(text: string): Record<string, unknown> {
|
||||
try { return record(JSON.parse(text)) } catch { throw new GatewayError(400, 'INVALID_JSON') }
|
||||
}
|
||||
function originAllowed(request: Request) {
|
||||
const origin = request.headers.get('origin')
|
||||
return !origin || origin === new URL(request.url).origin || ['http://127.0.0.1:5174', 'http://localhost:5174'].includes(origin)
|
||||
}
|
||||
function usage(value: Record<string, unknown>, audit: GatewayAudit) {
|
||||
if (value.usage && typeof value.usage === 'object') {
|
||||
const u = value.usage as Record<string, unknown>
|
||||
if (Number.isSafeInteger(u.prompt_tokens) && Number(u.prompt_tokens) >= 0) audit.inputTokens = Number(u.prompt_tokens)
|
||||
if (Number.isSafeInteger(u.completion_tokens) && Number(u.completion_tokens) >= 0) audit.outputTokens = Number(u.completion_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
export function gatewayRoutes(store: GatewayStore, timeoutMs = 120_000) {
|
||||
const app = new Hono<{ Bindings: SocketBindings; Variables: { requestId: string } }>()
|
||||
app.onError((error, c) => {
|
||||
const safe = error instanceof GatewayError ? error : new GatewayError(500, 'GATEWAY_FAILURE')
|
||||
return c.json({ error: { code: safe.code, message: safe.message }, requestId: c.get('requestId') ?? randomUUID() }, safe.status)
|
||||
})
|
||||
app.use('*', async (c, next) => {
|
||||
const inference = (c.req.method === 'POST' && c.req.path === '/api/v1/gateway/v1/chat/completions') || (c.req.method === 'GET' && c.req.path === '/api/v1/gateway/v1/models')
|
||||
if (inference) await next()
|
||||
else return localAdmin(c, next)
|
||||
})
|
||||
const body = async (request: Request) => {
|
||||
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw new GatewayError(415, 'JSON_REQUIRED')
|
||||
return json(await readBounded(request.body, REQUEST_LIMIT, AbortSignal.any([request.signal, AbortSignal.timeout(timeoutMs)])))
|
||||
}
|
||||
app.get('/config', async c => c.json(await store.snapshot()))
|
||||
app.post('/upstreams', async c => c.json(await store.upstream(await body(c.req.raw))))
|
||||
app.put('/upstreams/:id', async c => c.json(await store.upstream(await body(c.req.raw), c.req.param('id'))))
|
||||
app.post('/models', async c => c.json(await store.model(await body(c.req.raw))))
|
||||
app.put('/models/:id', async c => c.json(await store.model(await body(c.req.raw), c.req.param('id'))))
|
||||
app.get('/access-keys', async c => c.json(await store.accessKeys()))
|
||||
app.post('/access-keys', async c => c.json(await store.createAccessKey(await body(c.req.raw))))
|
||||
app.post('/access-keys/:id/revoke', async c => c.json(await store.revokeAccessKey(c.req.param('id'))))
|
||||
app.post('/upstreams/:id/discover', async c => {
|
||||
const upstream = (await store.snapshot()).upstreams.find(u => u.id === c.req.param('id'))
|
||||
if (!upstream) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND')
|
||||
if (!upstream.enabled) throw new GatewayError(403, 'UPSTREAM_DISABLED')
|
||||
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)])
|
||||
const headers = await store.headers(upstream.id)
|
||||
try {
|
||||
const response = await fetch(`${upstream.baseUrl}/models`, { headers, redirect: 'error', signal })
|
||||
if (!response.ok) { await response.body?.cancel(); throw new GatewayError(502, 'UPSTREAM_HTTP_ERROR', `Upstream returned HTTP ${response.status}.`) }
|
||||
const data = json(await readBounded(response.body, RESPONSE_LIMIT, signal)).data
|
||||
if (!Array.isArray(data) || data.some(m => !m || typeof m.id !== 'string' || m.id.length > 200)) throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE')
|
||||
return c.json({ models: [...new Set(data.map(m => m.id as string))] })
|
||||
} catch (error) {
|
||||
if (error instanceof GatewayError && error.code === 'UPSTREAM_HTTP_ERROR') throw error
|
||||
throw new GatewayError(signal.aborted ? 504 : 502, signal.aborted ? 'REQUEST_TIMEOUT' : 'UPSTREAM_FAILURE')
|
||||
}
|
||||
})
|
||||
app.get('/v1/models', async c => {
|
||||
const key = await store.authenticate(c.req.header('authorization'))
|
||||
const snapshot = await store.snapshot()
|
||||
const available = []
|
||||
for (const model of snapshot.models) {
|
||||
if (!key.modelIds.includes(model.id) || !model.enabled || !snapshot.upstreams.some(u => u.id === model.upstreamId && u.enabled)) continue
|
||||
try { await store.headers(model.upstreamId) }
|
||||
catch (error) { if (error instanceof GatewayError && ['CREDENTIAL_UNAVAILABLE', 'CREDENTIAL_NOT_CONFIGURED', 'UPSTREAM_DISABLED'].includes(error.code)) continue; throw error }
|
||||
available.push({ id: model.alias, object: 'model', created: 0, owned_by: 'local-gateway' })
|
||||
}
|
||||
return c.json({ object: 'list', data: available })
|
||||
})
|
||||
app.on('POST', ['/v1/chat/completions', '/test'], async c => {
|
||||
const started = Date.now()
|
||||
const audit: GatewayAudit = { id: randomUUID(), time: new Date().toISOString(), model: '', upstreamId: null, accessKeyId: null, stream: false, status: 'failed', httpStatus: 500, durationMs: 0, inputTokens: null, outputTokens: null, errorCode: null }
|
||||
c.set('requestId', audit.id)
|
||||
const controller = new AbortController()
|
||||
let timedOut = false
|
||||
let cancelled = false
|
||||
let finished = false
|
||||
const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
|
||||
const cancel = () => { cancelled = true; controller.abort() }
|
||||
c.req.raw.signal.addEventListener('abort', cancel, { once: true })
|
||||
if (c.req.raw.signal.aborted) cancel()
|
||||
const finish = async (status: GatewayAudit['status'], httpStatus: number, code: string | null) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearTimeout(timer)
|
||||
c.req.raw.signal.removeEventListener('abort', cancel)
|
||||
Object.assign(audit, { status, httpStatus, errorCode: code, durationMs: Date.now() - started })
|
||||
await store.audit(audit)
|
||||
}
|
||||
const failure = (error: unknown) => cancelled ? new GatewayError(400, 'CLIENT_CANCELLED') : timedOut ? new GatewayError(504, 'REQUEST_TIMEOUT') : error instanceof GatewayError ? error : new GatewayError(502, 'UPSTREAM_FAILURE')
|
||||
try {
|
||||
const key = c.req.path === '/api/v1/gateway/test' ? null : await store.authenticate(c.req.header('authorization'))
|
||||
audit.accessKeyId = key?.id ?? null
|
||||
if (!originAllowed(c.req.raw)) throw new GatewayError(403, 'ORIGIN_DENIED')
|
||||
if (!c.req.header('content-type')?.toLowerCase().startsWith('application/json')) throw new GatewayError(415, 'JSON_REQUIRED')
|
||||
const input = json(await readBounded(c.req.raw.body, REQUEST_LIMIT, controller.signal))
|
||||
audit.stream = input.stream === true
|
||||
if (c.req.path === '/api/v1/gateway/test' && input.stream === true) throw new GatewayError(400, 'TEST_NON_STREAMING_ONLY')
|
||||
if (Object.keys(input).some(key => !CHAT_FIELDS.has(key))) throw new GatewayError(400, 'UNSUPPORTED_FIELD', 'Unsupported chat field or routing override.')
|
||||
if (typeof input.model !== 'string' || input.model.length > 200 || !Array.isArray(input.messages) || !input.messages.length ||
|
||||
input.messages.some(m => !m || typeof m !== 'object' || !['system', 'developer', 'user', 'assistant', 'tool', 'function'].includes(m.role)) ||
|
||||
(input.stream !== undefined && typeof input.stream !== 'boolean')) throw new GatewayError(400, 'INVALID_CHAT_REQUEST')
|
||||
const snapshot = await store.snapshot()
|
||||
const model = snapshot.models.find(m => m.alias === input.model)
|
||||
// Only configured aliases are persisted, never arbitrary user text.
|
||||
if (key && (!model || !key.modelIds.includes(model.id))) throw new GatewayError(403, 'MODEL_NOT_AUTHORIZED')
|
||||
if (!model) throw new GatewayError(404, 'MODEL_NOT_FOUND')
|
||||
audit.model = model.alias
|
||||
audit.upstreamId = model.upstreamId
|
||||
const upstream = snapshot.upstreams.find(u => u.id === model.upstreamId)!
|
||||
if (!model.enabled || !upstream.enabled) throw new GatewayError(403, 'MODEL_DISABLED')
|
||||
const response = await fetch(`${upstream.baseUrl}/chat/completions`, {
|
||||
method: 'POST', headers: await store.headers(upstream.id), body: JSON.stringify({ ...input, model: model.remoteModel }), redirect: 'error', signal: controller.signal
|
||||
})
|
||||
if (!response.ok) { await response.body?.cancel(); throw new GatewayError(502, 'UPSTREAM_HTTP_ERROR', `Upstream returned HTTP ${response.status}.`) }
|
||||
if (!input.stream) {
|
||||
let output: Record<string, unknown>
|
||||
try { output = json(await readBounded(response.body, RESPONSE_LIMIT, controller.signal)) }
|
||||
catch { throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE') }
|
||||
if (output.error || !Array.isArray(output.choices)) throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE')
|
||||
usage(output, audit)
|
||||
output.model = model.alias
|
||||
await finish('success', 200, null)
|
||||
return c.json(output)
|
||||
}
|
||||
if (!response.headers.get('content-type')?.includes('text/event-stream') || !response.body) {
|
||||
await response.body?.cancel()
|
||||
throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE')
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const encoder = new TextEncoder()
|
||||
let buffer = ''
|
||||
let bytes = 0
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(output) {
|
||||
const abortStream = () => {
|
||||
if (finished) return
|
||||
void reader.cancel().catch(() => undefined)
|
||||
void finish(cancelled ? 'cancelled' : 'failed', cancelled ? 499 : 504, cancelled ? 'CLIENT_CANCELLED' : 'REQUEST_TIMEOUT')
|
||||
.then(() => output.error(new Error(cancelled ? 'Client cancelled.' : 'Gateway request timed out.')))
|
||||
.catch(() => output.error(new Error('Gateway storage failed.')))
|
||||
}
|
||||
controller.signal.addEventListener('abort', abortStream, { once: true })
|
||||
if (controller.signal.aborted) abortStream()
|
||||
},
|
||||
async pull(output) {
|
||||
if (finished) return
|
||||
try {
|
||||
while (true) {
|
||||
const boundary = /\r?\n\r?\n/.exec(buffer)
|
||||
if (boundary) {
|
||||
const event = buffer.slice(0, boundary.index)
|
||||
buffer = buffer.slice(boundary.index + boundary[0].length)
|
||||
if (event.length > EVENT_LIMIT) throw new GatewayError(502, 'STREAM_TOO_LARGE')
|
||||
const data = event.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n')
|
||||
if (!data) continue
|
||||
if (data === '[DONE]') {
|
||||
await finish('success', 200, null)
|
||||
output.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
output.close()
|
||||
controller.abort()
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return
|
||||
}
|
||||
let chunk: Record<string, unknown>
|
||||
try { chunk = json(data) } catch { throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE') }
|
||||
if (chunk.error || !Array.isArray(chunk.choices)) throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE')
|
||||
usage(chunk, audit)
|
||||
chunk.model = model.alias
|
||||
output.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
|
||||
return
|
||||
}
|
||||
if (buffer.length > EVENT_LIMIT) throw new GatewayError(502, 'STREAM_TOO_LARGE')
|
||||
const next = await reader.read()
|
||||
if (finished) return
|
||||
if (next.done) throw new GatewayError(502, 'INCOMPLETE_STREAM')
|
||||
bytes += next.value.byteLength
|
||||
if (bytes > RESPONSE_LIMIT) throw new GatewayError(502, 'STREAM_TOO_LARGE')
|
||||
buffer += decoder.decode(next.value, { stream: true })
|
||||
}
|
||||
} catch (error) {
|
||||
if (finished) {
|
||||
output.error(new Error('Gateway stream failed.'))
|
||||
controller.abort()
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return
|
||||
}
|
||||
const safe = failure(error)
|
||||
await reader.cancel().catch(() => undefined)
|
||||
try {
|
||||
await finish(cancelled ? 'cancelled' : 'failed', cancelled ? 499 : safe.status, safe.code)
|
||||
if (!cancelled) { output.enqueue(encoder.encode(`data: ${JSON.stringify({ error: { code: safe.code, message: 'Gateway stream failed.' }, requestId: audit.id })}\n\n`)); output.close() }
|
||||
} catch { output.error(new Error('Gateway stream failed.')) }
|
||||
controller.abort()
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
try { await finish('cancelled', 499, 'CLIENT_CANCELLED') }
|
||||
finally {
|
||||
cancel()
|
||||
await reader.cancel().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'x-accel-buffering': 'no' } })
|
||||
} catch (error) {
|
||||
const safe = failure(error)
|
||||
controller.abort()
|
||||
await finish(cancelled ? 'cancelled' : 'failed', cancelled ? 499 : safe.status, safe.code)
|
||||
throw safe
|
||||
}
|
||||
})
|
||||
app.all('*', c => c.json({ error: { code: 'NOT_FOUND', message: 'API route not found.' }, requestId: randomUUID() }, 404))
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import type { GatewayAccessKey, GatewayAudit, GatewayModel, GatewayModelInput, GatewaySnapshot, GatewayUpstream, GatewayUpstreamInput } from '../../shared/gateway.js'
|
||||
import { decryptCredential, encryptCredential, type EncryptedCredential } from './gateway-credentials.js'
|
||||
|
||||
export class GatewayError extends Error {
|
||||
constructor(public status: 400 | 401 | 403 | 404 | 409 | 413 | 415 | 500 | 502 | 503 | 504, public code: string, message = 'Gateway request failed.') {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
export function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new GatewayError(400, 'INVALID_INPUT')
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
function exact(value: unknown, keys: string[]) {
|
||||
const item = record(value)
|
||||
if (Object.keys(item).length !== keys.length || keys.some(key => !Object.hasOwn(item, key))) throw new GatewayError(400, 'INVALID_FIELDS')
|
||||
return item
|
||||
}
|
||||
function text(value: unknown, max = 200): string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > max || Array.from(value).some(c => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) throw new GatewayError(400, 'INVALID_TEXT')
|
||||
return value
|
||||
}
|
||||
function enabled(value: unknown): boolean {
|
||||
if (typeof value !== 'boolean') throw new GatewayError(400, 'INVALID_ENABLED')
|
||||
return value
|
||||
}
|
||||
function uuid(value: unknown): string {
|
||||
const id = text(value)
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) throw new GatewayError(400, 'INVALID_ID')
|
||||
return id
|
||||
}
|
||||
export function upstreamInput(value: unknown): GatewayUpstreamInput {
|
||||
const item = record(value)
|
||||
exact(item, ['name', 'baseUrl', 'protocol', 'authentication', 'enabled', 'credentialAction', ...(Object.hasOwn(item, 'apiKey') ? ['apiKey'] : [])])
|
||||
if (item.protocol !== 'openai-chat-completions' || !['api-key', 'none'].includes(String(item.authentication)) || !['keep', 'replace', 'clear'].includes(String(item.credentialAction))) throw new GatewayError(400, 'INVALID_UPSTREAM')
|
||||
if ((item.credentialAction !== 'replace' && Object.hasOwn(item, 'apiKey')) || (item.authentication === 'none' && item.credentialAction === 'replace')) throw new GatewayError(400, 'INVALID_CREDENTIAL_ACTION')
|
||||
return {
|
||||
name: text(item.name), baseUrl: baseUrl(item.baseUrl), protocol: item.protocol,
|
||||
authentication: item.authentication as GatewayUpstreamInput['authentication'], enabled: enabled(item.enabled),
|
||||
credentialAction: item.credentialAction as GatewayUpstreamInput['credentialAction'],
|
||||
...(item.credentialAction === 'replace' ? { apiKey: text(item.apiKey, 16384) } : {})
|
||||
}
|
||||
}
|
||||
function baseUrl(value: unknown): string {
|
||||
let url: URL
|
||||
const raw = text(value, 2048)
|
||||
try { url = new URL(raw) } catch { throw new GatewayError(400, 'INVALID_BASE_URL') }
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || raw.includes('?') || raw.includes('#')) {
|
||||
throw new GatewayError(400, 'INVALID_BASE_URL', 'Use HTTP or HTTPS without credentials, query or fragment.')
|
||||
}
|
||||
return url.href.replace(/\/+$/, '')
|
||||
}
|
||||
export function modelInput(value: unknown, legacy = false): GatewayModelInput {
|
||||
const item = record(value)
|
||||
exact(item, ['alias', 'upstreamId', 'remoteModel', 'enabled', ...(Object.hasOwn(item, 'displayName') ? ['displayName'] : [])])
|
||||
const alias = text(item.alias, legacy ? 200 : 128)
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(alias)) throw new GatewayError(400, 'INVALID_ALIAS')
|
||||
return { alias, upstreamId: uuid(item.upstreamId), remoteModel: text(item.remoteModel), enabled: enabled(item.enabled), ...(item.displayName !== undefined ? { displayName: text(item.displayName) } : {}) }
|
||||
}
|
||||
type StoredUpstream = Omit<GatewayUpstream, 'credentialConfigured'> & { credential: EncryptedCredential | null; apiKeyEnv?: string }
|
||||
type StoredAccessKey = GatewayAccessKey & { digest: string }
|
||||
type State = { version: 2; upstreams: StoredUpstream[]; models: GatewayModel[]; audits: GatewayAudit[]; accessKeys: StoredAccessKey[] }
|
||||
function publicUpstream(u: StoredUpstream): GatewayUpstream {
|
||||
return { id: u.id, name: u.name, baseUrl: u.baseUrl, protocol: u.protocol, authentication: u.authentication, enabled: u.enabled,
|
||||
credentialConfigured: Boolean(u.credential || (u.apiKeyEnv && process.env[u.apiKeyEnv]?.trim())) }
|
||||
}
|
||||
function publicAccessKey(k: StoredAccessKey): GatewayAccessKey {
|
||||
return { id: k.id, name: k.name, modelIds: [...k.modelIds], createdAt: k.createdAt, revokedAt: k.revokedAt }
|
||||
}
|
||||
function timestamp(value: unknown): string {
|
||||
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) throw new Error('Invalid timestamp')
|
||||
return value
|
||||
}
|
||||
function parseState(value: unknown): State {
|
||||
const item = record(value)
|
||||
exact(item, ['version', 'upstreams', 'models', 'audits', ...(item.version === 2 ? ['accessKeys'] : [])])
|
||||
if ((item.version !== 1 && item.version !== 2) || !Array.isArray(item.upstreams) || !Array.isArray(item.models) || !Array.isArray(item.audits) || item.audits.length > 200) throw new Error('Invalid state')
|
||||
const upstreams = item.upstreams.map(value => {
|
||||
const u = record(value)
|
||||
if (item.version === 1) {
|
||||
exact(u, ['id', 'name', 'baseUrl', 'apiKeyEnv', 'enabled'])
|
||||
if (typeof u.apiKeyEnv !== 'string' || !/^[A-Z_][A-Z0-9_]{0,127}$/.test(u.apiKeyEnv)) throw new Error('Invalid legacy credential')
|
||||
return { id: uuid(u.id), name: text(u.name), baseUrl: baseUrl(u.baseUrl), enabled: enabled(u.enabled), protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, apiKeyEnv: u.apiKeyEnv, credential: null }
|
||||
}
|
||||
exact(u, ['id', 'name', 'baseUrl', 'protocol', 'authentication', 'enabled', 'credential', ...(Object.hasOwn(u, 'apiKeyEnv') ? ['apiKeyEnv'] : [])])
|
||||
const input = upstreamInput({ name: u.name, baseUrl: u.baseUrl, protocol: u.protocol, authentication: u.authentication, enabled: u.enabled, credentialAction: 'keep' })
|
||||
if (u.apiKeyEnv !== undefined && (typeof u.apiKeyEnv !== 'string' || !/^[A-Z_][A-Z0-9_]{0,127}$/.test(u.apiKeyEnv))) throw new Error('Invalid credential reference')
|
||||
if (u.credential !== null) {
|
||||
const cipher = exact(u.credential, ['nonce', 'tag', 'ciphertext'])
|
||||
if (typeof cipher.nonce !== 'string' || !/^[a-f0-9]{24}$/.test(cipher.nonce) || typeof cipher.tag !== 'string' || !/^[a-f0-9]{32}$/.test(cipher.tag) || typeof cipher.ciphertext !== 'string' || !/^(?:[a-f0-9]{2}){1,65536}$/.test(cipher.ciphertext)) throw new Error('Invalid ciphertext')
|
||||
}
|
||||
if (u.credential && u.apiKeyEnv) throw new Error('Ambiguous credential')
|
||||
return { id: uuid(u.id), name: input.name, baseUrl: input.baseUrl, protocol: input.protocol, authentication: input.authentication, enabled: input.enabled, credential: u.credential as EncryptedCredential | null, ...(u.apiKeyEnv ? { apiKeyEnv: u.apiKeyEnv as string } : {}) }
|
||||
})
|
||||
const models = item.models.map(value => {
|
||||
const { id, ...input } = record(value)
|
||||
return { id: uuid(id), ...modelInput(input, true) }
|
||||
})
|
||||
const audits = item.audits.map(value => {
|
||||
const audit: Record<string, unknown> = { ...record(value), ...(item.version === 1 ? { accessKeyId: null } : {}) }
|
||||
exact(audit, ['id', 'time', 'model', 'upstreamId', 'accessKeyId', 'stream', 'status', 'httpStatus', 'durationMs', 'inputTokens', 'outputTokens', 'errorCode'])
|
||||
uuid(audit.id)
|
||||
if (audit.accessKeyId !== null) uuid(audit.accessKeyId)
|
||||
if (typeof audit.time !== 'string' || !Number.isFinite(Date.parse(audit.time)) || new Date(audit.time).toISOString() !== audit.time ||
|
||||
typeof audit.model !== 'string' || audit.model.length > 200 || (audit.upstreamId !== null && !upstreams.some(u => u.id === audit.upstreamId)) ||
|
||||
typeof audit.stream !== 'boolean' || !['success', 'failed', 'cancelled'].includes(String(audit.status)) ||
|
||||
!Number.isInteger(audit.httpStatus) || Number(audit.httpStatus) < 100 || Number(audit.httpStatus) > 599 ||
|
||||
!Number.isFinite(audit.durationMs) || Number(audit.durationMs) < 0 ||
|
||||
[audit.inputTokens, audit.outputTokens].some(n => n !== null && (!Number.isSafeInteger(n) || Number(n) < 0)) ||
|
||||
(audit.errorCode !== null && (typeof audit.errorCode !== 'string' || !/^[A-Z_]{1,64}$/.test(audit.errorCode)))) throw new Error('Invalid audit')
|
||||
return audit as GatewayAudit
|
||||
})
|
||||
if (new Set(upstreams.map(u => u.id)).size !== upstreams.length || new Set(models.map(m => m.id)).size !== models.length ||
|
||||
new Set(models.map(m => m.alias)).size !== models.length || new Set(audits.map(a => a.id)).size !== audits.length ||
|
||||
models.some(m => !upstreams.some(u => u.id === m.upstreamId))) throw new Error('Invalid references')
|
||||
if (item.version === 2 && !Array.isArray(item.accessKeys)) throw new Error('Invalid access keys')
|
||||
const accessKeys = ((item.accessKeys ?? []) as unknown[]).map(value => {
|
||||
const k = exact(value, ['id', 'name', 'modelIds', 'createdAt', 'revokedAt', 'digest'])
|
||||
if (!Array.isArray(k.modelIds) || !k.modelIds.length || k.modelIds.some(id => !models.some(m => m.id === id)) || new Set(k.modelIds).size !== k.modelIds.length || typeof k.digest !== 'string' || !/^[a-f0-9]{64}$/.test(k.digest)) throw new Error('Invalid access key')
|
||||
return { id: uuid(k.id), name: text(k.name), modelIds: k.modelIds as string[], createdAt: timestamp(k.createdAt), revokedAt: k.revokedAt === null ? null : timestamp(k.revokedAt), digest: k.digest }
|
||||
})
|
||||
if (new Set(accessKeys.map(k => k.id)).size !== accessKeys.length || new Set(accessKeys.map(k => k.digest)).size !== accessKeys.length || audits.some(a => a.accessKeyId !== null && !accessKeys.some(k => k.id === a.accessKeyId))) throw new Error('Invalid access key references')
|
||||
return { version: 2, upstreams, models, audits, accessKeys }
|
||||
}
|
||||
|
||||
// One process and one store per data directory; publish memory only after atomic replacement.
|
||||
export class GatewayStore {
|
||||
private state?: State
|
||||
private queue: Promise<unknown> = Promise.resolve()
|
||||
private readonly filename: string
|
||||
constructor(private readonly directory = process.env.SHARESERVER_DATA_DIR ?? './data') {
|
||||
this.filename = resolve(directory, 'gateway.json')
|
||||
}
|
||||
private run<T>(operation: (state: State) => T | Promise<T>, write = false): Promise<T> {
|
||||
const result = this.queue.then(async () => {
|
||||
if (!this.state) {
|
||||
try { this.state = parseState(JSON.parse(await readFile(this.filename, 'utf8'))) }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new GatewayError(503, 'GATEWAY_STORAGE_UNAVAILABLE')
|
||||
this.state = { version: 2, upstreams: [], models: [], audits: [], accessKeys: [] }
|
||||
}
|
||||
}
|
||||
const next = structuredClone(this.state)
|
||||
const value = await operation(next)
|
||||
if (write) {
|
||||
const temporary = `${this.filename}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await mkdir(this.directory, { recursive: true })
|
||||
const file = await open(temporary, 'wx', 0o600)
|
||||
try { await file.writeFile(JSON.stringify(next) + '\n'); await file.sync() } finally { await file.close() }
|
||||
await rename(temporary, this.filename)
|
||||
this.state = next
|
||||
} catch { throw new GatewayError(503, 'GATEWAY_STORAGE_UNAVAILABLE') }
|
||||
finally { await rm(temporary, { force: true }).catch(() => undefined) }
|
||||
}
|
||||
return value
|
||||
})
|
||||
this.queue = result.catch(() => undefined)
|
||||
return result
|
||||
}
|
||||
snapshot(): Promise<GatewaySnapshot> {
|
||||
return this.run(state => {
|
||||
let gatewayBaseUrl: string | null = null
|
||||
if (process.env.SHARESERVER_GATEWAY_BASE_URL) {
|
||||
try { gatewayBaseUrl = baseUrl(process.env.SHARESERVER_GATEWAY_BASE_URL) }
|
||||
catch { throw new GatewayError(503, 'INVALID_GATEWAY_BASE_URL') }
|
||||
}
|
||||
return { models: state.models, audits: state.audits, upstreams: state.upstreams.map(publicUpstream), gatewayBaseUrl }
|
||||
})
|
||||
}
|
||||
upstream(value: unknown, id?: string) {
|
||||
const input = upstreamInput(value)
|
||||
return this.run(async state => {
|
||||
const index = id === undefined ? -1 : state.upstreams.findIndex(u => u.id === id)
|
||||
if (id !== undefined && index < 0) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND')
|
||||
const { credentialAction, apiKey, ...fields } = input
|
||||
const previous = state.upstreams[index]
|
||||
const upstream: StoredUpstream = { ...fields, id: id ?? randomUUID(), credential: previous?.credential ?? null, ...(previous?.apiKeyEnv ? { apiKeyEnv: previous.apiKeyEnv } : {}) }
|
||||
if (credentialAction === 'replace') {
|
||||
try {
|
||||
// A changed deployment key must not silently create a mixed-key store.
|
||||
for (const existing of state.upstreams) {
|
||||
if (existing.credential) await decryptCredential(this.directory, existing.id, existing.credential)
|
||||
}
|
||||
upstream.credential = await encryptCredential(this.directory, upstream.id, apiKey!)
|
||||
}
|
||||
catch { throw new GatewayError(503, 'CREDENTIAL_UNAVAILABLE') }
|
||||
delete upstream.apiKeyEnv
|
||||
} else if (credentialAction === 'clear') {
|
||||
upstream.credential = null
|
||||
delete upstream.apiKeyEnv
|
||||
}
|
||||
if (index < 0) state.upstreams.push(upstream); else state.upstreams[index] = upstream
|
||||
return publicUpstream(upstream)
|
||||
}, true)
|
||||
}
|
||||
model(value: unknown, id?: string) {
|
||||
return this.run(state => {
|
||||
const index = id === undefined ? -1 : state.models.findIndex(m => m.id === id)
|
||||
if (id !== undefined && index < 0) throw new GatewayError(404, 'MODEL_NOT_FOUND')
|
||||
const input = modelInput(value, index >= 0 && record(value).alias === state.models[index].alias)
|
||||
if (!state.upstreams.some(u => u.id === input.upstreamId)) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND')
|
||||
if (state.models.some(m => m.alias === input.alias && m.id !== id)) throw new GatewayError(409, 'ALIAS_EXISTS')
|
||||
const model = { ...input, id: id ?? randomUUID() }
|
||||
if (index < 0) state.models.push(model); else state.models[index] = model
|
||||
return model
|
||||
}, true)
|
||||
}
|
||||
headers(id: string): Promise<Record<string, string>> {
|
||||
return this.run(async (state): Promise<Record<string, string>> => {
|
||||
const upstream = state.upstreams.find(u => u.id === id)
|
||||
if (!upstream) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND')
|
||||
if (!upstream.enabled) throw new GatewayError(403, 'UPSTREAM_DISABLED')
|
||||
if (upstream.authentication === 'none') return { 'content-type': 'application/json' }
|
||||
let key: string | undefined
|
||||
try { key = upstream.credential ? await decryptCredential(this.directory, id, upstream.credential) : upstream.apiKeyEnv ? process.env[upstream.apiKeyEnv]?.trim() : undefined }
|
||||
catch { throw new GatewayError(503, 'CREDENTIAL_UNAVAILABLE') }
|
||||
if (!key) throw new GatewayError(503, 'CREDENTIAL_NOT_CONFIGURED')
|
||||
return { 'content-type': 'application/json', authorization: `Bearer ${key}` }
|
||||
})
|
||||
}
|
||||
accessKeys() {
|
||||
return this.run(state => ({ accessKeys: state.accessKeys.map(publicAccessKey) }))
|
||||
}
|
||||
createAccessKey(value: unknown) {
|
||||
const input = exact(value, ['name', 'modelIds'])
|
||||
const name = text(input.name)
|
||||
return this.run(state => {
|
||||
if (!Array.isArray(input.modelIds) || !input.modelIds.length || input.modelIds.some(id => typeof id !== 'string' || !state.models.some(m => m.id === id)) || new Set(input.modelIds).size !== input.modelIds.length) throw new GatewayError(400, 'INVALID_MODEL_SCOPE')
|
||||
const key = `ss_${randomBytes(32).toString('base64url')}`
|
||||
const accessKey: StoredAccessKey = { id: randomUUID(), name, modelIds: [...input.modelIds] as string[], createdAt: new Date().toISOString(), revokedAt: null, digest: createHash('sha256').update(key).digest('hex') }
|
||||
state.accessKeys.push(accessKey)
|
||||
return { accessKey: publicAccessKey(accessKey), key }
|
||||
}, true)
|
||||
}
|
||||
revokeAccessKey(id: string) {
|
||||
return this.run(state => {
|
||||
const key = state.accessKeys.find(k => k.id === id)
|
||||
if (!key) throw new GatewayError(404, 'ACCESS_KEY_NOT_FOUND')
|
||||
key.revokedAt ??= new Date().toISOString()
|
||||
return publicAccessKey(key)
|
||||
}, true)
|
||||
}
|
||||
authenticate(authorization?: string) {
|
||||
if (!authorization || !/^Bearer ss_[A-Za-z0-9_-]{43}$/i.test(authorization)) throw new GatewayError(401, 'INVALID_ACCESS_KEY')
|
||||
const digest = createHash('sha256').update(authorization.slice(7)).digest()
|
||||
return this.run(state => {
|
||||
const key = state.accessKeys.find(k => timingSafeEqual(Buffer.from(k.digest, 'hex'), digest))
|
||||
if (!key || key.revokedAt) throw new GatewayError(401, 'INVALID_ACCESS_KEY')
|
||||
return publicAccessKey(key)
|
||||
})
|
||||
}
|
||||
audit(audit: GatewayAudit) {
|
||||
return this.run(state => { state.audits = [audit, ...state.audits].slice(0, 200) }, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// @vitest-environment node
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { once } from 'node:events'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from './app.js'
|
||||
import { gatewayRoutes } from './gateway-routes.js'
|
||||
import { GatewayStore } from './gateway-store.js'
|
||||
import { SharingStore } from './sharing-store.js'
|
||||
import { withLocalSocket } from './test-socket.js'
|
||||
|
||||
let accessKey: string
|
||||
const post = (body: unknown, extra: RequestInit = {}): RequestInit => ({ method: 'POST', body: JSON.stringify(body), ...extra, headers: { 'content-type': 'application/json', authorization: `Bearer ${accessKey}`, ...extra.headers } })
|
||||
const prefix = '/api/v1/gateway'
|
||||
const chat = { model: 'local', messages: [{ role: 'user', content: 'private prompt' }] }
|
||||
describe('local model gateway', () => {
|
||||
let directory: string
|
||||
let store: GatewayStore
|
||||
let app: ReturnType<typeof createApp>
|
||||
let server: Server
|
||||
let baseUrl: string
|
||||
let calls: { path: string; authorization?: string; body: Record<string, unknown> }[]
|
||||
let handler: (req: IncomingMessage, res: ServerResponse) => void
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'gateway-test-'))
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', 'ab'.repeat(32))
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', '')
|
||||
vi.stubEnv('SHARESERVER_GATEWAY_BASE_URL', '')
|
||||
accessKey = ''
|
||||
store = new GatewayStore(directory)
|
||||
app = withLocalSocket(createApp(new SharingStore(directory), store))
|
||||
calls = []
|
||||
handler = (req, res) => {
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify(req.url === '/v1/models' ? { data: [{ id: 'remote' }] } : {
|
||||
id: 'completion', model: 'remote', choices: [{ message: { role: 'assistant', content: 'private output', tool_calls: [{ id: 'tool', type: 'function', function: { name: 'lookup', arguments: '{}' } }] } }], usage: { prompt_tokens: 7, completion_tokens: 4 }
|
||||
}))
|
||||
}
|
||||
server = createServer(async (req, res) => {
|
||||
let body = ''
|
||||
for await (const chunk of req) body += chunk
|
||||
calls.push({ path: req.url!, authorization: req.headers.authorization, body: body ? JSON.parse(body) : {} })
|
||||
handler(req, res)
|
||||
})
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
const address = server.address() as { port: number }
|
||||
baseUrl = `http://127.0.0.1:${address.port}/v1`
|
||||
process.env.GATEWAY_TEST_API_KEY = 'test-only-secret'
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
delete process.env.GATEWAY_TEST_API_KEY
|
||||
delete process.env.GATEWAY_TEST_BASE_URL
|
||||
delete process.env.GATEWAY_TEST_MODEL
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
async function configure() {
|
||||
const input = { name: 'Test', baseUrl, protocol: 'openai-chat-completions', authentication: 'api-key', credentialAction: 'replace', apiKey: 'test-only-secret', enabled: true }
|
||||
const upstream = await (await app.request(`${prefix}/upstreams`, post(input))).json()
|
||||
const model = await (await app.request(`${prefix}/models`, post({ alias: 'local', upstreamId: upstream.id, remoteModel: 'remote', enabled: true }))).json()
|
||||
accessKey = (await store.createAccessKey({ name: 'Test client', modelIds: [model.id] })).key
|
||||
return { upstream, model, input }
|
||||
}
|
||||
it('starts empty; persists managed credentials and full updates across restart', async () => {
|
||||
expect(await (await app.request(`${prefix}/config`)).json()).toEqual({ upstreams: [], models: [], audits: [], gatewayBaseUrl: null })
|
||||
const { input } = await configure()
|
||||
const snapshot = await new GatewayStore(directory).snapshot()
|
||||
expect(snapshot.upstreams).toHaveLength(1)
|
||||
expect(snapshot.models).toHaveLength(1)
|
||||
const u = snapshot.upstreams[0]
|
||||
expect(u.id).toMatch(/^[a-f0-9-]{36}$/)
|
||||
const updated = await app.request(`${prefix}/upstreams/${u.id}`, post({ ...input, name: 'Updated', enabled: false }, { method: 'PUT' }))
|
||||
expect(updated.status).toBe(200)
|
||||
expect((await new GatewayStore(directory).snapshot()).upstreams[0].enabled).toBe(false)
|
||||
expect(await readFile(join(directory, 'gateway.json'), 'utf8')).not.toContain('test-only-secret')
|
||||
expect((await app.request(`${prefix}/import-env`, post({ prefix: 'GATEWAY_TEST' }))).status).toBe(404)
|
||||
})
|
||||
it('fails closed on malformed persisted data without overwriting it', async () => {
|
||||
for (const data of ['{broken', JSON.stringify({ version: 1, upstreams: [], models: [], audits: [{ prompt: 'bad' }] })]) {
|
||||
await writeFile(join(directory, 'gateway.json'), data)
|
||||
const broken = withLocalSocket(new Hono().route(prefix, gatewayRoutes(new GatewayStore(directory))))
|
||||
expect((await broken.request(`${prefix}/config`)).status).toBe(503)
|
||||
expect((await broken.request(`${prefix}/upstreams`, post({ name: 'Test', baseUrl, protocol: 'openai-chat-completions', authentication: 'none', credentialAction: 'clear', enabled: true }))).status).toBe(503)
|
||||
expect(await readFile(join(directory, 'gateway.json'), 'utf8')).toBe(data)
|
||||
}
|
||||
})
|
||||
it('discovers real models, maps aliases, preserves tools and records only metadata', async () => {
|
||||
const { upstream } = await configure()
|
||||
expect(await (await app.request(`${prefix}/upstreams/${upstream.id}/discover`, { method: 'POST' })).json()).toEqual({ models: ['remote'] })
|
||||
expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${accessKey}` } })).json()).toMatchObject({ data: [{ id: 'local' }] })
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, tools: [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object' } } }] }))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ model: 'local', choices: [{ message: { tool_calls: [{ id: 'tool' }] } }] })
|
||||
expect(calls[1]).toMatchObject({ authorization: 'Bearer test-only-secret', body: { model: 'remote', tools: [{ type: 'function' }] } })
|
||||
const snapshot = await new GatewayStore(directory).snapshot()
|
||||
expect(snapshot.audits[0]).toMatchObject({ status: 'success', inputTokens: 7, outputTokens: 4 })
|
||||
const persisted = await readFile(join(directory, 'gateway.json'), 'utf8')
|
||||
for (const secret of ['private prompt', 'private output', 'caller-secret', 'test-only-secret']) expect(persisted).not.toContain(secret)
|
||||
expect(JSON.stringify(snapshot)).not.toContain('test-only-secret')
|
||||
})
|
||||
it('rejects disabled routes, unknown fields, hostile origins and missing runtime credentials', async () => {
|
||||
const { model, upstream, input } = await configure()
|
||||
expect((await app.request(`${prefix}/models`, post({ alias: 'local', upstreamId: upstream.id, remoteModel: 'other', enabled: true }))).status).toBe(409)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, base_url: 'https://evil.example' }))).status).toBe(400)
|
||||
const denied = await app.request(`${prefix}/v1/chat/completions`, post(chat, { headers: { 'content-type': 'application/json', origin: 'https://evil.example' } }))
|
||||
expect(denied.status).toBe(403)
|
||||
expect(await denied.json()).toMatchObject({ error: { code: 'ORIGIN_DENIED' }, requestId: (await store.snapshot()).audits[0].id })
|
||||
expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'failed', httpStatus: 403, errorCode: 'ORIGIN_DENIED' })
|
||||
expect((await app.request(`${prefix}/upstreams`, post(input, { headers: { 'content-type': 'application/json', origin: 'null' } }))).status).toBe(403)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, { headers: { 'content-type': 'application/json', origin: 'http://localhost:5174' } }))).status).toBe(200)
|
||||
const { id, ...modelInput } = model
|
||||
expect((await app.request(`${prefix}/models/${id}`, post({ ...modelInput, enabled: false }, { method: 'PUT' }))).status).toBe(200)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(403)
|
||||
expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${accessKey}` } })).json()).toMatchObject({ data: [] })
|
||||
await app.request(`${prefix}/models/${id}`, post(modelInput, { method: 'PUT' }))
|
||||
delete process.env.SHARESERVER_MASTER_KEY
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(503)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'failed', inputTokens: null, outputTokens: null })
|
||||
})
|
||||
it('validates URLs and sanitizes upstream HTTP errors and redirects', async () => {
|
||||
const { input } = await configure()
|
||||
for (const url of ['https://user:secret@example.com', `${baseUrl}?key=secret`, `${baseUrl}#fragment`, 'file:///tmp/test']) {
|
||||
expect((await app.request(`${prefix}/upstreams`, post({ ...input, baseUrl: url }))).status).toBe(400)
|
||||
}
|
||||
for (const status of [401, 302]) {
|
||||
handler = (_req, res) => { res.writeHead(status, { location: `${baseUrl}/leak` }); res.end('test-only-secret upstream-sensitive-error') }
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post(chat))
|
||||
expect(response.status).toBe(502)
|
||||
const error = await response.text()
|
||||
expect(error).not.toContain('test-only-secret')
|
||||
expect(error).not.toContain(baseUrl)
|
||||
expect(error).not.toContain('upstream-sensitive-error')
|
||||
}
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
it('streams fragmented SSE tool calls and usage through completion', async () => {
|
||||
await configure()
|
||||
handler = (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call","function":{"name":"lookup","arguments":"{}"}}]}}]}\r')
|
||||
setTimeout(() => res.end('\n\r\ndata: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2}}\n\ndata: [DONE]\n\n'), 10)
|
||||
}
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true, stream_options: { include_usage: true } }))
|
||||
const text = await response.text()
|
||||
expect(text).toContain('tool_calls')
|
||||
expect(text).toContain('lookup')
|
||||
expect(text).toContain('data: [DONE]')
|
||||
expect((await new GatewayStore(directory).snapshot()).audits[0]).toMatchObject({ status: 'success', stream: true, inputTokens: 3, outputTokens: 2 })
|
||||
})
|
||||
it('cancels upstream on downstream cancellation and persists cancellation', async () => {
|
||||
await configure()
|
||||
let closed!: () => void
|
||||
const closure = new Promise<void>(resolve => { closed = resolve })
|
||||
handler = (_req, res) => {
|
||||
res.on('close', closed)
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n')
|
||||
}
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
const reader = response.body!.getReader()
|
||||
await reader.read()
|
||||
await reader.cancel()
|
||||
await closure
|
||||
expect((await new GatewayStore(directory).snapshot()).audits[0]).toMatchObject({ status: 'cancelled', httpStatus: 499, inputTokens: null, outputTokens: null })
|
||||
})
|
||||
it('allows an already started stream to finish after key revocation but rejects subsequent calls', async () => {
|
||||
await configure()
|
||||
let complete!: () => void
|
||||
handler = (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[{"delta":{"content":"first"}}]}\n\n')
|
||||
complete = () => res.end('data: [DONE]\n\n')
|
||||
}
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
const reader = response.body!.getReader()
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toContain('first')
|
||||
const issued = await store.authenticate(`Bearer ${accessKey}`)
|
||||
await store.revokeAccessKey(issued.id)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(401)
|
||||
complete()
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toContain('[DONE]')
|
||||
expect((await reader.read()).done).toBe(true)
|
||||
expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'success', accessKeyId: issued.id })
|
||||
})
|
||||
it('bounds requests and detects truncated streams and timeouts', async () => {
|
||||
await configure()
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, messages: [{ role: 'user', content: 'x'.repeat(1024 * 1024) }] }))).status).toBe(413)
|
||||
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }); res.end('data: {"choices":[]}\n\n') }
|
||||
const truncated = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
expect(await truncated.text()).toContain('INCOMPLETE_STREAM')
|
||||
expect((await store.snapshot()).audits[0].status).toBe('failed')
|
||||
handler = () => undefined
|
||||
const short = new Hono().route(prefix, gatewayRoutes(store, 50))
|
||||
expect((await short.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(504)
|
||||
expect((await store.snapshot()).audits[0].errorCode).toBe('REQUEST_TIMEOUT')
|
||||
})
|
||||
it('bounds upstream responses and streams, without persisting response data', async () => {
|
||||
await configure()
|
||||
handler = (_req, res) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [], extra: 'x'.repeat(16 * 1024 * 1024) })) }
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(502)
|
||||
handler = (_req, res) => { res.setHeader('content-type', 'text/event-stream'); res.end(`data: ${'x'.repeat(1024 * 1024 + 1)}`) }
|
||||
const stream = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
expect(await stream.text()).toContain('STREAM_TOO_LARGE')
|
||||
expect((await store.snapshot()).audits.every(a => a.status === 'failed')).toBe(true)
|
||||
})
|
||||
it('persists only the newest 200 audits under concurrent writes', async () => {
|
||||
await Promise.all(Array.from({ length: 205 }, (_, i) => store.audit({
|
||||
id: randomUUID(), time: new Date().toISOString(), model: '', upstreamId: null, accessKeyId: null, stream: false,
|
||||
status: 'failed', httpStatus: 404, durationMs: i, inputTokens: null, outputTokens: null, errorCode: 'MODEL_NOT_FOUND'
|
||||
})))
|
||||
const snapshot = await new GatewayStore(directory).snapshot()
|
||||
expect(snapshot.audits).toHaveLength(200)
|
||||
expect(snapshot.audits[0].durationMs).toBe(204)
|
||||
expect(snapshot.audits[199].durationMs).toBe(5)
|
||||
})
|
||||
it('propagates a real HTTP client disconnect to the upstream', async () => {
|
||||
await configure()
|
||||
let closed!: () => void
|
||||
const closure = new Promise<void>(resolve => { closed = resolve })
|
||||
handler = (_req, res) => {
|
||||
res.on('close', closed)
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n')
|
||||
}
|
||||
const gateway = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' })
|
||||
await once(gateway, 'listening')
|
||||
try {
|
||||
const address = gateway.address() as { port: number }
|
||||
const controller = new AbortController()
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}${prefix}/v1/chat/completions`, post({ ...chat, stream: true }, { signal: controller.signal }))
|
||||
await response.body!.getReader().read()
|
||||
controller.abort()
|
||||
await closure
|
||||
await expect.poll(async () => (await store.snapshot()).audits[0]?.status).toBe('cancelled')
|
||||
} finally {
|
||||
(gateway as Server).closeAllConnections()
|
||||
await new Promise<void>(resolve => gateway.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
it('times out a stalled SSE stream even without downstream reads', async () => {
|
||||
await configure()
|
||||
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }); res.write('data: {"choices":[]}\n\n') }
|
||||
const short = new Hono().route(prefix, gatewayRoutes(store, 100))
|
||||
const response = await short.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
await expect.poll(async () => (await store.snapshot()).audits[0]?.errorCode).toBe('REQUEST_TIMEOUT')
|
||||
await expect(response.text()).rejects.toThrow('timed out')
|
||||
})
|
||||
it('errors the stream and closes upstream when persisting a successful stream audit fails', async () => {
|
||||
await configure()
|
||||
// A directory at the destination makes the real atomic rename fail on all platforms.
|
||||
await rm(join(directory, 'gateway.json'))
|
||||
await mkdir(join(directory, 'gateway.json'))
|
||||
let closed!: () => void
|
||||
const closure = new Promise<void>(resolve => { closed = resolve })
|
||||
handler = (_req, res) => {
|
||||
res.on('close', closed)
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2}}\n\ndata: [DONE]\n\n')
|
||||
}
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.text()).rejects.toThrow('Gateway stream failed.')
|
||||
await closure
|
||||
expect((await store.snapshot()).audits).toEqual([])
|
||||
})
|
||||
it('still closes upstream when persisting cancellation fails', async () => {
|
||||
await configure()
|
||||
await rm(join(directory, 'gateway.json'))
|
||||
await mkdir(join(directory, 'gateway.json'))
|
||||
let closed!: () => void
|
||||
const closure = new Promise<void>(resolve => { closed = resolve })
|
||||
handler = (_req, res) => {
|
||||
res.on('close', closed)
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[]}\n\n')
|
||||
}
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
const reader = response.body!.getReader()
|
||||
await reader.read()
|
||||
await expect(reader.cancel()).rejects.toMatchObject({ code: 'GATEWAY_STORAGE_UNAVAILABLE' })
|
||||
await closure
|
||||
})
|
||||
it('handles abort after upstream headers but before stream startup, including EOF', async () => {
|
||||
await configure()
|
||||
const controller = new AbortController()
|
||||
handler = (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.end('data: [DONE]\n\n')
|
||||
}
|
||||
const originalFetch = globalThis.fetch
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (...args) => {
|
||||
const response = await originalFetch(...args)
|
||||
controller.abort()
|
||||
return response
|
||||
})
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }, { signal: controller.signal }))
|
||||
await expect(response.text()).rejects.toThrow('Client cancelled.')
|
||||
expect((await new GatewayStore(directory).snapshot()).audits).toMatchObject([
|
||||
{ status: 'cancelled', httpStatus: 499, errorCode: 'CLIENT_CANCELLED', inputTokens: null, outputTokens: null }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -7,9 +7,12 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createApp } from './app.js'
|
||||
import { createApp as createUnboundApp } from './app.js'
|
||||
import { withLocalSocket } from './test-socket.js'
|
||||
import { SharingStore } from './sharing-store.js'
|
||||
|
||||
const createApp = (...args: Parameters<typeof createUnboundApp>) => withLocalSocket(createUnboundApp(...args))
|
||||
|
||||
const device = { id: 'desktop-1', name: 'Local desktop', platform: 'win32', appVersion: '1.0.0' }
|
||||
const publication = {
|
||||
id: 'pub-1', deviceId: device.id, name: 'Local knowledge', kind: 'knowledge',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Hono } from 'hono'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { SocketBindings } from './admin-boundary.js'
|
||||
|
||||
export const socketBinding = (remoteAddress = '127.0.0.1'): SocketBindings => ({ incoming: { socket: { remoteAddress } } as IncomingMessage })
|
||||
|
||||
// Inject the same binding read from IncomingMessage in production, never a header.
|
||||
export function withLocalSocket<T extends { request: Hono['request'] }>(app: T): T {
|
||||
const request = app.request.bind(app)
|
||||
app.request = (input, init, env, executionCtx) => request(input, init, env ?? socketBinding(), executionCtx)
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export type GatewayUpstreamInput = {
|
||||
name: string
|
||||
baseUrl: string
|
||||
protocol: 'openai-chat-completions'
|
||||
authentication: 'api-key' | 'none'
|
||||
enabled: boolean
|
||||
credentialAction: 'keep' | 'replace' | 'clear'
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export type GatewayUpstream = Omit<GatewayUpstreamInput, 'credentialAction' | 'apiKey'> & {
|
||||
id: string
|
||||
credentialConfigured: boolean
|
||||
}
|
||||
|
||||
export type GatewayModelInput = {
|
||||
alias: string
|
||||
upstreamId: string
|
||||
remoteModel: string
|
||||
enabled: boolean
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export type GatewayModel = GatewayModelInput & { id: string }
|
||||
|
||||
export type GatewayAccessKey = {
|
||||
id: string
|
||||
name: string
|
||||
modelIds: string[]
|
||||
createdAt: string
|
||||
revokedAt: string | null
|
||||
}
|
||||
export type GatewayAccessKeyInput = { name: string; modelIds: string[] }
|
||||
export type GatewayAccessKeyCreated = { accessKey: GatewayAccessKey; key: string }
|
||||
|
||||
export type GatewayAudit = {
|
||||
id: string
|
||||
time: string
|
||||
model: string
|
||||
upstreamId: string | null
|
||||
accessKeyId: string | null
|
||||
stream: boolean
|
||||
status: 'success' | 'failed' | 'cancelled'
|
||||
httpStatus: number
|
||||
durationMs: number
|
||||
inputTokens: number | null
|
||||
outputTokens: number | null
|
||||
errorCode: string | null
|
||||
}
|
||||
|
||||
export type GatewaySnapshot = {
|
||||
upstreams: GatewayUpstream[]
|
||||
models: GatewayModel[]
|
||||
audits: GatewayAudit[]
|
||||
gatewayBaseUrl: string | null
|
||||
}
|
||||
+77
-81
@@ -1,6 +1,6 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { prototypeSnapshot } from '../../shared/prototype-data'
|
||||
import { pages } from './app-config'
|
||||
import App from './App'
|
||||
|
||||
describe('ShareServer console', () => {
|
||||
@@ -10,102 +10,98 @@ describe('ShareServer console', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => prototypeSnapshot
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url: string) => {
|
||||
if (url === '/api/v1/gateway/config') {
|
||||
return { ok: true, json: async () => ({ upstreams: [], models: [], audits: [], gatewayBaseUrl: null }) }
|
||||
}
|
||||
if (url === '/api/v1/gateway/access-keys') return { ok: true, json: async () => ({ accessKeys: [] }) }
|
||||
if (url === '/api/v1/sharing/devices') return { ok: true, json: async () => ({ devices: [] }) }
|
||||
if (url === '/api/v1/sharing/publications') return { ok: true, json: async () => ({ publications: [] }) }
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
}))
|
||||
window.history.replaceState({}, '', '/')
|
||||
})
|
||||
|
||||
it('renders the organization overview from the API snapshot', async () => {
|
||||
it('renders service links without a snapshot request, fabricated metrics or identity', () => {
|
||||
render(<App />)
|
||||
expect(await screen.findByRole('heading', { name: '运行概览' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Nebula ShareServer')).toBeInTheDocument()
|
||||
expect(screen.getByText('在线设备')).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: '管理控制台' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: '运行概览' })).toBeInTheDocument()
|
||||
const services = screen.getByRole('region', { name: 'GoodBuddy 联动服务' })
|
||||
expect(within(services).getByRole('link', { name: /模型网关/ })).toHaveAttribute('href', '/model-gateway')
|
||||
expect(within(services).getByRole('link', { name: /共享中心/ })).toHaveAttribute('href', '/capabilities')
|
||||
const nav = screen.getByRole('navigation', { name: '管理控制台' })
|
||||
for (const page of pages) expect(within(nav).getByRole('link', { name: page.title })).toHaveAttribute('href', page.path)
|
||||
expect(within(nav).getByRole('link', { name: '审批' })).toHaveTextContent(/^审批$/)
|
||||
expect(screen.queryByText(/Nebula|MesaLab|林江|在线设备|任务活动|网络脉冲|示例数据/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /通知|搜索|切换到成员|切换到管理/ })).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(window, { key: 'k', ctrlKey: true })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/devices', expect.anything())
|
||||
expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/publications', expect.anything())
|
||||
expect(fetch).toHaveBeenCalledWith('/api/v1/gateway/config', expect.anything())
|
||||
})
|
||||
|
||||
it('opens global search with the keyboard shortcut', async () => {
|
||||
it.each(pages.filter((page) => !['/', '/capabilities', '/model-gateway', '/distribution', '/devices'].includes(page.path)))(
|
||||
'shows a boundary state without pretend actions at $path', (page) => {
|
||||
window.history.replaceState({}, '', page.path)
|
||||
render(<App />)
|
||||
const main = screen.getByRole('main')
|
||||
expect(within(main).getByRole('heading', { name: page.title })).toBeInTheDocument()
|
||||
expect(within(main).getByRole('heading', { name: '当前没有可用的业务数据' })).toBeInTheDocument()
|
||||
expect(within(main).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(within(main).getAllByRole('tab').length).toBeGreaterThan(0)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves theme switching and closes mobile navigation after navigation', () => {
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: '运行概览' })
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }))
|
||||
expect(await screen.findByRole('dialog', { name: '全局搜索' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换到浅色主题' }))
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'light')
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换到深色主题' }))
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开导航' }))
|
||||
const navigation = screen.getAllByRole('navigation', { name: '管理控制台' })
|
||||
expect(navigation).toHaveLength(2)
|
||||
fireEvent.click(within(navigation[1]).getByRole('link', { name: '组织与成员' }))
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(screen.getByRole('heading', { name: '组织与成员' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('changes list data when navigating between existing console routes', async () => {
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: '运行概览' })
|
||||
fireEvent.click(screen.getByRole('link', { name: 'GoodBuddy 接入' }))
|
||||
expect(screen.getByText('Jiang-PC')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('link', { name: '组织与成员' }))
|
||||
expect(screen.getByText('jiang@mesalab.internal')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Jiang-PC')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retains gateway state across navigation and blocks publishing against a disabled upstream', async () => {
|
||||
it('preserves distribution and its link to the real gateway', async () => {
|
||||
window.history.replaceState({}, '', '/distribution')
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: '配置下发' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建配置草稿' }))
|
||||
fireEvent.change(screen.getByLabelText('版本'), { target: { value: '1.0.0' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
|
||||
fireEvent.click(screen.getByRole('link', { name: '模型网关' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '停用 示例上游' }))
|
||||
fireEvent.click(screen.getByRole('link', { name: '配置下发' }))
|
||||
expect(screen.getByRole('button', { name: '模拟发布' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('link', { name: '模型网关' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '启用 示例上游' }))
|
||||
fireEvent.click(screen.getByRole('link', { name: '配置下发' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '模拟发布' }))
|
||||
expect(screen.getByText('模拟已应用')).toBeInTheDocument()
|
||||
expect(screen.getByText('离线 · 待下发(模拟)')).toBeInTheDocument()
|
||||
expect(screen.getByText('已应用:1.0.0 / org-chat · 目标:1.0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('已应用:无 · 目标:1.0.0')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建配置草稿' }))
|
||||
fireEvent.change(screen.getByLabelText('版本'), { target: { value: '1.1.0' } })
|
||||
fireEvent.change(screen.getByLabelText('默认模型'), { target: { value: 'model-code' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存草稿' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '模拟发布' }))
|
||||
expect(screen.getByText('已应用:1.1.0 / org-code · 目标:1.1.0')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '模拟回退到 1.0.0' }))
|
||||
expect(screen.getByText('已应用:1.0.0 / org-chat · 目标:1.0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('已应用:无 · 目标:1.0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('尚无配置下发服务')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '创建配置草稿' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/org-chat|org-code|工程工作站/)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('link', { name: '前往模型网关' }))
|
||||
expect(await screen.findByText(/尚无上游服务/)).toBeInTheDocument()
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
expect(fetch).toHaveBeenCalledWith('/api/v1/gateway/config', expect.anything())
|
||||
})
|
||||
|
||||
it('removes personal delete authority after document transfer and preserves ownership through restore', async () => {
|
||||
window.history.replaceState({}, '', '/documents')
|
||||
it('renders the real device directory', async () => {
|
||||
vi.mocked(fetch).mockImplementation(async (input: string | URL | Request) => {
|
||||
const url = input.toString()
|
||||
if (url === '/api/v1/sharing/devices') return { ok: true, json: async () => ({ devices: [{ id: 'device-1', name: '本机工作站', platform: 'win32', appVersion: '1.0.0', registeredAt: '2026-10-01', updatedAt: '2026-10-02' }] }) } as Response
|
||||
if (url === '/api/v1/sharing/publications') return { ok: true, json: async () => ({ publications: [] }) } as Response
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
window.history.replaceState({}, '', '/devices')
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: '文档与协作' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开模拟 GoodBuddy 客户端' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认转移给组织' }))
|
||||
const document = screen.getByRole('heading', { name: '我的项目提案.docx' }).closest('article')!
|
||||
expect(within(document).getByText('归属:组织:MesaLab')).toBeInTheDocument()
|
||||
expect(within(document).getByRole('button', { name: '移入回收站' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.click(within(document).getByRole('button', { name: '移入回收站' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '回收站' }))
|
||||
expect(screen.getByText('归属:组织:MesaLab')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复文档' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '组织文档' }))
|
||||
expect(screen.getByRole('heading', { name: '我的项目提案.docx' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开模拟 GoodBuddy 客户端' }))
|
||||
expect(screen.queryByRole('button', { name: '删除我的文档' })).not.toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: '本机工作站' })).toBeInTheDocument()
|
||||
expect(screen.getByText('已登记')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: '查看共享内容' })).toHaveAttribute('href', '/capabilities?view=local&device=device-1')
|
||||
})
|
||||
|
||||
it('records denied gateway calls and hides administration from member navigation', async () => {
|
||||
window.history.replaceState({}, '', '/model-gateway')
|
||||
it('redirects unknown routes to the overview without requesting a snapshot', () => {
|
||||
window.history.replaceState({}, '', '/missing')
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: '模型网关' })
|
||||
fireEvent.click(screen.getByRole('tab', { name: '模型映射 / 访问策略' }))
|
||||
fireEvent.change(screen.getByLabelText('模拟调用者用户组'), { target: { value: '设计中心' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '模拟调用 org-code' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '调用审计' }))
|
||||
expect(screen.getByText('模拟拒绝')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /切换到成员自助/ }))
|
||||
expect(screen.getByRole('heading', { name: '我的设备' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: '模型网关' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: '运行概览' })).toBeInTheDocument()
|
||||
expect(window.location.pathname).toBe('/')
|
||||
expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/devices', expect.anything())
|
||||
expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/publications', expect.anything())
|
||||
expect(fetch).toHaveBeenCalledWith('/api/v1/gateway/config', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
+25
-122
@@ -1,132 +1,35 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
BrowserRouter,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation
|
||||
} from 'react-router-dom'
|
||||
import type { PrototypeRole, PrototypeSnapshot } from '../../shared/prototype-data'
|
||||
import { pages, visiblePages } from './app-config'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { pages } from './app-config'
|
||||
import { AppShell } from './components/app-shell'
|
||||
import { DataPage } from './components/data-page'
|
||||
import { SharingCenter } from './components/sharing-center'
|
||||
import { OverviewPage } from './components/overview-page'
|
||||
import { SettingsPage } from './components/settings-page'
|
||||
import { ServiceDemoPage, ServicesDemo } from './components/services-demo'
|
||||
import { Button } from './components/ui/button'
|
||||
|
||||
async function loadSnapshot(): Promise<PrototypeSnapshot> {
|
||||
const response = await fetch('/api/v1/web/prototype/snapshot')
|
||||
if (!response.ok) throw new Error('控制台快照加载失败')
|
||||
return response.json() as Promise<PrototypeSnapshot>
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-[#07111f]" role="status" aria-busy="true">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto size-10 animate-spin rounded-full border-2 border-cyan-300/15 border-t-cyan-300" />
|
||||
<p className="mt-4 text-sm text-slate-400">正在连接 ShareServer…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Console({
|
||||
snapshot,
|
||||
role,
|
||||
onRoleChange
|
||||
}: {
|
||||
snapshot: PrototypeSnapshot
|
||||
role: PrototypeRole
|
||||
onRoleChange: (role: PrototypeRole) => void
|
||||
}) {
|
||||
const location = useLocation()
|
||||
const allowedPaths = visiblePages(role).map((page) => page.path)
|
||||
const fallback = role === 'admin' ? '/' : '/me/devices'
|
||||
|
||||
if (!allowedPaths.includes(location.pathname)) {
|
||||
return <Navigate replace to={fallback} />
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell snapshot={snapshot} role={role} onRoleChange={onRoleChange}>
|
||||
<Routes>
|
||||
{pages.map((page) => (
|
||||
<Route
|
||||
key={page.path}
|
||||
path={page.path}
|
||||
element={
|
||||
page.path === '/' ? (
|
||||
<OverviewPage snapshot={snapshot} />
|
||||
) : page.path === '/capabilities' ? (
|
||||
<SharingCenter snapshot={snapshot} />
|
||||
) : page.path === '/settings' ? (
|
||||
<SettingsPage snapshot={snapshot} />
|
||||
) : ['/model-gateway', '/documents', '/distribution'].includes(page.path) ? (
|
||||
<ServiceDemoPage key={page.path} page={page} snapshot={snapshot} />
|
||||
) : (
|
||||
<DataPage key={page.path} page={page} snapshot={snapshot} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route path="*" element={<Navigate replace to={fallback} />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
import { GatewayPage } from './components/gateway-page'
|
||||
import { DistributionPage } from './components/distribution-page'
|
||||
import { DevicesPage } from './components/devices-page'
|
||||
import { UnavailablePage } from './components/unavailable-page'
|
||||
|
||||
export default function App() {
|
||||
const [snapshot, setSnapshot] = useState<PrototypeSnapshot | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
const [role, setRole] = useState<PrototypeRole>('admin')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadSnapshot()
|
||||
.then((data) => {
|
||||
if (!cancelled) setSnapshot(data)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(reason instanceof Error ? reason.message : '控制台加载失败')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [reloadKey])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-[#07111f] p-6 text-slate-100">
|
||||
<div role="alert" className="max-w-md rounded-2xl border border-rose-400/20 bg-rose-400/[0.06] p-6 text-center">
|
||||
<p className="font-medium">{error}</p>
|
||||
<p className="mt-2 text-sm text-slate-400">请确认 ShareServer API 已启动,然后重试。</p>
|
||||
<Button
|
||||
className="mt-5"
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setReloadKey((key) => key + 1)
|
||||
}}
|
||||
>
|
||||
重新连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!snapshot) return <LoadingState />
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ServicesDemo>
|
||||
<Console snapshot={snapshot} role={role} onRoleChange={setRole} />
|
||||
</ServicesDemo>
|
||||
<AppShell>
|
||||
<Routes>
|
||||
{pages.map((page) => (
|
||||
<Route
|
||||
key={page.path}
|
||||
path={page.path}
|
||||
element={
|
||||
page.path === '/' ? <OverviewPage />
|
||||
: page.path === '/capabilities' ? <SharingCenter />
|
||||
: page.path === '/model-gateway' ? <GatewayPage />
|
||||
: page.path === '/distribution' ? <DistributionPage />
|
||||
: page.path === '/devices' ? <DevicesPage />
|
||||
: <UnavailablePage title={page.title} description={page.description} boundary={page.boundary ?? '该模块的身份、权限和持久化接口尚未接入,因此当前不显示示例数据,也不提供不会生效的操作。'} next={page.next} tabs={page.tabs?.map(({ label, dataKey }) => ({ label, key: dataKey }))} />
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route path="*" element={<Navigate replace to="/" />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
+49
-19
@@ -30,6 +30,8 @@ export type PageConfig = {
|
||||
primaryAction?: string
|
||||
group?: string
|
||||
tabs?: Array<{ label: string; dataKey: string }>
|
||||
boundary?: string
|
||||
next?: { label: string; to: string }
|
||||
}
|
||||
|
||||
export const pages: PageConfig[] = [
|
||||
@@ -38,15 +40,17 @@ export const pages: PageConfig[] = [
|
||||
title: '运行概览',
|
||||
description: 'GoodBuddy 组织服务、接入状态与待处理事项。',
|
||||
icon: Gauge,
|
||||
role: 'admin'
|
||||
role: 'admin',
|
||||
tabs: [{ label: '服务状态', dataKey: 'status' }, { label: '待处理事项', dataKey: 'pending' }, { label: '最近活动', dataKey: 'activity' }]
|
||||
},
|
||||
{
|
||||
path: '/model-gateway',
|
||||
title: '模型网关',
|
||||
description: '接入上游模型服务,管理转发、访问策略和调用审计;ShareServer 不运行模型。',
|
||||
description: '本地技术预览:配置真实上游与模型别名、测试转发并查看审计;尚无组织认证与配额。',
|
||||
icon: Network,
|
||||
role: 'admin',
|
||||
group: 'GoodBuddy 服务'
|
||||
group: 'GoodBuddy 服务',
|
||||
tabs: [{ label: '概览', dataKey: 'overview' }, { label: '上游服务', dataKey: 'upstreams' }, { label: '模型目录', dataKey: 'models' }, { label: '访问密钥', dataKey: 'keys' }, { label: '请求测试', dataKey: 'test' }, { label: '调用审计', dataKey: 'audits' }, { label: '客户端接入', dataKey: 'client' }]
|
||||
},
|
||||
{
|
||||
path: '/documents',
|
||||
@@ -54,15 +58,17 @@ export const pages: PageConfig[] = [
|
||||
description: '文档存储在 ShareServer,在 GoodBuddy 中使用;管理归属、回收站和 OnlyOffice 服务。',
|
||||
icon: FileText,
|
||||
role: 'admin',
|
||||
group: 'GoodBuddy 服务'
|
||||
group: 'GoodBuddy 服务',
|
||||
tabs: [{ label: '文档库', dataKey: 'documents' }, { label: '编辑会话', dataKey: 'sessions' }, { label: 'Office 服务', dataKey: 'office' }, { label: '保存记录', dataKey: 'saves' }]
|
||||
},
|
||||
{
|
||||
path: '/distribution',
|
||||
title: '配置下发',
|
||||
description: '向 GoodBuddy 分配网关模型配置,查看版本、生效情况与回滚结果。',
|
||||
description: '配置分发尚未接入;可先在模型网关配置与测试真实上游。',
|
||||
icon: Send,
|
||||
role: 'admin',
|
||||
group: 'GoodBuddy 服务'
|
||||
group: 'GoodBuddy 服务',
|
||||
tabs: [{ label: '配置草稿', dataKey: 'drafts' }, { label: '设备生效', dataKey: 'devices' }, { label: '发布记录', dataKey: 'releases' }, { label: '回滚记录', dataKey: 'rollbacks' }]
|
||||
},
|
||||
{
|
||||
path: '/members',
|
||||
@@ -71,7 +77,8 @@ export const pages: PageConfig[] = [
|
||||
dataKey: 'members',
|
||||
icon: Building2,
|
||||
role: 'admin',
|
||||
primaryAction: '邀请成员',
|
||||
group: '组织管理',
|
||||
boundary: '成员、用户组、角色、登录和组织授权 API 尚未接入。当前不显示成员示例,也不能从此页面发送不会生效的邀请。',
|
||||
tabs: [
|
||||
{ label: '成员', dataKey: 'members' },
|
||||
{ label: '用户组', dataKey: 'groups' },
|
||||
@@ -85,7 +92,10 @@ export const pages: PageConfig[] = [
|
||||
dataKey: 'devices',
|
||||
icon: Cpu,
|
||||
role: 'admin',
|
||||
primaryAction: '生成注册链接'
|
||||
group: '共享资源',
|
||||
boundary: '当前可以查看真实登记设备和共享目录元数据。设备在线状态、身份认证、设备撤销和注册链接 API 尚未接入。',
|
||||
next: { label: '查看真实设备目录', to: '/devices' },
|
||||
tabs: [{ label: '设备列表', dataKey: 'list' }, { label: '设备详情', dataKey: 'detail' }, { label: '连接记录', dataKey: 'connections' }, { label: '撤销记录', dataKey: 'revocations' }]
|
||||
},
|
||||
{
|
||||
path: '/capabilities',
|
||||
@@ -93,7 +103,9 @@ export const pages: PageConfig[] = [
|
||||
description: '按内容、发布用户或提供设备查看共享能力与知识库。',
|
||||
dataKey: 'capabilities',
|
||||
icon: Boxes,
|
||||
role: 'admin'
|
||||
role: 'admin',
|
||||
group: '共享资源',
|
||||
tabs: [{ label: '能力目录', dataKey: 'capabilities' }, { label: '共享知识库', dataKey: 'knowledge' }, { label: '按成员查看', dataKey: 'members' }, { label: '按设备查看', dataKey: 'devices' }]
|
||||
},
|
||||
{
|
||||
path: '/policies',
|
||||
@@ -102,7 +114,9 @@ export const pages: PageConfig[] = [
|
||||
dataKey: 'policies',
|
||||
icon: ShieldCheck,
|
||||
role: 'admin',
|
||||
primaryAction: '新建策略'
|
||||
group: '组织管理',
|
||||
boundary: '权限策略、Grant、配额和组织授权 API 尚未接入。共享目录中的权限字段目前只是发布者声明,不能在此执行授权决策。',
|
||||
tabs: [{ label: '强制策略', dataKey: 'mandatory' }, { label: 'Grant', dataKey: 'grants' }, { label: '策略模拟', dataKey: 'simulation' }, { label: '变更记录', dataKey: 'changes' }]
|
||||
},
|
||||
{
|
||||
path: '/approvals',
|
||||
@@ -110,7 +124,9 @@ export const pages: PageConfig[] = [
|
||||
description: '处理调用、发布、更新和联邦申请。',
|
||||
dataKey: 'approvals',
|
||||
icon: UserRoundCheck,
|
||||
role: 'admin'
|
||||
role: 'admin',
|
||||
group: '组织管理',
|
||||
tabs: [{ label: '待处理', dataKey: 'pending' }, { label: '已处理', dataKey: 'processed' }, { label: '我的申请', dataKey: 'mine' }, { label: '审批规则', dataKey: 'rules' }]
|
||||
},
|
||||
{
|
||||
path: '/releases',
|
||||
@@ -119,7 +135,9 @@ export const pages: PageConfig[] = [
|
||||
dataKey: 'releases',
|
||||
icon: PackageCheck,
|
||||
role: 'admin',
|
||||
primaryAction: '上传能力包'
|
||||
group: '运营与审计',
|
||||
boundary: '能力包存储、签名校验、版本发布和客户端更新 API 尚未接入。当前不提供上传按钮,避免产生未保存的发布记录。',
|
||||
tabs: [{ label: '能力包', dataKey: 'packages' }, { label: '发布通道', dataKey: 'channels' }, { label: '更新策略', dataKey: 'policies' }, { label: '撤销记录', dataKey: 'revocations' }]
|
||||
},
|
||||
{
|
||||
path: '/federation',
|
||||
@@ -128,7 +146,9 @@ export const pages: PageConfig[] = [
|
||||
dataKey: 'federation',
|
||||
icon: GitFork,
|
||||
role: 'admin',
|
||||
primaryAction: '建立联邦关系'
|
||||
group: '运营与审计',
|
||||
boundary: '联邦握手、双边确认、共享范围和跨网关连接 API 尚未接入。当前不显示虚构的网关关系或连接状态。',
|
||||
tabs: [{ label: '合作组织', dataKey: 'partners' }, { label: '待确认', dataKey: 'pending' }, { label: '开放范围', dataKey: 'scope' }, { label: '事件记录', dataKey: 'events' }]
|
||||
},
|
||||
{
|
||||
path: '/tasks',
|
||||
@@ -136,7 +156,9 @@ export const pages: PageConfig[] = [
|
||||
description: '查看任务路由、终态和可选中继容量。',
|
||||
dataKey: 'tasks',
|
||||
icon: Activity,
|
||||
role: 'admin'
|
||||
role: 'admin',
|
||||
group: '运营与审计',
|
||||
tabs: [{ label: '任务列表', dataKey: 'tasks' }, { label: '活动任务', dataKey: 'active' }, { label: '中继连接', dataKey: 'relay' }, { label: '运行统计', dataKey: 'stats' }]
|
||||
},
|
||||
{
|
||||
path: '/audit',
|
||||
@@ -145,14 +167,19 @@ export const pages: PageConfig[] = [
|
||||
dataKey: 'audit',
|
||||
icon: ScrollText,
|
||||
role: 'admin',
|
||||
primaryAction: '导出当前结果'
|
||||
group: '运营与审计',
|
||||
boundary: '组织操作审计、筛选和导出 API 尚未接入。模型网关的调用元数据仍只能在模型网关页面查看。',
|
||||
next: { label: '查看模型调用记录', to: '/model-gateway' },
|
||||
tabs: [{ label: '事件查询', dataKey: 'events' }, { label: '调用链', dataKey: 'traces' }, { label: '导出任务', dataKey: 'exports' }, { label: '保留策略', dataKey: 'retention' }]
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
title: '系统设置',
|
||||
description: '查看实例、身份源、基础设施状态与保留策略。',
|
||||
icon: Settings2,
|
||||
role: 'admin'
|
||||
role: 'admin',
|
||||
group: '系统',
|
||||
tabs: [{ label: '实例信息', dataKey: 'instance' }, { label: '身份与登录', dataKey: 'identity' }, { label: '基础设施', dataKey: 'infrastructure' }, { label: '中继与联邦', dataKey: 'relay' }, { label: '通知', dataKey: 'notifications' }, { label: '保留与审计', dataKey: 'retention' }]
|
||||
},
|
||||
{
|
||||
path: '/me/devices',
|
||||
@@ -160,7 +187,8 @@ export const pages: PageConfig[] = [
|
||||
description: '查看你在当前组织注册的设备与连接状态。',
|
||||
dataKey: 'myDevices',
|
||||
icon: CircleUserRound,
|
||||
role: 'member'
|
||||
role: 'member',
|
||||
tabs: [{ label: '我的设备', dataKey: 'devices' }, { label: '我的授权', dataKey: 'grants' }, { label: '我的申请', dataKey: 'requests' }, { label: '我的数字人', dataKey: 'digital-humans' }]
|
||||
},
|
||||
{
|
||||
path: '/me/grants',
|
||||
@@ -168,7 +196,8 @@ export const pages: PageConfig[] = [
|
||||
description: '查看你获准发现、调用或下载的能力。',
|
||||
dataKey: 'myGrants',
|
||||
icon: FileKey2,
|
||||
role: 'member'
|
||||
role: 'member',
|
||||
tabs: [{ label: '授权详情', dataKey: 'grants' }, { label: '使用限制', dataKey: 'limits' }, { label: '变更记录', dataKey: 'changes' }]
|
||||
},
|
||||
{
|
||||
path: '/me/requests',
|
||||
@@ -176,7 +205,8 @@ export const pages: PageConfig[] = [
|
||||
description: '跟踪你的设备、能力与任务审批。',
|
||||
dataKey: 'myRequests',
|
||||
icon: UserRoundCheck,
|
||||
role: 'member'
|
||||
role: 'member',
|
||||
tabs: [{ label: '全部申请', dataKey: 'all' }, { label: '待处理', dataKey: 'pending' }, { label: '已完成', dataKey: 'completed' }]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import {
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
Command,
|
||||
Menu,
|
||||
MoonStar,
|
||||
Search,
|
||||
Shield,
|
||||
Sun,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import type { PrototypeRole, PrototypeSnapshot } from '../../../shared/prototype-data'
|
||||
import { visiblePages } from '../app-config'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { pages } from '../app-config'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Button } from './ui/button'
|
||||
import { Dialog } from './ui/dialog'
|
||||
|
||||
function BrandMark() {
|
||||
return (
|
||||
@@ -27,70 +21,38 @@ function BrandMark() {
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
snapshot,
|
||||
role,
|
||||
onRoleChange,
|
||||
children
|
||||
}: {
|
||||
snapshot: PrototypeSnapshot
|
||||
role: PrototypeRole
|
||||
onRoleChange: (role: PrototypeRole) => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [dark, setDark] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
const navigation = visiblePages(role).map((page) => ({
|
||||
const groupOrder = ['工作台', 'GoodBuddy 服务', '共享资源', '组织管理', '运营与审计', '系统', '成员自助']
|
||||
const navigation = pages.map((page) => ({
|
||||
...page,
|
||||
group: page.group ?? (role === 'member' ? '成员自助' : page.path === '/' ? '概览' : '组织与运维')
|
||||
}))
|
||||
group: page.group ?? (page.role === 'member' ? '成员自助' : page.path === '/' ? '工作台' : '组织管理')
|
||||
})).sort((left, right) => groupOrder.indexOf(left.group) - groupOrder.indexOf(right.group))
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
}, [dark])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
setSearchOpen(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
const nav = (
|
||||
<>
|
||||
<div className="flex h-19 items-center gap-3 border-b border-white/[0.07] px-5">
|
||||
<BrandMark />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold tracking-wide text-white">
|
||||
{snapshot.instance.name}
|
||||
ShareServer
|
||||
</p>
|
||||
<p className="truncate text-[11px] uppercase tracking-[0.16em] text-cyan-300/70">
|
||||
GoodBuddy 组织服务
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-4">
|
||||
<button className="flex w-full items-center gap-3 rounded-xl border border-white/[0.07] bg-white/[0.035] px-3 py-2.5 text-left outline-none transition hover:bg-white/[0.06] focus-visible:ring-2 focus-visible:ring-cyan-400/70">
|
||||
<span className="grid size-8 place-items-center rounded-lg bg-violet-400/12 text-sm font-semibold text-violet-200">
|
||||
M
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-slate-100">
|
||||
{snapshot.instance.organization}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-slate-500">当前组织</span>
|
||||
</span>
|
||||
<ChevronsUpDown aria-hidden="true" className="size-4 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
<nav aria-label={role === 'admin' ? '管理控制台' : '成员自助'} className="flex-1 overflow-y-auto px-3 pb-4">
|
||||
<nav aria-label="管理控制台" className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<p className="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-600">
|
||||
{role === 'admin' ? '组织控制台' : '成员自助'}
|
||||
控制台
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{navigation.map((item, index) => {
|
||||
@@ -118,11 +80,6 @@ export function AppShell({
|
||||
) : null}
|
||||
<Icon aria-hidden="true" className="size-[18px]" />
|
||||
<span>{item.title}</span>
|
||||
{item.path === '/approvals' ? (
|
||||
<span className="ml-auto rounded-full bg-amber-300/15 px-1.5 py-0.5 text-[10px] font-semibold text-amber-200">
|
||||
6
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
@@ -131,27 +88,6 @@ export function AppShell({
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="border-t border-white/[0.07] p-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextRole = role === 'admin' ? 'member' : 'admin'
|
||||
onRoleChange(nextRole)
|
||||
navigate(nextRole === 'admin' ? '/' : '/me/devices')
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left outline-none transition hover:bg-white/[0.05] focus-visible:ring-2 focus-visible:ring-cyan-400/70"
|
||||
>
|
||||
<span className="grid size-9 place-items-center rounded-full border border-violet-300/20 bg-gradient-to-br from-cyan-300/25 to-violet-400/25 text-sm font-semibold text-white">
|
||||
林
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-slate-200">林江</span>
|
||||
<span className="block truncate text-[11px] text-slate-500">
|
||||
{role === 'admin' ? '切换到成员自助' : '切换到管理控制台'}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronsUpDown aria-hidden="true" className="size-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -197,22 +133,8 @@ export function AppShell({
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
<button
|
||||
className="flex h-9 min-w-0 max-w-md flex-1 items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.035] px-3 text-left text-sm text-slate-500 outline-none transition hover:border-white/[0.15] hover:bg-white/[0.055] focus-visible:ring-2 focus-visible:ring-cyan-400/70 sm:flex-initial sm:basis-[360px]"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<Search aria-hidden="true" className="size-4" />
|
||||
<span className="min-w-0 flex-1 truncate">搜索设备、能力、任务或成员</span>
|
||||
<span className="hidden items-center gap-1 rounded border border-white/10 px-1.5 py-0.5 text-[10px] text-slate-500 sm:flex">
|
||||
<Command className="size-3" />K
|
||||
</span>
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<span className="mr-2 hidden items-center gap-2 text-xs text-slate-500 xl:flex">
|
||||
<span className="size-1.5 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,.8)]" />
|
||||
交互演示 · 非实时数据
|
||||
</span>
|
||||
<Button
|
||||
<Button
|
||||
aria-label={dark ? '切换到浅色主题' : '切换到深色主题'}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -220,36 +142,12 @@ export function AppShell({
|
||||
>
|
||||
{dark ? <Sun className="size-4" /> : <MoonStar className="size-4" />}
|
||||
</Button>
|
||||
<Button aria-label="查看通知,6 条待处理" size="icon" variant="ghost">
|
||||
<Bell className="size-4" />
|
||||
<span className="absolute ml-3 mt-[-13px] size-1.5 rounded-full bg-amber-300" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-h-[calc(100vh-4rem)] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||
<div className="mx-auto max-w-[1440px]">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
<Dialog
|
||||
open={searchOpen}
|
||||
onOpenChange={setSearchOpen}
|
||||
title="全局搜索"
|
||||
description={`搜索范围:组织 ${snapshot.instance.organization}`}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 size-4 text-slate-500" />
|
||||
<input
|
||||
autoFocus
|
||||
aria-label="搜索关键词"
|
||||
className="h-11 w-full rounded-xl border border-white/10 bg-white/[0.04] pl-10 pr-3 text-sm outline-none placeholder:text-slate-600 focus:border-cyan-300/40 focus:ring-2 focus:ring-cyan-400/15"
|
||||
placeholder="输入设备、能力、任务 ID 或成员名称…"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-5 text-center text-sm text-slate-500">
|
||||
输入关键词后,将按当前身份和组织权限显示结果。
|
||||
</p>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import type { Device, Publication } from '../../../shared/sharing'
|
||||
import { sharingRequest } from '../lib/sharing-api'
|
||||
import { PageHeader } from './page-header'
|
||||
import { Button } from './ui/button'
|
||||
import { Dialog } from './ui/dialog'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
import { PageTabs } from './page-tabs'
|
||||
|
||||
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
|
||||
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
|
||||
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm text-[var(--text-primary)] focus-visible:outline-2'
|
||||
|
||||
type State = { status: 'loading' } | { status: 'error'; message: string } | { status: 'loaded'; devices: Device[]; publications: Publication[] }
|
||||
|
||||
export function DevicesPage() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [state, setState] = useState<State>({ status: 'loading' })
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [activeTab, setActiveTab] = useState('list')
|
||||
const selectedId = params.get('device') ?? ''
|
||||
const query = params.get('q') ?? ''
|
||||
const selected = state.status === 'loaded' ? state.devices.find((device) => device.id === selectedId) : undefined
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
Promise.all([
|
||||
sharingRequest<{ devices: Device[] }>('/devices', controller.signal),
|
||||
sharingRequest<{ publications: Publication[] }>('/publications', controller.signal)
|
||||
]).then(([devices, publications]) => {
|
||||
if (!controller.signal.aborted) setState({ status: 'loaded', devices: devices.devices, publications: publications.publications })
|
||||
}).catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) setState({ status: 'error', message: error instanceof Error ? error.message : '网络请求失败' })
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [attempt])
|
||||
|
||||
function selectDevice(id: string) {
|
||||
const next = new URLSearchParams(params)
|
||||
if (id) next.set('device', id)
|
||||
else next.delete('device')
|
||||
setParams(next)
|
||||
}
|
||||
|
||||
return <>
|
||||
<PageHeader eyebrow="本地服务目录 · 只读" title="GoodBuddy 接入" description="查看已登记的 GoodBuddy 设备及其发布的共享目录元数据。当前没有在线状态、身份认证或设备管理 API。" action={<Button variant="secondary" onClick={() => { setState({ status: 'loading' }); setAttempt((value) => value + 1) }} disabled={state.status === 'loading'}>刷新目录</Button>} />
|
||||
<PageTabs title="GoodBuddy 接入" active={activeTab} onChange={setActiveTab} tabs={[{ key: 'list', label: '设备列表' }, { key: 'detail', label: '设备详情' }, { key: 'connections', label: '连接记录' }, { key: 'revocations', label: '撤销记录' }]} />
|
||||
{activeTab !== 'list' && <section className={panel}><h2 className="font-semibold">{activeTab === 'detail' ? '设备详情' : activeTab === 'connections' ? '连接记录' : '撤销记录'}</h2><p className={muted}>该 Tab 的生产接口尚未接入。当前服务只提供设备登记元数据,暂不显示在线状态、连接心跳或撤销结果。</p></section>}
|
||||
{state.status === 'loading' && <p role="status" className={muted}>正在加载登记设备…</p>}
|
||||
{state.status === 'error' && <section className={panel}><h2 className="font-semibold">设备目录加载失败</h2><p className={`${muted} break-words`}>{state.message}</p><Button variant="secondary" onClick={() => { setState({ status: 'loading' }); setAttempt((value) => value + 1) }}>重试</Button></section>}
|
||||
{state.status === 'loaded' && activeTab === 'list' && <>
|
||||
<section className={panel}>
|
||||
<label className="block text-sm">搜索设备<input className={control} value={query} placeholder="名称、平台、版本或设备标识" onChange={(event) => { const next = new URLSearchParams(params); if (event.target.value) next.set('q', event.target.value); else next.delete('q'); setParams(next, { replace: true }) }} /></label>
|
||||
<p role="status" className={muted}>当前登记设备:{state.devices.length} 台。登记记录不代表设备在线。</p>
|
||||
</section>
|
||||
{!state.devices.length ? <section className={panel}><h2 className="font-semibold">尚无登记设备</h2><p className={muted}>请在 GoodBuddy 的“设备共享(技术预览)”中开启功能并连接此服务。</p></section> : <section className="grid gap-4 lg:grid-cols-2" aria-label="登记设备列表">
|
||||
{state.devices.filter((device) => [device.name, device.id, device.platform, device.appVersion].join(' ').toLowerCase().includes(query.trim().toLowerCase())).map((device) => {
|
||||
const count = state.publications.filter((item) => item.deviceId === device.id).length
|
||||
return <article key={device.id} className={panel}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-semibold text-[var(--text-strong)]">{device.name}</h2><p className={muted}>{device.platform} · {device.appVersion}</p></div><StatusBadge status="已登记" tone="neutral" /></div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-2"><div><dt className={muted}>设备标识</dt><dd className="break-all">{device.id}</dd></div><div><dt className={muted}>共享记录</dt><dd>{count} 项</dd></div><div><dt className={muted}>登记时间</dt><dd><time dateTime={device.registeredAt}>{device.registeredAt}</time></dd></div><div><dt className={muted}>最近登记</dt><dd><time dateTime={device.updatedAt}>{device.updatedAt}</time></dd></div></dl>
|
||||
<div className="flex flex-wrap gap-2"><Button variant="secondary" onClick={() => selectDevice(device.id)}>查看详情</Button><Link className="inline-flex min-h-9 items-center rounded-lg px-3 text-sm underline underline-offset-4" to={`/capabilities?view=local&device=${encodeURIComponent(device.id)}`}>查看共享内容</Link></div>
|
||||
</article>
|
||||
})}
|
||||
</section>}
|
||||
{query && !state.devices.some((device) => [device.name, device.id, device.platform, device.appVersion].join(' ').toLowerCase().includes(query.trim().toLowerCase())) && <section className={panel}><h2 className="font-semibold">没有符合条件的设备</h2><p className={muted}>请调整搜索条件。</p></section>}
|
||||
</>}
|
||||
<Dialog open={Boolean(selected)} onOpenChange={(open) => { if (!open) selectDevice('') }} title={selected?.name ?? '设备详情'} description="以下信息来自本地登记记录,不代表在线连接或设备所有权。">
|
||||
{selected && <div className="space-y-4"><dl className="grid gap-3 text-sm sm:grid-cols-2"><div><dt className={muted}>设备标识</dt><dd className="break-all">{selected.id}</dd></div><div><dt className={muted}>平台</dt><dd>{selected.platform}</dd></div><div><dt className={muted}>应用版本</dt><dd>{selected.appVersion}</dd></div><div><dt className={muted}>登记时间</dt><dd>{selected.registeredAt}</dd></div></dl><Link className="inline-flex min-h-9 items-center rounded-lg bg-cyan-400 px-3 text-sm font-medium text-slate-950" to={`/capabilities?view=local&device=${encodeURIComponent(selected.id)}`} onClick={() => selectDevice('')}>查看此设备的共享内容</Link></div>}
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PageHeader } from './page-header'
|
||||
|
||||
export function DistributionPage() {
|
||||
return <div className="min-w-0 text-[var(--text-primary)]">
|
||||
<PageHeader eyebrow="当前服务器 · 待接入" title="配置下发" description="GoodBuddy 配置分发尚未接入。" />
|
||||
<section className="space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5">
|
||||
<h2 className="font-semibold">尚无配置下发服务</h2>
|
||||
<p className="text-sm leading-6 text-[var(--text-muted)]">目前没有可用的配置草稿、设备生效回执或发布回滚记录。模型网关可独立配置与测试;保存网关模型不会自动下发到 GoodBuddy。</p>
|
||||
<Link className="inline-flex min-h-10 items-center rounded-lg underline outline-offset-4" to="/model-gateway">前往模型网关</Link>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { GatewayAccessKey, GatewayAccessKeyCreated, GatewayAccessKeyInput, GatewayModel } from '../../../shared/gateway'
|
||||
import { gatewayRequest } from '../lib/gateway-api'
|
||||
import { Button } from './ui/button'
|
||||
import { Dialog } from './ui/dialog'
|
||||
|
||||
export function GatewayAccessKeys({ models }: { models: GatewayModel[] }) {
|
||||
const [keys, setKeys] = useState<GatewayAccessKey[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [created, setCreated] = useState<GatewayAccessKeyCreated | null>(null)
|
||||
const [revoking, setRevoking] = useState<GatewayAccessKey | null>(null)
|
||||
const active = useRef<AbortController | null>(null)
|
||||
|
||||
function load() {
|
||||
if (active.current) return
|
||||
const controller = new AbortController()
|
||||
active.current = controller
|
||||
return gatewayRequest<{ accessKeys: GatewayAccessKey[] }>('/access-keys', controller.signal).then(data => {
|
||||
if (!controller.signal.aborted) setKeys(data.accessKeys)
|
||||
}).catch((cause: unknown) => {
|
||||
if (!controller.signal.aborted) setError(`访问密钥加载失败:${cause instanceof Error ? cause.message : '请稍后重试。'}`)
|
||||
}).finally(() => {
|
||||
if (active.current === controller) active.current = null
|
||||
if (!controller.signal.aborted) setBusy(false)
|
||||
})
|
||||
}
|
||||
|
||||
async function run(operation: 'create' | 'revoke', input?: GatewayAccessKeyInput, id?: string) {
|
||||
if (active.current) return
|
||||
const controller = new AbortController()
|
||||
active.current = controller
|
||||
try {
|
||||
if (operation === 'create') {
|
||||
const data = await gatewayRequest<GatewayAccessKeyCreated>('/access-keys', controller.signal, 'POST', input)
|
||||
if (!controller.signal.aborted) {
|
||||
setKeys(previous => [...(previous ?? []), data.accessKey]); setCreated(data); setCreating(false)
|
||||
}
|
||||
} else {
|
||||
const data = await gatewayRequest<GatewayAccessKey>(`/access-keys/${encodeURIComponent(id!)}/revoke`, controller.signal, 'POST')
|
||||
if (!controller.signal.aborted) {
|
||||
setKeys(previous => previous?.map(key => key.id === data.id ? data : key) ?? []); setRevoking(null)
|
||||
setCreated(previous => previous?.accessKey.id === data.id ? null : previous)
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) setError(`${{ load: '访问密钥加载', create: '创建访问密钥', revoke: '撤销访问密钥' }[operation]}失败:${cause instanceof Error ? cause.message : '请稍后重试。'}`)
|
||||
} finally {
|
||||
if (active.current === controller) active.current = null
|
||||
if (!controller.signal.aborted) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function start(operation: 'load' | 'create' | 'revoke', input?: GatewayAccessKeyInput, id?: string) {
|
||||
if (active.current) return
|
||||
setBusy(true); setError('')
|
||||
if (operation === 'load') void load()
|
||||
else void run(operation, input, id)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
return () => { active.current?.abort(); active.current = null }
|
||||
}, [])
|
||||
|
||||
return <section className="space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5" aria-label="访问密钥">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="font-semibold">访问密钥</h2><div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" disabled={busy} onClick={() => start('load')}>刷新访问密钥</Button>
|
||||
<Button variant="secondary" disabled={busy || keys === null || !models.length || Boolean(created)} onClick={() => { setSelected([]); setError(''); setCreating(true) }}>创建访问密钥</Button>
|
||||
</div></div>
|
||||
<p className="text-sm leading-6 text-[var(--text-muted)]">每个密钥只允许调用所选模型。原文仅在创建成功时显示一次,关闭后无法再次查看。撤销后使用该密钥的客户端将无法继续调用。</p>
|
||||
{busy && <p role="status">正在处理访问密钥…</p>}
|
||||
{error && !creating && !revoking && <p role="alert" className="break-words">{error}</p>}
|
||||
{keys?.length === 0 && <p className="text-sm text-[var(--text-muted)]">暂无访问密钥。配置模型后创建。</p>}
|
||||
{keys?.map(key => <article key={key.id} className="space-y-2 border-t border-white/10 pt-4 text-sm">
|
||||
<h3 className="break-all font-semibold">{key.name}</h3>
|
||||
<p>{key.revokedAt ? '已撤销' : '有效'} · 创建于 <time dateTime={key.createdAt}>{key.createdAt}</time></p>
|
||||
<p className="break-all">授权模型:{key.modelIds.map(id => models.find(model => model.id === id)?.alias ?? '模型已不可用').join('、')}</p>
|
||||
{key.revokedAt ? <p>撤销于 <time dateTime={key.revokedAt}>{key.revokedAt}</time></p> : <Button variant="secondary" disabled={busy} onClick={() => { setError(''); setRevoking(key) }}>撤销密钥 {key.name}</Button>}
|
||||
</article>)}
|
||||
{created && <Dialog open title="访问密钥已创建" description="请立即将密钥填入客户端 API Key。关闭后无法再次查看;遗失时请撤销并重新创建。" onOpenChange={open => { if (!open) setCreated(null) }}>
|
||||
<label className="block text-sm">新访问密钥<input readOnly autoComplete="off" value={created.key} onFocus={event => event.target.select()} className="mt-2 w-full min-w-0 rounded-lg bg-[var(--control)] p-2 font-mono text-sm" /></label>
|
||||
<Button variant="secondary" onClick={() => setCreated(null)}>已保存,关闭密钥</Button>
|
||||
</Dialog>}
|
||||
{creating && <Dialog open title="创建访问密钥" description="为客户端命名,并选择允许调用的模型。授权绑定模型记录,修改别名不会丢失授权。" onOpenChange={open => { if (!open && !busy) { setCreating(false); setError('') } }}>
|
||||
<form className="space-y-4" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
start('create', { name: String(form.get('name')).trim(), modelIds: selected })
|
||||
}}>
|
||||
<fieldset disabled={busy} className="space-y-4">
|
||||
<label className="block text-sm">密钥名称<input name="name" required maxLength={200} className="mt-1 w-full rounded-lg border border-white/10 bg-[var(--control)] p-2" /></label>
|
||||
<fieldset className="space-y-2"><legend className="mb-2 text-sm">授权模型(至少选择一个)</legend>{models.map(model => <label key={model.id} className="flex items-start gap-2 break-all text-sm"><input type="checkbox" checked={selected.includes(model.id)} onChange={event => setSelected(previous => event.target.checked ? [...previous, model.id] : previous.filter(id => id !== model.id))} />{model.alias}{model.displayName ? ` · ${model.displayName}` : ''}{!model.enabled ? '(已停用)' : ''}</label>)}</fieldset>
|
||||
</fieldset>
|
||||
{error && <p role="alert" className="break-words">{error}</p>}
|
||||
<div className="flex flex-wrap justify-end gap-2"><Button variant="ghost" disabled={busy} onClick={() => { setCreating(false); setError('') }}>取消</Button><Button type="submit" disabled={busy || !selected.length}>{busy ? '正在创建…' : '确认创建'}</Button></div>
|
||||
</form>
|
||||
</Dialog>}
|
||||
{revoking && <Dialog open title="撤销访问密钥" description={`撤销「${revoking.name}」后,使用它的客户端将无法调用任何模型。此操作无法恢复。`} onOpenChange={open => { if (!open && !busy) { setRevoking(null); setError('') } }}>
|
||||
{error && <p role="alert" className="break-words">{error}</p>}
|
||||
<div className="flex flex-wrap justify-end gap-2"><Button variant="ghost" disabled={busy} onClick={() => { setRevoking(null); setError('') }}>取消</Button><Button disabled={busy} onClick={() => start('revoke', undefined, revoking.id)}>{busy ? '正在撤销…' : '确认撤销'}</Button></div>
|
||||
</Dialog>}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GatewayAccessKey, GatewaySnapshot, GatewayUpstream } from '../../../shared/gateway'
|
||||
import { GatewayPage } from './gateway-page'
|
||||
|
||||
const upstream: GatewayUpstream = { id: 'up-1', name: 'Local provider', baseUrl: 'https://provider.example/v1', protocol: 'openai-chat-completions', authentication: 'api-key', enabled: true, credentialConfigured: true }
|
||||
const model = { id: 'model-stable-id', alias: 'local-chat', remoteModel: 'remote-chat', upstreamId: upstream.id, enabled: true }
|
||||
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
const failure = () => response({ error: { code: 'CREDENTIAL_UNAVAILABLE', message: 'secret-should-not-echo' }, requestId: 'secret-should-not-echo' }, 503)
|
||||
|
||||
describe('real gateway UI', () => {
|
||||
let snapshot: GatewaySnapshot
|
||||
let keys: GatewayAccessKey[]
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
beforeEach(() => {
|
||||
snapshot = { upstreams: [], models: [], audits: [], gatewayBaseUrl: null }
|
||||
keys = []
|
||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url === '/api/v1/gateway/config') return response(snapshot)
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null
|
||||
if (url === '/api/v1/gateway/access-keys') {
|
||||
if (init?.method === 'POST') {
|
||||
const accessKey = { ...body, id: 'key-1', createdAt: '2026-10-01T00:00:00.000Z', revokedAt: null }
|
||||
keys.push(accessKey)
|
||||
return response({ accessKey, key: 'ss_only-once-secret' })
|
||||
}
|
||||
return response({ accessKeys: keys })
|
||||
}
|
||||
if (url === '/api/v1/gateway/access-keys/key-1/revoke') {
|
||||
keys = keys.map(key => ({ ...key, revokedAt: '2026-10-02T00:00:00.000Z' }))
|
||||
return response(keys[0])
|
||||
}
|
||||
if (url === '/api/v1/gateway/upstreams/up-1/discover') return response({ models: ['discovered-chat'] })
|
||||
if (url === '/api/v1/gateway/upstreams' || url === '/api/v1/gateway/upstreams/up-1') {
|
||||
const { apiKey, credentialAction, ...fields } = body
|
||||
const saved = { ...upstream, ...fields, credentialConfigured: credentialAction === 'clear' ? false : Boolean(apiKey) || (snapshot.upstreams[0]?.credentialConfigured ?? false) }
|
||||
snapshot = { ...snapshot, upstreams: [saved] }
|
||||
return response(saved)
|
||||
}
|
||||
if (url === '/api/v1/gateway/models' || url === `/api/v1/gateway/models/${model.id}`) {
|
||||
const saved = { ...model, ...body }
|
||||
snapshot = { ...snapshot, models: [saved] }
|
||||
return response(saved)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${init?.method} ${url}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||
|
||||
it('loads empty real data and asks for a configured client URL without inventing an origin', async () => {
|
||||
render(<GatewayPage />)
|
||||
await screen.findByText(/尚无上游服务/)
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
expect(screen.getByText(/尚无模型映射/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/SHARESERVER_GATEWAY_BASE_URL/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/127\.0\.0\.1:8787|localhost:5174/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/环境变量/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '发送真实请求' })).toBeDisabled()
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual(['/api/v1/gateway/config', '/api/v1/gateway/access-keys'])
|
||||
})
|
||||
|
||||
it('shows the configured baseURL and client protocol, alias and key instructions', async () => {
|
||||
snapshot = { ...snapshot, upstreams: [upstream], models: [{ ...model, displayName: '聊天模型' }], gatewayBaseUrl: 'https://gateway.example/custom/v1' }
|
||||
render(<GatewayPage />)
|
||||
const settings = await screen.findByRole('region', { name: '客户端设置' })
|
||||
expect(within(settings).getByText(snapshot.gatewayBaseUrl!)).toBeInTheDocument()
|
||||
expect(settings).toHaveTextContent('openai-chat-completions')
|
||||
expect(settings).toHaveTextContent(`模型 ID:${model.alias}`)
|
||||
expect(settings).toHaveTextContent('API Key 使用新建的访问密钥')
|
||||
})
|
||||
|
||||
it('retries load failures with useful Chinese errors without reflecting arbitrary server content', async () => {
|
||||
fetchMock.mockResolvedValueOnce(failure())
|
||||
render(<GatewayPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 503 · 服务器无法加密或读取凭据')
|
||||
expect(document.body).not.toHaveTextContent('secret-should-not-echo')
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试加载' }))
|
||||
await screen.findByText(/尚无上游服务/)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the last snapshot after refresh failure', async () => {
|
||||
snapshot.upstreams = [upstream]
|
||||
render(<GatewayPage />)
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
fetchMock.mockResolvedValueOnce(failure())
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新配置与审计' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('当前显示上次读取的数据')
|
||||
expect(screen.getByRole('heading', { name: upstream.name })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试加载' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('creates with a secret, keeps blank edits, and toggles without secrets', async () => {
|
||||
const storage = vi.spyOn(Storage.prototype, 'setItem')
|
||||
render(<GatewayPage />)
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加上游' }))
|
||||
fireEvent.change(screen.getByLabelText('上游名称'), { target: { value: upstream.name } })
|
||||
fireEvent.change(screen.getByLabelText('上游 Base URL'), { target: { value: upstream.baseUrl } })
|
||||
expect(screen.getByLabelText('上游 API Key')).toHaveAttribute('type', 'password')
|
||||
fireEvent.change(screen.getByLabelText('上游 API Key'), { target: { value: 'private-upstream-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
|
||||
await screen.findByRole('heading', { name: upstream.name })
|
||||
const input = { name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: 'api-key', credentialAction: 'replace', apiKey: 'private-upstream-key', enabled: true }
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/upstreams', expect.objectContaining({ method: 'POST', body: JSON.stringify(input) }))
|
||||
expect(screen.queryByDisplayValue('private-upstream-key')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` }))
|
||||
expect(screen.getByLabelText('上游 API Key')).toHaveValue('')
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
const keep = { name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: 'api-key', credentialAction: 'keep', enabled: true }
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/upstreams/up-1', expect.objectContaining({ body: JSON.stringify(keep) }))
|
||||
fireEvent.click(screen.getByRole('button', { name: `停用上游 ${upstream.name}` }))
|
||||
await screen.findByRole('button', { name: `启用上游 ${upstream.name}` })
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/upstreams/up-1', expect.objectContaining({ body: JSON.stringify({ ...keep, enabled: false }) }))
|
||||
expect(storage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears submitted secrets on errors and permits replacement retry', async () => {
|
||||
snapshot.upstreams = [upstream]
|
||||
render(<GatewayPage />)
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` }))
|
||||
fireEvent.change(screen.getByLabelText('上游 API Key'), { target: { value: 'secret-should-not-echo' } })
|
||||
fetchMock.mockResolvedValueOnce(failure())
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('保存配置失败')
|
||||
expect(screen.getByLabelText('上游 API Key')).toHaveValue('')
|
||||
expect(screen.getByLabelText('上游名称')).toHaveValue(upstream.name)
|
||||
expect(document.body).not.toHaveTextContent('secret-should-not-echo')
|
||||
fireEvent.change(screen.getByLabelText('上游 API Key'), { target: { value: 'replacement-secret' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` }))
|
||||
expect(screen.getByLabelText('上游 API Key')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('allows unauthenticated upstream discovery and testing without configured credentials', async () => {
|
||||
snapshot.upstreams = [{ ...upstream, authentication: 'none', credentialConfigured: false }]
|
||||
snapshot.models = [model]
|
||||
render(<GatewayPage />)
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
expect(screen.getByRole('button', { name: '发送真实请求' })).toBeEnabled()
|
||||
expect(screen.getByLabelText('测试模型')).toHaveValue(model.alias)
|
||||
fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` }))
|
||||
expect(screen.getByLabelText('认证方式')).toHaveValue('none')
|
||||
expect(screen.queryByRole('option', { name: '设置或替换凭据' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '发现模型 Local provider' }))
|
||||
expect(await screen.findByRole('button', { name: '添加映射 discovered-chat' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('discovers then selects a model and preserves alias and display name through edits and toggle', async () => {
|
||||
snapshot.upstreams = [upstream]
|
||||
render(<GatewayPage />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '发现模型 Local provider' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '添加映射 discovered-chat' }))
|
||||
expect(screen.getByLabelText('远端模型名称')).toHaveValue('discovered-chat')
|
||||
fireEvent.change(screen.getByLabelText('模型别名'), { target: { value: 'discovered-alias' } })
|
||||
fireEvent.change(screen.getByLabelText('显示名称(可选)'), { target: { value: '发现的模型' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
|
||||
await screen.findByRole('heading', { name: 'discovered-alias → discovered-chat' })
|
||||
const input = { alias: 'discovered-alias', upstreamId: upstream.id, remoteModel: 'discovered-chat', displayName: '发现的模型', enabled: true }
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/models', expect.objectContaining({ method: 'POST', body: JSON.stringify(input) }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑映射 discovered-alias' }))
|
||||
expect(screen.getByLabelText('显示名称(可选)')).toHaveValue('发现的模型')
|
||||
fireEvent.change(screen.getByLabelText('模型别名'), { target: { value: 'edited-alias' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
|
||||
await screen.findByRole('heading', { name: 'edited-alias → discovered-chat' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '停用映射 edited-alias' }))
|
||||
await screen.findByRole('button', { name: '启用映射 edited-alias' })
|
||||
expect(fetchMock).toHaveBeenCalledWith(`/api/v1/gateway/models/${model.id}`, expect.objectContaining({ method: 'PUT', body: JSON.stringify({ ...input, alias: 'edited-alias', enabled: false }) }))
|
||||
})
|
||||
|
||||
it('allows manual mappings when discovery is unavailable', async () => {
|
||||
snapshot.upstreams = [{ ...upstream, credentialConfigured: false }]
|
||||
render(<GatewayPage />)
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
expect(screen.getByRole('button', { name: '发现模型 Local provider' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '手动添加映射' }))
|
||||
const alias = screen.getByLabelText('模型别名')
|
||||
for (const value of ['spaces not allowed', '_invalid', '中文别名']) {
|
||||
fireEvent.change(alias, { target: { value } })
|
||||
expect(alias).toBeInvalid()
|
||||
}
|
||||
fireEvent.change(alias, { target: { value: 'provider/model:1.0_test-chat' } })
|
||||
expect(alias).toBeValid()
|
||||
fireEvent.change(screen.getByLabelText('模型别名'), { target: { value: 'manual-alias' } })
|
||||
fireEvent.change(screen.getByLabelText('远端模型名称'), { target: { value: 'manual-remote' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存配置' }))
|
||||
await screen.findByRole('heading', { name: 'manual-alias → manual-remote' })
|
||||
expect(screen.getByRole('button', { name: '发送真实请求' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('creates scoped keys by stable model IDs, dismisses raw secrets permanently, and revokes with retry', async () => {
|
||||
const storage = vi.spyOn(Storage.prototype, 'setItem')
|
||||
snapshot = { ...snapshot, upstreams: [upstream], models: [model] }
|
||||
const view = render(<GatewayPage />)
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建访问密钥' }))
|
||||
expect(screen.getByRole('button', { name: '确认创建' })).toBeDisabled()
|
||||
fireEvent.change(screen.getByLabelText('密钥名称'), { target: { value: 'Desktop' } })
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: model.alias }))
|
||||
fetchMock.mockResolvedValueOnce(failure())
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认创建' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('创建访问密钥失败')
|
||||
expect(screen.getByLabelText('密钥名称')).toHaveValue('Desktop')
|
||||
expect(screen.getByRole('checkbox', { name: model.alias })).toBeChecked()
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认创建' }))
|
||||
expect(await screen.findByLabelText('新访问密钥')).toHaveValue('ss_only-once-secret')
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/access-keys', expect.objectContaining({ method: 'POST', body: JSON.stringify({ name: 'Desktop', modelIds: [model.id] }) }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '已保存,关闭密钥' }))
|
||||
expect(screen.queryByDisplayValue('ss_only-once-secret')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新访问密钥' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '刷新访问密钥' })).toBeEnabled())
|
||||
expect(screen.queryByLabelText('新访问密钥')).not.toBeInTheDocument()
|
||||
view.unmount()
|
||||
snapshot.models = [{ ...model, alias: 'renamed-alias' }]
|
||||
render(<GatewayPage />)
|
||||
await screen.findByRole('heading', { name: 'Desktop' })
|
||||
expect(screen.getByText('授权模型:renamed-alias')).toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('ss_only-once-secret')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销密钥 Desktop' }))
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('此操作无法恢复')
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/revoke'))).toBe(false)
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销密钥 Desktop' }))
|
||||
fetchMock.mockResolvedValueOnce(failure())
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认撤销' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('撤销访问密钥失败')
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认撤销' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.queryByRole('button', { name: '撤销密钥 Desktop' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/已撤销/)).toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/access-keys/key-1/revoke', expect.objectContaining({ method: 'POST' }))
|
||||
expect(storage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps key list failures distinct from an empty list and supports retry', async () => {
|
||||
fetchMock.mockImplementationOnce(async () => response(snapshot)).mockResolvedValueOnce(failure())
|
||||
render(<GatewayPage />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('访问密钥加载失败')
|
||||
expect(screen.queryByText(/暂无访问密钥/)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新访问密钥' }))
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tests via POST /test nonstreaming and forgets prompt and response after unmount', async () => {
|
||||
snapshot = { ...snapshot, upstreams: [upstream], models: [model] }
|
||||
const view = render(<GatewayPage />)
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
fireEvent.change(screen.getByLabelText('测试模型'), { target: { value: model.alias } })
|
||||
fireEvent.change(screen.getByLabelText('测试提示词'), { target: { value: 'A private prompt' } })
|
||||
fetchMock.mockImplementationOnce(async () => {
|
||||
snapshot.audits = [{ id: 'audit-1', time: '2026-10-01T00:00:00Z', model: model.alias, upstreamId: upstream.id, accessKeyId: null, stream: false, status: 'success', httpStatus: 200, durationMs: 128, inputTokens: null, outputTokens: 0, errorCode: null }]
|
||||
return response({ choices: [{ message: { content: 'Actual response text' } }] })
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送真实请求' }))
|
||||
await screen.findByText('Actual response text')
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/test', expect.objectContaining({ method: 'POST', body: JSON.stringify({ model: model.alias, messages: [{ role: 'user', content: 'A private prompt' }], stream: false }) }))
|
||||
const table = await screen.findByRole('table')
|
||||
expect(within(table).getByText('未知')).toBeInTheDocument()
|
||||
expect(within(table).getByText('0')).toBeInTheDocument()
|
||||
expect(table).not.toHaveTextContent('A private prompt')
|
||||
view.unmount()
|
||||
render(<GatewayPage />)
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
expect(screen.getByLabelText('测试提示词')).toHaveValue('')
|
||||
expect(screen.queryByText('Actual response text')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels requests, ignores late responses, and aborts on unmount', async () => {
|
||||
snapshot = { ...snapshot, upstreams: [upstream], models: [model] }
|
||||
const view = render(<GatewayPage />)
|
||||
await screen.findByText(/暂无访问密钥/)
|
||||
fireEvent.change(screen.getByLabelText('测试模型'), { target: { value: model.alias } })
|
||||
fireEvent.change(screen.getByLabelText('测试提示词'), { target: { value: 'Cancel me' } })
|
||||
let signal: AbortSignal | undefined
|
||||
let resolve!: (value: Response) => void
|
||||
fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => { signal = init.signal!; return new Promise<Response>((done) => { resolve = done }) })
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送真实请求' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消测试' }))
|
||||
expect(signal?.aborted).toBe(true)
|
||||
await act(async () => resolve(response({ choices: [{ message: { content: 'Late response' } }] })))
|
||||
expect(screen.queryByText('Late response')).not.toBeInTheDocument()
|
||||
fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => { signal = init.signal!; return new Promise(() => {}) })
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送真实请求' }))
|
||||
view.unmount()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { GatewayModel, GatewayModelInput, GatewaySnapshot, GatewayUpstream, GatewayUpstreamInput } from '../../../shared/gateway'
|
||||
import { PageHeader } from './page-header'
|
||||
import { Button } from './ui/button'
|
||||
import { Dialog } from './ui/dialog'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
import { gatewayRequest as request } from '../lib/gateway-api'
|
||||
import { GatewayAccessKeys } from './gateway-access-keys'
|
||||
import { PageTabs } from './page-tabs'
|
||||
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
|
||||
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
|
||||
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm text-[var(--text-primary)] focus-visible:outline-2 focus-visible:outline-current'
|
||||
|
||||
type Editor = { kind: 'upstream'; value?: GatewayUpstream } | { kind: 'model'; value?: GatewayModel; upstreamId?: string; remoteModel?: string }
|
||||
|
||||
export function GatewayPage() {
|
||||
const [snapshot, setSnapshot] = useState<GatewaySnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [pending, setPending] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
const [authentication, setAuthentication] = useState<GatewayUpstreamInput['authentication']>('api-key')
|
||||
const [discovery, setDiscovery] = useState<{ upstreamId: string; models: string[] } | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testMessage, setTestMessage] = useState('')
|
||||
const [testError, setTestError] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [testAlias, setTestAlias] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const requests = useRef(new Set<AbortController>())
|
||||
const testRequest = useRef<AbortController | null>(null)
|
||||
const loadRequest = useRef<AbortController | null>(null)
|
||||
const actionRequest = useRef<AbortController | null>(null)
|
||||
|
||||
function loadConfig() {
|
||||
loadRequest.current?.abort()
|
||||
const controller = new AbortController()
|
||||
loadRequest.current = controller
|
||||
requests.current.add(controller)
|
||||
return request<GatewaySnapshot>('/config', controller.signal).then((data) => {
|
||||
if (!controller.signal.aborted) { setSnapshot(data); setLoadError('') }
|
||||
}).catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) setLoadError(`网关配置加载失败:${error instanceof Error ? error.message : '网络请求失败'}`)
|
||||
}).finally(() => {
|
||||
requests.current.delete(controller)
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
setLoading(true)
|
||||
return loadConfig()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig()
|
||||
const active = requests.current
|
||||
return () => { active.forEach((controller) => controller.abort()) }
|
||||
}, [])
|
||||
|
||||
async function act(label: string, path: string, method: string, body?: unknown, discoverId?: string) {
|
||||
if (actionRequest.current) return
|
||||
const controller = new AbortController()
|
||||
actionRequest.current = controller
|
||||
requests.current.add(controller)
|
||||
setPending(label); setActionError(''); setMessage('')
|
||||
try {
|
||||
const data = await request<GatewaySnapshot | { models: string[] }>(path, controller.signal, method, body)
|
||||
if (controller.signal.aborted) return
|
||||
if (discoverId) setDiscovery({ upstreamId: discoverId, models: (data as { models: string[] }).models })
|
||||
else {
|
||||
setEditor(null)
|
||||
await refresh()
|
||||
}
|
||||
setMessage(`${label}完成。${discoverId ? '请选择模型添加映射,也可手动填写。' : '配置已保存到服务器。'}`)
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) setActionError(`${label}失败:${error instanceof Error ? error.message : '网络请求失败'}`)
|
||||
} finally {
|
||||
requests.current.delete(controller)
|
||||
actionRequest.current = null
|
||||
if (!controller.signal.aborted) setPending('')
|
||||
}
|
||||
}
|
||||
|
||||
async function test(alias: string, prompt: string) {
|
||||
if (testRequest.current) return
|
||||
const controller = new AbortController()
|
||||
testRequest.current = controller
|
||||
requests.current.add(controller)
|
||||
setTesting(true); setResult(''); setTestError(''); setTestMessage('正在请求上游…')
|
||||
try {
|
||||
const data = await request<{ choices?: { message?: { content?: string | null } }[] }>('/test', controller.signal, 'POST', { model: alias, messages: [{ role: 'user', content: prompt }], stream: false })
|
||||
if (!controller.signal.aborted) {
|
||||
setResult(data.choices?.[0]?.message?.content ?? '上游未返回文本内容。')
|
||||
setTestMessage('请求完成。')
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) setTestError(`测试失败:${error instanceof Error ? error.message : '网络请求失败'}`)
|
||||
if (!controller.signal.aborted) setTestMessage('')
|
||||
} finally {
|
||||
requests.current.delete(controller)
|
||||
if (testRequest.current === controller) testRequest.current = null
|
||||
if (!controller.signal.aborted) { setTesting(false); void refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
const busy = loading || Boolean(pending)
|
||||
const available = snapshot?.models.filter((model) => model.enabled && snapshot.upstreams.some((upstream) => upstream.id === model.upstreamId && upstream.enabled)) ?? []
|
||||
return <div className="min-w-0 space-y-5 text-[var(--text-primary)]">
|
||||
<PageHeader eyebrow="当前服务器 · 本地技术预览" title="模型网关" description="真实保存上游与模型别名,向上游转发请求并查看调用元数据。ShareServer 不运行模型。" action={<Button disabled={busy} onClick={() => { setActionError(''); setAuthentication('api-key'); setEditor({ kind: 'upstream' }) }}>添加上游</Button>} />
|
||||
<aside className={panel} aria-label="网关使用说明">
|
||||
<p className={muted}>管理操作仅限服务器本机。客户端通过访问密钥调用获授权的模型;尚无组织认证或配额控制。</p>
|
||||
<p className={muted}>先添加上游,再发现或手动添加模型,完成测试后创建客户端访问密钥。上游 API Key 加密保存在服务器,编辑时不会返回原文。</p>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => void refresh()}>{loading ? '正在刷新…' : '刷新配置与审计'}</Button>
|
||||
</aside>
|
||||
{loading && <p role="status">正在加载网关配置…</p>}
|
||||
{loadError && <div className={panel}><p role="alert" className="break-words">{loadError}{snapshot ? '。当前显示上次读取的数据。' : ''}</p><Button variant="secondary" disabled={busy} onClick={() => void refresh()}>重试加载</Button></div>}
|
||||
{actionError && !editor && <p role="alert" className="break-words">{actionError}</p>}
|
||||
{pending && <p role="status">{pending}中…</p>}
|
||||
{message && <p role="status">{message}</p>}
|
||||
{snapshot && <>
|
||||
<PageTabs title="模型网关" active={activeTab} onChange={setActiveTab} tabs={[{ key: 'overview', label: '概览' }, { key: 'upstreams', label: '上游服务' }, { key: 'models', label: '模型目录' }, { key: 'keys', label: '访问密钥' }, { key: 'test', label: '请求测试' }, { key: 'audits', label: '调用审计' }, { key: 'client', label: '客户端接入' }]} />
|
||||
<section className={`${panel} ${activeTab === 'overview' ? '' : 'hidden'}`} aria-label="网关概览"><h2 className="font-semibold">网关概览</h2><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div className="rounded-xl border border-white/10 bg-white/[0.025] p-4"><p className={muted}>上游服务</p><p className="mt-1 text-2xl font-semibold">{snapshot.upstreams.length}</p></div><div className="rounded-xl border border-white/10 bg-white/[0.025] p-4"><p className={muted}>模型映射</p><p className="mt-1 text-2xl font-semibold">{snapshot.models.length}</p></div><div className="rounded-xl border border-white/10 bg-white/[0.025] p-4"><p className={muted}>调用记录</p><p className="mt-1 text-2xl font-semibold">{snapshot.audits.length}</p></div><div className="rounded-xl border border-white/10 bg-white/[0.025] p-4"><p className={muted}>客户端地址</p><p className="mt-1 break-all text-sm">{snapshot.gatewayBaseUrl ?? '未配置'}</p></div></div><p className={muted}>客户端通过访问密钥调用已授权模型;上游凭据只保存在服务器,网关不执行模型返回的工具调用。</p></section>
|
||||
<section className={`${panel} ${activeTab === 'upstreams' ? '' : 'hidden'}`} aria-label="上游服务"><h2 className="font-semibold">上游服务</h2>
|
||||
{!snapshot.upstreams.length && <p className={muted}>尚无上游服务。添加上游后,再配置模型映射。</p>}
|
||||
{snapshot.upstreams.map((upstream) => <article key={upstream.id} className="space-y-3 border-t border-white/10 pt-4">
|
||||
<div className="flex flex-wrap items-center gap-3"><h3 className="break-all font-semibold">{upstream.name}</h3><StatusBadge status={upstream.enabled ? '已启用' : '已停用'} tone={upstream.enabled ? 'online' : 'offline'} /></div>
|
||||
<p className={`${muted} break-all`}>{upstream.baseUrl}</p><p className={`${muted} break-all`}>OpenAI Chat Completions · {upstream.authentication === 'none' ? '无需认证' : upstream.credentialConfigured ? '凭据已配置' : '凭据未配置'}(不代表连接已验证)</p>
|
||||
<div className="flex flex-wrap gap-2"><Button variant="secondary" disabled={busy} onClick={() => { setActionError(''); setAuthentication(upstream.authentication); setEditor({ kind: 'upstream', value: upstream }) }}>编辑上游 {upstream.name}</Button><Button variant="secondary" disabled={busy} onClick={() => void act('更新上游', `/upstreams/${encodeURIComponent(upstream.id)}`, 'PUT', { name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: upstream.authentication, credentialAction: 'keep', enabled: !upstream.enabled } satisfies GatewayUpstreamInput)}>{upstream.enabled ? '停用' : '启用'}上游 {upstream.name}</Button><Button variant="secondary" disabled={busy || !upstream.enabled || (upstream.authentication === 'api-key' && !upstream.credentialConfigured)} onClick={() => { setDiscovery(null); void act('发现模型', `/upstreams/${encodeURIComponent(upstream.id)}/discover`, 'POST', undefined, upstream.id) }}>发现模型 {upstream.name}</Button></div>
|
||||
</article>)}
|
||||
{discovery && <div className="min-w-0 space-y-3"><h3 className="break-all font-semibold">发现结果 · {snapshot.upstreams.find((item) => item.id === discovery.upstreamId)?.name}</h3>{!discovery.models.length && <p className={muted}>上游未返回模型,可手动添加映射。</p>}<div className="flex flex-wrap gap-2">{discovery.models.map((remoteModel) => <Button key={remoteModel} className="h-auto min-w-0 max-w-full whitespace-normal break-all py-2 text-left" variant="secondary" disabled={busy} onClick={() => { setActionError(''); setEditor({ kind: 'model', upstreamId: discovery.upstreamId, remoteModel }) }}>添加映射 {remoteModel}</Button>)}</div></div>}
|
||||
</section>
|
||||
<section className={`${panel} ${activeTab === 'models' ? '' : 'hidden'}`} aria-label="模型映射"><div className="flex flex-wrap items-center justify-between gap-3"><h2 className="font-semibold">模型映射</h2><Button variant="secondary" disabled={busy || !snapshot.upstreams.length} onClick={() => { setActionError(''); setEditor({ kind: 'model' }) }}>手动添加映射</Button></div>
|
||||
{!snapshot.models.length && <p className={muted}>尚无模型映射。先添加上游,再发现模型或手动填写远端模型名称与别名。</p>}
|
||||
{snapshot.models.map((model) => <article key={model.id} className="space-y-3 border-t border-white/10 pt-4"><h3 className="break-all font-semibold">{model.alias} → {model.remoteModel}</h3>{model.displayName && <p className="break-all">{model.displayName}</p>}<p className={muted}>上游:{snapshot.upstreams.find((item) => item.id === model.upstreamId)?.name ?? model.upstreamId} · {model.enabled ? '映射已启用' : '映射已停用'}</p><div className="flex flex-wrap gap-2"><Button variant="secondary" disabled={busy} onClick={() => { setActionError(''); setEditor({ kind: 'model', value: model }) }}>编辑映射 {model.alias}</Button><Button variant="secondary" disabled={busy} onClick={() => void act('更新映射', `/models/${encodeURIComponent(model.id)}`, 'PUT', { alias: model.alias, upstreamId: model.upstreamId, remoteModel: model.remoteModel, ...(model.displayName ? { displayName: model.displayName } : {}), enabled: !model.enabled } satisfies GatewayModelInput)}>{model.enabled ? '停用' : '启用'}映射 {model.alias}</Button></div></article>)}
|
||||
</section>
|
||||
<div className={activeTab === 'keys' ? '' : 'hidden'}><GatewayAccessKeys models={snapshot.models} /></div>
|
||||
<section className={`${panel} ${activeTab === 'client' ? '' : 'hidden'}`} aria-label="客户端设置"><h2 className="font-semibold">客户端设置</h2>
|
||||
<p className={muted}>在 GoodBuddy 或兼容客户端中添加服务:协议选择 OpenAI Chat Completions(openai-chat-completions)。</p>
|
||||
{snapshot.gatewayBaseUrl ? <p className="break-all text-sm">baseURL:<code>{snapshot.gatewayBaseUrl}</code></p> : <p className={muted}>尚未配置客户端 baseURL。请管理员设置 SHARESERVER_GATEWAY_BASE_URL 为客户端可达的完整网关 API 基础地址并重启服务。</p>}
|
||||
<p className={muted}>模型 ID 填写下方模型别名,不填远端模型名称或显示名称。API Key 使用新建的访问密钥,并确保它已授权该模型。</p>
|
||||
<ul className="space-y-2 text-sm">{snapshot.models.map(model => <li key={model.id} className="break-all">模型 ID:<code>{model.alias}</code>{model.displayName ? ` · ${model.displayName}` : ''}</li>)}</ul>
|
||||
</section>
|
||||
<section className={`${panel} ${activeTab === 'test' ? '' : 'hidden'}`} aria-label="真实请求测试"><h2 className="font-semibold">真实请求测试</h2><p id="gateway-test-help" className={muted}>选择模型并发送一条真实请求。提示词和结果仅保留在当前页面,失败后可以直接修改并重试。</p>
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); const form = new FormData(event.currentTarget); void test(String(form.get('alias')), String(form.get('prompt'))) }}>
|
||||
<fieldset disabled={testing || busy || !available.length} className="space-y-4"><label className="block text-sm">测试模型<select name="alias" required value={testAlias || (available.length === 1 ? available[0].alias : '')} onChange={event => setTestAlias(event.target.value)} className={control}><option value="">请选择模型别名</option>{available.map((model) => <option key={model.id} value={model.alias}>{model.alias}</option>)}</select></label><label className="block text-sm">测试提示词<textarea name="prompt" required rows={3} aria-describedby="gateway-test-help" className={control} /></label><Button type="submit" variant="secondary">{testing ? '请求中…' : '发送真实请求'}</Button></fieldset>
|
||||
{!available.length && <p className={muted}>请先启用模型与上游。</p>}
|
||||
{testing && <Button variant="secondary" onClick={() => { testRequest.current?.abort(); testRequest.current = null; setTesting(false); setTestMessage('已取消测试;服务器审计可能稍后更新,请刷新查看。') }}>取消测试</Button>}
|
||||
</form>
|
||||
{testMessage && <p role="status">{testMessage}</p>}{testError && <p role="alert" className="break-words">{testError}</p>}{result && <div><h3 className="font-semibold">测试结果</h3><pre className="mt-2 whitespace-pre-wrap break-words text-sm">{result}</pre><Button variant="ghost" onClick={() => setResult('')}>清除结果</Button></div>}
|
||||
</section>
|
||||
<section className={`${panel} ${activeTab === 'audits' ? '' : 'hidden'}`} aria-label="调用审计"><h2 className="font-semibold">调用审计</h2><p className={muted}>服务器返回的调用元数据;不含提示词或响应正文。用量未返回时显示「未知」,不按零计算。</p>
|
||||
{!snapshot.audits.length ? <p className={muted}>暂无调用审计。完成真实请求后刷新查看。</p> : <div className="max-w-full overflow-x-auto" tabIndex={0} role="region" aria-label="调用审计表格"><table className="w-full min-w-[900px] text-left text-sm"><caption className="sr-only">真实网关调用元数据</caption><thead><tr>{['时间 / 编号', '模型 / 上游', '方式', '结果 / HTTP', '耗时 ms', '输入 Token', '输出 Token', '错误码', '访问密钥 ID'].map((heading, index) => <th key={heading} scope="col" className={`p-2 ${index >= 4 && index <= 6 ? 'text-right' : ''}`}>{heading}</th>)}</tr></thead><tbody>{snapshot.audits.map((audit) => <tr key={audit.id} className="border-t border-white/10"><td className="p-2"><time dateTime={audit.time}>{audit.time}</time><div>{audit.id}</div></td><td className="break-all p-2">{audit.model}<div>{audit.upstreamId ?? '未知'}</div></td><td className="p-2">{audit.stream ? '流式' : '非流式'}</td><td className="p-2">{{ success: '成功', failed: '失败', cancelled: '已取消' }[audit.status]} / {audit.httpStatus}</td><td className="p-2 text-right">{audit.durationMs}</td><td className="p-2 text-right">{audit.inputTokens ?? '未知'}</td><td className="p-2 text-right">{audit.outputTokens ?? '未知'}</td><td className="p-2">{audit.errorCode ?? '无'}</td><td className="break-all p-2">{audit.accessKeyId ?? '未关联密钥'}</td></tr>)}</tbody></table></div>}
|
||||
</section>
|
||||
</>}
|
||||
{editor && <Dialog open title={`${editor.value ? '编辑' : '添加'}${editor.kind === 'upstream' ? '上游服务' : '模型映射'}`} description="保存到当前服务器。失败后保留普通字段;密钥提交后清空,重试时需重新输入。" onOpenChange={(open) => { if (!open && !pending) { setEditor(null); setActionError('') } }}>
|
||||
<form className="space-y-4" aria-describedby={actionError ? 'gateway-editor-error' : undefined} onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
const text = (key: string) => String(form.get(key) ?? '').trim()
|
||||
const apiKey = text('apiKey')
|
||||
const body = editor.kind === 'upstream' ? { name: text('name'), baseUrl: text('baseUrl'), protocol: 'openai-chat-completions', authentication, credentialAction: apiKey ? 'replace' : 'keep', ...(apiKey ? { apiKey } : {}), enabled: form.has('enabled') } satisfies GatewayUpstreamInput : { alias: text('alias'), upstreamId: text('upstreamId'), remoteModel: text('remoteModel'), ...(text('displayName') ? { displayName: text('displayName') } : {}), enabled: form.has('enabled') } satisfies GatewayModelInput
|
||||
const secret = event.currentTarget.elements.namedItem('apiKey')
|
||||
if (secret instanceof HTMLInputElement) secret.value = ''
|
||||
void act('保存配置', `/${editor.kind === 'upstream' ? 'upstreams' : 'models'}${editor.value ? `/${encodeURIComponent(editor.value.id)}` : ''}`, editor.value ? 'PUT' : 'POST', body)
|
||||
}}>
|
||||
<fieldset disabled={Boolean(pending)} className="space-y-4">
|
||||
{editor.kind === 'upstream' ? <>
|
||||
<label className="block text-sm">上游名称<input name="name" required maxLength={200} defaultValue={editor.value?.name} className={control} /></label>
|
||||
<label className="block text-sm">上游 Base URL<input name="baseUrl" type="url" required maxLength={2048} defaultValue={editor.value?.baseUrl} placeholder="https://api.deepseek.com/v1" className={control} /></label>
|
||||
<label className="block text-sm">上游协议<select className={control} defaultValue="openai-chat-completions"><option value="openai-chat-completions">OpenAI Chat Completions</option></select></label>
|
||||
<label className="block text-sm">认证方式<select value={authentication} className={control} onChange={event => setAuthentication(event.target.value as typeof authentication)}><option value="api-key">API Key</option><option value="none">无需认证</option></select></label>
|
||||
{authentication === 'api-key' && <label className="block text-sm">上游 API Key(留空则保留现有值)<input aria-label="上游 API Key" name="apiKey" type="password" autoComplete="new-password" maxLength={16384} className={control} /></label>}
|
||||
<p className={muted}>保存时不填写 API Key 会保留当前凭据;填写新值会覆盖当前凭据。凭据提交后不会再次显示。</p>
|
||||
</> : <>
|
||||
<label className="block text-sm">对外暴露名称(模型 ID)<input aria-label="模型别名" name="alias" required maxLength={editor.value?.alias.length && editor.value.alias.length > 128 ? 200 : 128} pattern="[A-Za-z0-9][A-Za-z0-9._:\/\-]*" defaultValue={editor.value?.alias} className={control} aria-describedby="gateway-alias-help" /></label>
|
||||
<p id="gateway-alias-help" className={muted}>客户端使用这个名称调用模型,以字母或数字开头;可包含字母、数字、点、下划线、冒号、斜杠和连字符。</p>
|
||||
<label className="block text-sm">显示名称(可选)<input name="displayName" maxLength={200} defaultValue={editor.value?.displayName} className={control} /></label>
|
||||
{(editor.value?.upstreamId || editor.upstreamId) ? <><input type="hidden" name="upstreamId" value={editor.value?.upstreamId ?? editor.upstreamId} /><p className={muted}>所属上游:{snapshot?.upstreams.find((upstream) => upstream.id === (editor.value?.upstreamId ?? editor.upstreamId))?.name ?? '未知上游'}</p></> : <label className="block text-sm">映射上游<select name="upstreamId" required defaultValue={snapshot?.upstreams[0]?.id} className={control}>{snapshot?.upstreams.map((upstream) => <option key={upstream.id} value={upstream.id}>{upstream.name}</option>)}</select></label>}
|
||||
<label className="block text-sm">远端模型名称<input name="remoteModel" required maxLength={200} defaultValue={editor.value?.remoteModel ?? editor.remoteModel} className={control} /></label>
|
||||
</>}
|
||||
<label className="flex items-center gap-2 text-sm"><input name="enabled" type="checkbox" defaultChecked={editor.value?.enabled ?? true} />保存后启用</label>
|
||||
</fieldset>
|
||||
{actionError && <p id="gateway-editor-error" role="alert" className="break-words">{actionError}</p>}
|
||||
<div className="flex flex-wrap justify-end gap-2"><Button variant="ghost" disabled={Boolean(pending)} onClick={() => { setEditor(null); setActionError('') }}>取消</Button><Button type="submit" disabled={Boolean(pending)}>{pending ? '正在保存…' : '保存配置'}</Button></div>
|
||||
</form>
|
||||
</Dialog>}
|
||||
</div>
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { prototypeSnapshot } from '../../../shared/prototype-data'
|
||||
import type { Device, Publication } from '../../../shared/sharing'
|
||||
import { SharingCenter } from './sharing-center'
|
||||
|
||||
@@ -22,7 +21,7 @@ function mockDirectory(deviceRows = devices, publicationRows = publications) {
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
}
|
||||
function mount() { return render(<BrowserRouter><SharingCenter snapshot={prototypeSnapshot} /></BrowserRouter>) }
|
||||
function mount() { return render(<BrowserRouter><SharingCenter /></BrowserRouter>) }
|
||||
|
||||
describe('local sharing directory', () => {
|
||||
beforeEach(() => {
|
||||
@@ -45,7 +44,8 @@ describe('local sharing directory', () => {
|
||||
for (const text of ['已撤销', '预算说明', '搜索:声明允许', '读取原文:未声明允许', '下载:声明允许']) expect(within(revoked).getByText(text)).toBeInTheDocument()
|
||||
expect(within(revoked).getByText(/来源声明:服务器/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/当前示例访问者/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /发布|登记|下载/ })).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: '撤销发布' })).toHaveLength(2)
|
||||
expect(screen.queryByRole('button', { name: /^(发布|登记|下载)/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('combines device, kind, search and status filters, restores URL state and clears no results', async () => {
|
||||
@@ -102,22 +102,16 @@ describe('local sharing directory', () => {
|
||||
expect(await screen.findByRole('heading', { name: '本机工作站' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps fixture tabs separate and aborts pending local loads when leaving', async () => {
|
||||
it('ignores obsolete fixture links and aborts pending loads when leaving', () => {
|
||||
const signals: AbortSignal[] = []
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((_url, { signal }) => { signals.push(signal); return new Promise(() => {}) }))
|
||||
window.history.replaceState({}, '', '/capabilities?view=local&item=k-1')
|
||||
mount()
|
||||
const app = mount()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '共享知识库' }))
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(4)
|
||||
expect(screen.queryByRole('link', { name: '设计规范归档' })).not.toBeInTheDocument()
|
||||
app.unmount()
|
||||
expect(signals).toHaveLength(2)
|
||||
expect(signals.every((signal) => signal.aborted)).toBe(true)
|
||||
expect(screen.getByRole('link', { name: '设计规范归档' })).toBeInTheDocument()
|
||||
expect(screen.queryByText(/此页读取当前服务/)).not.toBeInTheDocument()
|
||||
vi.stubGlobal('fetch', mockDirectory())
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '共享知识库' }), { key: 'Home' })
|
||||
expect(screen.getByRole('tab', { name: '本地服务目录' })).toHaveFocus()
|
||||
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'sharing-tab-local')
|
||||
await screen.findByRole('heading', { name: '本地分析能力' })
|
||||
expect(screen.queryByRole('link', { name: '设计规范归档' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import type { Device, Publication } from '../../../shared/sharing'
|
||||
import { sharingRequest } from '../lib/sharing-api'
|
||||
import { Button } from './ui/button'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
|
||||
@@ -14,6 +15,8 @@ export function LocalSharing() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [state, setState] = useState<LoadState>({ status: 'loading' })
|
||||
const [pending, setPending] = useState('')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const deviceId = params.get('device') ?? ''
|
||||
const kind = params.get('kind') ?? ''
|
||||
const status = params.get('status') ?? ''
|
||||
@@ -21,17 +24,9 @@ export function LocalSharing() {
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
async function load<T>(resource: string, label: string): Promise<T> {
|
||||
const response = await fetch(`/api/v1/sharing/${resource}`, { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => null)
|
||||
throw new Error(`${label}加载失败(HTTP ${response.status})${typeof error?.code === 'string' ? ` · ${error.code}` : ''}${typeof error?.requestId === 'string' ? ` · 请求 ID:${error.requestId}` : ''}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
Promise.all([
|
||||
load<Pick<Directory, 'devices'>>('devices', '设备'),
|
||||
load<Pick<Directory, 'publications'>>('publications', '发布记录')
|
||||
sharingRequest<Pick<Directory, 'devices'>>('/devices', controller.signal),
|
||||
sharingRequest<Pick<Directory, 'publications'>>('/publications', controller.signal)
|
||||
]).then(([devices, publications]) => {
|
||||
if (!controller.signal.aborted) setState({ status: 'loaded', data: { ...devices, ...publications } })
|
||||
}).catch((error: unknown) => {
|
||||
@@ -48,11 +43,26 @@ export function LocalSharing() {
|
||||
setParams(next, { replace: true })
|
||||
}
|
||||
|
||||
async function revoke(publication: Publication) {
|
||||
if (publication.status !== 'published' || pending || !window.confirm(`撤销“${publication.name}”?撤销后会保留记录,但不能继续作为已发布内容使用。`)) return
|
||||
setPending(publication.id)
|
||||
setActionError('')
|
||||
try {
|
||||
await sharingRequest(`/publications/${encodeURIComponent(publication.id)}/revoke`, undefined, 'POST')
|
||||
reload()
|
||||
} catch (error) {
|
||||
setActionError(`撤销失败:${error instanceof Error ? error.message : '网络请求失败'}`)
|
||||
} finally {
|
||||
setPending('')
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className={panel}>
|
||||
<p className={muted}>此页读取当前服务的真实设备和发布元数据,与其他页签的模拟用户、组织数据独立。请在 GoodBuddy 应用中心登记设备、发布或撤销共享内容,再刷新此目录。</p>
|
||||
<p className={muted}>此页读取当前服务的设备和发布元数据。请在 GoodBuddy 应用中心登记设备、发布或撤销共享内容,再刷新此目录。</p>
|
||||
<p className={muted}>登记不代表设备在线;已发布不代表可以调用。来源模式仅为声明,服务器未同步正文。知识权限分别记录声明,尚未执行授权校验;此页不搜索正文、读取原文、下载文件或调用能力。</p>
|
||||
<Button variant="secondary" disabled={state.status === 'loading'} onClick={reload}>刷新目录</Button>
|
||||
{actionError && <p role="alert" className="text-sm text-rose-300">{actionError}</p>}
|
||||
</div>
|
||||
{state.status === 'loading' && <p role="status" className={muted}>正在加载本地服务目录…</p>}
|
||||
{state.status === 'error' && <div className={panel}>
|
||||
@@ -90,8 +100,9 @@ export function LocalSharing() {
|
||||
<p className={muted}>发布标识:{item.id} · 设备标识:{item.deviceId}</p>
|
||||
<p className={muted}>来源声明:{item.sourceMode === 'server' ? '服务器' : '设备'}(仅描述来源,不代表内容已同步或可取用)</p>
|
||||
<p className={muted}>发布时间:<time dateTime={item.createdAt}>{item.createdAt}</time> · 更新时间:<time dateTime={item.updatedAt}>{item.updatedAt}</time></p>
|
||||
{item.kind === 'knowledge' && <div className="space-y-2"><h3 className="text-sm font-semibold">知识权限声明(各自独立,未执行授权校验)</h3><div className="flex flex-wrap gap-3 text-sm"><span>搜索:{item.permissions.search ? '声明允许' : '未声明允许'}</span><span>读取原文:{item.permissions.read ? '声明允许' : '未声明允许'}</span><span>下载:{item.permissions.download ? '声明允许' : '未声明允许'}</span></div></div>}
|
||||
</article>)}
|
||||
{item.kind === 'knowledge' && <div className="space-y-2"><h3 className="text-sm font-semibold">知识权限声明(各自独立,未执行授权校验)</h3><div className="flex flex-wrap gap-3 text-sm"><span>搜索:{item.permissions.search ? '声明允许' : '未声明允许'}</span><span>读取原文:{item.permissions.read ? '声明允许' : '未声明允许'}</span><span>下载:{item.permissions.download ? '声明允许' : '未声明允许'}</span></div></div>}
|
||||
{item.status === 'published' && <Button variant="danger" size="sm" disabled={Boolean(pending)} onClick={() => void revoke(item)}>{pending === item.id ? '撤销中…' : '撤销发布'}</Button>}
|
||||
</article>)}
|
||||
</>
|
||||
})()}
|
||||
</>
|
||||
|
||||
@@ -1,226 +1,78 @@
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
GitFork,
|
||||
RadioTower,
|
||||
ServerCog,
|
||||
ShieldCheck,
|
||||
Sparkles
|
||||
} from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { Metric, PrototypeSnapshot } from '../../../shared/prototype-data'
|
||||
import { ArrowUpRight, CircleAlert, Router, Server, Share2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { Device, Publication } from '../../../shared/sharing'
|
||||
import type { GatewaySnapshot } from '../../../shared/gateway'
|
||||
import { sharingRequest } from '../lib/sharing-api'
|
||||
import { gatewayRequest } from '../lib/gateway-api'
|
||||
import { PageHeader } from './page-header'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
import { PageTabs } from './page-tabs'
|
||||
|
||||
const metricStyles: Record<Metric['tone'], string> = {
|
||||
cyan: 'from-cyan-400/16 to-cyan-400/[0.025] border-cyan-300/15 text-cyan-300',
|
||||
violet: 'from-violet-400/16 to-violet-400/[0.025] border-violet-300/15 text-violet-300',
|
||||
amber: 'from-amber-400/16 to-amber-400/[0.025] border-amber-300/15 text-amber-200',
|
||||
rose: 'from-rose-400/16 to-rose-400/[0.025] border-rose-300/15 text-rose-300'
|
||||
const panel = 'rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
|
||||
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
|
||||
|
||||
type Topology = { devices: Device[]; publications: Publication[]; gateway: GatewaySnapshot }
|
||||
|
||||
function TopologyNode({ icon: Icon, title, detail, tone = 'cyan' }: { icon: LucideIcon; title: string; detail: string; tone?: 'cyan' | 'violet' | 'slate' }) {
|
||||
const colors = { cyan: 'border-cyan-300/25 bg-cyan-300/[0.08] text-cyan-100', violet: 'border-violet-300/25 bg-violet-300/[0.08] text-violet-100', slate: 'border-white/10 bg-white/[0.04] text-slate-200' }
|
||||
return <div className={`min-w-0 rounded-xl border p-4 ${colors[tone]}`}><Icon aria-hidden="true" className="mb-3 size-5" /><p className="font-medium">{title}</p><p className="mt-1 break-words text-xs leading-5 text-[var(--text-muted)]">{detail}</p></div>
|
||||
}
|
||||
|
||||
export function OverviewPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
const navigate = useNavigate()
|
||||
const maxActivity = Math.max(...snapshot.activity.map((item) => item.value))
|
||||
const recent = [
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: '策略决定',
|
||||
copy: '生产数据跨组织请求被强制策略拒绝',
|
||||
time: '2 分钟前',
|
||||
tone: 'warning' as const
|
||||
},
|
||||
{
|
||||
icon: CheckCircle2,
|
||||
title: '能力发布',
|
||||
copy: 'GPU-Worker-05 的图像生成能力已批准',
|
||||
time: '4 分钟前',
|
||||
tone: 'online' as const
|
||||
},
|
||||
{
|
||||
icon: GitFork,
|
||||
title: '联邦心跳',
|
||||
copy: '法务服务商网关完成双边状态同步',
|
||||
time: '8 分钟前',
|
||||
tone: 'neutral' as const
|
||||
}
|
||||
]
|
||||
export function OverviewPage() {
|
||||
const [topology, setTopology] = useState<Topology | null>(null)
|
||||
const [topologyError, setTopologyError] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('status')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
Promise.all([
|
||||
sharingRequest<{ devices: Device[] }>('/devices', controller.signal),
|
||||
sharingRequest<{ publications: Publication[] }>('/publications', controller.signal),
|
||||
gatewayRequest<GatewaySnapshot>('/config', controller.signal)
|
||||
]).then(([devices, publications, gateway]) => {
|
||||
if (!controller.signal.aborted) setTopology({ devices: devices.devices, publications: publications.publications, gateway })
|
||||
}).catch((error: unknown) => {
|
||||
if (!controller.signal.aborted) setTopologyError(error instanceof Error ? error.message : '拓扑数据加载失败')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={`实例 · ${snapshot.instance.region}`}
|
||||
eyebrow="ShareServer"
|
||||
title="运行概览"
|
||||
description={`用户在 GoodBuddy 中工作,${snapshot.instance.name} 为 ${snapshot.instance.organization} 管理服务与组织资源。`}
|
||||
action={
|
||||
<div className="flex items-center gap-2 rounded-xl border border-emerald-400/15 bg-emerald-400/[0.06] px-3 py-2 text-xs text-emerald-200">
|
||||
<span className="size-1.5 rounded-full bg-emerald-300 shadow-[0_0_10px_rgba(52,211,153,.9)]" />
|
||||
交互原型 · 示例数据
|
||||
</div>
|
||||
}
|
||||
description="管理当前服务的模型网关与本地共享目录。"
|
||||
/>
|
||||
|
||||
<section aria-label="GoodBuddy 联动服务" className="mb-6 grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{ path: '/model-gateway', title: '模型网关', flow: 'GoodBuddy → ShareServer → 上游模型', copy: '管理模型转发和授权,演示允许或拒绝调用及审计记录。' },
|
||||
{ path: '/documents', title: '文档与协作', flow: 'GoodBuddy → 服务端文档 → OnlyOffice', copy: '管理文档归属与编辑服务,演示个人文档转交组织及回收恢复。' },
|
||||
{ path: '/distribution', title: '配置下发', flow: '组织配置 → GoodBuddy → 生效回执', copy: '选择网关模型、发布配置,演示在线生效、离线等待和版本回滚。' }
|
||||
].map((service) => <button key={service.path} onClick={() => navigate(service.path)} className="rounded-2xl border border-white/10 bg-[var(--panel)] p-5 text-left outline-none transition hover:bg-[var(--control)] focus-visible:ring-2 focus-visible:ring-cyan-400/70">
|
||||
<span className="flex items-center justify-between gap-2 font-semibold text-[var(--text-strong)]">{service.title}<ArrowUpRight className="size-4" /></span>
|
||||
<span className="mt-3 block text-xs leading-5 text-[var(--text-primary)]">{service.flow}</span>
|
||||
<span className="mt-2 block text-sm leading-6 text-[var(--text-muted)]">{service.copy}</span>
|
||||
</button>)}
|
||||
<PageTabs title="运行概览" active={activeTab} onChange={setActiveTab} tabs={[{ key: 'status', label: '服务状态' }, { key: 'pending', label: '待处理事项' }, { key: 'activity', label: '最近活动' }]} />
|
||||
{activeTab !== 'status' && <section className={`${panel} mb-6`}><h2 className="font-semibold">{activeTab === 'pending' ? '待处理事项' : '最近活动'}</h2><p className={`mt-2 ${muted}`}>该 Tab 的组织事件接口尚未接入,当前不生成示例审批、任务或活动记录。</p></section>}
|
||||
{activeTab === 'status' && <>
|
||||
<section aria-label="服务拓扑" className={`${panel} mb-6`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-semibold text-[var(--text-strong)]">服务拓扑</h2><p className={`mt-1 ${muted}`}>根据当前登记设备、共享目录和模型网关配置展示实际连接关系。</p></div><Link className="text-sm underline underline-offset-4" to="/devices">查看接入设备</Link></div>
|
||||
{topologyError && <p role="alert" className="mt-4 flex items-center gap-2 text-sm text-rose-300"><CircleAlert aria-hidden="true" className="size-4" />拓扑数据加载失败:{topologyError}</p>}
|
||||
{!topology && !topologyError && <p role="status" className={`mt-5 ${muted}`}>正在读取服务拓扑…</p>}
|
||||
{topology && <div className="mt-5 grid items-center gap-3 lg:grid-cols-[1fr_auto_1fr_auto_1fr]">
|
||||
<TopologyNode icon={Share2} title="GoodBuddy 设备" detail={`${topology.devices.length} 台已登记设备;登记记录不代表在线`} />
|
||||
<div aria-hidden="true" className="hidden h-px bg-cyan-300/30 lg:block" />
|
||||
<TopologyNode icon={Server} title="ShareServer" detail={`${topology.publications.filter((item) => item.status === 'published').length} 项已发布目录;负责网关转发与目录管理`} tone="violet" />
|
||||
<div aria-hidden="true" className="hidden h-px bg-violet-300/30 lg:block" />
|
||||
<TopologyNode icon={Router} title="外部模型上游" detail={`${topology.gateway.upstreams.filter((item) => item.enabled).length} 个启用上游,${topology.gateway.models.filter((item) => item.enabled).length} 个启用模型`} tone="slate" />
|
||||
</div>}
|
||||
{topology && <p className={`mt-4 ${muted}`}>共享目录当前只保存发布元数据;模型请求通过 ShareServer 转发到已配置上游,GoodBuddy 不直接读取上游凭据。</p>}
|
||||
</section>
|
||||
|
||||
<section aria-label="关键指标" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{snapshot.metrics.map((metric) => (
|
||||
<button
|
||||
key={metric.label}
|
||||
className={`group relative overflow-hidden rounded-2xl border bg-gradient-to-br p-5 text-left outline-none transition duration-200 hover:-translate-y-0.5 hover:border-white/20 focus-visible:ring-2 focus-visible:ring-cyan-400/70 ${metricStyles[metric.tone]}`}
|
||||
onClick={() => navigate(metric.target)}
|
||||
>
|
||||
<div className="absolute -right-8 -top-8 size-24 rounded-full bg-current opacity-[0.055] blur-2xl" />
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-xs font-medium text-[var(--text-muted)]">{metric.label}</span>
|
||||
<ArrowUpRight className="size-4 opacity-50 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:opacity-100" />
|
||||
</div>
|
||||
<p className="mt-5 text-3xl font-semibold tracking-[-0.04em] text-[var(--text-strong)]">
|
||||
{metric.value}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-[var(--text-muted)]">{metric.detail}</p>
|
||||
</button>
|
||||
<section aria-label="GoodBuddy 联动服务" className="mb-6 grid gap-4 md:grid-cols-2">
|
||||
{[
|
||||
{ path: '/model-gateway', title: '模型网关', copy: '配置上游与模型别名、测试请求转发并查看调用审计。' },
|
||||
{ path: '/capabilities', title: '共享中心', copy: '查看当前服务登记的设备与发布记录,按设备、类型和发布状态筛选。' }
|
||||
].map((service) => (
|
||||
<Link key={service.path} to={service.path} className="rounded-2xl border border-white/10 bg-[var(--panel)] p-5 text-left outline-none transition hover:bg-[var(--control)] focus-visible:ring-2 focus-visible:ring-cyan-400/70">
|
||||
<span className="flex items-center justify-between gap-2 font-semibold text-[var(--text-strong)]">{service.title}<ArrowUpRight aria-hidden="true" className="size-4" /></span>
|
||||
<span className="mt-2 block text-sm leading-6 text-[var(--text-muted)]">{service.copy}</span>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-[1.55fr_.85fr]">
|
||||
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--text-strong)]">任务活动</h2>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">过去 12 小时创建的任务</p>
|
||||
</div>
|
||||
<span className="rounded-lg border border-white/[0.07] bg-white/[0.035] px-2.5 py-1.5 text-xs text-[var(--text-muted)]">
|
||||
24 小时
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-8 flex h-52 items-end gap-2 sm:gap-3" role="img" aria-label="过去十二小时任务量柱状图">
|
||||
{snapshot.activity.map((item, index) => (
|
||||
<div key={item.time} className="group flex h-full min-w-0 flex-1 flex-col justify-end">
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 min-h-1 rounded-t-md bg-gradient-to-t from-cyan-400/35 to-cyan-300/90 shadow-[0_0_18px_-8px_rgba(34,211,238,.8)] transition group-hover:from-violet-400/50 group-hover:to-violet-300"
|
||||
style={{ height: `${Math.max(6, (item.value / maxActivity) * 100)}%` }}
|
||||
>
|
||||
<span className="absolute -top-6 left-1/2 hidden -translate-x-1/2 text-[10px] text-cyan-200 group-hover:block">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`mt-2 text-center text-[9px] text-slate-600 ${index % 2 ? 'hidden sm:block' : ''}`}>
|
||||
{item.time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-white/[0.075] bg-[var(--panel)] p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--text-strong)]">网络脉冲</h2>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">连接与路由示例摘要</p>
|
||||
</div>
|
||||
<RadioTower className="size-5 text-cyan-300" />
|
||||
</div>
|
||||
<div className="relative mt-7 grid place-items-center py-4">
|
||||
<div className="pulse-ring absolute size-36 rounded-full border border-cyan-300/10" />
|
||||
<div className="pulse-ring pulse-delay absolute size-24 rounded-full border border-cyan-300/15" />
|
||||
<div className="relative grid size-16 place-items-center rounded-full border border-cyan-300/25 bg-cyan-300/10 shadow-[0_0_45px_-10px_rgba(34,211,238,.7)]">
|
||||
<ServerCog className="size-7 text-cyan-200" />
|
||||
</div>
|
||||
</div>
|
||||
<dl className="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-xl bg-white/[0.025] p-3">
|
||||
<dt className="text-[10px] text-slate-500">设备连接</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-slate-200">18</dd>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/[0.025] p-3">
|
||||
<dt className="text-[10px] text-slate-500">联邦网关</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-slate-200">1 / 2</dd>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/[0.025] p-3">
|
||||
<dt className="text-[10px] text-slate-500">中继占用</dt>
|
||||
<dd className="mt-1 text-sm font-semibold text-slate-200">34%</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)]">
|
||||
<header className="flex items-center justify-between border-b border-white/[0.065] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--text-strong)]">需要关注</h2>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">按影响与等待时间排序</p>
|
||||
</div>
|
||||
<CircleAlert className="size-5 text-amber-300" />
|
||||
</header>
|
||||
<div className="divide-y divide-white/[0.055]">
|
||||
{snapshot.pages.approvals.slice(0, 3).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="flex w-full items-center gap-4 px-5 py-4 text-left outline-none transition hover:bg-white/[0.025] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cyan-400/60"
|
||||
onClick={() => navigate('/approvals')}
|
||||
>
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl border border-amber-300/15 bg-amber-300/[0.07] text-amber-200">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-[var(--text-strong)]">{item.primary}</span>
|
||||
<span className="mt-1 block truncate text-xs text-[var(--text-muted)]">{item.secondary}</span>
|
||||
</span>
|
||||
<span className="hidden text-xs text-slate-600 sm:block">{item.meta[2]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-2xl border border-white/[0.075] bg-[var(--panel)]">
|
||||
<header className="flex items-center justify-between border-b border-white/[0.065] px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-[var(--text-strong)]">最近活动</h2>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">仅显示有界审计元数据</p>
|
||||
</div>
|
||||
<Activity className="size-5 text-violet-300" />
|
||||
</header>
|
||||
<div className="divide-y divide-white/[0.055]">
|
||||
{recent.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.copy} className="flex items-center gap-4 px-5 py-4">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-white/[0.04] text-slate-300">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium text-slate-500">{item.title}</span>
|
||||
<span className="mt-1 block truncate text-sm text-[var(--text-primary)]">{item.copy}</span>
|
||||
</span>
|
||||
<span className="text-xs text-slate-600">{item.time}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="mt-6 flex flex-wrap items-center gap-3 text-[11px] text-slate-600">
|
||||
<StatusBadge status="原型数据" tone="neutral" />
|
||||
<span>{snapshot.instance.protocol}</span>
|
||||
<span>·</span>
|
||||
<span>快照 {new Date(snapshot.instance.generatedAt).toLocaleString('zh-CN')}</span>
|
||||
</footer>
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useId } from 'react'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
export type PageTab = { label: string; key: string }
|
||||
|
||||
export function PageTabs({
|
||||
title,
|
||||
tabs,
|
||||
active,
|
||||
onChange
|
||||
}: {
|
||||
title: string
|
||||
tabs: PageTab[]
|
||||
active: string
|
||||
onChange: (key: string) => void
|
||||
}) {
|
||||
const id = useId()
|
||||
const activeIndex = Math.max(0, tabs.findIndex((tab) => tab.key === active))
|
||||
return <div role="tablist" aria-label={`${title}分类`} className="mb-5 flex max-w-full gap-1 overflow-x-auto rounded-xl border border-white/[0.08] bg-white/[0.025] p-1">
|
||||
{tabs.map((tab, index) => <button
|
||||
key={tab.key}
|
||||
id={`${id}-tab-${tab.key}`}
|
||||
role="tab"
|
||||
aria-selected={active === tab.key}
|
||||
aria-controls={`${id}-panel`}
|
||||
tabIndex={active === tab.key ? 0 : -1}
|
||||
className={cn(
|
||||
'min-h-10 shrink-0 rounded-lg px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-cyan-400/70',
|
||||
active === tab.key ? 'bg-white/[0.09] font-medium text-[var(--text-strong)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
onClick={() => onChange(tab.key)}
|
||||
onKeyDown={(event) => {
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length : -1
|
||||
if (next < 0) return
|
||||
event.preventDefault()
|
||||
onChange(tabs[next].key)
|
||||
document.getElementById(`${id}-tab-${tabs[next].key}`)?.focus()
|
||||
}}
|
||||
>{tab.label}</button>)}
|
||||
</div>
|
||||
}
|
||||
@@ -6,26 +6,14 @@ import { Button } from './ui/button'
|
||||
import { Dialog } from './ui/dialog'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
|
||||
type Upstream = { id: string; name: string; url: string; enabled: boolean }
|
||||
type Model = { id: string; upstreamId: string; alias: string; remote: string; enabled: boolean; group: string }
|
||||
type Audit = { id: string; time: string; model: string; upstream: string; group: string; result: string; allowed: boolean }
|
||||
type Document = { id: string; name: string; owner: 'self' | 'other' | 'organization'; deleted: boolean }
|
||||
type Office = { id: string; name: string; url: string; checks: number; healthy: boolean }
|
||||
type Release = { version: string; modelId: string; modelName: string; group: string }
|
||||
type Device = { id: string; name: string; group: string; online: boolean; applied: Release | null }
|
||||
type DemoState = {
|
||||
upstreams: Upstream[]
|
||||
models: Model[]
|
||||
audits: Audit[]
|
||||
documents: Document[]
|
||||
offices: Office[]
|
||||
organizationRole: boolean
|
||||
draft: Release | null
|
||||
releases: Record<string, Release[]>
|
||||
devices: Device[]
|
||||
}
|
||||
|
||||
const groups = ['平台工程', '设计中心']
|
||||
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm text-[var(--text-primary)] focus-visible:outline-2 focus-visible:outline-current'
|
||||
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
|
||||
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
|
||||
@@ -33,27 +21,13 @@ const DemoContext = createContext<{ state: DemoState; setState: Dispatch<SetStat
|
||||
|
||||
function initialState(): DemoState {
|
||||
return {
|
||||
upstreams: [{ id: 'upstream-demo', name: '示例上游', url: 'https://gateway.example/v1', enabled: true }],
|
||||
models: [
|
||||
{ id: 'model-chat', upstreamId: 'upstream-demo', alias: 'org-chat', remote: 'demo-chat', enabled: true, group: '全部用户组' },
|
||||
{ id: 'model-code', upstreamId: 'upstream-demo', alias: 'org-code', remote: 'demo-code', enabled: true, group: '平台工程' }
|
||||
],
|
||||
audits: [],
|
||||
documents: [
|
||||
{ id: 'doc-org', name: '组织入门指南.docx', owner: 'organization', deleted: false },
|
||||
{ id: 'doc-self', name: '我的项目提案.docx', owner: 'self', deleted: false },
|
||||
{ id: 'doc-other', name: '其他成员文档(仅归属元数据)', owner: 'other', deleted: false }
|
||||
],
|
||||
offices: [{ id: 'office-demo', name: '示例 OnlyOffice', url: 'https://office.example', checks: 0, healthy: true }],
|
||||
organizationRole: false,
|
||||
draft: null,
|
||||
releases: {},
|
||||
devices: [
|
||||
{ id: 'device-1', name: '工程工作站(示例)', group: groups[0], online: true, applied: null },
|
||||
{ id: 'device-2', name: '工程笔记本(示例)', group: groups[0], online: false, applied: null },
|
||||
{ id: 'device-3', name: '设计工作站(示例)', group: groups[1], online: true, applied: null },
|
||||
{ id: 'device-4', name: '设计笔记本(示例)', group: groups[1], online: false, applied: null }
|
||||
]
|
||||
organizationRole: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +43,6 @@ function useDemo() {
|
||||
return value
|
||||
}
|
||||
|
||||
function availableModels(state: DemoState, group?: string) {
|
||||
return state.models.filter((model) => model.enabled &&
|
||||
state.upstreams.some((upstream) => upstream.id === model.upstreamId && upstream.enabled) &&
|
||||
(!group || model.group === '全部用户组' || model.group === group))
|
||||
}
|
||||
|
||||
function Tabs({ labels, children }: { labels: string[]; children: (index: number) => ReactNode }) {
|
||||
const [active, setActive] = useState(0)
|
||||
const id = useId()
|
||||
@@ -128,63 +96,6 @@ function EndpointDialog({ title, onClose, onSave }: { title: string; onClose: ()
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
function GatewayDemo() {
|
||||
const { state, setState } = useDemo()
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [selectedAudit, setSelectedAudit] = useState<Audit | null>(null)
|
||||
const [group, setGroup] = useState(groups[0])
|
||||
const [message, setMessage] = useState('')
|
||||
return <>
|
||||
<p className={`${muted} mb-4`}>ShareServer 不托管模型。此页仅模拟访问策略判断与向上游转发,不运行推理,不发送提示词或真实请求。</p>
|
||||
{message && <p role="status" className="mb-4 text-sm">{message}</p>}
|
||||
<Tabs labels={['上游服务', '模型映射 / 访问策略', '调用审计']}>{(tab) => <>
|
||||
{tab === 0 && <>
|
||||
<Button onClick={() => setAdding(true)}>添加上游</Button>
|
||||
{state.upstreams.map((upstream) => <article className={panel} key={upstream.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="font-semibold">{upstream.name}</h2><StatusBadge status={upstream.enabled ? '已启用(模拟)' : '已停用'} tone={upstream.enabled ? 'online' : 'offline'} /></div>
|
||||
<p className={`${muted} break-all`}>{upstream.url}</p>
|
||||
<Button variant="secondary" onClick={() => setState((current) => ({ ...current, upstreams: current.upstreams.map((item) => item.id === upstream.id ? { ...item, enabled: !item.enabled } : item) }))}>{upstream.enabled ? '停用' : '启用'} {upstream.name}</Button>
|
||||
</article>)}
|
||||
</>}
|
||||
{tab === 1 && <>
|
||||
<label className="block max-w-sm text-sm">模拟调用者用户组<select className={control} value={group} onChange={(event) => setGroup(event.target.value)}>{groups.map((item) => <option key={item}>{item}</option>)}</select></label>
|
||||
{state.models.map((model) => <article className={panel} key={model.id}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="break-all font-semibold">{model.alias} → {model.remote}</h2><StatusBadge status={availableModels(state).some((item) => item.id === model.id) ? '映射可用' : '映射不可用'} tone={availableModels(state).some((item) => item.id === model.id) ? 'online' : 'offline'} /></div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="text-sm">上游服务<select className={control} value={model.upstreamId} onChange={(event) => setState((current) => ({ ...current, models: current.models.map((item) => item.id === model.id ? { ...item, upstreamId: event.target.value } : item) }))}>{state.upstreams.map((item) => <option key={item.id} value={item.id}>{item.name}{item.enabled ? '' : '(已停用)'}</option>)}</select></label>
|
||||
<label className="text-sm">允许访问的用户组<select className={control} value={model.group} onChange={(event) => setState((current) => ({ ...current, models: current.models.map((item) => item.id === model.id ? { ...item, group: event.target.value } : item) }))}>{['全部用户组', ...groups].map((item) => <option key={item}>{item}</option>)}</select></label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => setState((current) => ({ ...current, models: current.models.map((item) => item.id === model.id ? { ...item, enabled: !item.enabled } : item) }))}>{model.enabled ? '停用映射' : '启用映射'}</Button>
|
||||
<Button variant="secondary" onClick={() => {
|
||||
const allowed = availableModels(state, group).some((item) => item.id === model.id)
|
||||
const audit: Audit = { id: crypto.randomUUID(), time: new Date().toLocaleString('zh-CN'), model: `${model.alias} → ${model.remote}`, upstream: state.upstreams.find((item) => item.id === model.upstreamId)?.name ?? '无上游', group, allowed, result: allowed ? '模拟允许转发,未发送请求' : '模拟拒绝:上游 / 映射停用或用户组不在策略范围' }
|
||||
setState((current) => ({ ...current, audits: [audit, ...current.audits].slice(0, 50) }))
|
||||
setMessage(`${model.alias}:${audit.result}。已记录调用审计。`)
|
||||
}}>模拟调用 {model.alias}</Button>
|
||||
</div>
|
||||
</article>)}
|
||||
</>}
|
||||
{tab === 2 && <>
|
||||
<p className={muted}>最近 50 条模拟调用;仅记录路由与策略结果,不记录请求或响应正文。</p>
|
||||
{!state.audits.length && <p className={panel}>暂无调用。请在模型映射页执行一次模拟调用。</p>}
|
||||
{state.audits.map((audit) => <article key={audit.id} className={panel}>
|
||||
<h2 className="font-semibold">{audit.model}</h2><p className={muted}>{audit.time} · {audit.group}</p>
|
||||
<div className="flex flex-wrap items-center gap-3"><StatusBadge status={audit.allowed ? '模拟允许' : '模拟拒绝'} tone={audit.allowed ? 'online' : 'danger'} /><Button variant="secondary" onClick={() => setSelectedAudit(audit)}>查看审计详情</Button></div>
|
||||
</article>)}
|
||||
</>}
|
||||
</>}</Tabs>
|
||||
{adding && <EndpointDialog title="添加上游服务" onClose={() => setAdding(false)} onSave={(name, url) => {
|
||||
if (state.upstreams.some((item) => item.name === name || item.url === url)) return '该名称或地址已存在。'
|
||||
setState((current) => ({ ...current, upstreams: [...current.upstreams, { id: crypto.randomUUID(), name, url, enabled: true }] }))
|
||||
setMessage('已添加示例上游。可在模型映射页将模型关联到此服务。')
|
||||
}} />}
|
||||
<Dialog open={Boolean(selectedAudit)} onOpenChange={(open) => { if (!open) setSelectedAudit(null) }} title="调用审计详情" description="模拟事实,不代表真实转发或上游可用性。">
|
||||
{selectedAudit && <dl className="space-y-3 text-sm">{Object.entries({ 编号: selectedAudit.id, 时间: selectedAudit.time, 模型映射: selectedAudit.model, 上游: selectedAudit.upstream, 调用组: selectedAudit.group, 策略结果: selectedAudit.result }).map(([label, value]) => <div key={label}><dt className={muted}>{label}</dt><dd className="break-words">{value}</dd></div>)}</dl>}
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
|
||||
function DocumentsDemo({ organization }: { organization: string }) {
|
||||
const { state, setState } = useDemo()
|
||||
const [adding, setAdding] = useState(false)
|
||||
@@ -248,89 +159,13 @@ function DocumentsDemo({ organization }: { organization: string }) {
|
||||
</>
|
||||
}
|
||||
|
||||
function DistributionDemo() {
|
||||
const { state, setState } = useDemo()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [group, setGroup] = useState(groups[0])
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const models = availableModels(state, group)
|
||||
const draftAllowed = state.draft && availableModels(state, state.draft.group).some((model) => model.id === state.draft?.modelId)
|
||||
function releaseToDevices(current: DemoState, release: Release): Device[] {
|
||||
return current.devices.map((device) => device.group === release.group && device.online ? { ...device, applied: release } : device)
|
||||
}
|
||||
return <div className="space-y-5">
|
||||
<p className={muted}>仅模拟配置发布,不执行真实设备下发。每个用户组独立保留发布历史;离线设备保持待下发,已应用版本不会被假装更新。</p>
|
||||
{message && <p role="status" className="text-sm">{message}</p>}
|
||||
<Button disabled={Boolean(state.draft)} onClick={() => { setError(''); setGroup(groups[0]); setCreating(true) }}>创建配置草稿</Button>
|
||||
{state.draft && <section className={panel}>
|
||||
<h2 className="font-semibold">草稿 {state.draft.version}</h2><p className={muted}>{state.draft.group} · 默认模型:{state.draft.modelName}</p>
|
||||
{!draftAllowed && <p role="alert" className="text-sm text-rose-300">默认模型已停用或不允许目标组访问。请调整网关策略,或丢弃草稿后重新选择。</p>}
|
||||
<div className="flex flex-wrap gap-2"><Button disabled={!draftAllowed} onClick={() => {
|
||||
setState((current) => {
|
||||
const draft = current.draft
|
||||
if (!draft || !availableModels(current, draft.group).some((item) => item.id === draft.modelId)) return current
|
||||
return { ...current, draft: null, releases: { ...current.releases, [draft.group]: [...(current.releases[draft.group] ?? []), draft] }, devices: releaseToDevices(current, draft) }
|
||||
})
|
||||
setMessage('已模拟发布:目标组在线设备更新,离线设备保持待下发。')
|
||||
}}>模拟发布</Button><Button variant="ghost" onClick={() => setState((current) => ({ ...current, draft: null }))}>丢弃草稿</Button></div>
|
||||
</section>}
|
||||
{groups.map((target) => {
|
||||
const history = state.releases[target] ?? []
|
||||
const active = history.at(-1)
|
||||
const previous = history.at(-2)
|
||||
const rollbackAllowed = previous && availableModels(state, target).some((item) => item.id === previous.modelId)
|
||||
return <section key={target} className={panel}>
|
||||
<h2 className="font-semibold">{target}</h2>
|
||||
<p className={muted}>当前发布:{active ? `${active.version} · 默认模型 ${active.modelName}` : '尚未发布'}</p>
|
||||
<p className={muted}>可回退版本:{previous ? `${previous.version} · 默认模型 ${previous.modelName}` : '无历史版本'}</p>
|
||||
{previous && !rollbackAllowed && <p className="text-sm text-rose-300">历史版本模型不可用或策略不允许,暂不能回退。</p>}
|
||||
<Button variant="secondary" disabled={!rollbackAllowed} onClick={() => {
|
||||
setState((current) => {
|
||||
const versions = current.releases[target] ?? []
|
||||
const prior = versions.at(-2)
|
||||
if (!prior || !availableModels(current, target).some((item) => item.id === prior.modelId)) return current
|
||||
return { ...current, releases: { ...current.releases, [target]: versions.slice(0, -1) }, devices: releaseToDevices(current, prior) }
|
||||
})
|
||||
setMessage(`${target} 已模拟回退到 ${previous?.version},同时恢复该版本默认模型;离线设备等待该版本。`)
|
||||
}}>模拟回退{previous ? `到 ${previous.version}` : ''}</Button>
|
||||
<ul className="space-y-3">{state.devices.filter((device) => device.group === target).map((device) => <li key={device.id} className="flex flex-col justify-between gap-2 border-t border-white/10 pt-3 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0"><p className="break-words text-sm font-medium">{device.name} · {device.online ? '在线' : '离线'}</p><p className={muted}>已应用:{device.applied ? `${device.applied.version} / ${device.applied.modelName}` : '无'} · 目标:{active?.version ?? '无'}</p></div>
|
||||
<StatusBadge status={!active ? '尚未下发' : device.online ? '模拟已应用' : '离线 · 待下发(模拟)'} tone={!active ? 'neutral' : device.online ? 'online' : 'warning'} />
|
||||
</li>)}</ul>
|
||||
</section>
|
||||
})}
|
||||
<Dialog open={creating} onOpenChange={setCreating} title="创建配置草稿" description="默认模型来自当前启用的网关模型,并受目标组访问策略约束。">
|
||||
<form className="space-y-4" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const data = new FormData(event.currentTarget)
|
||||
const version = String(data.get('version') ?? '').trim()
|
||||
const model = models.find((item) => item.id === data.get('model'))
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version)) { setError('版本格式应为三个数字段,例如 1.0.0。'); return }
|
||||
if (!model) { setError('请选择目标组可访问的启用模型。'); return }
|
||||
if (state.releases[group]?.some((item) => item.version === version)) { setError('该用户组的发布历史中已存在此版本。'); return }
|
||||
setState((current) => ({ ...current, draft: { version, group, modelId: model.id, modelName: model.alias } }))
|
||||
setCreating(false)
|
||||
setMessage('草稿已保存到内存,尚未模拟发布。')
|
||||
}}>
|
||||
<label className="block text-sm">目标用户组<select value={group} className={control} onChange={(event) => setGroup(event.target.value)}>{groups.map((item) => <option key={item}>{item}</option>)}</select></label>
|
||||
<label className="block text-sm">默认模型<select key={group} name="model" required className={control} defaultValue={models[0]?.id ?? ''} disabled={!models.length}>{!models.length && <option value="">无可用模型</option>}{models.map((model) => <option key={model.id} value={model.id}>{model.alias}</option>)}</select></label>
|
||||
{!models.length && <p className={muted}>请先在模型网关启用上游与模型,并允许目标组访问。</p>}
|
||||
<label className="block text-sm">版本<input name="version" required maxLength={30} placeholder="1.0.0" className={control} /></label>
|
||||
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
|
||||
<div className="flex flex-wrap justify-end gap-2"><Button variant="ghost" onClick={() => setCreating(false)}>取消</Button><Button type="submit" disabled={!models.length}>保存草稿</Button></div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ServiceDemoPage({ page, snapshot }: { page: PageConfig; snapshot: PrototypeSnapshot }) {
|
||||
useDemo()
|
||||
return <div className="min-w-0 text-[var(--text-primary)]">
|
||||
<PageHeader eyebrow={`组织 · ${snapshot.instance.organization}`} title={page.title} description={page.description} />
|
||||
<aside className={`${panel} mb-5 text-sm leading-6`} aria-label="原型说明">
|
||||
交互原型:所有操作均为模拟,仅保存在当前页面会话内存中。三个服务页面间导航保留状态,刷新页面后重置。不调用网络,不写入本地存储,不代表真实授权、健康检查或设备下发。
|
||||
文档交互原型:此页所有操作均为模拟,仅保存在当前页面会话内存中。导航后保留文档状态,刷新页面后重置。不调用网络,不写入本地存储,不代表真实授权或健康检查。与真实模型网关独立。
|
||||
</aside>
|
||||
{page.path === '/model-gateway' ? <GatewayDemo /> : page.path === '/documents' ? <DocumentsDemo organization={snapshot.instance.organization} /> : page.path === '/distribution' ? <DistributionDemo /> : <p role="alert">此页面不属于服务演示。</p>}
|
||||
{page.path === '/documents' ? <DocumentsDemo organization={snapshot.instance.organization} /> : <p role="alert">此页面不属于文档演示。</p>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { PrototypeSnapshot } from '../../../shared/prototype-data'
|
||||
import { PageHeader } from './page-header'
|
||||
import { Button } from './ui/button'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
import { PageTabs } from './page-tabs'
|
||||
|
||||
function SettingCard({
|
||||
icon: Icon,
|
||||
@@ -77,6 +78,7 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
const [federation, setFederation] = useState(true)
|
||||
const [oidc, setOidc] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('instance')
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -101,8 +103,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
原型设置已保存到当前浏览器会话,不会写入生产配置。
|
||||
</div>
|
||||
) : null}
|
||||
<PageTabs title="系统设置" active={activeTab} onChange={setActiveTab} tabs={[{ key: 'instance', label: '实例信息' }, { key: 'identity', label: '身份与登录' }, { key: 'infrastructure', label: '基础设施' }, { key: 'relay', label: '中继与联邦' }, { key: 'notifications', label: '通知' }, { key: 'retention', label: '保留与审计' }]} />
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<SettingCard
|
||||
{activeTab === 'instance' && <SettingCard
|
||||
icon={CloudCog}
|
||||
title="实例信息"
|
||||
description="公开名称、地址与协议协商信息。"
|
||||
@@ -127,9 +130,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
<StatusBadge status="兼容" tone="online" />
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</SettingCard>}
|
||||
|
||||
<SettingCard
|
||||
{activeTab === 'infrastructure' && <SettingCard
|
||||
icon={Database}
|
||||
title="基础设施"
|
||||
description="状态可见,敏感连接信息不在 Web 控制台回显。"
|
||||
@@ -150,9 +153,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingCard>
|
||||
</SettingCard>}
|
||||
|
||||
<SettingCard
|
||||
{activeTab === 'identity' && <SettingCard
|
||||
icon={KeyRound}
|
||||
title="身份与登录"
|
||||
description="本地账号可用;OIDC 需要部署管理员配置固定回调地址。"
|
||||
@@ -172,9 +175,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
<StatusBadge status="已启用" tone="online" />
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</SettingCard>}
|
||||
|
||||
<SettingCard
|
||||
{activeTab === 'relay' && <SettingCard
|
||||
icon={RadioTower}
|
||||
title="中继与联邦"
|
||||
description="控制面独立运行,关闭中继不会影响设备注册和目录。"
|
||||
@@ -193,9 +196,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
description="仍需双方管理员核对指纹并分别确认关系。"
|
||||
/>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</SettingCard>}
|
||||
|
||||
<SettingCard
|
||||
{activeTab === 'notifications' && <SettingCard
|
||||
icon={Mail}
|
||||
title="通知"
|
||||
description="用于邀请、审批和高影响状态通知。"
|
||||
@@ -207,9 +210,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
</span>
|
||||
<Button size="sm" variant="secondary">发送测试</Button>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</SettingCard>}
|
||||
|
||||
<SettingCard
|
||||
{activeTab === 'retention' && <SettingCard
|
||||
icon={Braces}
|
||||
title="保留与审计"
|
||||
description="默认只保留有界元数据,不保存提示正文、文件与模型输出。"
|
||||
@@ -231,9 +234,9 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</SettingCard>}
|
||||
</div>
|
||||
<section className="mt-5 rounded-2xl border border-rose-400/15 bg-rose-400/[0.035] p-5">
|
||||
{activeTab === 'instance' && <section className="mt-5 rounded-2xl border border-rose-400/15 bg-rose-400/[0.035] p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-rose-300" />
|
||||
<div className="flex-1">
|
||||
@@ -244,7 +247,7 @@ export function SettingsPage({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
</div>
|
||||
<Button variant="danger">删除组织</Button>
|
||||
</div>
|
||||
</section>
|
||||
</section>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,122 +1,84 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { prototypeSnapshot } from '../../../shared/prototype-data'
|
||||
import App from '../App'
|
||||
import { sharedItems, sharingAvailability } from '../lib/sharing-fixtures'
|
||||
|
||||
function emptyDirectory() {
|
||||
return vi.fn().mockImplementation(async (url: string) => {
|
||||
if (url === '/api/v1/sharing/devices') return { ok: true, json: async () => ({ devices: [] }) }
|
||||
if (url === '/api/v1/sharing/publications') return { ok: true, json: async () => ({ publications: [] }) }
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
}
|
||||
|
||||
describe('sharing center', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/capabilities')
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => prototypeSnapshot }))
|
||||
vi.stubGlobal('fetch', emptyDirectory())
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it('keeps synced copies available offline while remote retrieval waits for the device', () => {
|
||||
const synced = sharedItems.find((item) => item.id === 'k-1')!
|
||||
const remote = sharedItems.find((item) => item.id === 'k-2')!
|
||||
expect(sharingAvailability(synced, '离线').available).toBe(true)
|
||||
expect(sharingAvailability(remote, '离线').available).toBe(false)
|
||||
expect(sharingAvailability(remote, '在线').available).toBe(true)
|
||||
expect(sharingAvailability(remote, '未知').available).toBe(false)
|
||||
expect(sharingAvailability({ ...synced, paused: true }, '离线').available).toBe(false)
|
||||
expect(sharingAvailability(synced, '已撤销').available).toBe(false)
|
||||
})
|
||||
|
||||
it('displays independent permissions and source availability without granting content access to admins', async () => {
|
||||
window.history.replaceState({}, '', '/capabilities?view=knowledge')
|
||||
it.each(['', '?view=local', '?view=knowledge&item=k-1'])('loads the real directory without fixture tabs at /capabilities%s', async (search) => {
|
||||
window.history.replaceState({}, '', `/capabilities${search}`)
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: '共享中心' })
|
||||
const synced = screen.getByRole('link', { name: '设计规范归档' }).closest('article')!
|
||||
expect(within(synced).getByText(/设备状态:离线/)).toBeInTheDocument()
|
||||
expect(within(synced).getByText('服务器副本就绪(示例)')).toBeInTheDocument()
|
||||
expect(within(synced).getByText('读取原文:允许')).toBeInTheDocument()
|
||||
expect(within(synced).getByText('下载:未授权')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('link', { name: '设计项目素材' }))
|
||||
const dialog = screen.getByRole('dialog', { name: '设计项目素材详情' })
|
||||
expect(within(dialog).getByText('搜索:允许')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('读取原文:未授权')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('下载:允许')).toBeInTheDocument()
|
||||
expect(within(dialog).getByText('设备离线,远程取用不可用')).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('button', { name: '下载(未接入)' })).toBeDisabled()
|
||||
expect(within(dialog).getByText(/管理员身份不会增加这些权限/)).toBeInTheDocument()
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('heading', { name: '共享中心' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: '尚无发布记录' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/尚无登记设备/)).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(4)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/设计规范归档|设计项目素材|GPU-Worker|MesaLab|当前示例访问者/)).not.toBeInTheDocument()
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
for (const resource of ['devices', 'publications']) {
|
||||
expect(fetch).toHaveBeenCalledWith(`/api/v1/sharing/${resource}`, { signal: expect.any(AbortSignal) })
|
||||
}
|
||||
})
|
||||
|
||||
it('shows metadata-only knowledge even when all content permissions are denied', async () => {
|
||||
window.history.replaceState({}, '', '/capabilities?view=knowledge&item=k-4')
|
||||
render(<App />)
|
||||
const dialog = await screen.findByRole('dialog', { name: '受限研究资料详情' })
|
||||
for (const action of ['搜索', '读取原文', '下载']) expect(within(dialog).getByText(`${action}:未授权`)).toBeInTheDocument()
|
||||
for (const button of within(dialog).getAllByRole('button', { name: /未接入/ })) expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('combines filters, recovers from no results, and restores URL state on remount', async () => {
|
||||
window.history.replaceState({}, '', '/capabilities?view=knowledge')
|
||||
it('preserves local URL filters across mounting and editing', async () => {
|
||||
window.history.replaceState({}, '', '/capabilities?view=local&device=registered-device&kind=knowledge&status=revoked&q=manual')
|
||||
const app = render(<App />)
|
||||
await screen.findByRole('heading', { name: '共享中心' })
|
||||
fireEvent.change(screen.getByLabelText('内容来源'), { target: { value: 'synced' } })
|
||||
fireEvent.change(screen.getByLabelText('可用状态'), { target: { value: 'ready' } })
|
||||
fireEvent.change(screen.getByLabelText('共享对象'), { target: { value: '全组织' } })
|
||||
fireEvent.change(screen.getByLabelText('搜索共享元数据'), { target: { value: 'Design-Mac' } })
|
||||
expect(screen.getByText('当前结果:1 项共享内容')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: '设计规范归档' })).toBeInTheDocument()
|
||||
await screen.findByRole('heading', { name: '尚无发布记录' })
|
||||
expect(screen.getByLabelText('登记设备')).toHaveValue('registered-device')
|
||||
expect(screen.getByLabelText('共享类型')).toHaveValue('knowledge')
|
||||
expect(screen.getByLabelText('发布状态')).toHaveValue('revoked')
|
||||
expect(screen.getByLabelText('搜索本地元数据')).toHaveValue('manual')
|
||||
fireEvent.change(screen.getByLabelText('搜索本地元数据'), { target: { value: 'updated' } })
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
expect(params.get('device')).toBe('registered-device')
|
||||
expect(params.get('kind')).toBe('knowledge')
|
||||
expect(params.get('status')).toBe('revoked')
|
||||
expect(params.get('q')).toBe('updated')
|
||||
app.unmount()
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: '共享中心' })
|
||||
expect(screen.getByLabelText('搜索共享元数据')).toHaveValue('Design-Mac')
|
||||
expect(screen.getByText('当前结果:1 项共享内容')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('可用状态'), { target: { value: 'unavailable' } })
|
||||
expect(screen.getByRole('heading', { name: '没有符合条件的结果' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
|
||||
expect(window.location.search).toBe('?view=knowledge')
|
||||
expect(screen.getByText('当前结果:4 项共享内容')).toBeInTheDocument()
|
||||
expect(await screen.findByLabelText('搜索本地元数据')).toHaveValue('updated')
|
||||
})
|
||||
|
||||
it('navigates from content to publisher and provider with matching details and clears stale filters', async () => {
|
||||
window.history.replaceState({}, '', '/capabilities?view=knowledge&q=归档&item=k-1')
|
||||
it('shows API errors without substituting examples and recovers on retry', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false, status: 503,
|
||||
json: async () => ({ code: 'STORAGE_UNAVAILABLE', requestId: 'sharing-request' })
|
||||
}))
|
||||
render(<App />)
|
||||
const detail = await screen.findByRole('dialog', { name: '设计规范归档详情' })
|
||||
fireEvent.click(within(detail).getByRole('link', { name: '发布者:陈汐' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: '按用户查看' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByLabelText('发布用户')).toHaveValue('m-2')
|
||||
expect(screen.getByText('当前结果:3 项共享内容')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('link', { name: '图像生成' }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('link', { name: '提供设备:GPU-Worker-02' }))
|
||||
expect(screen.getByLabelText('提供设备')).toHaveValue('d-2')
|
||||
expect(screen.getByRole('link', { name: '代码安全扫描' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('link', { name: '代码安全扫描' }))
|
||||
expect(within(screen.getByRole('dialog')).getByText('发布已暂停')).toBeInTheDocument()
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent('HTTP 503')
|
||||
expect(alert).toHaveTextContent('STORAGE_UNAVAILABLE')
|
||||
expect(alert).toHaveTextContent('sharing-request')
|
||||
expect(screen.queryByRole('article')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: '尚无发布记录' })).not.toBeInTheDocument()
|
||||
vi.stubGlobal('fetch', emptyDirectory())
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
expect(await screen.findByRole('heading', { name: '尚无发布记录' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links the existing device detail to its exact sharing view including empty devices', async () => {
|
||||
window.history.replaceState({}, '', '/devices')
|
||||
it('aborts directory requests when navigating away', () => {
|
||||
const signals: AbortSignal[] = []
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation((_url, { signal }) => {
|
||||
signals.push(signal)
|
||||
return new Promise(() => {})
|
||||
}))
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: 'GoodBuddy 接入' })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Design-Mac-07/ }))
|
||||
const link = within(screen.getByRole('dialog')).getByRole('link', { name: '查看此设备的共享内容' })
|
||||
expect(link).toHaveAttribute('href', '/capabilities?view=devices&device=d-3')
|
||||
fireEvent.click(link)
|
||||
expect(screen.getByLabelText('提供设备')).toHaveValue('d-3')
|
||||
expect(screen.getByText('当前结果:2 项共享内容')).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('提供设备'), { target: { value: 'd-4' } })
|
||||
expect(screen.getByRole('heading', { name: 'Legacy-Lab · 已撤销 · 0 项' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: '没有符合条件的结果' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }))
|
||||
expect(screen.getByText('当前结果:7 项共享内容')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports keyboard tabs and keeps invalid detail links recoverable', async () => {
|
||||
render(<App />)
|
||||
await screen.findByRole('heading', { name: '共享中心' })
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '能力目录' }), { key: 'ArrowRight' })
|
||||
expect(screen.getByRole('tab', { name: '共享知识库' })).toHaveFocus()
|
||||
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'sharing-tab-knowledge')
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '共享知识库' }), { key: 'End' })
|
||||
expect(screen.getByRole('tab', { name: '按设备查看' })).toHaveFocus()
|
||||
cleanup()
|
||||
window.history.replaceState({}, '', '/capabilities?view=knowledge&item=missing')
|
||||
render(<App />)
|
||||
expect(await screen.findByRole('dialog', { name: '共享内容不存在' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('link', { name: '运行概览' }))
|
||||
expect(signals).toHaveLength(5)
|
||||
expect(signals.slice(0, 2).every((signal) => signal.aborted)).toBe(true)
|
||||
expect(screen.getByRole('heading', { name: '运行概览' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,128 +1,15 @@
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import type { PrototypeSnapshot } from '../../../shared/prototype-data'
|
||||
import { sharedItems, sharingAvailability, type SharedItem } from '../lib/sharing-fixtures'
|
||||
import { PageHeader } from './page-header'
|
||||
import { Button } from './ui/button'
|
||||
import { Dialog } from './ui/dialog'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
import { LocalSharing } from './local-sharing'
|
||||
import { useState } from 'react'
|
||||
import { PageTabs } from './page-tabs'
|
||||
|
||||
const tabs = [ ['local', '本地服务目录'], ['catalog', '能力目录'], ['knowledge', '共享知识库'], ['users', '按用户查看'], ['devices', '按设备查看'] ] as const
|
||||
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
|
||||
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
|
||||
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm focus-visible:outline-2'
|
||||
const link = 'rounded text-sm underline underline-offset-4 focus-visible:outline-2'
|
||||
const sourceLabels = { synced: '服务器同步副本', remote: '远程设备取用' }
|
||||
|
||||
export function SharingCenter({ snapshot }: { snapshot: PrototypeSnapshot }) {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const view = tabs.some(([key]) => key === params.get('view')) ? params.get('view')! : 'catalog'
|
||||
const query = params.get('q') ?? ''
|
||||
const status = params.get('status') ?? ''
|
||||
const source = params.get('source') ?? ''
|
||||
const audience = params.get('audience') ?? ''
|
||||
const scopeKey = view === 'users' ? 'user' : 'device'
|
||||
const scope = params.get(scopeKey) ?? ''
|
||||
const entities = snapshot.pages[view === 'users' ? 'members' : 'devices'] ?? []
|
||||
const device = (item: SharedItem) => snapshot.pages.devices.find((entry) => entry.id === item.device)
|
||||
const publisher = (item: SharedItem) => snapshot.pages.members.find((entry) => entry.id === item.publisher)
|
||||
const availability = (item: SharedItem) => sharingAvailability(item, device(item)?.status ?? '未知')
|
||||
const update = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params)
|
||||
if (value) next.set(key, value)
|
||||
else next.delete(key)
|
||||
next.delete('item')
|
||||
setParams(next, { replace: true })
|
||||
}
|
||||
const itemHref = (item: SharedItem) => {
|
||||
const next = new URLSearchParams(params)
|
||||
next.set('item', item.id)
|
||||
return `/capabilities?${next}`
|
||||
}
|
||||
const visible = sharedItems.filter((item) => {
|
||||
if (view === 'catalog' && item.kind !== 'capability') return false
|
||||
if (view === 'knowledge' && item.kind !== 'knowledge') return false
|
||||
if (view === 'users' && scope && item.publisher !== scope) return false
|
||||
if (view === 'devices' && scope && item.device !== scope) return false
|
||||
return (!query.trim() || [item.name, item.version, publisher(item)?.primary, device(item)?.primary, item.audience].join(' ').toLowerCase().includes(query.trim().toLowerCase())) &&
|
||||
(!status || (status === 'ready' ? availability(item).available : !availability(item).available)) &&
|
||||
(!source || item.source === source) && (!audience || item.audience === audience)
|
||||
})
|
||||
const selected = sharedItems.find((item) => item.id === params.get('item'))
|
||||
const grouped = view === 'users' || view === 'devices'
|
||||
const permissionSummary = (item: SharedItem) => <div className="space-y-2">
|
||||
<p className={muted}>当前示例访问者的显式权限(独立判断)</p>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<span>搜索:{item.permissions.search ? '允许' : '未授权'}</span>
|
||||
<span>读取原文:{item.permissions.read ? '允许' : '未授权'}</span>
|
||||
<span>下载:{item.permissions.download ? '允许' : '未授权'}</span>
|
||||
</div>
|
||||
</div>
|
||||
const metadata = (item: SharedItem) => <>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link className={link} to={`/capabilities?view=users&user=${item.publisher}`}>发布者:{publisher(item)?.primary ?? item.publisher}</Link>
|
||||
<Link className={link} to={`/capabilities?view=devices&device=${item.device}`}>提供设备:{device(item)?.primary ?? item.device}</Link>
|
||||
</div>
|
||||
<p className={muted}>版本:{item.version} · 设备状态:{device(item)?.status ?? '未知'} · 共享对象:{item.audience}</p>
|
||||
<p className={muted}>来源:{sourceLabels[item.source]} · 发布状态:{item.paused ? '已暂停' : '已发布'}</p>
|
||||
{item.kind === 'knowledge' && permissionSummary(item)}
|
||||
</>
|
||||
const cards = (items: SharedItem[]) => items.map((item) => <article key={item.id} className={panel}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="font-semibold text-[var(--text-strong)]"><Link className={link} to={itemHref(item)}>{item.name}</Link></h3>
|
||||
<StatusBadge status={availability(item).label} tone={availability(item).available ? 'online' : 'warning'} />
|
||||
</div>
|
||||
{metadata(item)}
|
||||
</article>)
|
||||
|
||||
export function SharingCenter() {
|
||||
const [activeTab, setActiveTab] = useState('capabilities')
|
||||
return <>
|
||||
<PageHeader eyebrow={view === 'local' ? '当前本地服务 · 真实元数据' : `示例组织 · ${snapshot.instance.organization}`} title="共享中心" description={view === 'local' ? '查看当前服务登记的设备与发布记录。其他页签为示例数据。' : '查看示例共享内容、发布用户和提供设备的关联。'} />
|
||||
<div role="tablist" aria-label="共享中心分类" className="mb-5 flex max-w-full shrink-0 gap-1 overflow-x-auto rounded-xl border border-white/10 p-1">
|
||||
{tabs.map(([key, label], index) => <button key={key} id={`sharing-tab-${key}`} role="tab" aria-selected={view === key} aria-controls="sharing-panel" tabIndex={view === key ? 0 : -1}
|
||||
className={`min-h-10 shrink-0 rounded-lg px-3 text-sm focus-visible:outline-2 ${view === key ? 'bg-[var(--control)] font-semibold text-[var(--text-strong)]' : 'text-[var(--text-muted)]'}`}
|
||||
onClick={() => setParams({ view: key })} onKeyDown={(event) => {
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : event.key === 'ArrowRight' ? (index + 1) % tabs.length : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length : null
|
||||
if (next === null) return
|
||||
event.preventDefault()
|
||||
setParams({ view: tabs[next][0] })
|
||||
document.getElementById(`sharing-tab-${tabs[next][0]}`)?.focus()
|
||||
}}>{label}</button>)}
|
||||
</div>
|
||||
<section id="sharing-panel" role="tabpanel" aria-labelledby={`sharing-tab-${view}`} className="space-y-4">
|
||||
{view === 'local' ? <LocalSharing /> : <>
|
||||
<p className={muted}>交互原型:仅展示示例元数据。管理员可见元数据不代表获得内容权限;本页不搜索正文、读取原文、下载文件或调用能力。</p>
|
||||
<div className={panel}>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label className="text-sm">搜索共享元数据<input className={control} value={query} placeholder="名称、发布者、设备或版本" onChange={(event) => update('q', event.target.value)} /></label>
|
||||
<label className="text-sm">可用状态<select className={control} value={status} onChange={(event) => update('status', event.target.value)}><option value="">全部状态</option><option value="ready">就绪</option><option value="unavailable">不可用</option></select></label>
|
||||
<label className="text-sm">内容来源<select className={control} value={source} onChange={(event) => update('source', event.target.value)}><option value="">全部来源</option><option value="synced">服务器同步副本</option><option value="remote">远程设备取用</option></select></label>
|
||||
<label className="text-sm">共享对象<select className={control} value={audience} onChange={(event) => update('audience', event.target.value)}><option value="">全部对象</option>{[...new Set(sharedItems.map((item) => item.audience))].map((value) => <option key={value}>{value}</option>)}</select></label>
|
||||
{grouped && <label className="text-sm">{view === 'users' ? '发布用户' : '提供设备'}<select className={control} value={scope} onChange={(event) => update(scopeKey, event.target.value)}><option value="">全部</option>{entities.map((entity) => <option key={entity.id} value={entity.id}>{entity.primary}</option>)}</select></label>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3"><p className={muted} role="status">当前结果:{visible.length} 项共享内容</p><Button variant="secondary" onClick={() => setParams({ view })}>清除筛选</Button></div>
|
||||
</div>
|
||||
{grouped ? entities.filter((entity) => !scope || entity.id === scope).map((entity) => {
|
||||
const items = visible.filter((item) => (view === 'users' ? item.publisher : item.device) === entity.id)
|
||||
return <section key={entity.id} className="space-y-4" aria-label={`${entity.primary}的共享内容`}>
|
||||
<h2 className="font-semibold">{entity.primary} · {entity.status} · {items.length} 项</h2>
|
||||
{cards(items)}
|
||||
{!items.length && <p className={muted}>当前条件下没有共享内容。</p>}
|
||||
</section>
|
||||
}) : cards(visible)}
|
||||
{!visible.length && <div className={panel}><h2 className="font-semibold">没有符合条件的结果</h2><p className={muted}>请修改关键词或使用“清除筛选”恢复当前页签的全部示例。</p></div>}
|
||||
</>}
|
||||
<PageHeader eyebrow="当前本地服务" title="共享中心" description="查看当前服务登记的设备与发布记录。" />
|
||||
<PageTabs title="共享中心" active={activeTab} onChange={setActiveTab} tabs={[{ key: 'capabilities', label: '能力目录' }, { key: 'knowledge', label: '共享知识库' }, { key: 'members', label: '按成员查看' }, { key: 'devices', label: '按设备查看' }]} />
|
||||
<section aria-label="本地服务目录" className="space-y-4">
|
||||
{activeTab === 'capabilities' ? <LocalSharing /> : <div className="space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5"><h2 className="font-semibold">{activeTab === 'knowledge' ? '共享知识库' : activeTab === 'members' ? '按成员查看' : '按设备查看'}</h2><p className="text-sm leading-6 text-[var(--text-muted)]">该 Tab 的生产接口尚未接入。当前目录只保存发布元数据,不提供正文读取、成员授权或设备在线状态。</p></div>}
|
||||
</section>
|
||||
<Dialog open={view !== 'local' && params.has('item')} onOpenChange={(open) => { if (!open) { const next = new URLSearchParams(params); next.delete('item'); setParams(next) } }} title={selected ? `${selected.name}详情` : '共享内容不存在'} description="仅查看示例元数据,不访问真实内容。">
|
||||
{selected ? <div className="space-y-4">
|
||||
{metadata(selected)}
|
||||
<StatusBadge status={availability(selected).label} tone={availability(selected).available ? 'online' : 'warning'} />
|
||||
{selected.kind === 'knowledge' && <>
|
||||
<p className={muted}>{selected.source === 'synced' ? '示例副本已同步到服务器;来源设备离线不影响该版本副本。更新仍需设备重新连接。' : '内容保留在提供设备,取用依赖该设备在线;服务器未保存内容副本。'}</p>
|
||||
<p className={muted}>授权与来源可用性分别判断,允许搜索不代表允许读取原文或下载。管理员身份不会增加这些权限。</p>
|
||||
<div className="flex flex-wrap gap-2"><Button disabled variant="secondary">搜索正文(未接入)</Button><Button disabled variant="secondary">读取原文(未接入)</Button><Button disabled variant="secondary">下载(未接入)</Button></div>
|
||||
</>}
|
||||
{selected.kind === 'capability' && <p className={muted}>就绪仅表示示例设备在线且发布未暂停,不代表当前访问者获准调用。能力执行未接入。</p>}
|
||||
</div> : <p className={muted}>该链接没有对应的示例条目,请关闭详情并重新选择。</p>}
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PageHeader } from './page-header'
|
||||
import { StatusBadge } from './ui/status-badge'
|
||||
import { PageTabs, type PageTab } from './page-tabs'
|
||||
import { useState } from 'react'
|
||||
|
||||
type UnavailablePageProps = { title: string; description: string; boundary: string; next?: { label: string; to: string }; tabs?: PageTab[] }
|
||||
|
||||
export function UnavailablePage({ title, description, boundary, next, tabs }: UnavailablePageProps) {
|
||||
const [active, setActive] = useState(tabs?.[0]?.key ?? '')
|
||||
return <>
|
||||
<PageHeader eyebrow="服务端接口 · 待接入" title={title} description={description} />
|
||||
{tabs?.length ? <PageTabs title={title} tabs={tabs} active={active} onChange={setActive} /> : null}
|
||||
<section className="space-y-5 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="font-semibold">当前没有可用的业务数据</h2><StatusBadge status="待接入" tone="warning" /></div>
|
||||
{tabs?.length ? <p className="text-sm font-medium text-[var(--text-primary)]">当前 Tab:{tabs.find((tab) => tab.key === active)?.label}</p> : null}
|
||||
<p className="max-w-3xl text-sm leading-6 text-[var(--text-muted)]">{boundary}</p>
|
||||
{next && <Link className="inline-flex min-h-9 items-center rounded-lg bg-cyan-400 px-3 text-sm font-medium text-slate-950" to={next.to}>{next.label}</Link>}
|
||||
</section>
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const errors: Record<string, string> = {
|
||||
CREDENTIAL_UNAVAILABLE: '服务器无法加密或读取凭据,请管理员检查网关主密钥配置。',
|
||||
CREDENTIAL_NOT_CONFIGURED: '请编辑上游并设置 API Key,或选择无需认证。',
|
||||
INVALID_BASE_URL: '请输入 HTTP 或 HTTPS 地址,不含用户名、密码、查询参数或片段。',
|
||||
INVALID_GATEWAY_BASE_URL: '请管理员检查 SHARESERVER_GATEWAY_BASE_URL 配置。',
|
||||
INVALID_CREDENTIAL_ACTION: '请检查凭据操作;只有替换凭据时才可填写 API Key。',
|
||||
INVALID_ALIAS: '模型别名须以字母或数字开头,仅支持字母、数字、点、下划线、冒号、斜杠和连字符。',
|
||||
ALIAS_EXISTS: '该模型别名已存在,请更换别名。',
|
||||
INVALID_MODEL_SCOPE: '请至少选择一个仍存在的模型,刷新后重试。',
|
||||
UPSTREAM_HTTP_ERROR: '上游拒绝请求,请检查上游地址、凭据和模型权限。',
|
||||
UPSTREAM_FAILURE: '无法连接上游,请检查地址和服务器网络。',
|
||||
REQUEST_TIMEOUT: '请求超时,请检查上游服务后重试。',
|
||||
INVALID_UPSTREAM_RESPONSE: '上游响应不符合 OpenAI Chat Completions 协议。',
|
||||
GATEWAY_STORAGE_UNAVAILABLE: '网关数据无法读取或保存,请管理员检查存储目录。',
|
||||
MODEL_DISABLED: '模型或上游已停用,请启用后重试。',
|
||||
UPSTREAM_DISABLED: '上游已停用,请启用后重试。',
|
||||
ADMIN_ACCESS_DENIED: '管理操作仅允许从服务器本机直接访问,请检查访问地址与代理配置。',
|
||||
INVALID_TEXT: '请检查必填项、长度及不可见字符。',
|
||||
INVALID_FIELDS: '请求字段不符合服务器接口,请刷新页面后重试。',
|
||||
MODEL_NOT_FOUND: '模型已不存在,请刷新配置后重试。',
|
||||
UPSTREAM_NOT_FOUND: '上游已不存在,请刷新配置后重试。',
|
||||
ACCESS_KEY_NOT_FOUND: '访问密钥已不存在,请刷新列表后重试。',
|
||||
}
|
||||
|
||||
export async function gatewayRequest<T>(path: string, signal: AbortSignal, method = 'GET', body?: unknown): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`/api/v1/gateway${path}`, { signal, method, ...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) })
|
||||
} catch { throw new Error('网络请求失败,请检查连接后重试。') }
|
||||
if (!response.ok) {
|
||||
const envelope = await response.json().catch(() => null)
|
||||
const code = envelope?.error?.code ?? envelope?.code
|
||||
// Never render arbitrary server messages: they may echo submitted credentials.
|
||||
const message = typeof code === 'string' && Object.hasOwn(errors, code) ? errors[code] : '请求失败,请检查输入与服务器配置后重试。'
|
||||
throw new Error(`HTTP ${response.status} · ${message}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export async function sharingRequest<T>(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
method = 'GET'
|
||||
): Promise<T> {
|
||||
const init: RequestInit = { signal }
|
||||
if (method !== 'GET') init.method = method
|
||||
const response = await fetch(`/api/v1/sharing${path}`, init)
|
||||
const payload = await response.json().catch(() => null) as { code?: string; message?: string; requestId?: string } | null
|
||||
if (!response.ok) {
|
||||
const details = [payload?.code, payload?.requestId ? `请求 ID:${payload.requestId}` : ''].filter(Boolean).join(' · ')
|
||||
throw new Error(`${payload?.message ?? `HTTP ${response.status}`}${details ? `(${details})` : ''}`)
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment node
|
||||
import { once } from 'node:events'
|
||||
import { createServer as createHttpServer, request, type OutgoingHttpHeaders } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createServer, type ViteDevServer } from 'vite'
|
||||
import config from '../vite.config'
|
||||
|
||||
describe('Vite API socket boundary over real HTTP', () => {
|
||||
const forwarded: string[] = []
|
||||
const peers: string[] = []
|
||||
const backend = createHttpServer((req, res) => {
|
||||
forwarded.push(req.url!)
|
||||
res.end('backend reached')
|
||||
})
|
||||
const lanAddress = Object.values(networkInterfaces()).flat().find(address => address?.family === 'IPv4' && !address.internal)?.address
|
||||
let vite: ViteDevServer | undefined
|
||||
let port: number
|
||||
|
||||
beforeAll(async () => {
|
||||
backend.listen(0, '127.0.0.1')
|
||||
await once(backend, 'listening')
|
||||
vite = await createServer({
|
||||
...config,
|
||||
configFile: false,
|
||||
plugins: [{
|
||||
name: 'observe-real-peer',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
peers.push(req.socket.remoteAddress!)
|
||||
next()
|
||||
})
|
||||
}
|
||||
}, ...config.plugins!],
|
||||
server: {
|
||||
...config.server,
|
||||
host: '::',
|
||||
port: 0,
|
||||
hmr: false,
|
||||
watch: null,
|
||||
proxy: { '/api': `http://127.0.0.1:${(backend.address() as AddressInfo).port}` }
|
||||
}
|
||||
})
|
||||
await vite.listen()
|
||||
port = (vite.httpServer!.address() as AddressInfo).port
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await vite?.close()
|
||||
if (backend.listening) await new Promise<void>((resolve, reject) => backend.close(error => error ? reject(error) : resolve()))
|
||||
})
|
||||
|
||||
function get(host: string, path: string, headers: OutgoingHttpHeaders = {}, method = 'GET') {
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const req = request({ host, port, path, headers, method, agent: false }, res => {
|
||||
let body = ''
|
||||
res.setEncoding('utf8')
|
||||
res.on('data', chunk => { body += chunk })
|
||||
res.on('end', () => resolve({ status: res.statusCode!, body }))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.setTimeout(5000, () => req.destroy(new Error('HTTP request timed out')))
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
it('defaults to an explicit IPv4 loopback listener', () => {
|
||||
expect(config.server?.host).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it.each(['127.0.0.1', '::1'])('proxies loopback %s regardless of forwarded headers', async host => {
|
||||
const path = '/api/v1/gateway/config'
|
||||
const count = forwarded.length
|
||||
expect(await get(host, path, { 'X-Forwarded-For': '203.0.113.10' })).toEqual({ status: 200, body: 'backend reached' })
|
||||
expect(forwarded.slice(count)).toEqual([path])
|
||||
expect(peers.at(-1)).toBe(host === '127.0.0.1' ? '::ffff:127.0.0.1' : '::1')
|
||||
})
|
||||
|
||||
it.skipIf(!lanAddress)('rejects LAN reads and mutations before proxying, even with spoofed loopback headers', async () => {
|
||||
const count = forwarded.length
|
||||
for (const [method, path] of [['GET', '/api/v1/gateway/config'], ['POST', '/api/v1/gateway/access-keys'], ['PUT', '/api/v1/gateway/upstreams/test'], ['GET', '/api'], ['GET', '/api/v1/models']]) {
|
||||
for (const headers of [{}, { Forwarded: 'for=127.0.0.1', 'X-Forwarded-For': '::1', 'X-Real-IP': '127.0.0.1', Host: 'localhost' }]) {
|
||||
const response = await get(lanAddress!, path, headers, method)
|
||||
expect(response.status).toBe(403)
|
||||
expect(JSON.parse(response.body).error.code).toBe('ADMIN_ACCESS_DENIED')
|
||||
}
|
||||
}
|
||||
expect(peers.at(-1)).toBe(`::ffff:${lanAddress}`)
|
||||
expect(forwarded).toHaveLength(count)
|
||||
expect((await get(lanAddress!, '/@vite/client')).status).toBe(200)
|
||||
})
|
||||
})
|
||||
+18
-2
@@ -1,19 +1,35 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { isIP } from 'node:net'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL('.', import.meta.url)),
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [react(), tailwindcss(), {
|
||||
name: 'loopback-api-only',
|
||||
configureServer(server) {
|
||||
// Check the original socket before the proxy replaces it with a local connection.
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!req.url?.startsWith('/api')) return next()
|
||||
const address = req.socket.remoteAddress?.toLowerCase()
|
||||
const ipv4 = address?.startsWith('::ffff:') ? address.slice(7) : address
|
||||
if (address === '::1' || (ipv4 !== undefined && isIP(ipv4) === 4 && ipv4.startsWith('127.'))) return next()
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: { code: 'ADMIN_ACCESS_DENIED', message: 'Direct loopback administration required.' } }))
|
||||
})
|
||||
}
|
||||
}],
|
||||
build: {
|
||||
outDir: '../dist/web',
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8787'
|
||||
'/api': `http://127.0.0.1:${process.env.SHARESERVER_API_PORT ?? process.env.SHARESERVER_PORT ?? '8787'}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user