Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ac7372585 | ||
|
|
5d730ba1b8 | ||
|
|
caf18f706d | ||
|
|
0dd521d8c9 | ||
|
|
0a47534ddb | ||
|
|
9e75e2df8a | ||
|
|
8d7c947897 | ||
|
|
71bde7d9e4 | ||
|
|
5037089057 | ||
|
|
b4c96f8524 | ||
|
|
e496fbe4b7 | ||
|
|
5d9c40e454 | ||
|
|
5592c27386 | ||
|
|
94142d7031 |
+14
-152
@@ -1,159 +1,21 @@
|
|||||||
name: Build Push and Deploy
|
name: Frontend CI/CD v2
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
- "latest"
|
workflow_dispatch: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-image:
|
build-test-publish-and-deploy:
|
||||||
runs-on: ubuntu-22.04
|
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
|
||||||
permissions:
|
with:
|
||||||
contents: read
|
image_name: gitea.waternetwork.cn/orgtjwater/tjwaterfrontend_refine
|
||||||
defaults:
|
dockerfile: Dockerfile
|
||||||
run:
|
build_context: .
|
||||||
shell: sh
|
deploy_service: frontend
|
||||||
|
deploy_host: 192.168.1.114
|
||||||
steps:
|
secrets:
|
||||||
- name: Checkout code
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
env:
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
SERVER_URL: ${{ github.server_url }}
|
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
|
||||||
REPOSITORY: ${{ github.repository }}
|
|
||||||
COMMIT_SHA: ${{ github.sha }}
|
|
||||||
GIT_USERNAME: ${{ github.actor }}
|
|
||||||
GIT_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
case "$SERVER_URL" in
|
|
||||||
http://*)
|
|
||||||
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
|
|
||||||
;;
|
|
||||||
https://*)
|
|
||||||
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
AUTH_SERVER_URL="$SERVER_URL"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ ! -d .git ]; then
|
|
||||||
git init .
|
|
||||||
fi
|
|
||||||
|
|
||||||
if git remote get-url origin >/dev/null 2>&1; then
|
|
||||||
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
|
||||||
else
|
|
||||||
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
|
|
||||||
fi
|
|
||||||
|
|
||||||
git fetch --depth=1 origin "$COMMIT_SHA"
|
|
||||||
git checkout --force --detach FETCH_HEAD
|
|
||||||
git clean -ffdx
|
|
||||||
|
|
||||||
- name: Normalize image metadata
|
|
||||||
env:
|
|
||||||
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
|
|
||||||
RAW_REPOSITORY: ${{ github.repository }}
|
|
||||||
IMAGE_TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
|
|
||||||
REGISTRY_HOST="${REGISTRY_HOST#https://}"
|
|
||||||
REGISTRY_HOST="${REGISTRY_HOST%/}"
|
|
||||||
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
|
|
||||||
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
|
|
||||||
{
|
|
||||||
echo "REGISTRY_HOST=${REGISTRY_HOST}"
|
|
||||||
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
|
|
||||||
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
|
|
||||||
echo "IMAGE_NAME=${IMAGE_NAME}"
|
|
||||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
|
||||||
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
|
|
||||||
} >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY_HOST" \
|
|
||||||
--username "${{ secrets.REGISTRY_USERNAME }}" \
|
|
||||||
--password-stdin
|
|
||||||
|
|
||||||
- name: Build and Push Image
|
|
||||||
run: |
|
|
||||||
push_with_retry() {
|
|
||||||
image_ref="$1"
|
|
||||||
attempt=1
|
|
||||||
max_attempts=3
|
|
||||||
|
|
||||||
while [ "$attempt" -le "$max_attempts" ]; do
|
|
||||||
if docker push "$image_ref"; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
docker build \
|
|
||||||
--network=host \
|
|
||||||
-f ./Dockerfile \
|
|
||||||
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
|
|
||||||
-t "${IMAGE_NAME}:latest" \
|
|
||||||
--build-arg NPM_CONFIG_REGISTRY="https://registry.npmmirror.com" \
|
|
||||||
.
|
|
||||||
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
|
|
||||||
push_with_retry "${IMAGE_NAME}:latest"
|
|
||||||
|
|
||||||
- name: Notify Deploy Server
|
|
||||||
run: |
|
|
||||||
post_deploy_webhook() {
|
|
||||||
label="$1"
|
|
||||||
payload="$2"
|
|
||||||
|
|
||||||
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "${{ vars.DEPLOY_WEBHOOK_URL }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.DEPLOY_WEBHOOK_TOKEN }}" \
|
|
||||||
-d "$payload")
|
|
||||||
|
|
||||||
echo "[$label] webhook HTTP status: ${http_code}"
|
|
||||||
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[$label] response headers:"
|
|
||||||
cat /tmp/deploy_headers.txt
|
|
||||||
echo "[$label] response body:"
|
|
||||||
cat /tmp/deploy_response.txt
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
|
|
||||||
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
|
|
||||||
|
|
||||||
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
|
|
||||||
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
|
|
||||||
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Primary webhook request failed, retrying with lowercase repo path..."
|
|
||||||
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
|
|
||||||
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Deploy webhook failed after primary and fallback attempts."
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
deploy-fallback-log:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
needs: docker-image
|
|
||||||
if: failure()
|
|
||||||
steps:
|
|
||||||
- name: Deployment not triggered
|
|
||||||
run: echo "Image build/push failed, deployment webhook was not called."
|
|
||||||
|
|||||||
@@ -1011,8 +1011,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"request",
|
"request",
|
||||||
|
"auto",
|
||||||
"always"
|
"always"
|
||||||
]
|
],
|
||||||
|
"description": "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1386,6 +1388,155 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/agent/sessions/{session_id}/credential-refreshes": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "post_sessions_session_id_credential_refreshes",
|
||||||
|
"tags": [
|
||||||
|
"Agent"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Resume a waiting agent tool call with refreshed credentials",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"required": true,
|
||||||
|
"name": "session_id",
|
||||||
|
"in": "path"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"request_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"request_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"202": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Authentication required",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Insufficient permission",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Resource not found",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Resource conflict",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation error",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal server error",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Upstream dependency error",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Dependency unavailable",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/agent/sessions/{session_id}/permission-responses": {
|
"/api/v1/agent/sessions/{session_id}/permission-responses": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_sessions_session_id_permission_responses",
|
"operationId": "post_sessions_session_id_permission_responses",
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
"contracts": {
|
"contracts": {
|
||||||
"agent": {
|
"agent": {
|
||||||
"file": "agent-v1.openapi.json",
|
"file": "agent-v1.openapi.json",
|
||||||
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259"
|
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"file": "server-v1.openapi.json",
|
|
||||||
"sha256": "d80a968d281fdb2953364a5979c2d61fda5151a1e1759c01cc96780b11a6d56c"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-334
@@ -2598,6 +2598,18 @@
|
|||||||
"title": "Map Y",
|
"title": "Map Y",
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"max_pipe_diameter": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "节点关联管道的最大管径,单位:毫米",
|
||||||
|
"title": "Max Pipe Diameter"
|
||||||
|
},
|
||||||
"node_id": {
|
"node_id": {
|
||||||
"title": "Node Id",
|
"title": "Node Id",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2613,6 +2625,7 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"node_id",
|
"node_id",
|
||||||
|
"max_pipe_diameter",
|
||||||
"project_x",
|
"project_x",
|
||||||
"project_y",
|
"project_y",
|
||||||
"map_x",
|
"map_x",
|
||||||
@@ -19425,108 +19438,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/network-schemas/user": {
|
|
||||||
"get": {
|
|
||||||
"description": "获取指定网络的用户模式定义",
|
|
||||||
"operationId": "get_network_schemas_user",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "header",
|
|
||||||
"name": "X-Project-Id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"title": "X-Project-Id",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"additionalProperties": {
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"title": "Response Get Network Schemas User",
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Successful Response"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Authentication required"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Insufficient permission"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Resource not found"
|
|
||||||
},
|
|
||||||
"409": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Resource conflict"
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Validation error"
|
|
||||||
},
|
|
||||||
"503": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Dependency unavailable"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"OAuth2PasswordBearer": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"summary": "获取用户模式",
|
|
||||||
"tags": [
|
|
||||||
"Users"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/network-schemas/valve": {
|
"/api/v1/network-schemas/valve": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "获取指定水网中所有阀门的架构和字段定义",
|
"description": "获取指定水网中所有阀门的架构和字段定义",
|
||||||
@@ -27430,7 +27341,7 @@
|
|||||||
},
|
},
|
||||||
"/api/v1/projects/current/lock/ownership": {
|
"/api/v1/projects/current/lock/ownership": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "检查指定项目是否被当前客户端 (IP) 锁定。",
|
"description": "检查指定项目是否被当前访问地址 (IP) 锁定。",
|
||||||
"operationId": "get_projects_current_lock_ownership",
|
"operationId": "get_projects_current_lock_ownership",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -35648,6 +35559,114 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/sensor-placement-candidates/{node_id}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "get_sensor_placement_candidates_node_id",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "node_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"maxLength": 32,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Node Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"in": "header",
|
||||||
|
"name": "X-Project-Id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"title": "X-Project-Id",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SensorPointResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Authentication required"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Insufficient permission"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Resource not found"
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Resource conflict"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Validation error"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Dependency unavailable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"OAuth2PasswordBearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "获取监测点候选节点详情",
|
||||||
|
"tags": [
|
||||||
|
"Sensor Placement"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/sensor-placement-optimization-runs": {
|
"/api/v1/sensor-placement-optimization-runs": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "post_sensor_placement_optimization_runs",
|
"operationId": "post_sensor_placement_optimization_runs",
|
||||||
@@ -47552,237 +47571,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/v1/users": {
|
|
||||||
"get": {
|
|
||||||
"description": "获取指定网络的所有用户列表",
|
|
||||||
"operationId": "get_users",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "limit",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"default": 100,
|
|
||||||
"maximum": 1000,
|
|
||||||
"minimum": 1,
|
|
||||||
"title": "Limit",
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "offset",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"default": 0,
|
|
||||||
"minimum": 0,
|
|
||||||
"title": "Offset",
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "header",
|
|
||||||
"name": "X-Project-Id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"title": "X-Project-Id",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/Page_dict_Any__Any__"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Successful Response"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Authentication required"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Insufficient permission"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Resource not found"
|
|
||||||
},
|
|
||||||
"409": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Resource conflict"
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Validation error"
|
|
||||||
},
|
|
||||||
"503": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Dependency unavailable"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"OAuth2PasswordBearer": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"summary": "获取所有用户",
|
|
||||||
"tags": [
|
|
||||||
"Users"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/users/detail": {
|
|
||||||
"get": {
|
|
||||||
"description": "获取指定网络中的单个用户信息",
|
|
||||||
"operationId": "get_users_detail",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"description": "用户名",
|
|
||||||
"in": "query",
|
|
||||||
"name": "user_name",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"description": "用户名",
|
|
||||||
"title": "User Name",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "header",
|
|
||||||
"name": "X-Project-Id",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"title": "X-Project-Id",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"title": "Response Get Users Detail",
|
|
||||||
"type": "object"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Successful Response"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Authentication required"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Insufficient permission"
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Resource not found"
|
|
||||||
},
|
|
||||||
"409": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Resource conflict"
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Validation error"
|
|
||||||
},
|
|
||||||
"503": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": "Dependency unavailable"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"OAuth2PasswordBearer": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"summary": "获取单个用户",
|
|
||||||
"tags": [
|
|
||||||
"Users"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/v1/valve-closure-analyses": {
|
"/api/v1/valve-closure-analyses": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。",
|
"description": "高级版本的阀门关闭分析,支持同时关闭多个阀门,并在指定持续时间内进行模拟。返回纯文本格式的分析结果。",
|
||||||
|
|||||||
+35
-18
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "@refinedev/core";
|
} from "@refinedev/core";
|
||||||
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
|
||||||
import { RefineSnackbarProvider } from "@refinedev/mui";
|
import { RefineSnackbarProvider } from "@refinedev/mui";
|
||||||
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
import { SessionProvider, signIn, useSession } from "next-auth/react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
@@ -17,10 +17,13 @@ import { ColorModeContextProvider } from "@contexts/color-mode";
|
|||||||
import { dataProvider } from "@providers/data-provider";
|
import { dataProvider } from "@providers/data-provider";
|
||||||
import { ProjectProvider } from "@/contexts/ProjectContext";
|
import { ProjectProvider } from "@/contexts/ProjectContext";
|
||||||
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
|
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
|
||||||
|
import { SessionExpiryDialog } from "@/components/auth/SessionExpiryDialog";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
|
||||||
|
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
|
||||||
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
|
||||||
@@ -57,6 +60,8 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const { data, status } = useSession();
|
const { data, status } = useSession();
|
||||||
const to = usePathname();
|
const to = usePathname();
|
||||||
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
const setAccessToken = useAuthStore((state) => state.setAccessToken);
|
||||||
|
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
|
||||||
|
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
|
||||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
const permissions = useAccessStore((state) => state.permissions);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
const setAccessContext = useAccessStore((state) => state.setContext);
|
const setAccessContext = useAccessStore((state) => state.setContext);
|
||||||
@@ -70,6 +75,20 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
);
|
);
|
||||||
}, [data?.accessToken, setAccessToken]);
|
}, [data?.accessToken, setAccessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.error === "SessionExpired") {
|
||||||
|
markSessionExpired("session_max_age");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data?.error === "RefreshAccessTokenError") {
|
||||||
|
markSessionExpired("refresh_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "authenticated") {
|
||||||
|
clearSessionExpired();
|
||||||
|
}
|
||||||
|
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status !== "authenticated") {
|
if (status !== "authenticated") {
|
||||||
resetAccess();
|
resetAccess();
|
||||||
@@ -135,28 +154,25 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
logout: async () => {
|
logout: () =>
|
||||||
try {
|
completeLogout({
|
||||||
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
|
reportAudit: () =>
|
||||||
method: "POST",
|
reportLogoutAudit({
|
||||||
headers: { "Content-Type": "application/json" },
|
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
|
||||||
body: JSON.stringify({ event: "logout" }),
|
accessToken:
|
||||||
projectHeaderMode: "omit",
|
typeof data?.accessToken === "string"
|
||||||
skipAuthRedirect: true,
|
? data.accessToken
|
||||||
});
|
: useAuthStore.getState().accessToken,
|
||||||
} catch {
|
}),
|
||||||
// Logout must still complete when audit storage is unavailable.
|
clearLocalState: () => {
|
||||||
}
|
|
||||||
if (data?.user?.id) {
|
if (data?.user?.id) {
|
||||||
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
|
||||||
}
|
}
|
||||||
signOut({ redirect: true, callbackUrl: "/login" });
|
clearSessionRecoveryDrafts();
|
||||||
return { success: true };
|
|
||||||
},
|
},
|
||||||
|
navigate: (path) => window.location.assign(path),
|
||||||
|
}),
|
||||||
onError: async (error) => {
|
onError: async (error) => {
|
||||||
if (error.response?.status === 401) {
|
|
||||||
return { logout: true };
|
|
||||||
}
|
|
||||||
return { error };
|
return { error };
|
||||||
},
|
},
|
||||||
check: async () =>
|
check: async () =>
|
||||||
@@ -351,6 +367,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
warnWhenUnsavedChanges: true,
|
warnWhenUnsavedChanges: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
|
||||||
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
|
||||||
<RefineKbar />
|
<RefineKbar />
|
||||||
</Refine>
|
</Refine>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import authOptions from "./options";
|
||||||
|
|
||||||
|
describe("NextAuth access token refresh", () => {
|
||||||
|
it("forces a Keycloak refresh for an agent credential request", async () => {
|
||||||
|
const previousEnv = {
|
||||||
|
issuer: process.env.KEYCLOAK_ISSUER,
|
||||||
|
clientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||||
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||||
|
};
|
||||||
|
process.env.KEYCLOAK_ISSUER = "https://keycloak.example/realms/tjwater";
|
||||||
|
process.env.KEYCLOAK_CLIENT_ID = "frontend";
|
||||||
|
process.env.KEYCLOAK_CLIENT_SECRET = "secret";
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const fetchMock = jest.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: "fresh-access-token",
|
||||||
|
expires_in: 900,
|
||||||
|
refresh_token: "rotated-refresh-token",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jwt = authOptions.callbacks?.jwt as (input: unknown) => Promise<{
|
||||||
|
accessToken?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
}>;
|
||||||
|
const result = await jwt({
|
||||||
|
token: {
|
||||||
|
accessToken: "still-fresh-access-token",
|
||||||
|
accessTokenExpires: Date.now() + 600_000,
|
||||||
|
accessTokenIssuedAt: Date.now(),
|
||||||
|
refreshToken: "refresh-token",
|
||||||
|
},
|
||||||
|
trigger: "update",
|
||||||
|
session: { forceRefresh: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.accessToken).toBe("fresh-access-token");
|
||||||
|
expect(result.refreshToken).toBe("rotated-refresh-token");
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://keycloak.example/realms/tjwater/protocol/openid-connect/token",
|
||||||
|
expect.objectContaining({ method: "POST" }),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (originalFetch) globalThis.fetch = originalFetch;
|
||||||
|
else delete (globalThis as { fetch?: typeof fetch }).fetch;
|
||||||
|
restoreEnv("KEYCLOAK_ISSUER", previousEnv.issuer);
|
||||||
|
restoreEnv("KEYCLOAK_CLIENT_ID", previousEnv.clientId);
|
||||||
|
restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["network failure", () => Promise.reject(new Error("connection refused"))],
|
||||||
|
[
|
||||||
|
"non-JSON response",
|
||||||
|
() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
json: async () => {
|
||||||
|
throw new SyntaxError("Unexpected token");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"invalid token payload",
|
||||||
|
() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ access_token: " ", expires_in: 0 }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
])("returns a session error for %s", async (_label, fetchResult) => {
|
||||||
|
const previousEnv = {
|
||||||
|
issuer: process.env.KEYCLOAK_ISSUER,
|
||||||
|
clientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||||
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||||
|
};
|
||||||
|
process.env.KEYCLOAK_ISSUER = "https://keycloak.example/realms/tjwater";
|
||||||
|
process.env.KEYCLOAK_CLIENT_ID = "frontend";
|
||||||
|
process.env.KEYCLOAK_CLIENT_SECRET = "secret";
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = jest.fn(fetchResult) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jwt = authOptions.callbacks?.jwt as (input: unknown) => Promise<{
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
await expect(
|
||||||
|
jwt({
|
||||||
|
token: { refreshToken: "refresh-token" },
|
||||||
|
trigger: "update",
|
||||||
|
session: { forceRefresh: true },
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ error: "RefreshAccessTokenError" });
|
||||||
|
} finally {
|
||||||
|
if (originalFetch) globalThis.fetch = originalFetch;
|
||||||
|
else delete (globalThis as { fetch?: typeof fetch }).fetch;
|
||||||
|
restoreEnv("KEYCLOAK_ISSUER", previousEnv.issuer);
|
||||||
|
restoreEnv("KEYCLOAK_CLIENT_ID", previousEnv.clientId);
|
||||||
|
restoreEnv("KEYCLOAK_CLIENT_SECRET", previousEnv.clientSecret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoreEnv = (key: string, value: string | undefined) => {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
};
|
||||||
@@ -3,6 +3,9 @@ import { JWT } from "next-auth/jwt";
|
|||||||
import KeycloakProvider from "next-auth/providers/keycloak";
|
import KeycloakProvider from "next-auth/providers/keycloak";
|
||||||
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
import Avatar from "@assets/avatar/avatar-small.jpeg";
|
||||||
|
|
||||||
|
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
|
||||||
|
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
|
||||||
|
|
||||||
type KeycloakTokenResponse = {
|
type KeycloakTokenResponse = {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
@@ -36,24 +39,43 @@ const refreshAccessToken = async (token: JWT): Promise<JWT> => {
|
|||||||
refresh_token: token.refreshToken,
|
refresh_token: token.refreshToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await fetch(keycloakTokenEndpoint, {
|
const response = await fetch(keycloakTokenEndpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
const refreshed = (await response.json()) as KeycloakTokenResponse;
|
const refreshed = (await response.json()) as Partial<KeycloakTokenResponse> | null;
|
||||||
|
|
||||||
if (!response.ok || !refreshed.access_token || typeof refreshed.expires_in !== "number") {
|
if (
|
||||||
|
!response.ok ||
|
||||||
|
!refreshed ||
|
||||||
|
typeof refreshed.access_token !== "string" ||
|
||||||
|
!refreshed.access_token.trim() ||
|
||||||
|
typeof refreshed.expires_in !== "number" ||
|
||||||
|
!Number.isFinite(refreshed.expires_in) ||
|
||||||
|
refreshed.expires_in <= 0
|
||||||
|
) {
|
||||||
return { ...token, error: "RefreshAccessTokenError" };
|
return { ...token, error: "RefreshAccessTokenError" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rotatedRefreshToken =
|
||||||
|
typeof refreshed.refresh_token === "string" &&
|
||||||
|
refreshed.refresh_token.trim()
|
||||||
|
? refreshed.refresh_token.trim()
|
||||||
|
: token.refreshToken;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...token,
|
...token,
|
||||||
accessToken: refreshed.access_token,
|
accessToken: refreshed.access_token.trim(),
|
||||||
|
accessTokenIssuedAt: Date.now(),
|
||||||
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
|
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
|
||||||
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
refreshToken: rotatedRefreshToken,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
};
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...token, error: "RefreshAccessTokenError" };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const authOptions: NextAuthOptions = {
|
const authOptions: NextAuthOptions = {
|
||||||
@@ -76,7 +98,7 @@ const authOptions: NextAuthOptions = {
|
|||||||
],
|
],
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
jwt: async ({ token, profile, account }) => {
|
jwt: async ({ token, profile, account, trigger, session }) => {
|
||||||
if (profile?.sub) {
|
if (profile?.sub) {
|
||||||
token.sub = profile.sub;
|
token.sub = profile.sub;
|
||||||
}
|
}
|
||||||
@@ -88,12 +110,17 @@ const authOptions: NextAuthOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
|
token.sessionExpiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000;
|
||||||
if (account.access_token) {
|
if (account.access_token) {
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
|
token.accessTokenIssuedAt = Date.now();
|
||||||
}
|
}
|
||||||
if (account.refresh_token) {
|
if (account.refresh_token) {
|
||||||
token.refreshToken = account.refresh_token;
|
token.refreshToken = account.refresh_token;
|
||||||
}
|
}
|
||||||
|
if (account.id_token) {
|
||||||
|
token.idToken = account.id_token;
|
||||||
|
}
|
||||||
if (typeof account.expires_at === "number") {
|
if (typeof account.expires_at === "number") {
|
||||||
token.accessTokenExpires = account.expires_at * 1000;
|
token.accessTokenExpires = account.expires_at * 1000;
|
||||||
}
|
}
|
||||||
@@ -101,7 +128,26 @@ const authOptions: NextAuthOptions = {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof token.accessTokenExpires === "number" && Date.now() < token.accessTokenExpires - 30_000) {
|
if (
|
||||||
|
typeof token.sessionExpiresAt === "number" &&
|
||||||
|
Date.now() >= token.sessionExpiresAt
|
||||||
|
) {
|
||||||
|
return { ...token, error: "SessionExpired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trigger === "update" &&
|
||||||
|
(session as { forceRefresh?: unknown } | undefined)?.forceRefresh === true
|
||||||
|
) {
|
||||||
|
return refreshAccessToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessTokenIsFresh =
|
||||||
|
typeof token.accessTokenExpires === "number" &&
|
||||||
|
typeof token.accessTokenIssuedAt === "number" &&
|
||||||
|
Date.now() < token.accessTokenExpires - ACCESS_TOKEN_REFRESH_SKEW_MS;
|
||||||
|
|
||||||
|
if (accessTokenIsFresh) {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +166,19 @@ const authOptions: NextAuthOptions = {
|
|||||||
if (token.error) {
|
if (token.error) {
|
||||||
session.error = token.error;
|
session.error = token.error;
|
||||||
}
|
}
|
||||||
|
if (typeof token.sessionExpiresAt === "number") {
|
||||||
|
session.sessionExpiresAt = token.sessionExpiresAt;
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default authOptions;
|
export default authOptions;
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment node
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
|
import { GET } from "./route";
|
||||||
|
|
||||||
|
jest.mock("next-auth/jwt", () => ({
|
||||||
|
getToken: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getTokenMock = getToken as jest.MockedFunction<typeof getToken>;
|
||||||
|
|
||||||
|
describe("GET /api/auth/keycloak-logout", () => {
|
||||||
|
const originalIssuer = process.env.KEYCLOAK_ISSUER;
|
||||||
|
const originalClientId = process.env.KEYCLOAK_CLIENT_ID;
|
||||||
|
const originalNextAuthUrl = process.env.NEXTAUTH_URL;
|
||||||
|
const originalPostLogoutRedirectUri =
|
||||||
|
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.KEYCLOAK_ISSUER = "https://keycloak.example.com/realms/tjwater";
|
||||||
|
process.env.KEYCLOAK_CLIENT_ID = "tjwater";
|
||||||
|
process.env.NEXTAUTH_URL = "https://frontend.example.com";
|
||||||
|
delete process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI;
|
||||||
|
getTokenMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env.KEYCLOAK_ISSUER = originalIssuer;
|
||||||
|
process.env.KEYCLOAK_CLIENT_ID = originalClientId;
|
||||||
|
process.env.NEXTAUTH_URL = originalNextAuthUrl;
|
||||||
|
if (originalPostLogoutRedirectUri) {
|
||||||
|
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI = originalPostLogoutRedirectUri;
|
||||||
|
} else {
|
||||||
|
delete process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the local session and redirects the browser to Keycloak logout", async () => {
|
||||||
|
getTokenMock.mockResolvedValue({
|
||||||
|
idToken: "header.payload.signature",
|
||||||
|
});
|
||||||
|
const request = new NextRequest(
|
||||||
|
"https://frontend.example.com/api/auth/keycloak-logout",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
cookie:
|
||||||
|
"__Secure-next-auth.session-token.0=first; __Secure-next-auth.session-token.1=second; next-auth.session-token=local",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET(request);
|
||||||
|
const logoutUrl = new URL(response.headers.get("location") ?? "");
|
||||||
|
|
||||||
|
expect(logoutUrl.origin).toBe("https://keycloak.example.com");
|
||||||
|
expect(logoutUrl.pathname).toBe(
|
||||||
|
"/realms/tjwater/protocol/openid-connect/logout",
|
||||||
|
);
|
||||||
|
expect(logoutUrl.searchParams.get("id_token_hint")).toBe(
|
||||||
|
"header.payload.signature",
|
||||||
|
);
|
||||||
|
expect(logoutUrl.searchParams.get("client_id")).toBe("tjwater");
|
||||||
|
expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBeNull();
|
||||||
|
expect(
|
||||||
|
response.cookies.get("__Secure-next-auth.session-token.0"),
|
||||||
|
).toMatchObject({ value: "", path: "/", secure: true });
|
||||||
|
expect(
|
||||||
|
response.cookies.get("__Secure-next-auth.session-token.1"),
|
||||||
|
).toMatchObject({ value: "", path: "/", secure: true });
|
||||||
|
expect(response.cookies.get("next-auth.session-token")).toMatchObject({
|
||||||
|
value: "",
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
response.cookies.get("next-auth.session-token")?.secure,
|
||||||
|
).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a legacy automatic post-logout redirect setting", async () => {
|
||||||
|
process.env.KEYCLOAK_POST_LOGOUT_REDIRECT_URI =
|
||||||
|
"https://frontend.example.com/login";
|
||||||
|
getTokenMock.mockResolvedValue({ idToken: "header.payload.signature" });
|
||||||
|
|
||||||
|
const response = await GET(
|
||||||
|
new NextRequest(
|
||||||
|
"https://frontend.example.com/api/auth/keycloak-logout",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const logoutUrl = new URL(response.headers.get("location") ?? "");
|
||||||
|
|
||||||
|
expect(logoutUrl.searchParams.get("post_logout_redirect_uri")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
|
type KeycloakToken = {
|
||||||
|
idToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionCookiePrefixes = [
|
||||||
|
"next-auth.session-token",
|
||||||
|
"__Secure-next-auth.session-token",
|
||||||
|
];
|
||||||
|
|
||||||
|
const isSessionCookie = (name: string) =>
|
||||||
|
sessionCookiePrefixes.some((prefix) => name.startsWith(prefix));
|
||||||
|
|
||||||
|
export const GET = async (request: NextRequest) => {
|
||||||
|
const localLoginUrl = new URL(
|
||||||
|
"/login",
|
||||||
|
process.env.NEXTAUTH_URL ?? request.nextUrl.origin,
|
||||||
|
);
|
||||||
|
const issuer = process.env.KEYCLOAK_ISSUER?.replace(/\/$/, "");
|
||||||
|
const clientId = process.env.KEYCLOAK_CLIENT_ID;
|
||||||
|
const token = (await getToken({
|
||||||
|
req: request,
|
||||||
|
secret: process.env.NEXTAUTH_SECRET,
|
||||||
|
})) as KeycloakToken | null;
|
||||||
|
const logoutUrl = issuer
|
||||||
|
? new URL(`${issuer}/protocol/openid-connect/logout`)
|
||||||
|
: localLoginUrl;
|
||||||
|
|
||||||
|
if (issuer) {
|
||||||
|
if (clientId) logoutUrl.searchParams.set("client_id", clientId);
|
||||||
|
if (token?.idToken) logoutUrl.searchParams.set("id_token_hint", token.idToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.redirect(logoutUrl);
|
||||||
|
for (const { name } of request.cookies.getAll()) {
|
||||||
|
if (isSessionCookie(name)) {
|
||||||
|
response.cookies.delete({
|
||||||
|
name,
|
||||||
|
path: "/",
|
||||||
|
secure: name.startsWith("__Secure-"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
|
|
||||||
type MetadataUser = {
|
type MetadataUser = {
|
||||||
@@ -479,6 +480,20 @@ export const SystemAdminPanel = () => {
|
|||||||
const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms);
|
const [databaseForms, setDatabaseForms] = useState(createDefaultDatabaseForms);
|
||||||
const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth);
|
const [databaseHealth, setDatabaseHealth] = useState(createEmptyDatabaseHealth);
|
||||||
|
|
||||||
|
const recoveryDraft = useMemo(
|
||||||
|
() => ({ tab, projectId, memberForm, projectForm, createProjectOpen, createProjectForm }),
|
||||||
|
[createProjectForm, createProjectOpen, memberForm, projectForm, projectId, tab],
|
||||||
|
);
|
||||||
|
const restoreRecoveryDraft = useCallback((draft: typeof recoveryDraft) => {
|
||||||
|
setTab(draft.tab);
|
||||||
|
setProjectId(draft.projectId);
|
||||||
|
setMemberForm(draft.memberForm);
|
||||||
|
setProjectForm(draft.projectForm);
|
||||||
|
setCreateProjectOpen(draft.createProjectOpen);
|
||||||
|
setCreateProjectForm(draft.createProjectForm);
|
||||||
|
}, []);
|
||||||
|
useSessionRecoveryDraft("system-admin", recoveryDraft, restoreRecoveryDraft);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
openNotificationRef.current = openNotification;
|
openNotificationRef.current = openNotification;
|
||||||
}, [openNotification]);
|
}, [openNotification]);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createTheme, ThemeProvider } from "@mui/material/styles";
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { SessionExpiryDialog } from "./SessionExpiryDialog";
|
||||||
|
|
||||||
|
jest.mock("next-auth/react", () => ({
|
||||||
|
signIn: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("SessionExpiryDialog", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => useAuthStore.getState().clearSessionExpired());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders above every regular application overlay", () => {
|
||||||
|
const theme = createTheme();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<SessionExpiryDialog />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialogRoot = screen.getByRole("dialog").closest(".MuiModal-root");
|
||||||
|
expect(dialogRoot).not.toBeNull();
|
||||||
|
expect(dialogRoot).toHaveStyle({
|
||||||
|
zIndex: theme.zIndex.tooltip + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import AccessTimeOutlinedIcon from "@mui/icons-material/AccessTimeOutlined";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Stack,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
|
const WARNING_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
type SessionExpiryDialogProps = {
|
||||||
|
expiresAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionExpiryDialog = ({
|
||||||
|
expiresAt,
|
||||||
|
}: SessionExpiryDialogProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||||
|
const reason = useAuthStore((state) => state.sessionExpiryReason);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const [warningDismissed, setWarningDismissed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isExpiringSoon = useMemo(
|
||||||
|
() =>
|
||||||
|
!sessionExpired &&
|
||||||
|
!warningDismissed &&
|
||||||
|
typeof expiresAt === "number" &&
|
||||||
|
expiresAt > now &&
|
||||||
|
expiresAt - now <= WARNING_WINDOW_MS,
|
||||||
|
[expiresAt, now, sessionExpired, warningDismissed],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReauthenticate = () => {
|
||||||
|
const callbackUrl = `${window.location.pathname}${window.location.search}`;
|
||||||
|
void signIn("keycloak", { callbackUrl, redirect: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOpen = sessionExpired || isExpiringSoon;
|
||||||
|
const title = sessionExpired ? "登录已过期" : "登录即将到期";
|
||||||
|
const detail = sessionExpired
|
||||||
|
? reason === "session_max_age"
|
||||||
|
? "已达到 12 小时的最长连续登录时间。请重新认证后继续。"
|
||||||
|
: "无法续期当前登录。请重新认证后继续。"
|
||||||
|
: "当前登录将在 15 分钟内到期。请先保存正在编辑的内容。";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
style={{ zIndex: theme.zIndex.tooltip + 1 }}
|
||||||
|
disableEscapeKeyDown={sessionExpired}
|
||||||
|
onClose={sessionExpired ? undefined : () => setWarningDismissed(true)}
|
||||||
|
aria-labelledby="session-expiry-dialog-title"
|
||||||
|
>
|
||||||
|
<DialogTitle id="session-expiry-dialog-title">{title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ pt: 0.5 }}>
|
||||||
|
<Alert icon={<AccessTimeOutlinedIcon />} severity={sessionExpired ? "warning" : "info"}>
|
||||||
|
{detail}
|
||||||
|
</Alert>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
重新认证不会自动重放已失败的写入请求;请在返回后确认内容并再次提交。
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
{!sessionExpired && (
|
||||||
|
<Button onClick={() => setWarningDismissed(true)}>稍后处理</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="contained" onClick={handleReauthenticate}>
|
||||||
|
重新认证
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -44,7 +44,7 @@ describe("AgentComposer", () => {
|
|||||||
modelOptions={[{ id: "test-model", label: "测试模型" }]}
|
modelOptions={[{ id: "test-model", label: "测试模型" }]}
|
||||||
selectedModel="test-model"
|
selectedModel="test-model"
|
||||||
onModelChange={jest.fn()}
|
onModelChange={jest.fn()}
|
||||||
approvalMode="request"
|
approvalMode="auto"
|
||||||
onApprovalModeChange={jest.fn()}
|
onApprovalModeChange={jest.fn()}
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>,
|
</ThemeProvider>,
|
||||||
@@ -56,6 +56,56 @@ describe("AgentComposer", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument();
|
||||||
expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument();
|
expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument();
|
||||||
expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument();
|
expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("自动批准")).toBeInTheDocument();
|
||||||
expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true);
|
expect(voiceButton.nextElementSibling?.contains(sendButton)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables input while the Agent runtime is unavailable", () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider theme={createTheme()}>
|
||||||
|
<AgentComposer
|
||||||
|
isStreaming={false}
|
||||||
|
runtimeState="unavailable"
|
||||||
|
isListening={false}
|
||||||
|
isSttSupported={false}
|
||||||
|
presets={[]}
|
||||||
|
onSend={jest.fn()}
|
||||||
|
onAbort={jest.fn()}
|
||||||
|
onStartListening={jest.fn()}
|
||||||
|
onStopListening={jest.fn()}
|
||||||
|
modelOptions={[]}
|
||||||
|
onModelChange={jest.fn()}
|
||||||
|
approvalMode="auto"
|
||||||
|
onApprovalModeChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText("Agent 服务未就绪,暂时无法发送消息")).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "发送" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the always-allow mode as an explicit warning state", () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider theme={createTheme()}>
|
||||||
|
<AgentComposer
|
||||||
|
isStreaming={false}
|
||||||
|
isListening={false}
|
||||||
|
isSttSupported={false}
|
||||||
|
presets={[]}
|
||||||
|
onSend={jest.fn()}
|
||||||
|
onAbort={jest.fn()}
|
||||||
|
onStartListening={jest.fn()}
|
||||||
|
onStopListening={jest.fn()}
|
||||||
|
modelOptions={[]}
|
||||||
|
onModelChange={jest.fn()}
|
||||||
|
approvalMode="always"
|
||||||
|
onApprovalModeChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("始终允许")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("WarningAmberRoundedIcon")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import BoltRounded from "@mui/icons-material/BoltRounded";
|
|||||||
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||||
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||||
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
|
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
|
||||||
|
import WarningAmberRounded from "@mui/icons-material/WarningAmberRounded";
|
||||||
|
import type { AgentRuntimeState } from "@/lib/agentRuntime";
|
||||||
import type { AgentModelOption } from "@/lib/chatModels";
|
import type { AgentModelOption } from "@/lib/chatModels";
|
||||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ export type AgentComposerHandle = {
|
|||||||
|
|
||||||
type AgentComposerProps = {
|
type AgentComposerProps = {
|
||||||
isHydrating?: boolean;
|
isHydrating?: boolean;
|
||||||
|
runtimeState?: AgentRuntimeState;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isSttSupported: boolean;
|
isSttSupported: boolean;
|
||||||
@@ -55,6 +58,35 @@ type AgentComposerProps = {
|
|||||||
onApprovalModeChange: (mode: AgentApprovalMode) => void;
|
onApprovalModeChange: (mode: AgentApprovalMode) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const approvalModeOptions: ReadonlyArray<{
|
||||||
|
value: AgentApprovalMode;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: "request",
|
||||||
|
label: "请求批准",
|
||||||
|
description: "白名单外的工具权限逐次确认",
|
||||||
|
icon: VerifiedUserRounded,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "auto",
|
||||||
|
label: "自动批准",
|
||||||
|
description: "低风险自动批准,其余仍需确认",
|
||||||
|
icon: AdminPanelSettingsRounded,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "always",
|
||||||
|
label: "始终允许",
|
||||||
|
description: "除明确禁止项外自动放行",
|
||||||
|
icon: WarningAmberRounded,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getApprovalModeOption = (value: AgentApprovalMode) =>
|
||||||
|
approvalModeOptions.find((option) => option.value === value) ?? approvalModeOptions[0];
|
||||||
|
|
||||||
const renderModelIcon = (
|
const renderModelIcon = (
|
||||||
icon: AgentModelOption["icon"] | undefined,
|
icon: AgentModelOption["icon"] | undefined,
|
||||||
props?: React.ComponentProps<typeof BoltRounded>,
|
props?: React.ComponentProps<typeof BoltRounded>,
|
||||||
@@ -67,6 +99,7 @@ const renderModelIcon = (
|
|||||||
|
|
||||||
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||||
isHydrating = false,
|
isHydrating = false,
|
||||||
|
runtimeState = "ready",
|
||||||
isStreaming,
|
isStreaming,
|
||||||
isListening,
|
isListening,
|
||||||
isSttSupported,
|
isSttSupported,
|
||||||
@@ -85,8 +118,19 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
||||||
const [input, setInput] = React.useState("");
|
const [input, setInput] = React.useState("");
|
||||||
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
||||||
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
const isRuntimeReady = runtimeState === "ready";
|
||||||
|
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating && isRuntimeReady;
|
||||||
|
const placeholder = isHydrating
|
||||||
|
? "正在加载对话记录..."
|
||||||
|
: runtimeState === "checking"
|
||||||
|
? "正在连接 Agent 服务..."
|
||||||
|
: runtimeState === "models_unavailable"
|
||||||
|
? "模型尚未加载,暂时无法发送消息"
|
||||||
|
: runtimeState === "unavailable"
|
||||||
|
? "Agent 服务未就绪,暂时无法发送消息"
|
||||||
|
: "描述你的分析目标,或点击上方指令库...";
|
||||||
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
|
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
|
||||||
|
const selectedApprovalModeOption = getApprovalModeOption(approvalMode);
|
||||||
|
|
||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
@@ -102,10 +146,10 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
|
|
||||||
const handleSend = React.useCallback(() => {
|
const handleSend = React.useCallback(() => {
|
||||||
const prompt = input.trim();
|
const prompt = input.trim();
|
||||||
if (!prompt || isStreaming || isHydrating) return;
|
if (!prompt || isStreaming || isHydrating || !isRuntimeReady) return;
|
||||||
setInput("");
|
setInput("");
|
||||||
onSend(prompt);
|
onSend(prompt);
|
||||||
}, [input, isHydrating, isStreaming, onSend]);
|
}, [input, isHydrating, isRuntimeReady, isStreaming, onSend]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
|
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
|
||||||
@@ -154,6 +198,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
label={prompt.replace(/[。.]$/, "")}
|
label={prompt.replace(/[。.]$/, "")}
|
||||||
size="medium"
|
size="medium"
|
||||||
clickable
|
clickable
|
||||||
|
disabled={!isRuntimeReady || isHydrating || isStreaming}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setInput(prompt);
|
setInput(prompt);
|
||||||
setIsPresetOpen(false);
|
setIsPresetOpen(false);
|
||||||
@@ -209,12 +254,12 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
|
placeholder={placeholder}
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
maxRows={5}
|
maxRows={5}
|
||||||
variant="standard"
|
variant="standard"
|
||||||
disabled={isHydrating}
|
disabled={isHydrating || !isRuntimeReady}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
disableUnderline: true,
|
disableUnderline: true,
|
||||||
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
|
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
|
||||||
@@ -223,26 +268,33 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
|
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
|
||||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||||
<FormControl size="small" sx={{ minWidth: 96 }}>
|
<FormControl size="small" sx={{ minWidth: 128 }}>
|
||||||
<Select
|
<Select
|
||||||
value={approvalMode}
|
value={approvalMode}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
onApprovalModeChange(event.target.value as AgentApprovalMode)
|
onApprovalModeChange(event.target.value as AgentApprovalMode)
|
||||||
}
|
}
|
||||||
disabled={isHydrating || isStreaming}
|
disabled={isHydrating || isStreaming || !isRuntimeReady}
|
||||||
aria-label="权限批准模式"
|
aria-label="权限批准模式"
|
||||||
renderValue={(val) => (
|
renderValue={() => {
|
||||||
|
const SelectedApprovalIcon = selectedApprovalModeOption.icon;
|
||||||
|
return (
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
|
||||||
{val === "always" ? (
|
<SelectedApprovalIcon
|
||||||
<AdminPanelSettingsRounded sx={{ fontSize: 18, color: "inherit" }} />
|
sx={{
|
||||||
) : (
|
fontSize: 18,
|
||||||
<VerifiedUserRounded sx={{ fontSize: 18, color: "inherit" }} />
|
color:
|
||||||
)}
|
selectedApprovalModeOption.value === "always"
|
||||||
|
? "warning.main"
|
||||||
|
: "inherit",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
|
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
|
||||||
{val === "always" ? "始终允许" : "请求批准"}
|
{selectedApprovalModeOption.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
MenuProps={{
|
MenuProps={{
|
||||||
anchorOrigin: { vertical: "top", horizontal: "left" },
|
anchorOrigin: { vertical: "top", horizontal: "left" },
|
||||||
transformOrigin: { vertical: "bottom", horizontal: "left" },
|
transformOrigin: { vertical: "bottom", horizontal: "left" },
|
||||||
@@ -250,7 +302,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
PaperProps: {
|
PaperProps: {
|
||||||
sx: {
|
sx: {
|
||||||
mb: 1.5,
|
mb: 1.5,
|
||||||
width: 210,
|
width: 248,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
bgcolor: alpha("#fff", 0.9),
|
bgcolor: alpha("#fff", 0.9),
|
||||||
backdropFilter: "blur(24px)",
|
backdropFilter: "blur(24px)",
|
||||||
@@ -269,6 +321,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
|
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
|
||||||
"& .title": { color: "#00838f" },
|
"& .title": { color: "#00838f" },
|
||||||
"& .icon": { color: "#00acc1" },
|
"& .icon": { color: "#00acc1" },
|
||||||
|
"& .always-icon": { color: "warning.main" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -298,20 +351,47 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="request">
|
{approvalModeOptions.map((option) => {
|
||||||
<VerifiedUserRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
|
const ApprovalIcon = option.icon;
|
||||||
|
const isAlways = option.value === "always";
|
||||||
|
return (
|
||||||
|
<MenuItem key={option.value} value={option.value}>
|
||||||
|
<ApprovalIcon
|
||||||
|
className={isAlways ? "icon always-icon" : "icon"}
|
||||||
|
sx={{
|
||||||
|
mr: 1.5,
|
||||||
|
mt: 0.15,
|
||||||
|
fontSize: 18,
|
||||||
|
color: isAlways ? "warning.main" : "text.secondary",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>请求批准</Typography>
|
<Typography
|
||||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>工具权限逐次确认</Typography>
|
className="title"
|
||||||
</Box>
|
sx={{
|
||||||
</MenuItem>
|
mb: 0.2,
|
||||||
<MenuItem value="always">
|
color: "text.primary",
|
||||||
<AdminPanelSettingsRounded className="icon" sx={{ mr: 1.5, mt: 0.15, fontSize: 18, color: "text.secondary" }} />
|
fontSize: "0.85rem",
|
||||||
<Box>
|
fontWeight: 700,
|
||||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2 }}>始终允许</Typography>
|
}}
|
||||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>自动允许本轮权限请求</Typography>
|
>
|
||||||
|
{option.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.description}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -452,7 +532,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
) : (
|
) : (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={onStartListening}
|
onClick={onStartListening}
|
||||||
disabled={isStreaming || isHydrating}
|
disabled={isStreaming || isHydrating || !isRuntimeReady}
|
||||||
aria-label="语音输入"
|
aria-label="语音输入"
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import CloseRounded from "@mui/icons-material/CloseRounded";
|
|||||||
import EditRounded from "@mui/icons-material/EditRounded";
|
import EditRounded from "@mui/icons-material/EditRounded";
|
||||||
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||||
import HistoryRounded from "@mui/icons-material/HistoryRounded";
|
import HistoryRounded from "@mui/icons-material/HistoryRounded";
|
||||||
|
import type { AgentRuntimeState } from "@/lib/agentRuntime";
|
||||||
|
|
||||||
type AgentHeaderProps = {
|
type AgentHeaderProps = {
|
||||||
sessionTitle?: string;
|
sessionTitle?: string;
|
||||||
canRenameSessionTitle?: boolean;
|
canRenameSessionTitle?: boolean;
|
||||||
isHydrating?: boolean;
|
isHydrating?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
runtimeState?: AgentRuntimeState;
|
||||||
isHistoryOpen: boolean;
|
isHistoryOpen: boolean;
|
||||||
onHistoryToggle: () => void;
|
onHistoryToggle: () => void;
|
||||||
onRenameSessionTitle?: (title: string) => void;
|
onRenameSessionTitle?: (title: string) => void;
|
||||||
@@ -37,6 +39,7 @@ export const AgentHeader = ({
|
|||||||
canRenameSessionTitle = false,
|
canRenameSessionTitle = false,
|
||||||
isHydrating = false,
|
isHydrating = false,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
runtimeState = "ready",
|
||||||
isHistoryOpen,
|
isHistoryOpen,
|
||||||
onHistoryToggle,
|
onHistoryToggle,
|
||||||
onRenameSessionTitle,
|
onRenameSessionTitle,
|
||||||
@@ -47,6 +50,14 @@ export const AgentHeader = ({
|
|||||||
const displayTitle = sessionTitle?.trim() || "新对话";
|
const displayTitle = sessionTitle?.trim() || "新对话";
|
||||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||||
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
|
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
|
||||||
|
const runtimeStatus =
|
||||||
|
runtimeState === "unavailable" || runtimeState === "models_unavailable"
|
||||||
|
? { color: "#e53935", label: "Agent 服务未就绪" }
|
||||||
|
: runtimeState === "checking"
|
||||||
|
? { color: "#ffb300", label: "正在连接 Agent 服务" }
|
||||||
|
: isStreaming
|
||||||
|
? { color: "#ff9800", label: "Agent 正在生成" }
|
||||||
|
: { color: "#00e676", label: "Agent 已就绪" };
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isEditingTitle) {
|
if (!isEditingTitle) {
|
||||||
@@ -109,17 +120,19 @@ export const AgentHeader = ({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box
|
<Box
|
||||||
|
role="status"
|
||||||
|
aria-label={runtimeStatus.label}
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: -2,
|
bottom: -2,
|
||||||
right: -2,
|
right: -2,
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
bgcolor: isStreaming ? "#ff9800" : "#00e676",
|
bgcolor: runtimeStatus.color,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
border: "2.5px solid #fff",
|
border: "2.5px solid #fff",
|
||||||
boxShadow: `0 0 10px ${isStreaming ? "#ff9800" : "#00e676"}`,
|
boxShadow: `0 0 10px ${runtimeStatus.color}`,
|
||||||
animation: isStreaming ? "pulse 1.5s infinite" : "none",
|
animation: isStreaming && runtimeState === "ready" ? "pulse 1.5s infinite" : "none",
|
||||||
"@keyframes pulse": {
|
"@keyframes pulse": {
|
||||||
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
|
"0%": { boxShadow: `0 0 0 0 ${alpha("#ff9800", 0.7)}` },
|
||||||
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
|
"70%": { boxShadow: `0 0 0 6px ${alpha("#ff9800", 0)}` },
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import TerminalRounded from "@mui/icons-material/TerminalRounded";
|
|||||||
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
|
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
|
||||||
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
import BlockRounded from "@mui/icons-material/BlockRounded";
|
import BlockRounded from "@mui/icons-material/BlockRounded";
|
||||||
import PushPinRounded from "@mui/icons-material/PushPinRounded";
|
|
||||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||||
|
import GppGoodRounded from "@mui/icons-material/GppGoodRounded";
|
||||||
|
|
||||||
import type { PermissionReply } from "@/lib/chatStream";
|
import type { PermissionDecision } from "@/lib/chatStream";
|
||||||
import type { Message } from "./GlobalChatbox.types";
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
|
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
|
||||||
@@ -58,7 +58,7 @@ const PermissionIcon = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
|
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
|
||||||
if (status === "approved_always") return "已始终允许";
|
if (status === "approved_always") return "已保存授权";
|
||||||
if (status === "approved_once") return "已允许一次";
|
if (status === "approved_once") return "已允许一次";
|
||||||
if (status === "rejected") return "已拒绝";
|
if (status === "rejected") return "已拒绝";
|
||||||
if (status === "aborted") return "已中断";
|
if (status === "aborted") return "已中断";
|
||||||
@@ -99,7 +99,7 @@ const PermissionRequestCard = ({
|
|||||||
}: {
|
}: {
|
||||||
permission: NonNullable<Message["permissions"]>[number];
|
permission: NonNullable<Message["permissions"]>[number];
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
onReply: (requestId: string, reply: PermissionReply) => void;
|
onReply: (requestId: string, reply: PermissionDecision) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isPending =
|
const isPending =
|
||||||
@@ -109,6 +109,9 @@ const PermissionRequestCard = ({
|
|||||||
const accentColor = getPermissionStatusColor(permission.status, theme);
|
const accentColor = getPermissionStatusColor(permission.status, theme);
|
||||||
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
|
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||||||
const statusLabel = getPermissionStatusLabel(permission.status);
|
const statusLabel = getPermissionStatusLabel(permission.status);
|
||||||
|
const persistentScope = permission.always.length > 0
|
||||||
|
? permission.always
|
||||||
|
: permission.patterns;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -203,6 +206,29 @@ const PermissionRequestCard = ({
|
|||||||
{primaryValue}
|
{primaryValue}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
{isPending || isSubmitting ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.85,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: alpha(theme.palette.success.main, 0.055),
|
||||||
|
border: `1px solid ${alpha(theme.palette.success.main, 0.11)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="success.dark" fontWeight={800}>
|
||||||
|
保存授权范围
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
|
||||||
|
sx={{ display: "block", mt: 0.2, lineHeight: 1.45, wordBreak: "break-word", whiteSpace: "pre-wrap" }}
|
||||||
|
>
|
||||||
|
{persistentScope.join("\n")}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{permission.error ? (
|
{permission.error ? (
|
||||||
@@ -264,10 +290,11 @@ const PermissionRequestCard = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
color="success"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={() => onReply(permission.requestId, "always")}
|
onClick={() => onReply(permission.requestId, "always")}
|
||||||
startIcon={<PushPinRounded fontSize="small" />}
|
startIcon={<GppGoodRounded fontSize="small" />}
|
||||||
sx={{
|
sx={{
|
||||||
height: 34,
|
height: 34,
|
||||||
borderRadius: "17px",
|
borderRadius: "17px",
|
||||||
@@ -275,16 +302,15 @@ const PermissionRequestCard = ({
|
|||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
fontSize: "0.78rem",
|
fontSize: "0.78rem",
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
color: "#00838f",
|
borderColor: alpha(theme.palette.success.main, 0.28),
|
||||||
borderColor: alpha("#00838f", 0.24),
|
|
||||||
bgcolor: alpha("#fff", 0.45),
|
bgcolor: alpha("#fff", 0.45),
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
borderColor: alpha("#00838f", 0.36),
|
borderColor: alpha(theme.palette.success.main, 0.42),
|
||||||
bgcolor: alpha("#00838f", 0.08),
|
bgcolor: alpha(theme.palette.success.main, 0.08),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
始终允许
|
保存授权
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -323,7 +349,7 @@ export const PermissionRequestGroup = ({
|
|||||||
}: {
|
}: {
|
||||||
permissions: NonNullable<Message["permissions"]>;
|
permissions: NonNullable<Message["permissions"]>;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
onReply: (requestId: string, reply: PermissionReply) => void;
|
onReply: (requestId: string, reply: PermissionDecision) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
|
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
|
||||||
@@ -348,7 +374,7 @@ export const PermissionRequestGroup = ({
|
|||||||
const summaryItems = [
|
const summaryItems = [
|
||||||
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
|
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
|
||||||
{ label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) },
|
{ label: "允许一次", value: onceCount, color: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) },
|
||||||
{ label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
|
{ label: "保存授权", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
|
||||||
{ label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
|
{ label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
|
||||||
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
|
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -112,4 +112,56 @@ describe("AgentTurn speech selection", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "从这里开始朗读" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers one-time and saved permission approval with distinct actions", () => {
|
||||||
|
const onReplyPermission = jest.fn();
|
||||||
|
render(
|
||||||
|
<AgentTurn
|
||||||
|
message={{
|
||||||
|
id: "assistant-permission",
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
progress: [
|
||||||
|
{
|
||||||
|
id: "permission-progress",
|
||||||
|
phase: "permission",
|
||||||
|
status: "running",
|
||||||
|
title: "等待权限确认",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
requestId: "permission-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
target: "npm test",
|
||||||
|
always: ["npm test"],
|
||||||
|
createdAt: 1,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
isStreaming
|
||||||
|
messageSpeechState="idle"
|
||||||
|
onSpeak={jest.fn()}
|
||||||
|
onPause={jest.fn()}
|
||||||
|
onResume={jest.fn()}
|
||||||
|
onStopSpeech={jest.fn()}
|
||||||
|
isTtsSupported
|
||||||
|
onCreateBranch={jest.fn()}
|
||||||
|
onReplyPermission={onReplyPermission}
|
||||||
|
onReplyQuestion={jest.fn()}
|
||||||
|
onRejectQuestion={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "允许一次" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("保存授权范围")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("npm test")).toHaveLength(2);
|
||||||
|
expect(screen.getByTestId("GppGoodRoundedIcon")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "保存授权" }));
|
||||||
|
expect(onReplyPermission).toHaveBeenCalledWith("permission-1", "always");
|
||||||
|
expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
import ContentCopyRounded from "@mui/icons-material/ContentCopyRounded";
|
||||||
import { TbArrowsSplit2 } from "react-icons/tb";
|
import { TbArrowsSplit2 } from "react-icons/tb";
|
||||||
import type { PermissionReply } from "@/lib/chatStream";
|
import type { PermissionDecision } from "@/lib/chatStream";
|
||||||
import {
|
import {
|
||||||
parseAssistantMessageSections,
|
parseAssistantMessageSections,
|
||||||
parseContentWithToolCalls,
|
parseContentWithToolCalls,
|
||||||
@@ -100,7 +100,7 @@ type AgentTurnProps = {
|
|||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
isTtsSupported: boolean;
|
isTtsSupported: boolean;
|
||||||
onCreateBranch: (messageId: string) => void;
|
onCreateBranch: (messageId: string) => void;
|
||||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
|
||||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
onRejectQuestion: (requestId: string) => void;
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
import { AgentWorkspace } from "./AgentWorkspace";
|
import { AgentWorkspace } from "./AgentWorkspace";
|
||||||
import type { Message } from "./GlobalChatbox.types";
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
@@ -85,6 +85,38 @@ describe("AgentWorkspace", () => {
|
|||||||
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
|
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows runtime startup failures in the existing empty state and retries there", () => {
|
||||||
|
const onRetryRuntime = jest.fn();
|
||||||
|
render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
runtimeState="unavailable"
|
||||||
|
onRetryRuntime={onRetryRuntime}
|
||||||
|
messages={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Agent 服务未就绪")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("我已就绪,请描述任务")).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "重新检测" }));
|
||||||
|
expect(onRetryRuntime).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes model loading failures from backend startup failures", () => {
|
||||||
|
render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
runtimeState="models_unavailable"
|
||||||
|
messages={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("模型未加载")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/模型配置加载失败/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
|
it("keeps stable history turns from re-rendering while the last assistant message streams", () => {
|
||||||
const userMessage: Message = {
|
const userMessage: Message = {
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
@@ -164,4 +196,70 @@ describe("AgentWorkspace", () => {
|
|||||||
expect(unmountCounts.get("assistant-1") ?? 0).toBe(0);
|
expect(unmountCounts.get("assistant-1") ?? 0).toBe(0);
|
||||||
expect(streamingFlags.get("assistant-1")).toBe(false);
|
expect(streamingFlags.get("assistant-1")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("windows long conversations instead of mounting every turn", () => {
|
||||||
|
const messages = Array.from({ length: 200 }, (_, index): Message => ({
|
||||||
|
id: `message-${index}`,
|
||||||
|
role: index % 2 === 0 ? "user" : "assistant",
|
||||||
|
content: `message ${index}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
messages={messages}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(renderCounts.size).toBeGreaterThan(0);
|
||||||
|
expect(renderCounts.size).toBeLessThan(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the flex alignment context around windowed turns", () => {
|
||||||
|
const messages = Array.from({ length: 41 }, (_, index): Message => ({
|
||||||
|
id: `message-${index}`,
|
||||||
|
role: index % 2 === 0 ? "user" : "assistant",
|
||||||
|
content: `message ${index}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
messages={messages}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("turn-message-0").parentElement).toHaveStyle({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps visible turns mounted when crossing the windowing threshold", () => {
|
||||||
|
const messages = Array.from({ length: 41 }, (_, index): Message => ({
|
||||||
|
id: `message-${index}`,
|
||||||
|
role: index % 2 === 0 ? "user" : "assistant",
|
||||||
|
content: `message ${index}`,
|
||||||
|
}));
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
messages={messages.slice(0, 40)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
messages={messages}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mountCounts.get("message-0")).toBe(1);
|
||||||
|
expect(unmountCounts.get("message-0") ?? 0).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
|
import { Box, Button, CircularProgress, Paper, Skeleton, Stack, Typography, alpha, Grid } from "@mui/material";
|
||||||
import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
|
import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
|
||||||
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||||||
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
||||||
import MapRounded from "@mui/icons-material/MapRounded";
|
import MapRounded from "@mui/icons-material/MapRounded";
|
||||||
|
import ReplayRounded from "@mui/icons-material/ReplayRounded";
|
||||||
|
|
||||||
import { AgentTurn } from "./AgentTurn";
|
import { AgentTurn } from "./AgentTurn";
|
||||||
import type { PermissionReply } from "@/lib/chatStream";
|
import type { AgentRuntimeState } from "@/lib/agentRuntime";
|
||||||
|
import type { PermissionDecision } from "@/lib/chatStream";
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
SpeechState,
|
SpeechState,
|
||||||
@@ -19,6 +21,8 @@ import type {
|
|||||||
type AgentWorkspaceProps = {
|
type AgentWorkspaceProps = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
runtimeState?: AgentRuntimeState;
|
||||||
|
onRetryRuntime?: () => void;
|
||||||
isLoadingSession?: boolean;
|
isLoadingSession?: boolean;
|
||||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||||
@@ -35,13 +39,15 @@ type AgentWorkspaceProps = {
|
|||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
isTtsSupported: boolean;
|
isTtsSupported: boolean;
|
||||||
onCreateBranch: (messageId: string) => void;
|
onCreateBranch: (messageId: string) => void;
|
||||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
|
||||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
onRejectQuestion: (requestId: string) => void;
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TurnListProps = {
|
type TurnListProps = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
|
scrollTop: number;
|
||||||
|
viewportHeight: number;
|
||||||
isAssistantStreaming: boolean;
|
isAssistantStreaming: boolean;
|
||||||
streamingMessageId: string | null;
|
streamingMessageId: string | null;
|
||||||
speakingMessageId: string | null;
|
speakingMessageId: string | null;
|
||||||
@@ -56,13 +62,18 @@ type TurnListProps = {
|
|||||||
onStopSpeech: () => void;
|
onStopSpeech: () => void;
|
||||||
isTtsSupported: boolean;
|
isTtsSupported: boolean;
|
||||||
onCreateBranch: (messageId: string) => void;
|
onCreateBranch: (messageId: string) => void;
|
||||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
onReplyPermission: (requestId: string, reply: PermissionDecision) => void;
|
||||||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
onRejectQuestion: (requestId: string) => void;
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||||
const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120;
|
const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120;
|
||||||
|
const TURN_WINDOW_THRESHOLD = 40;
|
||||||
|
const TURN_ESTIMATED_HEIGHT_PX = 220;
|
||||||
|
const TURN_GAP_PX = 16;
|
||||||
|
const TURN_OVERSCAN_PX = 600;
|
||||||
|
const DEFAULT_VIEWPORT_HEIGHT_PX = 720;
|
||||||
|
|
||||||
const sameMessages = (left: Message[], right: Message[]) =>
|
const sameMessages = (left: Message[], right: Message[]) =>
|
||||||
left.length === right.length &&
|
left.length === right.length &&
|
||||||
@@ -72,6 +83,8 @@ const TurnItem = React.memo(AgentTurn);
|
|||||||
|
|
||||||
const TurnListInner = ({
|
const TurnListInner = ({
|
||||||
messages,
|
messages,
|
||||||
|
scrollTop,
|
||||||
|
viewportHeight,
|
||||||
isAssistantStreaming,
|
isAssistantStreaming,
|
||||||
streamingMessageId,
|
streamingMessageId,
|
||||||
speakingMessageId,
|
speakingMessageId,
|
||||||
@@ -86,11 +99,98 @@ const TurnListInner = ({
|
|||||||
onReplyQuestion,
|
onReplyQuestion,
|
||||||
onRejectQuestion,
|
onRejectQuestion,
|
||||||
}: TurnListProps) => {
|
}: TurnListProps) => {
|
||||||
|
const [measuredHeights, setMeasuredHeights] = React.useState(
|
||||||
|
() => new Map<string, number>(),
|
||||||
|
);
|
||||||
|
const isWindowed = messages.length > TURN_WINDOW_THRESHOLD;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const activeIds = new Set(messages.map((message) => message.id));
|
||||||
|
setMeasuredHeights((current) => {
|
||||||
|
if ([...current.keys()].every((messageId) => activeIds.has(messageId))) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return new Map(
|
||||||
|
[...current].filter(([messageId]) => activeIds.has(messageId)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const updateMeasuredHeight = React.useCallback(
|
||||||
|
(messageId: string, height: number) => {
|
||||||
|
if (!Number.isFinite(height) || height <= 0) return;
|
||||||
|
const roundedHeight = Math.ceil(height);
|
||||||
|
setMeasuredHeights((current) => {
|
||||||
|
if (current.get(messageId) === roundedHeight) return current;
|
||||||
|
const next = new Map(current);
|
||||||
|
next.set(messageId, roundedHeight);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const turnOffsets = React.useMemo(() => {
|
||||||
|
const offsets = new Array<number>(messages.length + 1).fill(0);
|
||||||
|
if (!isWindowed) return offsets;
|
||||||
|
for (let index = 0; index < messages.length; index += 1) {
|
||||||
|
const message = messages[index];
|
||||||
|
const size =
|
||||||
|
(measuredHeights.get(message.id) ?? TURN_ESTIMATED_HEIGHT_PX) +
|
||||||
|
TURN_GAP_PX;
|
||||||
|
offsets[index + 1] = offsets[index] + size;
|
||||||
|
}
|
||||||
|
return offsets;
|
||||||
|
}, [isWindowed, measuredHeights, messages]);
|
||||||
|
|
||||||
|
const windowState = React.useMemo(() => {
|
||||||
|
if (!isWindowed) {
|
||||||
|
return {
|
||||||
|
endIndex: messages.length,
|
||||||
|
startIndex: 0,
|
||||||
|
topSpacerHeight: 0,
|
||||||
|
bottomSpacerHeight: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleStart = Math.max(0, scrollTop - TURN_OVERSCAN_PX);
|
||||||
|
const visibleEnd = scrollTop + viewportHeight + TURN_OVERSCAN_PX;
|
||||||
|
const startIndex = Math.max(
|
||||||
|
0,
|
||||||
|
lowerBound(turnOffsets, visibleStart, 1) - 1,
|
||||||
|
);
|
||||||
|
const endIndex = lowerBound(turnOffsets, visibleEnd, startIndex);
|
||||||
|
|
||||||
|
const boundedEndIndex = Math.min(
|
||||||
|
messages.length,
|
||||||
|
Math.max(startIndex + 1, endIndex),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
startIndex,
|
||||||
|
endIndex: boundedEndIndex,
|
||||||
|
topSpacerHeight: turnOffsets[startIndex],
|
||||||
|
bottomSpacerHeight:
|
||||||
|
turnOffsets[messages.length] - turnOffsets[boundedEndIndex],
|
||||||
|
};
|
||||||
|
}, [isWindowed, messages.length, scrollTop, turnOffsets, viewportHeight]);
|
||||||
|
|
||||||
|
const visibleMessages = isWindowed
|
||||||
|
? messages.slice(windowState.startIndex, windowState.endIndex)
|
||||||
|
: messages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{messages.map((message) => (
|
{windowState.topSpacerHeight > 0 ? (
|
||||||
<TurnItem
|
<Box aria-hidden sx={{ height: windowState.topSpacerHeight, flexShrink: 0 }} />
|
||||||
|
) : null}
|
||||||
|
{visibleMessages.map((message) => (
|
||||||
|
<MeasuredTurn
|
||||||
key={message.id}
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
measure={isWindowed}
|
||||||
|
onHeightChange={updateMeasuredHeight}
|
||||||
|
>
|
||||||
|
<TurnItem
|
||||||
message={message}
|
message={message}
|
||||||
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
|
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
|
||||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||||
@@ -104,15 +204,74 @@ const TurnListInner = ({
|
|||||||
onReplyQuestion={onReplyQuestion}
|
onReplyQuestion={onReplyQuestion}
|
||||||
onRejectQuestion={onRejectQuestion}
|
onRejectQuestion={onRejectQuestion}
|
||||||
/>
|
/>
|
||||||
|
</MeasuredTurn>
|
||||||
))}
|
))}
|
||||||
|
{windowState.bottomSpacerHeight > 0 ? (
|
||||||
|
<Box aria-hidden sx={{ height: windowState.bottomSpacerHeight, flexShrink: 0 }} />
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lowerBound = (values: number[], target: number, fromIndex: number) => {
|
||||||
|
let low = Math.max(0, fromIndex);
|
||||||
|
let high = values.length;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = low + Math.floor((high - low) / 2);
|
||||||
|
if (values[middle] < target) low = middle + 1;
|
||||||
|
else high = middle;
|
||||||
|
}
|
||||||
|
return low;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeasuredTurnProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
measure: boolean;
|
||||||
|
message: Message;
|
||||||
|
onHeightChange: (messageId: string, height: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MeasuredTurn = ({
|
||||||
|
children,
|
||||||
|
measure,
|
||||||
|
message,
|
||||||
|
onHeightChange,
|
||||||
|
}: MeasuredTurnProps) => {
|
||||||
|
const rowRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const row = rowRef.current;
|
||||||
|
if (!measure || !row) return;
|
||||||
|
const measureRow = () =>
|
||||||
|
onHeightChange(message.id, row.getBoundingClientRect().height);
|
||||||
|
measureRow();
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
const observer = new ResizeObserver(measureRow);
|
||||||
|
observer.observe(row);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [measure, message.id, onHeightChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={rowRef}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
flexShrink: 0,
|
||||||
|
mb: measure ? `${TURN_GAP_PX}px` : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const TurnList = React.memo(
|
const TurnList = React.memo(
|
||||||
TurnListInner,
|
TurnListInner,
|
||||||
(prevProps, nextProps) =>
|
(prevProps, nextProps) =>
|
||||||
sameMessages(prevProps.messages, nextProps.messages) &&
|
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||||
|
prevProps.scrollTop === nextProps.scrollTop &&
|
||||||
|
prevProps.viewportHeight === nextProps.viewportHeight &&
|
||||||
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
|
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
|
||||||
prevProps.streamingMessageId === nextProps.streamingMessageId &&
|
prevProps.streamingMessageId === nextProps.streamingMessageId &&
|
||||||
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||||
@@ -130,8 +289,34 @@ const TurnList = React.memo(
|
|||||||
|
|
||||||
TurnList.displayName = "TurnList";
|
TurnList.displayName = "TurnList";
|
||||||
|
|
||||||
const EmptyState = () => {
|
const EmptyState = ({
|
||||||
const theme = useTheme();
|
runtimeState,
|
||||||
|
onRetryRuntime,
|
||||||
|
}: {
|
||||||
|
runtimeState: AgentRuntimeState;
|
||||||
|
onRetryRuntime?: () => void;
|
||||||
|
}) => {
|
||||||
|
const isReady = runtimeState === "ready";
|
||||||
|
const isChecking = runtimeState === "checking";
|
||||||
|
const statusCopy = isChecking
|
||||||
|
? {
|
||||||
|
title: "正在连接 Agent 服务",
|
||||||
|
detail: "正在确认后端运行时和模型加载状态,请稍候。",
|
||||||
|
}
|
||||||
|
: runtimeState === "models_unavailable"
|
||||||
|
? {
|
||||||
|
title: "模型未加载",
|
||||||
|
detail: "Agent 服务已启动,但模型配置加载失败。请检查模型配置后重新检测。",
|
||||||
|
}
|
||||||
|
: runtimeState === "unavailable"
|
||||||
|
? {
|
||||||
|
title: "Agent 服务未就绪",
|
||||||
|
detail: "无法连接 Agent 后端,或运行时启动失败。请检查服务后重新检测。",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "我已就绪,请描述任务",
|
||||||
|
detail: "你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。",
|
||||||
|
};
|
||||||
const capabilities = [
|
const capabilities = [
|
||||||
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
|
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
|
||||||
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
|
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
|
||||||
@@ -147,6 +332,8 @@ const EmptyState = () => {
|
|||||||
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
|
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
|
||||||
>
|
>
|
||||||
<Paper
|
<Paper
|
||||||
|
role={isReady || isChecking ? "status" : "alert"}
|
||||||
|
aria-live="polite"
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
p: 4,
|
p: 4,
|
||||||
@@ -170,9 +357,9 @@ const EmptyState = () => {
|
|||||||
}} />
|
}} />
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
animate={{
|
||||||
y: [-6, 4, -6],
|
y: isReady ? [-6, 4, -6] : 0,
|
||||||
scale: [1, 1.04, 1],
|
scale: isReady ? [1, 1.04, 1] : 1,
|
||||||
rotate: [-3, 3, -3],
|
rotate: isReady ? [-3, 3, -3] : 0,
|
||||||
}}
|
}}
|
||||||
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
|
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
|
||||||
style={{
|
style={{
|
||||||
@@ -194,17 +381,32 @@ const EmptyState = () => {
|
|||||||
height={54}
|
height={54}
|
||||||
style={{
|
style={{
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))",
|
filter: isReady
|
||||||
|
? "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))"
|
||||||
|
: "grayscale(0.65) opacity(0.72)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
|
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
|
||||||
我已就绪,请描述任务
|
{statusCopy.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: isReady ? 3 : 2 }}>
|
||||||
你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。
|
{statusCopy.detail}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{isChecking ? (
|
||||||
|
<CircularProgress size={28} thickness={4} aria-label="正在检测 Agent 服务" />
|
||||||
|
) : !isReady ? (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<ReplayRounded />}
|
||||||
|
onClick={onRetryRuntime}
|
||||||
|
sx={{ borderRadius: 999, px: 2, boxShadow: "none" }}
|
||||||
|
>
|
||||||
|
重新检测
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Grid container spacing={1.5}>
|
<Grid container spacing={1.5}>
|
||||||
{capabilities.map((item) => (
|
{capabilities.map((item) => (
|
||||||
<Grid item xs={6} key={item.label}>
|
<Grid item xs={6} key={item.label}>
|
||||||
@@ -239,6 +441,7 @@ const EmptyState = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
@@ -309,6 +512,8 @@ const SessionLoadingSkeleton = () => (
|
|||||||
export const AgentWorkspace = ({
|
export const AgentWorkspace = ({
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
runtimeState = "ready",
|
||||||
|
onRetryRuntime,
|
||||||
isLoadingSession = false,
|
isLoadingSession = false,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
bottomRef,
|
bottomRef,
|
||||||
@@ -325,14 +530,23 @@ export const AgentWorkspace = ({
|
|||||||
onReplyQuestion,
|
onReplyQuestion,
|
||||||
onRejectQuestion,
|
onRejectQuestion,
|
||||||
}: AgentWorkspaceProps) => {
|
}: AgentWorkspaceProps) => {
|
||||||
|
const localScrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [scrollMetrics, setScrollMetrics] = React.useState({
|
||||||
|
scrollTop: 0,
|
||||||
|
viewportHeight: DEFAULT_VIEWPORT_HEIGHT_PX,
|
||||||
|
});
|
||||||
const streamingMessageId =
|
const streamingMessageId =
|
||||||
isStreaming && messages.at(-1)?.role === "assistant"
|
isStreaming && messages.at(-1)?.role === "assistant"
|
||||||
? messages.at(-1)?.id ?? null
|
? messages.at(-1)?.id ?? null
|
||||||
: null;
|
: null;
|
||||||
const handleScroll = React.useCallback(
|
const handleScroll = React.useCallback(
|
||||||
(event: React.UIEvent<HTMLDivElement>) => {
|
(event: React.UIEvent<HTMLDivElement>) => {
|
||||||
if (!onScrollStateChange) return;
|
|
||||||
const target = event.currentTarget;
|
const target = event.currentTarget;
|
||||||
|
setScrollMetrics({
|
||||||
|
scrollTop: target.scrollTop,
|
||||||
|
viewportHeight: target.clientHeight || DEFAULT_VIEWPORT_HEIGHT_PX,
|
||||||
|
});
|
||||||
|
if (!onScrollStateChange) return;
|
||||||
const distanceToBottom =
|
const distanceToBottom =
|
||||||
target.scrollHeight - target.scrollTop - target.clientHeight;
|
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||||
onScrollStateChange(
|
onScrollStateChange(
|
||||||
@@ -343,9 +557,37 @@ export const AgentWorkspace = ({
|
|||||||
[isStreaming, onScrollStateChange],
|
[isStreaming, onScrollStateChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setScrollContainer = React.useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
localScrollContainerRef.current = node;
|
||||||
|
if (scrollContainerRef) {
|
||||||
|
scrollContainerRef.current = node;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollContainerRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const container = localScrollContainerRef.current;
|
||||||
|
if (!container || typeof ResizeObserver === "undefined") return;
|
||||||
|
const updateViewportHeight = () => {
|
||||||
|
const viewportHeight =
|
||||||
|
container.clientHeight || DEFAULT_VIEWPORT_HEIGHT_PX;
|
||||||
|
setScrollMetrics((current) =>
|
||||||
|
current.viewportHeight === viewportHeight
|
||||||
|
? current
|
||||||
|
: { ...current, viewportHeight },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
updateViewportHeight();
|
||||||
|
const observer = new ResizeObserver(updateViewportHeight);
|
||||||
|
observer.observe(container);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={scrollContainerRef}
|
ref={setScrollContainer}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -363,13 +605,30 @@ export const AgentWorkspace = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{messages.length === 0 ? <EmptyState /> : null}
|
{messages.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
runtimeState={runtimeState}
|
||||||
|
onRetryRuntime={onRetryRuntime}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: messages.length > TURN_WINDOW_THRESHOLD ? 0 : 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TurnList
|
<TurnList
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
scrollTop={
|
||||||
|
messages.length > TURN_WINDOW_THRESHOLD
|
||||||
|
? scrollMetrics.scrollTop
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
viewportHeight={scrollMetrics.viewportHeight}
|
||||||
isAssistantStreaming={isStreaming}
|
isAssistantStreaming={isStreaming}
|
||||||
streamingMessageId={streamingMessageId}
|
streamingMessageId={streamingMessageId}
|
||||||
speakingMessageId={speakingMessageId}
|
speakingMessageId={speakingMessageId}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { act, render, screen } from "@testing-library/react";
|
|||||||
import { GlobalChatbox } from "./GlobalChatbox";
|
import { GlobalChatbox } from "./GlobalChatbox";
|
||||||
|
|
||||||
const createSession = jest.fn();
|
const createSession = jest.fn();
|
||||||
|
const mockFetchAgentRuntimeHealth = jest.fn();
|
||||||
|
const mockFetchAgentModels = jest.fn();
|
||||||
let mockCurrentProjectId = "project-1";
|
let mockCurrentProjectId = "project-1";
|
||||||
|
|
||||||
jest.mock("@refinedev/core", () => ({
|
jest.mock("@refinedev/core", () => ({
|
||||||
@@ -12,7 +14,12 @@ jest.mock("@refinedev/core", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@/lib/chatModels", () => ({
|
jest.mock("@/lib/chatModels", () => ({
|
||||||
fetchAgentModels: jest.fn(() => new Promise(() => {})),
|
fetchAgentModels: (...args: unknown[]) => mockFetchAgentModels(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@/lib/agentRuntime", () => ({
|
||||||
|
fetchAgentRuntimeHealth: (...args: unknown[]) =>
|
||||||
|
mockFetchAgentRuntimeHealth(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@/store/projectStore", () => ({
|
jest.mock("@/store/projectStore", () => ({
|
||||||
@@ -73,12 +80,14 @@ jest.mock("./AgentHistoryPanel", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("./AgentWorkspace", () => ({
|
jest.mock("./AgentWorkspace", () => ({
|
||||||
AgentWorkspace: () => <div data-testid="agent-workspace">Workspace</div>,
|
AgentWorkspace: ({ runtimeState }: { runtimeState: string }) => (
|
||||||
|
<div data-testid="agent-workspace">Workspace state: {runtimeState}</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("./AgentComposer", () => ({
|
jest.mock("./AgentComposer", () => ({
|
||||||
AgentComposer: React.forwardRef(function MockAgentComposer() {
|
AgentComposer: React.forwardRef(function MockAgentComposer(props: { approvalMode: string }, _ref) {
|
||||||
return <div>Composer</div>;
|
return <div>Composer mode: {props.approvalMode}</div>;
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -90,12 +99,17 @@ describe("GlobalChatbox lifecycle", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
createSession.mockClear();
|
createSession.mockClear();
|
||||||
|
mockFetchAgentRuntimeHealth.mockReset();
|
||||||
|
mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {}));
|
||||||
|
mockFetchAgentModels.mockReset();
|
||||||
|
mockFetchAgentModels.mockImplementation(() => new Promise(() => {}));
|
||||||
mockCurrentProjectId = "project-1";
|
mockCurrentProjectId = "project-1";
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.runOnlyPendingTimers();
|
jest.runOnlyPendingTimers();
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps content mounted and preserves the session across close and reopen", async () => {
|
it("keeps content mounted and preserves the session across close and reopen", async () => {
|
||||||
@@ -121,4 +135,60 @@ describe("GlobalChatbox lifecycle", () => {
|
|||||||
|
|
||||||
expect(createSession).toHaveBeenCalledTimes(2);
|
expect(createSession).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults the composer to automatic permission approval", () => {
|
||||||
|
render(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Composer mode: auto")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes backend startup failures into the existing workspace empty state", async () => {
|
||||||
|
mockFetchAgentRuntimeHealth.mockResolvedValueOnce(false);
|
||||||
|
render(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
|
||||||
|
await act(async () => Promise.resolve());
|
||||||
|
|
||||||
|
expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reserves models unavailable for an empty model configuration", async () => {
|
||||||
|
mockFetchAgentRuntimeHealth.mockResolvedValueOnce(true);
|
||||||
|
mockFetchAgentModels.mockResolvedValueOnce({ models: [] });
|
||||||
|
render(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Workspace state: models_unavailable")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an auth dependency failure as unavailable and retries quickly", async () => {
|
||||||
|
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
mockFetchAgentRuntimeHealth.mockResolvedValue(true);
|
||||||
|
mockFetchAgentModels
|
||||||
|
.mockRejectedValueOnce(new Error("authentication service unavailable"))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
defaultModel: "provider/model",
|
||||||
|
models: [{ id: "provider/model", label: "Model" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument();
|
||||||
|
expect(mockFetchAgentModels).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
jest.advanceTimersByTime(5_000);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Workspace state: ready")).toBeInTheDocument();
|
||||||
|
expect(mockFetchAgentModels).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
|||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
|
|
||||||
import { getAccessToken } from "@/lib/authToken";
|
import { getAccessToken } from "@/lib/authToken";
|
||||||
|
import {
|
||||||
|
fetchAgentRuntimeHealth,
|
||||||
|
type AgentRuntimeState,
|
||||||
|
} from "@/lib/agentRuntime";
|
||||||
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
|
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
|
||||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
@@ -26,6 +30,9 @@ import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
|||||||
|
|
||||||
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||||
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
|
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
|
||||||
|
const AGENT_RUNTIME_POLL_MS = 30_000;
|
||||||
|
const AGENT_RUNTIME_RETRY_MS = 5_000;
|
||||||
|
const AGENT_RUNTIME_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||||
const [width, setWidth] = useState(520);
|
const [width, setWidth] = useState(520);
|
||||||
@@ -34,8 +41,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
||||||
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||||
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
|
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
|
||||||
|
const [runtimeState, setRuntimeState] = useState<AgentRuntimeState>("checking");
|
||||||
const [approvalMode, setApprovalMode] =
|
const [approvalMode, setApprovalMode] =
|
||||||
useState<AgentApprovalMode>("request");
|
useState<AgentApprovalMode>("auto");
|
||||||
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const workspaceScrollRef = useRef<HTMLDivElement>(null);
|
const workspaceScrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -43,6 +51,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
const streamingScrollFrameRef = useRef<number | null>(null);
|
const streamingScrollFrameRef = useRef<number | null>(null);
|
||||||
const composerRef = useRef<AgentComposerHandle | null>(null);
|
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
|
const initializedProjectIdRef = useRef<string | null | undefined>(undefined);
|
||||||
|
const runtimeRequestIdRef = useRef(0);
|
||||||
|
const runtimeAbortRef = useRef<AbortController | null>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { open: openNotification } = useNotification();
|
const { open: openNotification } = useNotification();
|
||||||
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
const currentProjectId = useProjectStore((state) => state.currentProjectId);
|
||||||
@@ -68,13 +78,38 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
isSupported: isSttSupported,
|
isSupported: isSttSupported,
|
||||||
} = useSpeechRecognition(handleSpeechResult);
|
} = useSpeechRecognition(handleSpeechResult);
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshAgentRuntime = useCallback(async (showChecking = true) => {
|
||||||
let cancelled = false;
|
const requestId = ++runtimeRequestIdRef.current;
|
||||||
|
runtimeAbortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
runtimeAbortRef.current = controller;
|
||||||
|
const timeoutId = window.setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
AGENT_RUNTIME_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
let runtimeHealthy = false;
|
||||||
|
|
||||||
|
if (showChecking) setRuntimeState("checking");
|
||||||
|
|
||||||
const loadModels = async () => {
|
|
||||||
try {
|
try {
|
||||||
const modelConfig = await fetchAgentModels();
|
runtimeHealthy = await fetchAgentRuntimeHealth(controller.signal);
|
||||||
if (cancelled) return;
|
if (requestId !== runtimeRequestIdRef.current) return;
|
||||||
|
if (!runtimeHealthy) {
|
||||||
|
setRuntimeState("unavailable");
|
||||||
|
setModelOptions([]);
|
||||||
|
setSelectedModel(undefined);
|
||||||
|
return "unavailable" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelConfig = await fetchAgentModels(controller.signal);
|
||||||
|
if (requestId !== runtimeRequestIdRef.current) return;
|
||||||
|
if (modelConfig.models.length === 0) {
|
||||||
|
setRuntimeState("models_unavailable");
|
||||||
|
setModelOptions([]);
|
||||||
|
setSelectedModel(undefined);
|
||||||
|
return "models_unavailable" as const;
|
||||||
|
}
|
||||||
|
|
||||||
setModelOptions(modelConfig.models);
|
setModelOptions(modelConfig.models);
|
||||||
setSelectedModel((current) => {
|
setSelectedModel((current) => {
|
||||||
if (current && modelConfig.models.some((model) => model.id === current)) {
|
if (current && modelConfig.models.some((model) => model.id === current)) {
|
||||||
@@ -82,21 +117,48 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
}
|
}
|
||||||
return modelConfig.defaultModel;
|
return modelConfig.defaultModel;
|
||||||
});
|
});
|
||||||
|
setRuntimeState("ready");
|
||||||
|
return "ready" as const;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GlobalChatbox] Failed to load agent models:", error);
|
if (requestId !== runtimeRequestIdRef.current) return;
|
||||||
if (!cancelled) {
|
console.error("[GlobalChatbox] Failed to check agent runtime:", error);
|
||||||
|
setRuntimeState("unavailable");
|
||||||
setModelOptions([]);
|
setModelOptions([]);
|
||||||
setSelectedModel(undefined);
|
setSelectedModel(undefined);
|
||||||
|
return "unavailable" as const;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
if (runtimeAbortRef.current === controller) {
|
||||||
|
runtimeAbortRef.current = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let pollTimerId: number | undefined;
|
||||||
|
|
||||||
|
const pollRuntime = async (showChecking: boolean) => {
|
||||||
|
const nextState = await refreshAgentRuntime(showChecking);
|
||||||
|
if (cancelled || !nextState) return;
|
||||||
|
pollTimerId = window.setTimeout(
|
||||||
|
() => void pollRuntime(false),
|
||||||
|
nextState === "ready" ? AGENT_RUNTIME_POLL_MS : AGENT_RUNTIME_RETRY_MS,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
void loadModels();
|
void pollRuntime(true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (pollTimerId !== undefined) window.clearTimeout(pollTimerId);
|
||||||
|
runtimeRequestIdRef.current += 1;
|
||||||
|
runtimeAbortRef.current?.abort();
|
||||||
|
runtimeAbortRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [open, refreshAgentRuntime]);
|
||||||
|
|
||||||
const handleToolCall = useAgentToolActions();
|
const handleToolCall = useAgentToolActions();
|
||||||
const {
|
const {
|
||||||
@@ -203,7 +265,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
|
}, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
|
||||||
|
|
||||||
const handleSend = useCallback(async (prompt: string) => {
|
const handleSend = useCallback(async (prompt: string) => {
|
||||||
if (isStreaming || isCheckingAuth) return;
|
if (isStreaming || isCheckingAuth || runtimeState !== "ready") return;
|
||||||
|
|
||||||
setIsCheckingAuth(true);
|
setIsCheckingAuth(true);
|
||||||
try {
|
try {
|
||||||
@@ -229,7 +291,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsCheckingAuth(false);
|
setIsCheckingAuth(false);
|
||||||
}
|
}
|
||||||
}, [isCheckingAuth, isStreaming, openNotification, sendPrompt]);
|
}, [isCheckingAuth, isStreaming, openNotification, runtimeState, sendPrompt]);
|
||||||
|
|
||||||
const handleNewConversation = useCallback(() => {
|
const handleNewConversation = useCallback(() => {
|
||||||
handleStopSpeech();
|
handleStopSpeech();
|
||||||
@@ -373,6 +435,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
canRenameSessionTitle={Boolean(activeSessionId)}
|
canRenameSessionTitle={Boolean(activeSessionId)}
|
||||||
isHydrating={isHydrating}
|
isHydrating={isHydrating}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
runtimeState={runtimeState}
|
||||||
isHistoryOpen={isHistoryOpen}
|
isHistoryOpen={isHistoryOpen}
|
||||||
onHistoryToggle={handleHistoryToggle}
|
onHistoryToggle={handleHistoryToggle}
|
||||||
onRenameSessionTitle={handleRenameActiveSession}
|
onRenameSessionTitle={handleRenameActiveSession}
|
||||||
@@ -430,6 +493,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
<AgentWorkspace
|
<AgentWorkspace
|
||||||
messages={messages}
|
messages={messages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
runtimeState={runtimeState}
|
||||||
|
onRetryRuntime={() => void refreshAgentRuntime(true)}
|
||||||
isLoadingSession={Boolean(loadingSessionId)}
|
isLoadingSession={Boolean(loadingSessionId)}
|
||||||
scrollContainerRef={workspaceScrollRef}
|
scrollContainerRef={workspaceScrollRef}
|
||||||
bottomRef={bottomRef}
|
bottomRef={bottomRef}
|
||||||
@@ -450,6 +515,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
<AgentComposer
|
<AgentComposer
|
||||||
ref={composerRef}
|
ref={composerRef}
|
||||||
isHydrating={isHydrating || isCheckingAuth}
|
isHydrating={isHydrating || isCheckingAuth}
|
||||||
|
runtimeState={runtimeState}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
isListening={isListening}
|
isListening={isListening}
|
||||||
isSttSupported={isSttSupported}
|
isSttSupported={isSttSupported}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession";
|
|||||||
import {
|
import {
|
||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
|
replyAgentCredentialRefresh,
|
||||||
replyAgentPermission,
|
replyAgentPermission,
|
||||||
replyAgentQuestion,
|
replyAgentQuestion,
|
||||||
resumeAgentChatStream,
|
resumeAgentChatStream,
|
||||||
@@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream";
|
|||||||
jest.mock("@/lib/chatStream", () => ({
|
jest.mock("@/lib/chatStream", () => ({
|
||||||
abortAgentChat: jest.fn(async () => undefined),
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
forkAgentChat: jest.fn(async () => "forked-session"),
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
|
replyAgentCredentialRefresh: jest.fn(async () => undefined),
|
||||||
replyAgentPermission: jest.fn(async () => undefined),
|
replyAgentPermission: jest.fn(async () => undefined),
|
||||||
replyAgentQuestion: jest.fn(async () => undefined),
|
replyAgentQuestion: jest.fn(async () => undefined),
|
||||||
resumeAgentChatStream: jest.fn(async () => undefined),
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
streamAgentChat: jest.fn(async () => undefined),
|
streamAgentChat: jest.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockUpdateSession = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("next-auth/react", () => ({
|
||||||
|
useSession: () => ({ update: mockUpdateSession }),
|
||||||
|
}));
|
||||||
|
|
||||||
const listChatSessions = jest.fn();
|
const listChatSessions = jest.fn();
|
||||||
const deleteChatSession = jest.fn();
|
const deleteChatSession = jest.fn();
|
||||||
const updateChatSessionTitle = jest.fn();
|
const updateChatSessionTitle = jest.fn();
|
||||||
@@ -51,12 +59,14 @@ describe("useAgentChatSession", () => {
|
|||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
jest.mocked(forkAgentChat).mockReset();
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
|
jest.mocked(replyAgentCredentialRefresh).mockReset();
|
||||||
jest.mocked(replyAgentPermission).mockReset();
|
jest.mocked(replyAgentPermission).mockReset();
|
||||||
jest.mocked(replyAgentQuestion).mockReset();
|
jest.mocked(replyAgentQuestion).mockReset();
|
||||||
jest.mocked(resumeAgentChatStream).mockReset();
|
jest.mocked(resumeAgentChatStream).mockReset();
|
||||||
jest.mocked(streamAgentChat).mockReset();
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||||
|
jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined);
|
||||||
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||||
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAgentChatSession } from "./useAgentChatSession";
|
|||||||
import {
|
import {
|
||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
|
replyAgentCredentialRefresh,
|
||||||
replyAgentPermission,
|
replyAgentPermission,
|
||||||
replyAgentQuestion,
|
replyAgentQuestion,
|
||||||
resumeAgentChatStream,
|
resumeAgentChatStream,
|
||||||
@@ -16,12 +17,19 @@ import type { StreamEvent } from "@/lib/chatStream";
|
|||||||
jest.mock("@/lib/chatStream", () => ({
|
jest.mock("@/lib/chatStream", () => ({
|
||||||
abortAgentChat: jest.fn(async () => undefined),
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
forkAgentChat: jest.fn(async () => "forked-session"),
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
|
replyAgentCredentialRefresh: jest.fn(async () => undefined),
|
||||||
replyAgentPermission: jest.fn(async () => undefined),
|
replyAgentPermission: jest.fn(async () => undefined),
|
||||||
replyAgentQuestion: jest.fn(async () => undefined),
|
replyAgentQuestion: jest.fn(async () => undefined),
|
||||||
resumeAgentChatStream: jest.fn(async () => undefined),
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
streamAgentChat: jest.fn(async () => undefined),
|
streamAgentChat: jest.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockUpdateSession = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("next-auth/react", () => ({
|
||||||
|
useSession: () => ({ update: mockUpdateSession }),
|
||||||
|
}));
|
||||||
|
|
||||||
const listChatSessions = jest.fn();
|
const listChatSessions = jest.fn();
|
||||||
const deleteChatSession = jest.fn();
|
const deleteChatSession = jest.fn();
|
||||||
const updateChatSessionTitle = jest.fn();
|
const updateChatSessionTitle = jest.fn();
|
||||||
@@ -51,12 +59,14 @@ describe("useAgentChatSession", () => {
|
|||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
jest.mocked(forkAgentChat).mockReset();
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
|
jest.mocked(replyAgentCredentialRefresh).mockReset();
|
||||||
jest.mocked(replyAgentPermission).mockReset();
|
jest.mocked(replyAgentPermission).mockReset();
|
||||||
jest.mocked(replyAgentQuestion).mockReset();
|
jest.mocked(replyAgentQuestion).mockReset();
|
||||||
jest.mocked(resumeAgentChatStream).mockReset();
|
jest.mocked(resumeAgentChatStream).mockReset();
|
||||||
jest.mocked(streamAgentChat).mockReset();
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||||
|
jest.mocked(replyAgentCredentialRefresh).mockImplementation(async () => undefined);
|
||||||
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||||
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
|
|||||||
@@ -1,14 +1,52 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
import {
|
||||||
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
abortAgentChat,
|
||||||
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
forkAgentChat,
|
||||||
|
rejectAgentQuestion,
|
||||||
|
replyAgentCredentialRefresh,
|
||||||
|
replyAgentPermission,
|
||||||
|
replyAgentQuestion,
|
||||||
|
resumeAgentChatStream,
|
||||||
|
streamAgentChat,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
import type { PermissionDecision, StreamEvent } from "@/lib/chatStream";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import type {
|
||||||
|
AgentArtifact,
|
||||||
|
ChatSessionSummary,
|
||||||
|
Message,
|
||||||
|
} from "../GlobalChatbox.types";
|
||||||
import { cloneMessages } from "../globalChatboxUtils";
|
import { cloneMessages } from "../globalChatboxUtils";
|
||||||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
import {
|
||||||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
createEmptyChatState,
|
||||||
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
deleteChatSession,
|
||||||
|
listChatSessions,
|
||||||
|
loadChatSessionById,
|
||||||
|
updateChatSessionTitle,
|
||||||
|
} from "../chatStorage";
|
||||||
|
import {
|
||||||
|
applyQuestionResponse,
|
||||||
|
cancelRunningTodos,
|
||||||
|
completeRunningProgress,
|
||||||
|
createAssistantMessage,
|
||||||
|
createTodoUpdateFromEvent,
|
||||||
|
createUserMessage,
|
||||||
|
dedupeQuestionsAcrossMessages,
|
||||||
|
finalizeAssistantMessageAfterAbort,
|
||||||
|
normalizeSessionTodos,
|
||||||
|
toPermissionStatus,
|
||||||
|
upsertPermission,
|
||||||
|
upsertProgress,
|
||||||
|
upsertQuestionAcrossMessages,
|
||||||
|
} from "./agentChatSessionState";
|
||||||
|
import type {
|
||||||
|
PromptRunOptions,
|
||||||
|
UseAgentChatSessionOptions,
|
||||||
|
} from "./useAgentChatSession.types";
|
||||||
|
|
||||||
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
|
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
|
||||||
const TOKEN_PLAYBACK_BASE_CHARS = 28;
|
const TOKEN_PLAYBACK_BASE_CHARS = 28;
|
||||||
@@ -80,6 +118,7 @@ export const useAgentChatSession = ({
|
|||||||
getModel,
|
getModel,
|
||||||
getApprovalMode,
|
getApprovalMode,
|
||||||
}: UseAgentChatSessionOptions) => {
|
}: UseAgentChatSessionOptions) => {
|
||||||
|
const { update: updateSession } = useSession();
|
||||||
const hydrationNonceRef = useRef(0);
|
const hydrationNonceRef = useRef(0);
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
@@ -102,6 +141,7 @@ export const useAgentChatSession = ({
|
|||||||
content: string;
|
content: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
||||||
|
const credentialRefreshRequestIdsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionIdRef.current = sessionId;
|
sessionIdRef.current = sessionId;
|
||||||
@@ -289,6 +329,61 @@ export const useAgentChatSession = ({
|
|||||||
return assistant?.id ?? fallback;
|
return assistant?.id ?? fallback;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleCredentialRefresh = useCallback(
|
||||||
|
async (event: StreamEvent & { type: "credential_refresh_required" }) => {
|
||||||
|
if (
|
||||||
|
!event.sessionId ||
|
||||||
|
!event.requestId ||
|
||||||
|
credentialRefreshRequestIdsRef.current.has(event.requestId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
credentialRefreshRequestIdsRef.current.add(event.requestId);
|
||||||
|
try {
|
||||||
|
const refreshedSession = await updateSession({ forceRefresh: true });
|
||||||
|
if (
|
||||||
|
refreshedSession?.error ||
|
||||||
|
typeof refreshedSession?.accessToken !== "string" ||
|
||||||
|
!refreshedSession.accessToken
|
||||||
|
) {
|
||||||
|
throw new Error("登录凭据续期失败");
|
||||||
|
}
|
||||||
|
const authStore = useAuthStore.getState();
|
||||||
|
authStore.setAccessToken(refreshedSession.accessToken);
|
||||||
|
authStore.clearSessionExpired();
|
||||||
|
await replyAgentCredentialRefresh(event.sessionId, event.requestId);
|
||||||
|
} catch (error) {
|
||||||
|
useAuthStore.getState().markSessionExpired("refresh_failed");
|
||||||
|
const assistantMessageId = getLastAssistantMessageId();
|
||||||
|
if (assistantMessageId) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
content: item.content || `⚠️ **${message}**`,
|
||||||
|
isError: true,
|
||||||
|
progress: upsertProgress(item.progress, {
|
||||||
|
type: "progress",
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
id: `credential-refresh-${event.requestId}`,
|
||||||
|
phase: "credential_refresh",
|
||||||
|
status: "error",
|
||||||
|
title: "登录凭据续期失败",
|
||||||
|
detail: message,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getLastAssistantMessageId, updateSession],
|
||||||
|
);
|
||||||
|
|
||||||
const applyStreamEvent = useCallback(
|
const applyStreamEvent = useCallback(
|
||||||
(
|
(
|
||||||
event: StreamEvent,
|
event: StreamEvent,
|
||||||
@@ -421,6 +516,68 @@ export const useAgentChatSession = ({
|
|||||||
assistantMessageId,
|
assistantMessageId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else if (event.type === "credential_refresh_required") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
progress: upsertProgress(message.progress, {
|
||||||
|
type: "progress",
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
id: `credential-refresh-${event.requestId}`,
|
||||||
|
phase: "credential_refresh",
|
||||||
|
status: "running",
|
||||||
|
title: "正在续期登录凭据",
|
||||||
|
detail: `当前工具调用保持等待,最长 ${Math.ceil((event.timeoutMs ?? 30_000) / 1000)} 秒`,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
void handleCredentialRefresh(event);
|
||||||
|
} else if (event.type === "credential_refreshed") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
progress: upsertProgress(message.progress, {
|
||||||
|
type: "progress",
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
id: `credential-refresh-${event.requestId}`,
|
||||||
|
phase: "credential_refresh",
|
||||||
|
status: "completed",
|
||||||
|
title: "登录凭据已续期",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (event.type === "credential_refresh_failed") {
|
||||||
|
useAuthStore.getState().markSessionExpired("refresh_failed");
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
content: message.content || `⚠️ **${event.message}**`,
|
||||||
|
isError: true,
|
||||||
|
progress: upsertProgress(message.progress, {
|
||||||
|
type: "progress",
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
id: `credential-refresh-${event.requestId}`,
|
||||||
|
phase: "credential_refresh",
|
||||||
|
status: "error",
|
||||||
|
title: "登录凭据续期失败",
|
||||||
|
detail: event.message,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setIsStreaming(false);
|
||||||
} else if (event.type === "done") {
|
} else if (event.type === "done") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) => {
|
prev.map((message) => {
|
||||||
@@ -457,6 +614,7 @@ export const useAgentChatSession = ({
|
|||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
} else if (event.type === "auth_required") {
|
} else if (event.type === "auth_required") {
|
||||||
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) =>
|
prev.map((message) =>
|
||||||
message.id === assistantMessageId
|
message.id === assistantMessageId
|
||||||
@@ -477,6 +635,7 @@ export const useAgentChatSession = ({
|
|||||||
appendArtifact,
|
appendArtifact,
|
||||||
flushPendingTokens,
|
flushPendingTokens,
|
||||||
getLastAssistantMessageId,
|
getLastAssistantMessageId,
|
||||||
|
handleCredentialRefresh,
|
||||||
onToolCall,
|
onToolCall,
|
||||||
queueTokenContent,
|
queueTokenContent,
|
||||||
],
|
],
|
||||||
@@ -640,7 +799,7 @@ export const useAgentChatSession = ({
|
|||||||
}, [flushPendingTokens, getLastAssistantMessageId]);
|
}, [flushPendingTokens, getLastAssistantMessageId]);
|
||||||
|
|
||||||
const replyPermission = useCallback(
|
const replyPermission = useCallback(
|
||||||
async (requestId: string, reply: PermissionReply) => {
|
async (requestId: string, reply: PermissionDecision) => {
|
||||||
const target = messagesRef.current
|
const target = messagesRef.current
|
||||||
.flatMap((message) => message.permissions ?? [])
|
.flatMap((message) => message.permissions ?? [])
|
||||||
.find((permission) => permission.requestId === requestId);
|
.find((permission) => permission.requestId === requestId);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import "dayjs/locale/zh-cn";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { NETWORK_NAME } from "@config/config";
|
import { NETWORK_NAME } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import { BurstDetectionResult } from "./types";
|
import { BurstDetectionResult } from "./types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -121,6 +122,12 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
} = parametersState;
|
} = parametersState;
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [frequencyLoading, setFrequencyLoading] = useState(false);
|
const [frequencyLoading, setFrequencyLoading] = useState(false);
|
||||||
|
useSessionRecoveryDraft("burst-detection", parametersState, (draft) =>
|
||||||
|
setParametersState({
|
||||||
|
...draft,
|
||||||
|
targetTime: draft.targetTime ? dayjs(draft.targetTime) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (samplingIntervalSource !== "metadata") return;
|
if (samplingIntervalSource !== "metadata") return;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import "dayjs/locale/zh-cn";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { NETWORK_NAME, config } from "@config/config";
|
import { NETWORK_NAME, config } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||||
import { BurstLocationResult } from "./types";
|
import { BurstLocationResult } from "./types";
|
||||||
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
import { getBurstLocationErrorNotice } from "./burstLocationError";
|
||||||
@@ -105,6 +106,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
} = parametersState;
|
} = parametersState;
|
||||||
const [schemeLoading, setSchemeLoading] = useState(false);
|
const [schemeLoading, setSchemeLoading] = useState(false);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
|
useSessionRecoveryDraft("burst-location", parametersState, (draft) =>
|
||||||
|
setParametersState({
|
||||||
|
...draft,
|
||||||
|
burstStartTime: draft.burstStartTime ? dayjs(draft.burstStartTime) : null,
|
||||||
|
burstEndTime: draft.burstEndTime ? dayjs(draft.burstEndTime) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const isSimulationMode = dataSource === "simulation";
|
const isSimulationMode = dataSource === "simulation";
|
||||||
|
|
||||||
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
const applySchemeTimeRange = useCallback((scheme: SchemeItem) => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import { along, lineString, length, toMercator } from "@turf/turf";
|
import { along, lineString, length, toMercator } from "@turf/turf";
|
||||||
import { Point } from "ol/geom";
|
import { Point } from "ol/geom";
|
||||||
import { toLonLat } from "ol/proj";
|
import { toLonLat } from "ol/proj";
|
||||||
@@ -74,6 +75,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
);
|
);
|
||||||
const { pipePoints, startTime, duration, schemeName, network } =
|
const { pipePoints, startTime, duration, schemeName, network } =
|
||||||
parametersState;
|
parametersState;
|
||||||
|
useSessionRecoveryDraft("burst-simulation", parametersState, (draft) =>
|
||||||
|
setParametersState({
|
||||||
|
...draft,
|
||||||
|
startTime: draft.startTime ? dayjs(draft.startTime) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||||||
|
|
||||||
const [highlightLayer, setHighlightLayer] =
|
const [highlightLayer, setHighlightLayer] =
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
queryFeaturesByIds,
|
queryFeaturesByIds,
|
||||||
} from "@/utils/mapQueryService";
|
} from "@/utils/mapQueryService";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
|
|
||||||
export interface ContaminantAnalysisParametersState {
|
export interface ContaminantAnalysisParametersState {
|
||||||
schemeName: string;
|
schemeName: string;
|
||||||
@@ -62,7 +63,7 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
|
|
||||||
const network = NETWORK_NAME;
|
const network = NETWORK_NAME;
|
||||||
const [parametersState, , setFormField] = useControllableObjectState(
|
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
createContaminantAnalysisParametersState(),
|
createContaminantAnalysisParametersState(),
|
||||||
@@ -75,6 +76,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
duration,
|
duration,
|
||||||
pattern,
|
pattern,
|
||||||
} = parametersState;
|
} = parametersState;
|
||||||
|
useSessionRecoveryDraft("contaminant-simulation", parametersState, (draft) =>
|
||||||
|
setParametersState({
|
||||||
|
...draft,
|
||||||
|
startTime: draft.startTime ? dayjs(draft.startTime) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
const [isSelecting, setIsSelecting] = useState<boolean>(false);
|
||||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import { LeakageResultDetail } from "./types";
|
import { LeakageResultDetail } from "./types";
|
||||||
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
import { FLOW_DISPLAY_UNIT, toM3s } from "@utils/units";
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
onStateChange,
|
onStateChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { open } = useNotification();
|
const { open } = useNotification();
|
||||||
const [parametersState, , setFormField] = useControllableObjectState(
|
const [parametersState, setParametersState, setFormField] = useControllableObjectState(
|
||||||
state,
|
state,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
createDMALeakAnalysisParametersState(),
|
createDMALeakAnalysisParametersState(),
|
||||||
@@ -73,6 +74,13 @@ const AnalysisParameters: React.FC<Props> = ({
|
|||||||
qSum,
|
qSum,
|
||||||
advancedOpen,
|
advancedOpen,
|
||||||
} = parametersState;
|
} = parametersState;
|
||||||
|
useSessionRecoveryDraft("dma-leak-detection", parametersState, (draft) =>
|
||||||
|
setParametersState({
|
||||||
|
...draft,
|
||||||
|
startTime: draft.startTime ? dayjs(draft.startTime) : null,
|
||||||
|
endTime: draft.endTime ? dayjs(draft.endTime) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [qSumInput, setQSumInput] = useState(() => String(qSum));
|
const [qSumInput, setQSumInput] = useState(() => String(qSum));
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { useNotification } from "@refinedev/core";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { config, NETWORK_NAME } from "@/config/config";
|
import { config, NETWORK_NAME } from "@/config/config";
|
||||||
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
import { useControllableObjectState } from "@components/olmap/core/useControllableState";
|
||||||
|
import { useSessionRecoveryDraft } from "@/lib/sessionRecoveryDraft";
|
||||||
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
import PanelEmptyState from "@components/olmap/common/PanelEmptyState";
|
||||||
|
|
||||||
export interface ValveItem {
|
export interface ValveItem {
|
||||||
@@ -80,6 +81,12 @@ const AnalysisParameters: React.FC<AnalysisParametersProps> = ({
|
|||||||
);
|
);
|
||||||
const { schemeName, valves, drainageNode, startTime, flushFlow, duration } =
|
const { schemeName, valves, drainageNode, startTime, flushFlow, duration } =
|
||||||
parametersState;
|
parametersState;
|
||||||
|
useSessionRecoveryDraft("flushing-analysis", parametersState, (draft) =>
|
||||||
|
setParametersState({
|
||||||
|
...draft,
|
||||||
|
startTime: draft.startTime ? dayjs(draft.startTime) : null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const [valveFeatures, setValveFeatures] = useState<Feature[]>([]);
|
const [valveFeatures, setValveFeatures] = useState<Feature[]>([]);
|
||||||
const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null);
|
const [drainageFeature, setDrainageFeature] = useState<Feature | null>(null);
|
||||||
|
|
||||||
|
|||||||
+46
-3
@@ -1,5 +1,6 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import MonitoringPlaceOptimizationPanel, {
|
import MonitoringPlaceOptimizationPanel, {
|
||||||
|
getMonitoringLayerVisibility,
|
||||||
getTabIndicatorSx,
|
getTabIndicatorSx,
|
||||||
getTabIndicatorTransform,
|
getTabIndicatorTransform,
|
||||||
} from "./MonitoringPlaceOptimizationPanel";
|
} from "./MonitoringPlaceOptimizationPanel";
|
||||||
@@ -7,6 +8,7 @@ import { getSensorPlacementScheme } from "./schemeApi";
|
|||||||
import type { SensorPlacementScheme } from "./types";
|
import type { SensorPlacementScheme } from "./types";
|
||||||
|
|
||||||
const mockSchemeEditorRender = jest.fn();
|
const mockSchemeEditorRender = jest.fn();
|
||||||
|
const mockSchemeQueryRender = jest.fn();
|
||||||
|
|
||||||
jest.mock("@refinedev/core", () => ({
|
jest.mock("@refinedev/core", () => ({
|
||||||
useNotification: () => ({ open: jest.fn() }),
|
useNotification: () => ({ open: jest.fn() }),
|
||||||
@@ -20,9 +22,16 @@ jest.mock("./OptimizationParameters", () => ({
|
|||||||
|
|
||||||
jest.mock("./SchemeQuery", () => ({
|
jest.mock("./SchemeQuery", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: ({ onEdit }: { onEdit: (schemeId: number) => void }) => (
|
default: ({
|
||||||
<button onClick={() => onEdit(7)}>打开测试方案</button>
|
onEdit,
|
||||||
),
|
active,
|
||||||
|
}: {
|
||||||
|
onEdit: (schemeId: number) => void;
|
||||||
|
active?: boolean;
|
||||||
|
}) => {
|
||||||
|
mockSchemeQueryRender(active);
|
||||||
|
return <button onClick={() => onEdit(7)}>打开测试方案</button>;
|
||||||
|
},
|
||||||
createMonitoringSchemeQueryState: () => ({}),
|
createMonitoringSchemeQueryState: () => ({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -66,6 +75,7 @@ const scheme: SensorPlacementScheme = {
|
|||||||
describe("MonitoringPlaceOptimizationPanel", () => {
|
describe("MonitoringPlaceOptimizationPanel", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSchemeEditorRender.mockClear();
|
mockSchemeEditorRender.mockClear();
|
||||||
|
mockSchemeQueryRender.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies equal-width positioning to the rendered tab indicator", () => {
|
it("applies equal-width positioning to the rendered tab indicator", () => {
|
||||||
@@ -90,6 +100,21 @@ describe("MonitoringPlaceOptimizationPanel", () => {
|
|||||||
expect(indicator).toHaveStyle({ transform: "translateX(200%)" });
|
expect(indicator).toHaveStyle({ transform: "translateX(200%)" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps query and editor layers mutually exclusive by active tab", () => {
|
||||||
|
expect(getMonitoringLayerVisibility(0)).toEqual({
|
||||||
|
editor: false,
|
||||||
|
query: false,
|
||||||
|
});
|
||||||
|
expect(getMonitoringLayerVisibility(1)).toEqual({
|
||||||
|
editor: true,
|
||||||
|
query: false,
|
||||||
|
});
|
||||||
|
expect(getMonitoringLayerVisibility(2)).toEqual({
|
||||||
|
editor: false,
|
||||||
|
query: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("prepares a queried scheme before switching to the result editor", async () => {
|
it("prepares a queried scheme before switching to the result editor", async () => {
|
||||||
let resolveScheme: (value: SensorPlacementScheme) => void = () => {};
|
let resolveScheme: (value: SensorPlacementScheme) => void = () => {};
|
||||||
mockGetSensorPlacementScheme.mockReturnValue(
|
mockGetSensorPlacementScheme.mockReturnValue(
|
||||||
@@ -119,5 +144,23 @@ describe("MonitoringPlaceOptimizationPanel", () => {
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
]);
|
]);
|
||||||
|
expect(mockSchemeQueryRender.mock.calls.at(-1)?.[0]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the active result layer visible when the panel is collapsed", async () => {
|
||||||
|
mockGetSensorPlacementScheme.mockResolvedValue(scheme);
|
||||||
|
render(<MonitoringPlaceOptimizationPanel />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /方案查询/ }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "打开测试方案" }));
|
||||||
|
await screen.findByTestId("scheme-editor");
|
||||||
|
|
||||||
|
const collapseButton = screen
|
||||||
|
.getByTestId("ChevronRightIcon")
|
||||||
|
.closest("button");
|
||||||
|
expect(collapseButton).not.toBeNull();
|
||||||
|
fireEvent.click(collapseButton!);
|
||||||
|
|
||||||
|
expect(mockSchemeEditorRender.mock.calls.at(-1)?.[0]).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-1
@@ -69,6 +69,11 @@ export const getTabIndicatorSx = (tabIndex: number) => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getMonitoringLayerVisibility = (tabIndex: number) => ({
|
||||||
|
editor: tabIndex === 1,
|
||||||
|
query: tabIndex === 2,
|
||||||
|
});
|
||||||
|
|
||||||
interface PreparedSchemeEditorProps
|
interface PreparedSchemeEditorProps
|
||||||
extends React.ComponentProps<typeof SchemeEditor> {
|
extends React.ComponentProps<typeof SchemeEditor> {
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
@@ -126,6 +131,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const drawerWidth = currentTab === 1 ? 820 : 520;
|
const drawerWidth = currentTab === 1 ? 820 : 520;
|
||||||
|
const layerVisibility = getMonitoringLayerVisibility(currentTab);
|
||||||
|
|
||||||
const handleSchemeEditorReady = useCallback(() => {
|
const handleSchemeEditorReady = useCallback(() => {
|
||||||
setCurrentTab(1);
|
setCurrentTab(1);
|
||||||
@@ -326,7 +332,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
<PreparedSchemeEditor
|
<PreparedSchemeEditor
|
||||||
scheme={activeScheme}
|
scheme={activeScheme}
|
||||||
network={NETWORK_NAME}
|
network={NETWORK_NAME}
|
||||||
active={isOpen && currentTab === 1}
|
active={layerVisibility.editor}
|
||||||
onSaved={handleSchemeSaved}
|
onSaved={handleSchemeSaved}
|
||||||
onReady={
|
onReady={
|
||||||
pendingOpenSchemeId === activeScheme.id
|
pendingOpenSchemeId === activeScheme.id
|
||||||
@@ -346,6 +352,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
|||||||
<TabPanel value={currentTab} index={2}>
|
<TabPanel value={currentTab} index={2}>
|
||||||
<SchemeQuery
|
<SchemeQuery
|
||||||
schemes={schemes}
|
schemes={schemes}
|
||||||
|
active={layerVisibility.query}
|
||||||
onSchemesChange={setSchemes}
|
onSchemesChange={setSchemes}
|
||||||
state={queryState}
|
state={queryState}
|
||||||
onStateChange={setQueryState}
|
onStateChange={setQueryState}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import SchemeEditor from "./SchemeEditor";
|
import SchemeEditor from "./SchemeEditor";
|
||||||
|
import { getSensorPlacementCandidate } from "./schemeApi";
|
||||||
import type { SensorPlacementScheme } from "./types";
|
import type { SensorPlacementScheme } from "./types";
|
||||||
|
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||||
|
|
||||||
const mockOpen = jest.fn();
|
const mockOpen = jest.fn();
|
||||||
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
|
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
|
||||||
|
let mockGridColumns: Array<{
|
||||||
|
field: string;
|
||||||
|
headerName?: string;
|
||||||
|
valueFormatter?: (value: unknown) => string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
const mockMap = {
|
const mockMap = {
|
||||||
addLayer: jest.fn(),
|
addLayer: jest.fn(),
|
||||||
@@ -32,11 +39,14 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
|
|||||||
|
|
||||||
jest.mock("@mui/x-data-grid", () => ({
|
jest.mock("@mui/x-data-grid", () => ({
|
||||||
DataGrid: (props: {
|
DataGrid: (props: {
|
||||||
|
columns: typeof mockGridColumns;
|
||||||
density?: string;
|
density?: string;
|
||||||
initialState?: { density?: string };
|
initialState?: { density?: string };
|
||||||
rowHeight?: number;
|
rowHeight?: number;
|
||||||
columnHeaderHeight?: number;
|
columnHeaderHeight?: number;
|
||||||
}) => (
|
}) => {
|
||||||
|
mockGridColumns = props.columns;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="scheme-grid"
|
data-testid="scheme-grid"
|
||||||
data-density={props.density ?? "uncontrolled"}
|
data-density={props.density ?? "uncontrolled"}
|
||||||
@@ -44,7 +54,8 @@ jest.mock("@mui/x-data-grid", () => ({
|
|||||||
data-row-height={props.rowHeight ?? "automatic"}
|
data-row-height={props.rowHeight ?? "automatic"}
|
||||||
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
|
data-column-header-height={props.columnHeaderHeight ?? "automatic"}
|
||||||
/>
|
/>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
GridToolbar: () => null,
|
GridToolbar: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -119,11 +130,6 @@ jest.mock("ol/style", () => ({
|
|||||||
Text: class {},
|
Text: class {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("ol/proj", () => ({
|
|
||||||
fromLonLat: (coordinates: number[]) => coordinates,
|
|
||||||
toLonLat: (coordinates: number[]) => coordinates,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("@/utils/mapQueryService", () => ({
|
jest.mock("@/utils/mapQueryService", () => ({
|
||||||
handleMapClickSelectFeatures: jest.fn(),
|
handleMapClickSelectFeatures: jest.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -135,6 +141,7 @@ jest.mock("./SchemeDrawingDialog", () => ({
|
|||||||
|
|
||||||
jest.mock("./schemeApi", () => ({
|
jest.mock("./schemeApi", () => ({
|
||||||
exportSensorPlacementExcel: jest.fn(),
|
exportSensorPlacementExcel: jest.fn(),
|
||||||
|
getSensorPlacementCandidate: jest.fn(),
|
||||||
overwriteSensorPlacementScheme: jest.fn(),
|
overwriteSensorPlacementScheme: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -149,6 +156,7 @@ const scheme: SensorPlacementScheme = {
|
|||||||
sensor_points: [
|
sensor_points: [
|
||||||
{
|
{
|
||||||
node_id: "J1",
|
node_id: "J1",
|
||||||
|
max_pipe_diameter: 400,
|
||||||
project_x: 10,
|
project_x: 10,
|
||||||
project_y: 20,
|
project_y: 20,
|
||||||
map_x: 13500010,
|
map_x: 13500010,
|
||||||
@@ -161,10 +169,18 @@ const scheme: SensorPlacementScheme = {
|
|||||||
can_edit: true,
|
can_edit: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockGetSensorPlacementCandidate = jest.mocked(
|
||||||
|
getSensorPlacementCandidate,
|
||||||
|
);
|
||||||
|
const mockHandleMapClickSelectFeatures = jest.mocked(
|
||||||
|
handleMapClickSelectFeatures,
|
||||||
|
);
|
||||||
|
|
||||||
describe("SchemeEditor notifications", () => {
|
describe("SchemeEditor notifications", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
mockSingleClickHandler = undefined;
|
mockSingleClickHandler = undefined;
|
||||||
|
mockGridColumns = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
|
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
|
||||||
@@ -187,6 +203,38 @@ describe("SchemeEditor notifications", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads authoritative point details when adding a map-selected node", async () => {
|
||||||
|
mockHandleMapClickSelectFeatures.mockResolvedValue({
|
||||||
|
get: (key: string) => (key === "id" ? "J2" : undefined),
|
||||||
|
getId: () => "J2",
|
||||||
|
} as never);
|
||||||
|
mockGetSensorPlacementCandidate.mockResolvedValue({
|
||||||
|
node_id: "J2",
|
||||||
|
max_pipe_diameter: 500,
|
||||||
|
project_x: 30,
|
||||||
|
project_y: 40,
|
||||||
|
map_x: 13500030,
|
||||||
|
map_y: 3600040,
|
||||||
|
longitude: 121.1,
|
||||||
|
latitude: 31.1,
|
||||||
|
elevation: 6,
|
||||||
|
});
|
||||||
|
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "添加" }));
|
||||||
|
await waitFor(() => expect(mockSingleClickHandler).toBeDefined());
|
||||||
|
await act(async () => {
|
||||||
|
mockSingleClickHandler?.({
|
||||||
|
pixel: [0, 0],
|
||||||
|
coordinate: [13500030, 3600040],
|
||||||
|
stopPropagation: jest.fn(),
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetSensorPlacementCandidate).toHaveBeenCalledWith("J2");
|
||||||
|
});
|
||||||
|
|
||||||
it("lets the Data Grid density selector control row sizing", () => {
|
it("lets the Data Grid density selector control row sizing", () => {
|
||||||
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||||
|
|
||||||
@@ -207,4 +255,22 @@ describe("SchemeEditor notifications", () => {
|
|||||||
"automatic",
|
"automatic",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("places actions and status first and displays the selected pipe diameter", () => {
|
||||||
|
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||||
|
|
||||||
|
expect(mockGridColumns.slice(0, 5).map((column) => column.field)).toEqual([
|
||||||
|
"actions",
|
||||||
|
"adjustment_status",
|
||||||
|
"sequence",
|
||||||
|
"node_id",
|
||||||
|
"max_pipe_diameter",
|
||||||
|
]);
|
||||||
|
const diameterColumn = mockGridColumns.find(
|
||||||
|
(column) => column.field === "max_pipe_diameter",
|
||||||
|
);
|
||||||
|
expect(diameterColumn?.headerName).toBe("最大管径 (mm)");
|
||||||
|
expect(diameterColumn?.valueFormatter?.(400)).toBe("400");
|
||||||
|
expect(diameterColumn?.valueFormatter?.(null)).toBe("无");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,10 +45,7 @@ import Point from "ol/geom/Point";
|
|||||||
import VectorLayer from "ol/layer/Vector";
|
import VectorLayer from "ol/layer/Vector";
|
||||||
import VectorSource from "ol/source/Vector";
|
import VectorSource from "ol/source/Vector";
|
||||||
import { Circle, Fill, Stroke, Style, Text } from "ol/style";
|
import { Circle, Fill, Stroke, Style, Text } from "ol/style";
|
||||||
import { fromLonLat, toLonLat } from "ol/proj";
|
|
||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { config } from "@/config/config";
|
|
||||||
import { useMap } from "@components/olmap/core/MapComponent";
|
import { useMap } from "@components/olmap/core/MapComponent";
|
||||||
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
|
import { handleMapClickSelectFeatures } from "@/utils/mapQueryService";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +62,7 @@ import {
|
|||||||
} from "./schemeEditor";
|
} from "./schemeEditor";
|
||||||
import {
|
import {
|
||||||
exportSensorPlacementExcel,
|
exportSensorPlacementExcel,
|
||||||
|
getSensorPlacementCandidate,
|
||||||
overwriteSensorPlacementScheme,
|
overwriteSensorPlacementScheme,
|
||||||
} from "./schemeApi";
|
} from "./schemeApi";
|
||||||
import SchemeDrawingDialog from "./SchemeDrawingDialog";
|
import SchemeDrawingDialog from "./SchemeDrawingDialog";
|
||||||
@@ -147,6 +145,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
const [drawingOpen, setDrawingOpen] = useState(false);
|
const [drawingOpen, setDrawingOpen] = useState(false);
|
||||||
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
|
||||||
|
const activeRef = useRef(active);
|
||||||
|
activeRef.current = active;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditor(createSchemeEditorState(scheme));
|
setEditor(createSchemeEditorState(scheme));
|
||||||
@@ -171,6 +171,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
},
|
},
|
||||||
zIndex: 120,
|
zIndex: 120,
|
||||||
});
|
});
|
||||||
|
layer.setVisible(activeRef.current);
|
||||||
markerLayerRef.current = layer;
|
markerLayerRef.current = layer;
|
||||||
map.addLayer(layer);
|
map.addLayer(layer);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -206,47 +207,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
const feature = await handleMapClickSelectFeatures(event, map);
|
const feature = await handleMapClickSelectFeatures(event, map);
|
||||||
const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim();
|
const nodeId = String(feature?.get("id") ?? feature?.getId() ?? "").trim();
|
||||||
if (!nodeId) return null;
|
if (!nodeId) return null;
|
||||||
const featureGeometry = feature?.getGeometry();
|
|
||||||
const featureCoordinate =
|
|
||||||
featureGeometry instanceof Point
|
|
||||||
? featureGeometry.getCoordinates()
|
|
||||||
: event.coordinate;
|
|
||||||
const projectedFeatureCoordinate =
|
|
||||||
Math.abs(featureCoordinate[0]) <= 180 &&
|
|
||||||
Math.abs(featureCoordinate[1]) <= 90
|
|
||||||
? fromLonLat(featureCoordinate)
|
|
||||||
: featureCoordinate;
|
|
||||||
const resolution = map.getView().getResolution() ?? 1;
|
|
||||||
const isAlignedWithMap =
|
|
||||||
Math.hypot(
|
|
||||||
projectedFeatureCoordinate[0] - event.coordinate[0],
|
|
||||||
projectedFeatureCoordinate[1] - event.coordinate[1],
|
|
||||||
) <=
|
|
||||||
resolution * 20;
|
|
||||||
const [mapX, mapY] = isAlignedWithMap
|
|
||||||
? projectedFeatureCoordinate
|
|
||||||
: event.coordinate;
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get<{
|
return await getSensorPlacementCandidate(nodeId);
|
||||||
id: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
elevation: number;
|
|
||||||
}>(`${config.BACKEND_URL}/api/v1/junctions/properties`, {
|
|
||||||
params: { junction: nodeId },
|
|
||||||
});
|
|
||||||
if (!response.data?.id) return null;
|
|
||||||
const [longitude, latitude] = toLonLat([mapX, mapY]);
|
|
||||||
return {
|
|
||||||
node_id: String(response.data.id),
|
|
||||||
project_x: Number(response.data.x),
|
|
||||||
project_y: Number(response.data.y),
|
|
||||||
map_x: mapX,
|
|
||||||
map_y: mapY,
|
|
||||||
longitude,
|
|
||||||
latitude,
|
|
||||||
elevation: Number(response.data.elevation),
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -436,6 +398,70 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
|
|
||||||
const columns = useMemo<GridColDef<SensorPointRow>[]>(
|
const columns = useMemo<GridColDef<SensorPointRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
|
{
|
||||||
|
field: "actions",
|
||||||
|
headerName: "操作",
|
||||||
|
width: scheme.can_edit ? 138 : 54,
|
||||||
|
sortable: false,
|
||||||
|
filterable: false,
|
||||||
|
renderCell: ({ row }) => (
|
||||||
|
<Stack direction="row" spacing={0.25} sx={{ alignItems: "center" }}>
|
||||||
|
<Tooltip title="地图定位">
|
||||||
|
<IconButton
|
||||||
|
aria-label={`定位节点 ${row.node_id}`}
|
||||||
|
size="small"
|
||||||
|
onClick={() => locateRow(row)}
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<LocateIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{scheme.can_edit && (
|
||||||
|
<>
|
||||||
|
<Tooltip title="替换节点">
|
||||||
|
<IconButton
|
||||||
|
aria-label={`替换节点 ${row.node_id}`}
|
||||||
|
size="small"
|
||||||
|
onClick={() => activateMode("replace", row.node_id)}
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<ReplaceIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除节点">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
aria-label={`删除节点 ${row.node_id}`}
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleDelete(row.node_id)}
|
||||||
|
disabled={rows.length <= 1}
|
||||||
|
sx={{ width: 40, height: 40 }}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "adjustment_status",
|
||||||
|
headerName: "调整状态",
|
||||||
|
width: 108,
|
||||||
|
renderCell: ({ value }) => {
|
||||||
|
const status = value as AdjustmentStatus;
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={STATUS_LABELS[status]}
|
||||||
|
color={STATUS_COLORS[status]}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: "sequence",
|
field: "sequence",
|
||||||
headerName: "序号",
|
headerName: "序号",
|
||||||
@@ -445,6 +471,21 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
},
|
},
|
||||||
{ field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 },
|
{ field: "node_id", headerName: "节点 ID", minWidth: 120, flex: 0.8 },
|
||||||
|
{
|
||||||
|
field: "max_pipe_diameter",
|
||||||
|
headerName: "最大管径 (mm)",
|
||||||
|
description: "节点关联管道中的最大管径",
|
||||||
|
minWidth: 132,
|
||||||
|
flex: 0.75,
|
||||||
|
align: "right",
|
||||||
|
headerAlign: "right",
|
||||||
|
valueFormatter: (value) => {
|
||||||
|
if (value == null || !Number.isFinite(Number(value))) return "无";
|
||||||
|
return Number(value).toLocaleString("zh-CN", {
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: "longitude",
|
field: "longitude",
|
||||||
headerName: "经度",
|
headerName: "经度",
|
||||||
@@ -507,70 +548,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
|||||||
headerAlign: "right",
|
headerAlign: "right",
|
||||||
valueFormatter: (value) => Number(value).toFixed(3),
|
valueFormatter: (value) => Number(value).toFixed(3),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
field: "adjustment_status",
|
|
||||||
headerName: "调整状态",
|
|
||||||
width: 108,
|
|
||||||
renderCell: ({ value }) => {
|
|
||||||
const status = value as AdjustmentStatus;
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
label={STATUS_LABELS[status]}
|
|
||||||
color={STATUS_COLORS[status]}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "actions",
|
|
||||||
headerName: "操作",
|
|
||||||
width: scheme.can_edit ? 138 : 54,
|
|
||||||
sortable: false,
|
|
||||||
filterable: false,
|
|
||||||
renderCell: ({ row }) => (
|
|
||||||
<Stack direction="row" spacing={0.25} sx={{ alignItems: "center" }}>
|
|
||||||
<Tooltip title="地图定位">
|
|
||||||
<IconButton
|
|
||||||
aria-label={`定位节点 ${row.node_id}`}
|
|
||||||
size="small"
|
|
||||||
onClick={() => locateRow(row)}
|
|
||||||
sx={{ width: 40, height: 40 }}
|
|
||||||
>
|
|
||||||
<LocateIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
{scheme.can_edit && (
|
|
||||||
<>
|
|
||||||
<Tooltip title="替换节点">
|
|
||||||
<IconButton
|
|
||||||
aria-label={`替换节点 ${row.node_id}`}
|
|
||||||
size="small"
|
|
||||||
onClick={() => activateMode("replace", row.node_id)}
|
|
||||||
sx={{ width: 40, height: 40 }}
|
|
||||||
>
|
|
||||||
<ReplaceIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="删除节点">
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
aria-label={`删除节点 ${row.node_id}`}
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleDelete(row.node_id)}
|
|
||||||
disabled={rows.length <= 1}
|
|
||||||
sx={{ width: 40, height: 40 }}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[activateMode, handleDelete, locateRow, rows.length, scheme.can_edit],
|
[activateMode, handleDelete, locateRow, rows.length, scheme.can_edit],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -53,6 +53,7 @@ interface SchemaItem {
|
|||||||
|
|
||||||
interface SchemeQueryProps {
|
interface SchemeQueryProps {
|
||||||
schemes?: SchemeRecord[];
|
schemes?: SchemeRecord[];
|
||||||
|
active?: boolean;
|
||||||
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
onSchemesChange?: (schemes: SchemeRecord[]) => void;
|
||||||
onEdit?: (id: number) => void;
|
onEdit?: (id: number) => void;
|
||||||
network?: string;
|
network?: string;
|
||||||
@@ -77,6 +78,7 @@ export const createMonitoringSchemeQueryState =
|
|||||||
|
|
||||||
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||||
schemes: externalSchemes,
|
schemes: externalSchemes,
|
||||||
|
active = true,
|
||||||
onSchemesChange,
|
onSchemesChange,
|
||||||
onEdit,
|
onEdit,
|
||||||
network = NETWORK_NAME,
|
network = NETWORK_NAME,
|
||||||
@@ -98,6 +100,8 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
|
|
||||||
const [highlightLayer, setHighlightLayer] =
|
const [highlightLayer, setHighlightLayer] =
|
||||||
useState<VectorLayer<VectorSource> | null>(null);
|
useState<VectorLayer<VectorSource> | null>(null);
|
||||||
|
const activeRef = useRef(active);
|
||||||
|
activeRef.current = active;
|
||||||
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
const [highlightFeatures, setHighlightFeatures] = useState<Feature[]>([]);
|
||||||
// 使用外部提供的 schemes 或内部状态
|
// 使用外部提供的 schemes 或内部状态
|
||||||
const schemes =
|
const schemes =
|
||||||
@@ -142,6 +146,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
queryable: false,
|
queryable: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
highlightLayer.setVisible(activeRef.current);
|
||||||
|
|
||||||
map.addLayer(highlightLayer);
|
map.addLayer(highlightLayer);
|
||||||
setHighlightLayer(highlightLayer);
|
setHighlightLayer(highlightLayer);
|
||||||
@@ -151,6 +156,10 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
|||||||
};
|
};
|
||||||
}, [map]);
|
}, [map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
highlightLayer?.setVisible(active);
|
||||||
|
}, [active, highlightLayer]);
|
||||||
|
|
||||||
// 高亮要素的函数
|
// 高亮要素的函数
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightLayer) {
|
if (!highlightLayer) {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const point = (
|
|||||||
map_y: number,
|
map_y: number,
|
||||||
): SensorPointRow => ({
|
): SensorPointRow => ({
|
||||||
node_id,
|
node_id,
|
||||||
|
max_pipe_diameter: 300,
|
||||||
sequence,
|
sequence,
|
||||||
map_x,
|
map_x,
|
||||||
map_y,
|
map_y,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { config } from "@/config/config";
|
|||||||
import type {
|
import type {
|
||||||
AdjustmentStatus,
|
AdjustmentStatus,
|
||||||
SensorPlacementScheme,
|
SensorPlacementScheme,
|
||||||
|
SensorPoint,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface OptimizeSchemeInput {
|
export interface OptimizeSchemeInput {
|
||||||
@@ -32,6 +33,15 @@ export const getSensorPlacementScheme = async (
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getSensorPlacementCandidate = async (
|
||||||
|
nodeId: string,
|
||||||
|
): Promise<SensorPoint> => {
|
||||||
|
const response = await api.get<SensorPoint>(
|
||||||
|
`${config.BACKEND_URL}/api/v1/sensor-placement-candidates/${encodeURIComponent(nodeId)}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const overwriteSensorPlacementScheme = async (
|
export const overwriteSensorPlacementScheme = async (
|
||||||
schemeId: number,
|
schemeId: number,
|
||||||
expectedSensorLocation: string[],
|
expectedSensorLocation: string[],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { SensorPlacementScheme, SensorPoint } from "./types";
|
|||||||
|
|
||||||
const point = (node_id: string): SensorPoint => ({
|
const point = (node_id: string): SensorPoint => ({
|
||||||
node_id,
|
node_id,
|
||||||
|
max_pipe_diameter: 300,
|
||||||
project_x: Number(node_id.slice(1)) * 10,
|
project_x: Number(node_id.slice(1)) * 10,
|
||||||
project_y: Number(node_id.slice(1)) * 20,
|
project_y: Number(node_id.slice(1)) * 20,
|
||||||
map_x: 13500000 + Number(node_id.slice(1)) * 10,
|
map_x: 13500000 + Number(node_id.slice(1)) * 10,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type AdjustmentStatus = "current" | "original" | "added" | "replaced";
|
|||||||
|
|
||||||
export interface SensorPoint {
|
export interface SensorPoint {
|
||||||
node_id: string;
|
node_id: string;
|
||||||
|
max_pipe_diameter: number | null;
|
||||||
project_x: number;
|
project_x: number;
|
||||||
project_y: number;
|
project_y: number;
|
||||||
map_x: number;
|
map_x: number;
|
||||||
|
|||||||
+133
-2
@@ -109,6 +109,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/agent/sessions/{session_id}/credential-refreshes": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Resume a waiting agent tool call with refreshed credentials */
|
||||||
|
post: operations["post_sessions_session_id_credential_refreshes"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/agent/sessions/{session_id}/permission-responses": {
|
"/api/v1/agent/sessions/{session_id}/permission-responses": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -882,8 +899,11 @@ export interface operations {
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
message: string;
|
message: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
/** @enum {string} */
|
/**
|
||||||
approval_mode?: "request" | "always";
|
* @description request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode.
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
approval_mode?: "request" | "auto" | "always";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1195,6 +1215,117 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
post_sessions_session_id_credential_refreshes: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
session_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
request_id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful response */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Authentication required */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Insufficient permission */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Resource not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Resource conflict */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal server error */
|
||||||
|
500: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Upstream dependency error */
|
||||||
|
502: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Dependency unavailable */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
post_sessions_session_id_permission_responses: {
|
post_sessions_session_id_permission_responses: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+101
-297
@@ -2438,26 +2438,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/v1/network-schemas/user": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 获取用户模式
|
|
||||||
* @description 获取指定网络的用户模式定义
|
|
||||||
*/
|
|
||||||
get: operations["get_network_schemas_user"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/v1/network-schemas/valve": {
|
"/api/v1/network-schemas/valve": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3581,7 +3561,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* 检查项目是否被当前用户锁定
|
* 检查项目是否被当前用户锁定
|
||||||
* @description 检查指定项目是否被当前客户端 (IP) 锁定。
|
* @description 检查指定项目是否被当前访问地址 (IP) 锁定。
|
||||||
*/
|
*/
|
||||||
get: operations["get_projects_current_lock_ownership"];
|
get: operations["get_projects_current_lock_ownership"];
|
||||||
put?: never;
|
put?: never;
|
||||||
@@ -4853,6 +4833,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/sensor-placement-candidates/{node_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** 获取监测点候选节点详情 */
|
||||||
|
get: operations["get_sensor_placement_candidates_node_id"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/sensor-placement-optimization-runs": {
|
"/api/v1/sensor-placement-optimization-runs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -6581,46 +6578,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/api/v1/users": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 获取所有用户
|
|
||||||
* @description 获取指定网络的所有用户列表
|
|
||||||
*/
|
|
||||||
get: operations["get_users"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/v1/users/detail": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 获取单个用户
|
|
||||||
* @description 获取指定网络中的单个用户信息
|
|
||||||
*/
|
|
||||||
get: operations["get_users_detail"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/api/v1/valve-closure-analyses": {
|
"/api/v1/valve-closure-analyses": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -8213,6 +8170,11 @@ export interface components {
|
|||||||
map_x: number;
|
map_x: number;
|
||||||
/** Map Y */
|
/** Map Y */
|
||||||
map_y: number;
|
map_y: number;
|
||||||
|
/**
|
||||||
|
* Max Pipe Diameter
|
||||||
|
* @description 节点关联管道的最大管径,单位:毫米
|
||||||
|
*/
|
||||||
|
max_pipe_diameter: number | null;
|
||||||
/** Node Id */
|
/** Node Id */
|
||||||
node_id: string;
|
node_id: string;
|
||||||
/** Project X */
|
/** Project X */
|
||||||
@@ -20167,84 +20129,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
get_network_schemas_user: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header: {
|
|
||||||
"X-Project-Id": string;
|
|
||||||
};
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
[key: string]: Record<string, never>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Authentication required */
|
|
||||||
401: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Insufficient permission */
|
|
||||||
403: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Resource not found */
|
|
||||||
404: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Resource conflict */
|
|
||||||
409: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Dependency unavailable */
|
|
||||||
503: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
get_network_schemas_valve: {
|
get_network_schemas_valve: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -31789,6 +31673,84 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
get_sensor_placement_candidates_node_id: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header: {
|
||||||
|
"X-Project-Id": string;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
node_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SensorPointResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Authentication required */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Insufficient permission */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Resource not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Resource conflict */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Dependency unavailable */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
post_sensor_placement_optimization_runs: {
|
post_sensor_placement_optimization_runs: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -39792,164 +39754,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
get_users: {
|
|
||||||
parameters: {
|
|
||||||
query?: {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
};
|
|
||||||
header: {
|
|
||||||
"X-Project-Id": string;
|
|
||||||
};
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["Page_dict_Any__Any__"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Authentication required */
|
|
||||||
401: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Insufficient permission */
|
|
||||||
403: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Resource not found */
|
|
||||||
404: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Resource conflict */
|
|
||||||
409: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Dependency unavailable */
|
|
||||||
503: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
get_users_detail: {
|
|
||||||
parameters: {
|
|
||||||
query: {
|
|
||||||
/** @description 用户名 */
|
|
||||||
user_name: string;
|
|
||||||
};
|
|
||||||
header: {
|
|
||||||
"X-Project-Id": string;
|
|
||||||
};
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description Successful Response */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": Record<string, never>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Authentication required */
|
|
||||||
401: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Insufficient permission */
|
|
||||||
403: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Resource not found */
|
|
||||||
404: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Resource conflict */
|
|
||||||
409: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Validation error */
|
|
||||||
422: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Dependency unavailable */
|
|
||||||
503: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["ProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
post_valve_closure_analyses: {
|
post_valve_closure_analyses: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { fetchAgentRuntimeHealth } from "./agentRuntime";
|
||||||
|
|
||||||
|
const apiFetch = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("fetchAgentRuntimeHealth", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports ready only after runtime warmup is complete", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
ok: true,
|
||||||
|
ready: true,
|
||||||
|
warmed_up: true,
|
||||||
|
runtime: { healthy: true },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchAgentRuntimeHealth()).resolves.toBe(true);
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/health"),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "GET",
|
||||||
|
projectHeaderMode: "omit",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports unavailable when runtime health is not ready", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ ok: false, ready: false, warmed_up: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchAgentRuntimeHealth()).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { config } from "@config/config";
|
||||||
|
|
||||||
|
export type AgentRuntimeState =
|
||||||
|
| "checking"
|
||||||
|
| "ready"
|
||||||
|
| "unavailable"
|
||||||
|
| "models_unavailable";
|
||||||
|
|
||||||
|
type AgentHealthPayload = {
|
||||||
|
ok?: unknown;
|
||||||
|
ready?: unknown;
|
||||||
|
warmed_up?: unknown;
|
||||||
|
runtime?: {
|
||||||
|
healthy?: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAgentRuntimeHealth = async (
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const response = await apiFetch(`${config.AGENT_URL}/health`, {
|
||||||
|
method: "GET",
|
||||||
|
signal,
|
||||||
|
projectHeaderMode: "omit",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
const payload = (await response.json().catch(() => null)) as AgentHealthPayload | null;
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
response.ok &&
|
||||||
|
payload?.ok === true &&
|
||||||
|
payload.ready === true &&
|
||||||
|
payload.warmed_up === true &&
|
||||||
|
payload.runtime?.healthy === true,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { resolveRequestUrl } from "@/lib/api";
|
import { resolveRequestUrl } from "@/lib/api";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
describe("resolveRequestUrl", () => {
|
describe("resolveRequestUrl", () => {
|
||||||
it("does not prepend baseURL to an absolute request URL", () => {
|
it("does not prepend baseURL to an absolute request URL", () => {
|
||||||
@@ -19,3 +20,23 @@ describe("resolveRequestUrl", () => {
|
|||||||
).toBe("http://localhost:8000/api/v1/schemes");
|
).toBe("http://localhost:8000/api/v1/schemes");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("authentication lifecycle state", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: "access-token",
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears only the local access token when a request becomes unauthorized", () => {
|
||||||
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
|
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+1
-8
@@ -1,6 +1,5 @@
|
|||||||
import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
|
import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
import { signOut } from "next-auth/react";
|
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import {
|
import {
|
||||||
applyAuthContextHeaders,
|
applyAuthContextHeaders,
|
||||||
@@ -13,8 +12,6 @@ export const api = axios.create({
|
|||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
let isSigningOut = false;
|
|
||||||
|
|
||||||
export const resolveRequestUrl = (request: {
|
export const resolveRequestUrl = (request: {
|
||||||
baseURL?: string;
|
baseURL?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -63,11 +60,7 @@ api.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
if (error?.response?.status === 401 && typeof window !== "undefined") {
|
if (error?.response?.status === 401 && typeof window !== "undefined") {
|
||||||
useAuthStore.getState().setAccessToken(null);
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
if (!isSigningOut) {
|
|
||||||
isSigningOut = true;
|
|
||||||
await signOut({ redirect: true, callbackUrl: "/login" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-8
@@ -1,12 +1,9 @@
|
|||||||
import { signOut } from "next-auth/react";
|
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import {
|
import {
|
||||||
applyAuthContextHeaders,
|
applyAuthContextHeaders,
|
||||||
type AuthContextHeaderOptions,
|
type AuthContextHeaderOptions,
|
||||||
} from "@/lib/requestHeaders";
|
} from "@/lib/requestHeaders";
|
||||||
|
|
||||||
let isSigningOut = false;
|
|
||||||
|
|
||||||
const unwrapPage = async (response: Response) => {
|
const unwrapPage = async (response: Response) => {
|
||||||
if (
|
if (
|
||||||
!response.headers.get("content-type")?.includes("application/json")
|
!response.headers.get("content-type")?.includes("application/json")
|
||||||
@@ -58,11 +55,7 @@ export const apiFetch = async (
|
|||||||
const response = await fetch(input, requestInit);
|
const response = await fetch(input, requestInit);
|
||||||
|
|
||||||
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
|
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
|
||||||
useAuthStore.getState().setAccessToken(null);
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
if (!isSigningOut) {
|
|
||||||
isSigningOut = true;
|
|
||||||
await signOut({ redirect: true, callbackUrl: "/login" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return unwrapPage(response);
|
return unwrapPage(response);
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export const getAccessToken = async () => {
|
|||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
}
|
}
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
if (session?.error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const token = typeof session?.accessToken === "string" ? session.accessToken : null;
|
const token = typeof session?.accessToken === "string" ? session.accessToken : null;
|
||||||
if (token && !isTokenExpired(token)) {
|
if (token && !isTokenExpired(token)) {
|
||||||
setAccessToken(token);
|
setAccessToken(token);
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ const normalizeModelOption = (value: unknown): AgentModelOption | null => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
|
export const fetchAgentModels = async (signal?: AbortSignal): Promise<AgentModelConfig> => {
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/models`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
signal,
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
rejectAgentQuestion,
|
rejectAgentQuestion,
|
||||||
|
replyAgentCredentialRefresh,
|
||||||
replyAgentPermission,
|
replyAgentPermission,
|
||||||
replyAgentQuestion,
|
replyAgentQuestion,
|
||||||
type StreamEvent,
|
type StreamEvent,
|
||||||
@@ -171,6 +172,32 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses credential refresh lifecycle events", async () => {
|
||||||
|
mockNewSessionStream({
|
||||||
|
ok: true,
|
||||||
|
body: makeStream([
|
||||||
|
'event: credential_refresh_required\ndata: {"session_id":"s1","request_id":"credential-1","reason":"access_token_rejected","timeout_ms":30000}\n\n',
|
||||||
|
'event: credential_refreshed\ndata: {"session_id":"s1","request_id":"credential-1"}\n\n',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const events: StreamEvent[] = [];
|
||||||
|
await streamAgentChat({ message: "hi", onEvent: (event) => events.push(event) });
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "credential_refresh_required",
|
||||||
|
sessionId: "s1",
|
||||||
|
requestId: "credential-1",
|
||||||
|
reason: "access_token_rejected",
|
||||||
|
timeoutMs: 30000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "credential_refreshed",
|
||||||
|
sessionId: "s1",
|
||||||
|
requestId: "credential-1",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("parses tool_call arguments when params is empty", async () => {
|
it("parses tool_call arguments when params is empty", async () => {
|
||||||
mockNewSessionStream({
|
mockNewSessionStream({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -363,6 +390,7 @@ describe("streamAgentChat", () => {
|
|||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls permission reply endpoint", async () => {
|
it("calls permission reply endpoint", async () => {
|
||||||
@@ -386,6 +414,33 @@ describe("streamAgentChat", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await replyAgentPermission("s1", "perm-2", "always");
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenLastCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/agent/sessions/s1/permission-responses"),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
request_id: "perm-2",
|
||||||
|
reply: "always",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits refreshed credentials through the authenticated context", async () => {
|
||||||
|
apiFetch.mockResolvedValue({ ok: true, status: 202, text: async () => "" });
|
||||||
|
await replyAgentCredentialRefresh("s1", "credential-1");
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/agent/sessions/s1/credential-refreshes"),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ request_id: "credential-1" }),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls question reply and reject endpoints", async () => {
|
it("calls question reply and reject endpoints", async () => {
|
||||||
|
|||||||
+95
-15
@@ -1,10 +1,16 @@
|
|||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
|
|
||||||
|
const AGENT_SESSIONS_URL = `${config.AGENT_URL}/api/v1/agent/sessions`;
|
||||||
|
|
||||||
|
const getAgentSessionUrl = (sessionId: string, suffix = "") =>
|
||||||
|
`${AGENT_SESSIONS_URL}/${encodeURIComponent(sessionId)}${suffix}`;
|
||||||
|
|
||||||
export type AgentModel = string;
|
export type AgentModel = string;
|
||||||
|
|
||||||
export type PermissionReply = "once" | "always" | "reject";
|
export type PermissionDecision = "once" | "always" | "reject";
|
||||||
export type AgentApprovalMode = "request" | "always";
|
export type PermissionReply = PermissionDecision;
|
||||||
|
export type AgentApprovalMode = "request" | "auto" | "always";
|
||||||
|
|
||||||
export type AgentQuestionStatus =
|
export type AgentQuestionStatus =
|
||||||
| "pending"
|
| "pending"
|
||||||
@@ -90,6 +96,24 @@ export type StreamEvent =
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "credential_refresh_required";
|
||||||
|
sessionId: string;
|
||||||
|
requestId: string;
|
||||||
|
reason?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "credential_refreshed";
|
||||||
|
sessionId: string;
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "credential_refresh_failed";
|
||||||
|
sessionId: string;
|
||||||
|
requestId: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "tool_call";
|
type: "tool_call";
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -310,6 +334,7 @@ const emitParsedStreamEvent = (
|
|||||||
message_id?: string;
|
message_id?: string;
|
||||||
todos?: unknown;
|
todos?: unknown;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
timeout_ms?: number;
|
||||||
};
|
};
|
||||||
if (event === "state") {
|
if (event === "state") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -366,6 +391,27 @@ const emitParsedStreamEvent = (
|
|||||||
reason: parsed.reason,
|
reason: parsed.reason,
|
||||||
message: parsed.message ?? "登录态已过期,请刷新登录后重试",
|
message: parsed.message ?? "登录态已过期,请刷新登录后重试",
|
||||||
});
|
});
|
||||||
|
} else if (event === "credential_refresh_required") {
|
||||||
|
onEvent({
|
||||||
|
type: "credential_refresh_required",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
requestId: parsed.request_id ?? "",
|
||||||
|
reason: parsed.reason,
|
||||||
|
timeoutMs: parsed.timeout_ms,
|
||||||
|
});
|
||||||
|
} else if (event === "credential_refreshed") {
|
||||||
|
onEvent({
|
||||||
|
type: "credential_refreshed",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
requestId: parsed.request_id ?? "",
|
||||||
|
});
|
||||||
|
} else if (event === "credential_refresh_failed") {
|
||||||
|
onEvent({
|
||||||
|
type: "credential_refresh_failed",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
requestId: parsed.request_id ?? "",
|
||||||
|
message: parsed.message ?? "登录凭据续期失败",
|
||||||
|
});
|
||||||
} else if (event === "tool_call") {
|
} else if (event === "tool_call") {
|
||||||
onEvent({
|
onEvent({
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
@@ -469,7 +515,7 @@ const readStreamEvents = async (
|
|||||||
const ensureAgentSession = async (sessionId?: string) => {
|
const ensureAgentSession = async (sessionId?: string) => {
|
||||||
if (sessionId) return sessionId;
|
if (sessionId) return sessionId;
|
||||||
|
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
|
const response = await apiFetch(AGENT_SESSIONS_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -479,7 +525,10 @@ const ensureAgentSession = async (sessionId?: string) => {
|
|||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error((await response.text()) || `session creation failed: ${response.status}`);
|
throw new Error(
|
||||||
|
(await response.text()) ||
|
||||||
|
`session creation failed: ${response.status}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const payload = (await response.json()) as { session_id?: string };
|
const payload = (await response.json()) as { session_id?: string };
|
||||||
if (!payload.session_id) {
|
if (!payload.session_id) {
|
||||||
@@ -500,7 +549,7 @@ export const streamAgentChat = async ({
|
|||||||
try {
|
try {
|
||||||
const effectiveSessionId = await ensureAgentSession(sessionId);
|
const effectiveSessionId = await ensureAgentSession(sessionId);
|
||||||
response = await apiFetch(
|
response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(effectiveSessionId)}/runs`,
|
getAgentSessionUrl(effectiveSessionId, "/runs"),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal,
|
signal,
|
||||||
@@ -557,7 +606,7 @@ export const resumeAgentChatStream = async ({
|
|||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await apiFetch(
|
response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current/events`,
|
getAgentSessionUrl(sessionId, "/runs/current/events"),
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
signal,
|
signal,
|
||||||
@@ -598,11 +647,14 @@ export const abortAgentChat = async (sessionId?: string) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/runs/current`, {
|
const response = await apiFetch(
|
||||||
|
getAgentSessionUrl(sessionId, "/runs/current"),
|
||||||
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = await response.text();
|
const detail = await response.text();
|
||||||
@@ -613,10 +665,10 @@ export const abortAgentChat = async (sessionId?: string) => {
|
|||||||
export const replyAgentPermission = async (
|
export const replyAgentPermission = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
reply: PermissionReply,
|
reply: PermissionDecision,
|
||||||
) => {
|
) => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/permission-responses`,
|
getAgentSessionUrl(sessionId, "/permission-responses"),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -637,13 +689,35 @@ export const replyAgentPermission = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const replyAgentCredentialRefresh = async (
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
) => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
getAgentSessionUrl(sessionId, "/credential-refreshes"),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ request_id: requestId }),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
detail || `credential refresh reply failed: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const replyAgentQuestion = async (
|
export const replyAgentQuestion = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
answers: string[][],
|
answers: string[][],
|
||||||
) => {
|
) => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`,
|
getAgentSessionUrl(sessionId, "/question-responses"),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -670,7 +744,7 @@ export const rejectAgentQuestion = async (
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
) => {
|
) => {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}/question-responses`,
|
getAgentSessionUrl(sessionId, "/question-responses"),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -691,8 +765,13 @@ export const rejectAgentQuestion = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
|
export const forkAgentChat = async (
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId ?? "")}/forks`, {
|
sessionId: string | undefined,
|
||||||
|
keepMessageCount: number,
|
||||||
|
) => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
getAgentSessionUrl(sessionId ?? "", "/forks"),
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -702,7 +781,8 @@ export const forkAgentChat = async (sessionId: string | undefined, keepMessageCo
|
|||||||
}),
|
}),
|
||||||
projectHeaderMode: "include",
|
projectHeaderMode: "include",
|
||||||
skipAuthRedirect: true,
|
skipAuthRedirect: true,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = await response.text();
|
const detail = await response.text();
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { completeLogout, reportLogoutAudit } from "./logoutFlow";
|
||||||
|
|
||||||
|
describe("reportLogoutAudit", () => {
|
||||||
|
it("starts an authenticated keepalive request", async () => {
|
||||||
|
const fetcher = jest.fn(async () => ({ ok: true }) as Response);
|
||||||
|
|
||||||
|
await reportLogoutAudit({
|
||||||
|
endpoint: "https://server.example/api/v1/audit-events",
|
||||||
|
accessToken: "access-token",
|
||||||
|
fetcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetcher.mock.calls[0];
|
||||||
|
expect(url).toBe("https://server.example/api/v1/audit-events");
|
||||||
|
expect(init).toMatchObject({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ event: "logout" }),
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
expect(new Headers(init?.headers).get("Authorization")).toBe(
|
||||||
|
"Bearer access-token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("completeLogout", () => {
|
||||||
|
it("navigates immediately without waiting for audit reporting", async () => {
|
||||||
|
const reportAudit = jest.fn(
|
||||||
|
() => new Promise<unknown>(() => undefined),
|
||||||
|
);
|
||||||
|
const clearLocalState = jest.fn();
|
||||||
|
const navigate = jest.fn();
|
||||||
|
|
||||||
|
const result = completeLogout({
|
||||||
|
reportAudit,
|
||||||
|
clearLocalState,
|
||||||
|
navigate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(reportAudit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||||
|
await expect(result).resolves.toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes logout when audit reporting fails", async () => {
|
||||||
|
const reportAudit = jest.fn(() => Promise.reject(new Error("offline")));
|
||||||
|
const clearLocalState = jest.fn();
|
||||||
|
const navigate = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
completeLogout({ reportAudit, clearLocalState, navigate }),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
|
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes logout when audit reporting throws synchronously", async () => {
|
||||||
|
const clearLocalState = jest.fn();
|
||||||
|
const navigate = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
completeLogout({
|
||||||
|
reportAudit: () => {
|
||||||
|
throw new Error("invalid request");
|
||||||
|
},
|
||||||
|
clearLocalState,
|
||||||
|
navigate,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
|
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
|
||||||
|
|
||||||
|
type CompleteLogoutOptions = {
|
||||||
|
reportAudit: () => Promise<unknown>;
|
||||||
|
clearLocalState: () => void;
|
||||||
|
navigate: (path: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogoutAuditOptions = {
|
||||||
|
endpoint: string;
|
||||||
|
accessToken?: string | null;
|
||||||
|
fetcher?: typeof fetch;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reportLogoutAudit = ({
|
||||||
|
endpoint,
|
||||||
|
accessToken,
|
||||||
|
fetcher = fetch,
|
||||||
|
}: LogoutAuditOptions) => {
|
||||||
|
const headers = new Headers({ "Content-Type": "application/json" });
|
||||||
|
if (accessToken) {
|
||||||
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetcher(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ event: "logout" }),
|
||||||
|
keepalive: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeLogout = async ({
|
||||||
|
reportAudit,
|
||||||
|
clearLocalState,
|
||||||
|
navigate,
|
||||||
|
}: CompleteLogoutOptions) => {
|
||||||
|
try {
|
||||||
|
void reportAudit().catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
// Synchronous reporting errors must not block logout either.
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLocalState();
|
||||||
|
navigate(KEYCLOAK_LOGOUT_PATH);
|
||||||
|
return { success: true as const };
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useSessionRecoveryDraft } from "./sessionRecoveryDraft";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
|
const DraftFixture = ({ initial }: { initial: string }) => {
|
||||||
|
const [value, setValue] = useState(initial);
|
||||||
|
useSessionRecoveryDraft("fixture", { value }, (draft) => setValue(draft.value));
|
||||||
|
return <output>{value}</output>;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useSessionRecoveryDraft", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves an in-progress value when authentication expires and restores it once", () => {
|
||||||
|
const first = render(<DraftFixture initial="in-progress" />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBe(
|
||||||
|
JSON.stringify({ value: "in-progress" }),
|
||||||
|
);
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
useAuthStore.getState().clearSessionExpired();
|
||||||
|
const restored = render(<DraftFixture initial="empty" />);
|
||||||
|
|
||||||
|
expect(restored.getByText("in-progress")).toBeInTheDocument();
|
||||||
|
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = "tjwater-session-recovery:";
|
||||||
|
|
||||||
|
const storageKey = (key: string) => `${STORAGE_PREFIX}${key}`;
|
||||||
|
|
||||||
|
export const clearSessionRecoveryDrafts = () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
||||||
|
const key = sessionStorage.key(index);
|
||||||
|
if (key?.startsWith(STORAGE_PREFIX)) sessionStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSessionRecoveryDraft = <T,>(
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
restore: (value: T) => void,
|
||||||
|
) => {
|
||||||
|
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||||
|
const restoredRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (restoredRef.current || typeof window === "undefined") return;
|
||||||
|
restoredRef.current = true;
|
||||||
|
const raw = sessionStorage.getItem(storageKey(key));
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
restore(JSON.parse(raw) as T);
|
||||||
|
sessionStorage.removeItem(storageKey(key));
|
||||||
|
} catch {
|
||||||
|
sessionStorage.removeItem(storageKey(key));
|
||||||
|
}
|
||||||
|
}, [key, restore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionExpired || typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(storageKey(key), JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// Recovery is best-effort. Do not block re-authentication when storage is unavailable.
|
||||||
|
}
|
||||||
|
}, [key, sessionExpired, value]);
|
||||||
|
};
|
||||||
@@ -2,10 +2,27 @@ import { create } from "zustand";
|
|||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
|
sessionExpired: boolean;
|
||||||
|
sessionExpiryReason: "refresh_failed" | "session_max_age" | "unauthorized" | null;
|
||||||
setAccessToken: (token: string | null) => void;
|
setAccessToken: (token: string | null) => void;
|
||||||
|
markSessionExpired: (
|
||||||
|
reason: Exclude<AuthState["sessionExpiryReason"], null>,
|
||||||
|
) => void;
|
||||||
|
clearSessionExpired: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
setAccessToken: (token) => set({ accessToken: token }),
|
setAccessToken: (token) => set({ accessToken: token }),
|
||||||
|
markSessionExpired: (reason) => set({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: reason,
|
||||||
|
}),
|
||||||
|
clearSessionExpired: () => set({
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Vendored
+5
-2
@@ -4,7 +4,8 @@ import "next-auth/jwt";
|
|||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
error?: "RefreshAccessTokenError";
|
error?: "RefreshAccessTokenError" | "SessionExpired";
|
||||||
|
sessionExpiresAt?: number;
|
||||||
user?: {
|
user?: {
|
||||||
id?: string;
|
id?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -26,7 +27,9 @@ declare module "next-auth/jwt" {
|
|||||||
username?: string;
|
username?: string;
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
|
accessTokenIssuedAt?: number;
|
||||||
accessTokenExpires?: number;
|
accessTokenExpires?: number;
|
||||||
error?: "RefreshAccessTokenError";
|
sessionExpiresAt?: number;
|
||||||
|
error?: "RefreshAccessTokenError" | "SessionExpired";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user