Compare commits

..
14 Commits
Author SHA1 Message Date
jiang 1ac7372585 ci: replace webhook deployment with v2 workflow
Generic Container CI/CD / test-build-publish (push) Successful in 4m7s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m7s
2026-08-11 10:17:03 +08:00
jiang 5d730ba1b8 fix(auth): ensure Keycloak logout clears secure session 2026-08-07 14:47:20 +08:00
jiang caf18f706d fix(auth): prevent logout audit from blocking redirect 2026-08-07 12:01:39 +08:00
jiang 0dd521d8c9 fix(chat): distinguish auth outages from model failures
Runtime model checks previously mapped every authenticated endpoint failure to models_unavailable. Treat request failures as service outages and retry failed checks after five seconds; empty model configurations remain distinct.
2026-08-06 19:35:24 +08:00
jiang 0a47534ddb fix(chat): restore windowed turn alignment
Keep the virtual-window measurement wrapper as a flex column so AgentTurn alignSelf rules remain active. Add regression coverage for the alignment context.
2026-08-06 19:07:40 +08:00
jiang 9e75e2df8a feat(chat): 完善运行状态与权限交互 2026-08-06 18:41:53 +08:00
jiang 8d7c947897 fix(auth): keep expiry dialog above agent UI 2026-08-06 16:09:23 +08:00
jiang 71bde7d9e4 refactor(chat): centralize agent session URLs 2026-08-06 10:40:13 +08:00
jiang 5037089057 feat(chat): refresh agent credentials during tool calls 2026-08-06 10:16:50 +08:00
jiang b4c96f8524 fix(auth): set local logout redirect example 2026-08-05 17:57:49 +08:00
jiang e496fbe4b7 fix(auth): align session lifecycle with Keycloak 2026-08-05 17:10:59 +08:00
jiang 5d9c40e454 feat(sensor): 完善监测点结果编辑与图层切换 2026-08-03 19:00:16 +08:00
jiang 5592c27386 chore(api): sync internal server contract 2026-08-03 11:12:54 +08:00
jiang 94142d7031 fix(auth): complete Keycloak logout flow 2026-08-03 10:40:21 +08:00
57 changed files with 2927 additions and 1205 deletions
+14 -152
View File
@@ -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."
+152 -1
View File
@@ -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",
+1 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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;
};
+62 -6
View File
@@ -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();
});
});
+48
View File
@@ -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;
};
+15
View File
@@ -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>
);
};
+51 -1
View File
@@ -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();
});
}); });
+108 -28
View File
@@ -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) }}
+16 -3
View File
@@ -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)}` },
+38 -12
View File
@@ -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) },
]; ];
+52
View File
@@ -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();
});
}); });
+2 -2
View File
@@ -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;
}; };
+99 -1
View File
@@ -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);
});
}); });
+278 -19
View File
@@ -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}
+74 -4
View File
@@ -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);
});
}); });
+78 -12
View File
@@ -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);
@@ -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);
}); });
}); });
@@ -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
View File
@@ -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
View File
@@ -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: {
+44
View File
@@ -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);
});
});
+37
View File
@@ -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,
);
};
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+3
View File
@@ -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);
+2 -1
View File
@@ -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,
}); });
+55
View File
@@ -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
View File
@@ -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();
+79
View File
@@ -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");
});
});
+47
View File
@@ -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 };
};
+41
View File
@@ -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();
});
});
+49
View File
@@ -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]);
};
+17
View File
@@ -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,
}),
})); }));
+5 -2
View File
@@ -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";
} }
} }