Compare commits

..
103 Commits
Author SHA1 Message Date
jiang 3ebe3328aa fix(auth): handle expired session sources before permissions
Generic Container CI/CD / test-build-publish (push) Successful in 57s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 57s
The previous fix only prioritized authStore inside the route guard, so an unauthenticated NextAuth session or a suppressed access-context 401 still collapsed into an empty permission set. Propagate both authentication signals before authorization so expired sessions consistently reauthenticate.
2026-09-09 15:28:53 +08:00
jiang b04378397c test: type logout audit fetch mock
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m1s
2026-09-09 15:07:45 +08:00
jiang bad8769000 fix(auth): redirect expired sessions before permission checks 2026-09-09 15:07:25 +08:00
jiang 29b8babd68 fix(api): align frontend with project-scoped backend
Use project-scoped endpoints and run IDs for current project, SCADA metadata, historical time series, migrated DMA results, and sensor placement runs.

The regressions recurred because tests mocked pre-interceptor and new-only payload shapes, while history queries relied on implicit global scheme state and unbounded request fan-out. Cover post-interceptor pages, migrated result_rows, explicit run_id routing, multi-element requests, request limits, and cancellation.
2026-09-01 14:37:51 +08:00
jiang 0dad61ff1f feat(chat): 优化分析过程与结果展示
Generic Container CI/CD / test-build-publish (push) Successful in 2m25s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m25s
2026-08-26 18:05:03 +08:00
jiang fee4fc6ce1 refactor(frontend): align TJWater Next integrations 2026-08-26 12:26:29 +08:00
jiang 6623f4f7fb merge: sync Server storage cleanup contract
Merge PR #2 after API contract, lint, test, and production build verification.
2026-08-18 18:35:23 +08:00
jiang d2ce1687f2 chore(api): sync storage cleanup contract 2026-08-18 18:29:09 +08:00
jiang dca2817320 merge: integrate agent-mvp into master
Merge PR #1 after frontend, cross-repository contract, and container gates passed.
2026-08-18 17:56:45 +08:00
jiang 41bf7d71e3 chore(api): sync backend contract mirror 2026-08-18 17:56:11 +08:00
jiang 5bea86dbf6 fix(deps): update XML parser and nanoid 2026-08-18 16:50:46 +08:00
jiang 78a470e89e merge(frontend): integrate master into agent-mvp 2026-08-18 16:41:52 +08:00
jiang 6d505ca461 fix(flushing): 收紧空阀门区域高度
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m1s
2026-08-17 19:44:11 +08:00
jiang f34d81c933 fix(flushing): 对齐选择项空状态
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m1s
2026-08-17 19:36:33 +08:00
jiang 5053ddcd1f fix(map): 修正属性查询数据源与模拟属性
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
2026-08-17 19:31:54 +08:00
jiang 45def5bba3 fix(analysis): 统一方案名与接口错误提示
Generic Container CI/CD / test-build-publish (push) Successful in 1m2s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m2s
2026-08-17 18:54:29 +08:00
jiang 9e79d52dc8 feat(flushing): 添加阀门状态与设置值控制 2026-08-17 18:29:00 +08:00
jiang 4a978de905 ci(frontend): inject public build configuration 2026-08-11 10:44:56 +08:00
TJWater CI f5bcf28f7e fix(map): preserve hook order while data loads
Generic Container CI/CD / test-build-publish (push) Failing after 1m50s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m50s
2026-08-11 10:32:18 +08:00
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
TJWater CI ab45c8da8e ci: add frontend v2 deployment workflow
Generic Container CI/CD / test-build-publish (push) Failing after 1m13s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m14s
2026-08-11 09:51:13 +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
jiang 0e638046ed fix(frontend): update production dependency security patches
Build Push and Deploy / docker-image (push) Successful in 2m7s
Build Push and Deploy / deploy-fallback-log (push) Skipped
2026-07-30 22:41:30 +08:00
jiang 65ed1fd212 fix(sensor-placement): refine drawing export and editor density
Build Push and Deploy / docker-image (push) Failing after 23s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-07-30 21:39:27 +08:00
jiang 81cc5dcae1 fix(burst-location): clarify simulation baseline requirement 2026-07-30 20:55:01 +08:00
jiang 782363cfb6 fix(api): align frontend with REST contracts 2026-07-30 20:38:52 +08:00
jiang b57e58ff87 fix(chat): 调整输入区操作按钮布局 2026-07-30 19:07:17 +08:00
jiang 38d246eb7b fix(dma): 放宽总漏损流量下限 2026-07-30 18:15:36 +08:00
jiang 723931b6ae feat(frontend): enforce RBAC and refine burst analysis 2026-07-30 16:45:10 +08:00
jiang f355ddd002 fix(sensor-placement): smooth result editor transitions 2026-07-30 16:38:06 +08:00
jiang 6114e76735 fix(sensor-placement): stabilize engineering drawing export
Render the OpenLayers basemap at the final A3 map-box resolution with pixelRatio 1, carry provider attribution into the drawing, and keep the linework/basemap export path covered by regression tests.
2026-07-30 16:21:30 +08:00
jiang d643f09fcf feat(sensor-placement): add scheme engineering editor 2026-07-30 16:16:51 +08:00
jiang 04557b9363 feat(ui): 统一面板空状态引导 2026-07-30 14:34:43 +08:00
jiang 8836549697 fix(auth): unify scheme creator display 2026-07-30 14:21:20 +08:00
jiang 1d7e07174f fix(map): use component-level loading skeletons 2026-07-30 13:21:55 +08:00
jiang c4246cf25f feat(burst): add analysis report and valve binding
Replace the obsolete location result with a printable analysis report, bind valve analysis to the selected scheme, and cache report diameter lookups. Scheme identity is tracked with valve results so reports cannot reuse results from another scheme that shares the same pipe.
2026-07-30 13:16:05 +08:00
jiang 1b2a7f4fb8 perf(map): reduce tile snapshot work during zoom 2026-07-30 12:50:33 +08:00
jiang 82e75c03d0 feat(health-risk): add printable assessment report 2026-07-30 11:52:43 +08:00
jiang 5894ee277a fix(frontend): use success style for empty scheme queries 2026-07-30 11:04:31 +08:00
jiang a3331691de test(frontend): strengthen default time assertion 2026-07-30 11:04:15 +08:00
jiang 7745333a58 fix(frontend): query schemes by type 2026-07-30 11:01:45 +08:00
jiang 4596af7c12 docs: 编写中文 README 2026-07-22 11:26:06 +08:00
jiang 5332f8f0c5 fix(layout): prevent map page overflow
Build Push and Deploy / docker-image (push) Successful in 7s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-07-20 13:12:19 +08:00
jiang 1993cadba8 chore(config): replace env file with example 2026-07-20 12:09:24 +08:00
jiang 7a3677ee9f feat(config): inject frontend runtime config 2026-07-20 12:06:57 +08:00
jiang 0d559f6130 fix(scada): show backend detail on cleaning errors
Build Push and Deploy / docker-image (push) Successful in 1m3s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-07-17 16:27:40 +08:00
jiang 7af90e495d feat(burst-detection): update analysis parameters 2026-07-17 16:24:04 +08:00
jiang a52c04204d refactor(admin): simplify system management UI 2026-07-17 16:22:16 +08:00
jiang 08152ff978 fix(map): restore saved styles as inactive drafts 2026-07-17 15:28:44 +08:00
jiang 589cf45aa7 fix(map): stabilize tiled style rendering 2026-07-17 15:12:10 +08:00
jiang 59447a100c fix(scada): align device panel collapse animation 2026-07-17 14:25:03 +08:00
jiang 4adbcc1c4c fix(dma): defer leakage flow validation 2026-07-17 11:45:58 +08:00
jiang 202f18332f fix(notification): clarify query and progress feedback 2026-07-17 10:48:58 +08:00
jiang acf13639ef fix(map): allow selecting features under overlays 2026-07-17 10:21:16 +08:00
jiang d986e563a6 fix(map): convert actual demand display units 2026-07-17 10:13:50 +08:00
jiang d8ee2e1f0c fix(burst): show actual pipe diameters 2026-07-17 10:01:06 +08:00
jiang fddb0ceb34 fix(map): restrict waterflow layer to flow 2026-07-16 18:02:29 +08:00
jiang 3d7b594682 style(map): align valve setting input 2026-07-16 17:55:58 +08:00
jiang eb8950c89a feat(map): add valve setting editor 2026-07-16 17:48:16 +08:00
jiang ef2b045306 fix(map): show simulation pipe ids 2026-07-16 14:50:22 +08:00
jiang 209da0d295 fix(map): sort scheme queries by time 2026-07-16 14:29:29 +08:00
jiang dfaee645ff fix(timeline): use backend timestep 2026-07-16 14:16:07 +08:00
jiang d90ca7c951 fix(timeline): align range mask with slider 2026-07-16 11:38:22 +08:00
jiang 041b4ef89d fix(timeline): prevent negative initial time 2026-07-16 11:31:51 +08:00
jiang f5e7312e3b perf(map): reuse resources across routes
Build Push and Deploy / docker-image (push) Successful in 54s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
Preserve standard network layers between map pages while disposing route-owned overlays and controls to prevent memory growth.
2026-07-10 15:31:14 +08:00
jiang c6e6e24aab fix(chat): preserve agent UI across close 2026-07-10 14:38:25 +08:00
jiang adb53d9a13 feat(chat): add selection-based speech playback 2026-07-10 14:29:27 +08:00
jiang 14c76231d5 fix(map): prevent controllable state loops
Build Push and Deploy / docker-image (push) Successful in 55s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-07-09 14:50:33 +08:00
jiang 694f7629ee fix(analysis): preserve tab panel state 2026-07-09 13:59:01 +08:00
jiang 701c5a949d fix(burst-location): surface data source note 2026-07-09 11:55:49 +08:00
jiang 435a4172e4 fix(burst-location): explain normal data source 2026-07-09 11:51:40 +08:00
jiang 600a8703ed fix(ci): use host network for Docker build
Build Push and Deploy / docker-image (push) Successful in 2m3s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-07-08 19:41:46 +08:00
jiang b13fbe7dca chore(ci): print npm install debug logs
Build Push and Deploy / docker-image (push) Failing after 1m21s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
2026-07-08 19:38:52 +08:00
jiang e2e296dc06 fix(ci): pass npm mirror to Docker build
Build Push and Deploy / docker-image (push) Failing after 1m22s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-07-08 19:31:56 +08:00
jiang 9c2a6a386a fix(build): use npm mirror in Docker
Build Push and Deploy / docker-image (push) Failing after 1m21s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
2026-07-08 19:29:31 +08:00
jiang a4e7ab263a fix(build): pin Node Docker tag
Build Push and Deploy / docker-image (push) Failing after 1m22s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-07-08 19:22:44 +08:00
jiang 67c1a88afe fix(build): use Debian Node image
Build Push and Deploy / docker-image (push) Failing after 31s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-07-08 19:20:29 +08:00
jiang 76e62a2d3d fix(build): skip npm audit in Docker
Build Push and Deploy / docker-image (push) Failing after 1m16s
Build Push and Deploy / deploy-fallback-log (push) Successful in 1s
2026-07-08 18:58:19 +08:00
jiang 758100b345 fix(build): pin npm in Docker deps
Build Push and Deploy / docker-image (push) Failing after 1m15s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
2026-07-08 18:54:57 +08:00
jiang cf6386d209 feat(chat): add Edge TTS playback
Build Push and Deploy / docker-image (push) Failing after 1m17s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
2026-07-08 18:39:49 +08:00
jiang 0dea655f68 refactor(frontend): normalize naming conventions 2026-06-13 13:07:16 +08:00
jiang 6ff8886524 feat(audit): add audit log page 2026-06-12 15:49:43 +08:00
jiang 7cd0c61181 refactor(admin): remove geoserver config UI 2026-06-12 15:28:14 +08:00
jiang f6d2e19397 feat(admin): improve project management UI 2026-06-12 15:08:37 +08:00
jiang 181871e0cf feat(*): 添加系统管理面板及相关功能 2026-06-12 13:43:48 +08:00
jiang 8934844bd9 chore(build): remove Refine CLI 2026-06-12 12:58:41 +08:00
jiang 24cddc18a6 chore(devtools): remove Refine devtools 2026-06-12 12:54:25 +08:00
jiang 7f07f0449d fix(devtools): set local devtools URL 2026-06-12 12:43:10 +08:00
jiang 757eea49de fix(layout): hide default dashboard 2026-06-12 12:42:19 +08:00
jiang bb7311589c refactor(auth): remove agent user header 2026-06-12 10:18:41 +08:00
jiang 877b79ada8 refactor(chat): remove typing indicator
Build Push and Deploy / docker-image (push) Successful in 1m2s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped
2026-06-10 21:33:33 +08:00
267 changed files with 104389 additions and 12187 deletions
+2
View File
@@ -3,6 +3,8 @@ node_modules
out out
build build
.git .git
.env
.env.*
.env*.local .env*.local
README.md README.md
docker-compose.yml docker-compose.yml
-16
View File
@@ -1,16 +0,0 @@
KEYCLOAK_CLIENT_ID="tjwater"
KEYCLOAK_CLIENT_SECRET="83h0n413hau9bldzWdEaq6xRfASv24s5"
KEYCLOAK_ISSUER="https://keycloak.waternetwork.cn/realms/tjwater"
NEXTAUTH_SECRET="eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiS"
NEXTAUTH_URL="https://demo.waternetwork.cn/"
# 为前端暴露的变量添加 NEXT_PUBLIC_ 前缀
NEXT_PUBLIC_BACKEND_URL="https://server.waternetwork.cn"
NEXT_PUBLIC_AGENT_URL="https://agent.waternetwork.cn"
NEXT_PUBLIC_AUDIO_SERVICE_URL="https://tts.waternetwork.cn"
NEXT_PUBLIC_MAP_URL="https://geoserver.waternetwork.cn/geoserver"
NEXT_PUBLIC_MAP_WORKSPACE="tjwater"
NEXT_PUBLIC_MAP_EXTENT="13490131, 3630016, 13525879, 3666968.25"
NEXT_PUBLIC_NETWORK_NAME="tjwater"
NEXT_PUBLIC_MAPBOX_TOKEN="pk.eyJ1IjoiemhpZnUiLCJhIjoiY205azNyNGY1MGkyZDJxcTJleDUwaHV1ZCJ9.wOmSdOnDDdre-mB1Lpy6Fg"
NEXT_PUBLIC_TIANDITU_TOKEN="e3e8ad95ee911741fa71ed7bff2717ec"
+14
View File
@@ -0,0 +1,14 @@
KEYCLOAK_CLIENT_ID="tjwater"
KEYCLOAK_CLIENT_SECRET="replace-with-keycloak-client-secret"
KEYCLOAK_ISSUER="https://keycloak.example.com/realms/tjwater"
NEXTAUTH_SECRET="replace-with-nextauth-secret"
NEXTAUTH_URL="https://frontend.example.com/"
BACKEND_URL="https://server.example.com"
AGENT_URL="https://agent.example.com"
MAP_URL="https://geoserver.example.com/geoserver"
MAP_WORKSPACE="tjwater_next"
MAP_EXTENT="13508801.93,3608163.35,13555650.64,3633685.14"
NETWORK_NAME="tjwater_next"
MAPBOX_TOKEN="replace-with-public-mapbox-token"
TIANDITU_TOKEN="replace-with-public-tianditu-token"
+14 -159
View File
@@ -1,166 +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 \
-f ./Dockerfile \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
--build-arg NEXT_PUBLIC_BACKEND_URL="${{ vars.NEXT_PUBLIC_BACKEND_URL }}" \
--build-arg NEXT_PUBLIC_AGENT_URL="${{ vars.NEXT_PUBLIC_AGENT_URL }}" \
--build-arg NEXT_PUBLIC_AUDIO_SERVICE_URL="${{ vars.NEXT_PUBLIC_AUDIO_SERVICE_URL }}" \
--build-arg NEXT_PUBLIC_MAP_URL="${{ vars.NEXT_PUBLIC_MAP_URL }}" \
--build-arg NEXT_PUBLIC_MAP_WORKSPACE="${{ vars.NEXT_PUBLIC_MAP_WORKSPACE }}" \
--build-arg NEXT_PUBLIC_MAP_EXTENT="${{ vars.NEXT_PUBLIC_MAP_EXTENT }}" \
--build-arg NEXT_PUBLIC_NETWORK_NAME="${{ vars.NEXT_PUBLIC_NETWORK_NAME }}" \
--build-arg NEXT_PUBLIC_MAPBOX_TOKEN="${{ secrets.NEXT_PUBLIC_MAPBOX_TOKEN }}" \
--build-arg NEXT_PUBLIC_TIANDITU_TOKEN="${{ secrets.NEXT_PUBLIC_TIANDITU_TOKEN }}" \
.
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."
+4 -1
View File
@@ -19,6 +19,7 @@
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
/public/runtime-config.js
# debug # debug
npm-debug.log* npm-debug.log*
@@ -26,7 +27,9 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
# local env files # local env files
.env.local .env
.env.*
!.env.example
# vercel # vercel
.vercel .vercel
+6 -1
View File
@@ -22,9 +22,14 @@ npm run start
`npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build. `npm run dev` starts the Refine/Next development server. `npm run lint` runs ESLint. `npm test` runs Jest. `npm run build` creates the production build.
When the Server API changes, sync `contracts/server-v1.openapi.json` from the
backend source of truth, update its SHA-256 in `contracts/manifest.json`, then
run `npm run api:generate` and `npm run api:check` so the generated types and
contract mirror remain aligned.
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for components, `camelCase` for variables/functions, and descriptive feature-oriented filenames. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused. Use TypeScript and React function components. Follow ESLint and Next.js conventions. Use `PascalCase` for React component files and component names. Use `camelCase` for ordinary TypeScript modules, hooks, stores, providers, utilities, variables, and functions. Next.js route directories under `src/app` use `kebab-case`; route groups and dynamic segments keep the Next.js syntax such as `(main)` and `[...nextauth]`. Keep backend/Agent boundary fields and query parameters in the shape required by the API, typically `snake_case`, and do not translate third-party SDK fields. Prefer MUI components and existing design tokens/patterns for UI. Keep operational screens dense, clear, and task-focused.
## Testing Guidelines ## Testing Guidelines
+15 -21
View File
@@ -1,32 +1,23 @@
FROM refinedev/node:22 AS base FROM node:22.23.1-bookworm-slim AS base
WORKDIR /app/refine
ARG NPM_CONFIG_REGISTRY
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY}
FROM base AS deps FROM base AS deps
RUN apk add --no-cache libc6-compat
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \ RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \ elif [ -f package-lock.json ]; then npm ci || (echo "===== npm debug logs =====" && find /root/.npm/_logs -maxdepth 1 -type f -name "*-debug-0.log" -print -exec cat {} \; && exit 1); \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \ else echo "Lockfile not found." && exit 1; \
fi fi
FROM base AS builder FROM base AS builder
# 只定义 ARG 接收来自构建命令或 docker-compose.yaml 的参数
# Next.js 在 build 时会自动读取同名的 ARG 作为环境变量
ARG NEXT_PUBLIC_BACKEND_URL
ARG NEXT_PUBLIC_AGENT_URL
ARG NEXT_PUBLIC_AUDIO_SERVICE_URL
ARG NEXT_PUBLIC_MAP_URL
ARG NEXT_PUBLIC_MAP_WORKSPACE
ARG NEXT_PUBLIC_MAP_EXTENT
ARG NEXT_PUBLIC_NETWORK_NAME
ARG NEXT_PUBLIC_MAPBOX_TOKEN
ARG NEXT_PUBLIC_TIANDITU_TOKEN
COPY --from=deps /app/refine/node_modules ./node_modules COPY --from=deps /app/refine/node_modules ./node_modules
COPY . . COPY . .
@@ -37,19 +28,22 @@ FROM base AS runner
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=builder /app/refine/public ./public COPY --from=builder --chown=node:node /app/refine/public ./public
COPY --chown=node:node docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN mkdir .next RUN mkdir .next
RUN chown refine:nodejs .next RUN chown node:node .next
COPY --from=builder --chown=refine:nodejs /app/refine/.next/standalone ./ COPY --from=builder --chown=node:node /app/refine/.next/standalone ./
COPY --from=builder --chown=refine:nodejs /app/refine/.next/static ./.next/static COPY --from=builder --chown=node:node /app/refine/.next/static ./.next/static
USER refine USER node
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"] CMD ["node", "server.js"]
+56
View File
@@ -0,0 +1,56 @@
# Frontend Naming Audit
DOC-004 audit for the internal `TJWaterFrontend_Refine` application.
## Local frontend naming
- Next.js route directories under `src/app` are already `kebab-case`: `audit-logs`, `health-risk-analysis`, `hydraulic-simulation`, `monitoring-place-optimization`, `network-simulation`, `scada-data-cleaning`, and `system-admin`.
- Route groups and dynamic segments keep Next.js syntax: `(main)` and `[...nextauth]`.
- Local ordinary module filenames now use `camelCase`, including `src/utils/breaksClassification.ts`, `src/components/chat/globalChatboxUtils.ts`, and `src/components/chat/globalChatboxVoice.ts`.
- React component files remain `PascalCase.tsx`, including `src/app/RefineContext.tsx`, `src/components/chat/GlobalChatboxParts.tsx`, and domain directories under `src/components/olmap`.
## API boundary naming
Frontend request and response boundary fields intentionally keep backend/Agent wire names. Examples include `project_id`, `user_id`, `scheme_name`, `scheme_type`, `start_time`, `end_time`, `session_id`, `request_id`, and `keep_message_count`.
Current direct API calls mostly use new `kebab-case` URL paths, including:
- `/api/v1/admin/projects`
- `/api/v1/audit/logs`
- `/api/v1/projects/open`
- `/api/v1/project-info`
- `/api/v1/schemes`
- `/api/v1/sensor-placement-schemes`
- `/api/v1/burst-analysis`
- `/api/v1/valve-isolation-analysis`
- `/api/v1/flushing-analysis`
- `/api/v1/contaminant-simulation`
- `/api/v1/simulations/run-by-date`
- `/api/v1/burst-detection/detect`
- `/api/v1/burst-location/locate`
- `/api/v1/scada/by-ids-field-time-range`
- `/api/v1/composite/clean-scada`
- `/api/v1/agent/chat/render-ref/{render_ref}`
## Legacy URL inventory
The frontend no longer calls these active legacy URLs directly. The backend still exposes them as deprecated compatibility aliases:
| Current frontend URL | Files | Suggested target |
| --- | --- | --- |
| `/api/v1/openproject/` | `src/contexts/ProjectContext.tsx` | `/api/v1/projects/open` or `/api/v1/project/open` |
| `/api/v1/project_info/` | `src/contexts/ProjectContext.tsx` | `/api/v1/project-info` |
| `/api/v1/getallschemes/` | burst, burst simulation, contaminant, flushing scheme query components | `/api/v1/schemes` |
| `/api/v1/getallsensorplacements/` | `src/components/olmap/MonitoringPlaceOptimization/SchemeQuery.tsx` | `/api/v1/sensor-placement-schemes` |
| `/api/v1/sensorplacementscheme/create` | `src/components/olmap/MonitoringPlaceOptimization/OptimizationParameters.tsx` | `/api/v1/sensor-placement-schemes` |
| `/api/v1/burst_analysis/` | `src/components/olmap/BurstSimulation/AnalysisParameters.tsx` | `/api/v1/burst-analysis` |
| `/api/v1/valve_isolation_analysis/` | `src/components/olmap/BurstSimulation/ValveIsolation.tsx` | `/api/v1/valve-isolation-analysis` |
| `/api/v1/flushing_analysis/` | `src/components/olmap/FlushingAnalysis/AnalysisParameters.tsx` | `/api/v1/flushing-analysis` |
| `/api/v1/contaminant_simulation/` | `src/components/olmap/ContaminantSimulation/AnalysisParameters.tsx` | `/api/v1/contaminant-simulation` |
| `/api/v1/runsimulationmanuallybydate/` | `src/components/olmap/core/Controls/Timeline.tsx` | `/api/v1/simulations/run-by-date` |
Already migrated frontend code leaves comments showing older pre-`/api/v1` URLs in `src/components/olmap/core/Controls/Toolbar.tsx`; those comments are historical only and are not active calls.
## Follow-up
Continue tracking broad passive legacy backend routes under the shared legacy API compatibility strategy.
+72 -29
View File
@@ -1,48 +1,91 @@
# my-refine-app # TJWaterFrontend_Refine 内部前端
<div align="center" style="margin: 30px;"> `TJWaterFrontend_Refine` 是 TJWater 内部 Web 前端,基于 Refine、Next.js、React 和 MUI 构建。它承载管网地图、业务管理、用户认证、智能体聊天、SCADA/历史数据查看和结果可视化等内部功能。
<a href="https://refine.dev">
<img alt="refine logo" src="https://refine.ams3.cdn.digitaloceanspaces.com/readme/refine-readme-banner.png">
</a>
</div>
<br/>
This [Refine](https://github.com/refinedev/refine) project was generated with [create refine-app](https://github.com/refinedev/refine/tree/master/packages/create-refine-app). ## 技术栈
## Getting Started - Next.js 16
- React 19
- Refine 5
- MUI 6 / MUI X
- OpenLayers、deck.gl、Turf
- Zustand、NextAuth、Jest
A React Framework for building internal tools, admin panels, dashboards & B2B apps with unmatched flexibility ✨ ## 目录结构
Refine's hooks and components simplifies the development process and eliminates the repetitive tasks by providing industry-standard solutions for crucial aspects of a project, including authentication, access control, routing, networking, state management, and i18n. ```text
src/app/ Next.js App Router 页面
## Available Scripts src/components/ 复用 UI 组件
src/providers/ Refine、认证、数据和主题 provider
### Running the development server. src/hooks/ 业务 hooks
src/utils/ 通用工具
```bash public/ 静态资源
npm run dev scripts/ 运行时配置和辅助脚本
Dockerfile 镜像构建文件
docker-compose.yml 本地编排参考
``` ```
### Building for production. 新增功能应复用现有页面、组件、provider、地图和聊天结构,避免创建平行体系。
## 本地开发
要求 Node.js 20 或更高版本:
```bash ```bash
npm run build npm install
npm run dev
``` ```
### Running the production server. `npm run dev` 会先执行运行时配置生成,再启动 Next.js 开发服务。
## 常用命令
```bash ```bash
npm run start npm run lint
npm test
npm run test:coverage
npm run build
npm run start
docker build -t tjwater-frontend:local .
``` ```
## Learn More - `npm run lint`:运行 ESLint。
- `npm test`:运行 Jest。
- `npm run test:coverage`:生成测试覆盖率。
- `npm run build`:生成生产构建。
- `npm run start`:启动生产模式服务。
To learn more about **Refine**, please check out the [Documentation](https://refine.dev/docs) ## 配置说明
- **REST Data Provider** [Docs](https://refine.dev/docs/core/providers/data-provider/#overview) 运行时配置由 `scripts/generate-runtime-config.mjs` 生成。API 地址、Agent 地址、Keycloak/认证参数、地图服务地址和其他环境差异配置应通过环境变量或部署配置注入。
- **Material UI** [Docs](https://refine.dev/docs/ui-frameworks/mui/tutorial/)
- **Custom Auth Provider** [Docs](https://refine.dev/docs/core/providers/auth-provider/)
## License 只有允许暴露给浏览器的配置才应进入 public/runtime 配置;密钥和私有 token 不能进入前端构建产物。
MIT ## 开发规范
- React 组件文件使用 `PascalCase.tsx`。
- 普通 TypeScript 模块、hooks、store、provider 和工具使用 `camelCase.ts`。
- `src/app` 路由目录使用 `kebab-case`,保留 Next.js 路由组和动态段语法。
- UI 优先沿用 MUI、Refine 和既有地图/聊天界面模式。
- 与后端或 Agent 通信的字段保持接口原始格式,通常为 `snake_case`。
## 测试与发布
提交前建议运行:
```bash
npm run lint
npm test
```
发布镜像前运行:
```bash
npm run build
```
Gitea 包工作流位于 `.gitea/workflows/package.yml`,通常由 tag 触发构建和推送镜像。
## 安全规则
不要提交 `.env`、`.next/`、`node_modules/`、本地缓存、私有地图/API token、客户数据或部署密钥。CI/CD 凭据应放在 Gitea secrets 中。
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"contract_version": "1.0.0",
"contracts": {
"agent": {
"file": "agent-v1.openapi.json",
"sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
},
"server": {
"file": "server-v1.openapi.json",
"sha256": "d0364fb08c6f18fac2ea9c9980ef21115fc01f110cd10f25f90d97d0bc0e6367"
}
}
}
File diff suppressed because it is too large Load Diff
+8 -12
View File
@@ -6,19 +6,15 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL}
NEXT_PUBLIC_AGENT_URL: ${NEXT_PUBLIC_AGENT_URL}
NEXT_PUBLIC_AUDIO_SERVICE_URL: ${NEXT_PUBLIC_AUDIO_SERVICE_URL}
NEXT_PUBLIC_MAP_URL: ${NEXT_PUBLIC_MAP_URL}
NEXT_PUBLIC_MAP_WORKSPACE: ${NEXT_PUBLIC_MAP_WORKSPACE}
NEXT_PUBLIC_MAP_EXTENT: ${NEXT_PUBLIC_MAP_EXTENT}
NEXT_PUBLIC_NETWORK_NAME: ${NEXT_PUBLIC_NETWORK_NAME}
NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN}
NEXT_PUBLIC_TIANDITU_TOKEN: ${NEXT_PUBLIC_TIANDITU_TOKEN}
env_file:
- .env
environment: environment:
BACKEND_URL: ${BACKEND_URL}
AGENT_URL: ${AGENT_URL}
MAP_URL: ${MAP_URL}
MAP_WORKSPACE: ${MAP_WORKSPACE}
MAP_EXTENT: ${MAP_EXTENT}
NETWORK_NAME: ${NETWORK_NAME}
MAPBOX_TOKEN: ${MAPBOX_TOKEN}
TIANDITU_TOKEN: ${TIANDITU_TOKEN}
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID} KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID}
KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET} KEYCLOAK_CLIENT_SECRET: ${KEYCLOAK_CLIENT_SECRET}
KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER} KEYCLOAK_ISSUER: ${KEYCLOAK_ISSUER}
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
set -eu
node <<'NODE'
const fs = require("fs");
const parseExtent = (value) => {
if (!value) {
return [13508849, 3608036, 13555781, 3633813];
}
const extent = value.split(",").map(Number);
return extent.length === 4 && extent.every(Number.isFinite)
? extent
: [13508849, 3608036, 13555781, 3633813];
};
const config = {
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater",
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater",
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
};
fs.writeFileSync(
"/app/refine/public/runtime-config.js",
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
);
NODE
exec "$@"
+830 -3750
View File
File diff suppressed because it is too large Load Diff
+13 -15
View File
@@ -6,14 +6,16 @@
"node": ">=20" "node": ">=20"
}, },
"scripts": { "scripts": {
"dev": "cross-env NODE_OPTIONS=--max_old_space_size=4096 refine dev", "dev": "npm run runtime:config && cross-env NODE_OPTIONS=--max_old_space_size=4096 next dev",
"build": "refine build", "runtime:config": "node scripts/generate-runtime-config.mjs",
"start": "refine start", "build": "next build",
"start": "next start",
"lint": "eslint .", "lint": "eslint .",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
"refine": "refine", "api:generate": "openapi-typescript contracts/server-v1.openapi.json -o src/generated/serverApi.ts && openapi-typescript contracts/agent-v1.openapi.json -o src/generated/agentApi.ts",
"api:check": "node scripts/check-api-contracts.mjs",
"pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh" "pipeline:trigger": "bash scripts/trigger-gitea-pipeline.sh"
}, },
"dependencies": { "dependencies": {
@@ -38,12 +40,14 @@
"deck.gl": "^9.1.14", "deck.gl": "^9.1.14",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.5", "echarts-for-react": "^3.0.5",
"edge-tts-ts": "^1.0.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",
"ol": "^10.7.0", "ol": "^10.7.0",
"postcss": "^8.5.6", "openapi-fetch": "^0.17.0",
"postcss": "8.5.25",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-draggable": "^4.5.0", "react-draggable": "^4.5.0",
@@ -55,15 +59,11 @@
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"overrides": { "overrides": {
"fast-xml-parser": "5.5.9" "fast-xml-parser": "5.7.0",
"postcss": "8.5.25",
"sharp": "0.35.3"
}, },
"devDependencies": { "devDependencies": {
"@refinedev/cli": "^2.16.52",
"@refinedev/devtools": "^2.0.5",
"@refinedev/devtools-internal": "^2.0.2",
"@refinedev/devtools-server": "^2.0.2",
"@refinedev/devtools-shared": "^2.0.2",
"@refinedev/devtools-ui": "^2.0.3",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
@@ -80,10 +80,8 @@
"eslint-config-next": "^16.1.6", "eslint-config-next": "^16.1.6",
"jest": "^30.2.0", "jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0", "jest-environment-jsdom": "^30.2.0",
"openapi-typescript": "^7.13.0",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"typescript": "^5.8.3" "typescript": "^5.8.3"
},
"refine": {
"projectId": "4LwOCL-BBaV29-qUYMAJ"
} }
} }
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-labelledby="title">
<title id="title">TJWater</title>
<rect width="72" height="72" rx="18" fill="#1478d4"/>
<path d="M36 13c-7.8 11.1-16.1 19.4-16.1 29.3A16.1 16.1 0 0 0 36 58.4a16.1 16.1 0 0 0 16.1-16.1C52.1 32.4 43.8 24.1 36 13Z" fill="#f5fbfc"/>
<path d="M26.5 43.5h19M31 36.5l5 7 5-7" fill="none" stroke="#0b8f82" stroke-linecap="round" stroke-linejoin="round" stroke-width="3"/>
<circle cx="26.5" cy="43.5" r="2.7" fill="#0b8f82"/>
<circle cx="45.5" cy="43.5" r="2.7" fill="#0b8f82"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice">
<rect width="1440" height="900" fill="#edf5f5"/>
<path d="M0 0h790L650 900H0Z" fill="#dceced"/>
<path d="M0 182c187-78 303-73 451-16 153 59 288 47 409-25M0 552c193-45 327-19 461 74 128 90 266 100 410 47" fill="none" stroke="#c8dddd" stroke-width="2"/>
<g fill="none" stroke="#a9ccce" stroke-linecap="round" stroke-linejoin="round">
<path d="M-32 725 178 608l142 51 155-190 184 67 176-205" stroke-width="5"/>
<path d="m178 608 25-222 151-92 121 175" stroke-width="3"/>
<path d="m203 386-92-95 55-161M354 294l92-141 152 58 98-106" stroke-width="3"/>
<path d="m320 659-4 132 171 81M659 536l88 116 124-42" stroke-width="3"/>
</g>
<g fill="#edf5f5" stroke="#1478d4" stroke-width="4">
<circle cx="178" cy="608" r="10"/><circle cx="203" cy="386" r="9"/>
<circle cx="354" cy="294" r="9"/><circle cx="475" cy="469" r="11"/>
<circle cx="659" cy="536" r="10"/><circle cx="747" cy="652" r="9"/>
<circle cx="320" cy="659" r="8"/>
</g>
<g fill="#0b8f82">
<circle cx="111" cy="291" r="6"/><circle cx="166" cy="130" r="6"/>
<circle cx="446" cy="153" r="7"/><circle cx="598" cy="211" r="6"/>
<circle cx="696" cy="105" r="6"/><circle cx="316" cy="791" r="6"/>
<circle cx="487" cy="872" r="6"/><circle cx="835" cy="331" r="7"/>
</g>
<g fill="none" stroke="#86b8bb" stroke-width="2" opacity=".72">
<circle cx="615" cy="448" r="222"/><circle cx="615" cy="448" r="276"/>
<circle cx="615" cy="448" r="334"/>
</g>
<g fill="#1478d4" opacity=".08">
<rect x="40" y="40" width="118" height="10" rx="5"/>
<rect x="40" y="62" width="72" height="6" rx="3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+57
View File
@@ -0,0 +1,57 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const manifest = JSON.parse(
await readFile(new URL("../contracts/manifest.json", import.meta.url), "utf8"),
);
const run = promisify(execFile);
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
const generator = join(
projectRoot,
"node_modules",
"openapi-typescript",
"bin",
"cli.js",
);
const temporaryDirectory = await mkdtemp(
join(tmpdir(), "tjwater-api-contracts-"),
);
try {
for (const [name, contract] of Object.entries(manifest.contracts)) {
const contractPath = fileURLToPath(
new URL(`../contracts/${contract.file}`, import.meta.url),
);
const payload = await readFile(contractPath);
const actual = createHash("sha256").update(payload).digest("hex");
if (actual !== contract.sha256) {
throw new Error(
`${name} contract hash mismatch: expected ${contract.sha256}, got ${actual}`,
);
}
const generatedName = `${name}Api.ts`;
const temporaryOutput = join(temporaryDirectory, generatedName);
await run(process.execPath, [generator, contractPath, "-o", temporaryOutput]);
const expectedOutput = await readFile(
new URL(`../src/generated/${generatedName}`, import.meta.url),
);
const generatedOutput = await readFile(temporaryOutput);
if (!expectedOutput.equals(generatedOutput)) {
throw new Error(
`${basename(contract.file)} generated type is stale: run \`npm run api:generate\``,
);
}
}
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
console.log(
`validated API contract mirrors and generated types for ${manifest.contract_version}`,
);
+39
View File
@@ -0,0 +1,39 @@
import nextEnv from "@next/env";
import fs from "node:fs";
import path from "node:path";
const projectDir = process.cwd();
const { loadEnvConfig } = nextEnv;
loadEnvConfig(projectDir, process.env.NODE_ENV !== "production");
const parseExtent = (value) => {
if (!value) {
return [13508801.93, 3608163.35, 13555650.64, 3633685.14];
}
const extent = value.split(",").map(Number);
return extent.length === 4 && extent.every(Number.isFinite)
? extent
: [13508801.93, 3608163.35, 13555650.64, 3633685.14];
};
const config = {
BACKEND_URL: process.env.BACKEND_URL || "http://127.0.0.1:8000",
AGENT_URL: process.env.AGENT_URL || "http://127.0.0.1:8788",
MAP_URL: process.env.MAP_URL || "http://127.0.0.1:8080/geoserver",
MAP_WORKSPACE: process.env.MAP_WORKSPACE || "tjwater_next",
MAP_EXTENT: parseExtent(process.env.MAP_EXTENT),
NETWORK_NAME: process.env.NETWORK_NAME || "tjwater_next",
MAPBOX_TOKEN: process.env.MAPBOX_TOKEN || "",
TIANDITU_TOKEN: process.env.TIANDITU_TOKEN || "",
};
const outputPath = path.join(projectDir, "public", "runtime-config.js");
fs.writeFileSync(
outputPath,
`window.__TJWATER_RUNTIME_CONFIG__ = ${JSON.stringify(config)};\n`,
);
console.log(`Generated ${path.relative(projectDir, outputPath)}`);
@@ -0,0 +1,58 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton, MapTimelineSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
import {
RAINBOW_COLORS,
RISK_BREAKS,
} from "@components/olmap/HealthRiskAnalysis/types";
import { Box } from "@mui/material";
const Timeline = dynamic(
() => import("@components/olmap/HealthRiskAnalysis/Timeline"),
{
loading: () => <MapTimelineSkeleton />,
},
);
const HealthRiskStatistics = dynamic(
() =>
import("@components/olmap/HealthRiskAnalysis/HealthRiskStatistics"),
{
loading: () => <MapPanelSkeleton variant="health-risk-analysis" />,
},
);
const PredictDataPanel = dynamic(
() => import("@components/olmap/HealthRiskAnalysis/PredictDataPanel"),
{
loading: () => null,
},
);
export default function Home() {
return (
<HealthRiskProvider>
<MapToolbar
queryType="realtime"
hiddenButtons={["style"]}
HistoryPanel={PredictDataPanel}
/>
<Timeline />
<HealthRiskStatistics />
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
<StyleLegend
layerName="管道"
layerId="health-risk"
property="健康风险"
colors={RAINBOW_COLORS}
type="line"
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
breaks={[0, ...RISK_BREAKS]}
/>
</Box>
</HealthRiskProvider>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const BurstDetectionPanel = dynamic(
() => import("@/components/olmap/BurstDetection/BurstDetectionPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-detection" />,
},
);
export default function Home() {
return (
<>
<MapToolbar
queryType="scheme"
schemeType="burst_detection"
hiddenButtons={["style"]}
/>
<BurstDetectionPanel />
</>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const BurstLocationPanel = dynamic(
() => import("@/components/olmap/BurstLocation/BurstLocationPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-location" />,
},
);
export default function Home() {
return (
<>
<MapToolbar
queryType="scheme"
schemeType="burst_location"
hiddenButtons={["style"]}
/>
<BurstLocationPanel />
</>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const BurstPipeAnalysisPanel = dynamic(
() => import("@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel"),
{
loading: () => <MapPanelSkeleton variant="burst-simulation" />,
},
);
export default function Home() {
return (
<>
<MapToolbar
queryType="scheme"
schemeType="burst_analysis"
enableCompare
/>
<BurstPipeAnalysisPanel />
</>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const WaterQualityPanel = dynamic(
() => import("@/components/olmap/ContaminantSimulation/WaterQualityPanel"),
{
loading: () => <MapPanelSkeleton variant="contaminant-simulation" />,
},
);
export default function Home() {
return (
<>
<MapToolbar
queryType="scheme"
schemeType="contaminant_analysis"
enableCompare
/>
<WaterQualityPanel />
</>
);
}
@@ -0,0 +1,26 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const DMALeakDetectionPanel = dynamic(
() => import("@/components/olmap/DMALeakDetection/DMALeakDetectionPanel"),
{
loading: () => <MapPanelSkeleton variant="dma-leak-detection" />,
},
);
export default function Home() {
return (
<>
<MapToolbar
queryType="scheme"
schemeType="dma_leak_identification"
hiddenButtons={["style"]}
/>
<DMALeakDetectionPanel />
</>
);
}
@@ -0,0 +1,22 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const FlushingAnalysisPanel = dynamic(
() => import("@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel"),
{
loading: () => <MapPanelSkeleton variant="flushing-analysis" />,
},
);
export default function Home() {
return (
<>
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
<FlushingAnalysisPanel />
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import type { ReactNode } from "react";
import MapComponent from "@components/olmap/core/MapComponent";
export default function MapLayout({ children }: { children: ReactNode }) {
return <MapComponent>{children}</MapComponent>;
}
@@ -0,0 +1,27 @@
"use client";
import dynamic from "next/dynamic";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const MonitoringPlaceOptimizationPanel = dynamic(
() =>
import(
"@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel"
),
{
loading: () => (
<MapPanelSkeleton variant="monitoring-place-optimization" />
),
},
);
export default function Home() {
return (
<>
<MapToolbar hiddenButtons={["style"]} />
<MonitoringPlaceOptimizationPanel />
</>
);
}
@@ -0,0 +1,56 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useState } from "react";
import {
MapPanelSkeleton,
MapTimelineSkeleton,
} from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const Timeline = dynamic(
() => import("@components/olmap/core/Controls/Timeline"),
{
loading: () => <MapTimelineSkeleton />,
},
);
const SCADADeviceList = dynamic(
() => import("@components/olmap/SCADA/SCADADeviceList"),
{
loading: () => <MapPanelSkeleton variant="network-simulation" />,
},
);
const SCADADataPanel = dynamic(
() => import("@components/olmap/SCADA/SCADADataPanel"),
{
loading: () => null,
},
);
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const [panelVisible, setPanelVisible] = useState<boolean>(false);
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedDeviceIds(ids);
setPanelVisible(ids.length > 0);
}, []);
const handleDeviceClick = useCallback(() => {
setPanelVisible(true);
}, []);
return (
<>
<MapToolbar queryType="realtime" />
<Timeline />
<SCADADeviceList
onDeviceClick={handleDeviceClick}
onSelectionChange={handleSelectionChange}
selectedDeviceIds={selectedDeviceIds}
/>
<SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} />
</>
);
}
@@ -0,0 +1,51 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useState } from "react";
import { MapPanelSkeleton } from "@components/loading/MapComponentSkeletons";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
const SCADADeviceList = dynamic(
() => import("@components/olmap/SCADA/SCADADeviceList"),
{
loading: () => <MapPanelSkeleton variant="scada-data-cleaning" />,
},
);
const SCADADataPanel = dynamic(
() => import("@components/olmap/SCADA/SCADADataPanel"),
{
loading: () => null,
},
);
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const [panelVisible, setPanelVisible] = useState<boolean>(false);
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedDeviceIds(ids);
setPanelVisible(ids.length > 0);
}, []);
const handleDeviceClick = useCallback(() => {
setPanelVisible(true);
}, []);
return (
<>
<MapToolbar hiddenButtons={["style"]} />
<SCADADeviceList
onDeviceClick={handleDeviceClick}
onSelectionChange={handleSelectionChange}
selectedDeviceIds={selectedDeviceIds}
showCleaning={true}
/>
<SCADADataPanel
deviceIds={selectedDeviceIds}
visible={panelVisible}
showCleaning={true}
/>
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AuditLogPanel } from "@/components/audit/AuditLogPanel";
export default function AuditLogsPage() {
return <AuditLogPanel />;
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,43 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import Timeline from "@components/olmap/HealthRiskAnalysis/Timeline";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import { HealthRiskProvider } from "@components/olmap/HealthRiskAnalysis/HealthRiskContext";
import HealthRiskStatistics from "@components/olmap/HealthRiskAnalysis/HealthRiskStatistics";
import PredictDataPanel from "@components/olmap/HealthRiskAnalysis/PredictDataPanel";
import StyleLegend from "@components/olmap/core/Controls/StyleLegend";
import {
RAINBOW_COLORS,
RISK_BREAKS,
} from "@components/olmap/HealthRiskAnalysis/types";
import { Box } from "@mui/material";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<HealthRiskProvider>
<MapComponent>
<MapToolbar
queryType="realtime"
hiddenButtons={["style"]}
HistoryPanel={PredictDataPanel}
/>
<Timeline />
<HealthRiskStatistics />
<Box className="absolute bottom-40 right-4 drop-shadow-xl flex flex-row items-end max-w-screen-lg overflow-x-auto z-10">
<StyleLegend
layerName="管道"
layerId="health-risk"
property="健康风险"
colors={RAINBOW_COLORS}
type="line"
dimensions={Array(RAINBOW_COLORS.length).fill(2)}
breaks={[0, ...RISK_BREAKS]}
/>
</Box>
</MapComponent>
</HealthRiskProvider>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,16 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstDetectionPanel from "@/components/olmap/BurstDetection/BurstDetectionPanel";
export default function Home() {
return (
<div className="relative h-full w-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="burst_detection" hiddenButtons={["style"]} />
<BurstDetectionPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,20 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstLocationPanel from "@/components/olmap/BurstLocation/BurstLocationPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar
queryType="scheme"
schemeType="burst_location"
hiddenButtons={["style"]}
/>
<BurstLocationPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,20 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import BurstPipeAnalysisPanel from "@/components/olmap/BurstSimulation/BurstPipeAnalysisPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar
queryType="scheme"
schemeType="burst_analysis"
enableCompare
/>
<BurstPipeAnalysisPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,20 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import WaterQualityPanel from "@/components/olmap/ContaminantSimulation/WaterQualityPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar
queryType="scheme"
schemeType="contaminant_analysis"
enableCompare
/>
<WaterQualityPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,20 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import DMALeakDetectionPanel from "@/components/olmap/DMALeakDetection/DMALeakDetectionPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar
queryType="scheme"
schemeType="dma_leak_identification"
hiddenButtons={["style"]}
/>
<DMALeakDetectionPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,16 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import FlushingAnalysisPanel from "@/components/olmap/FlushingAnalysis/FlushingAnalysisPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="flushing_analysis" />
<FlushingAnalysisPanel />
</MapComponent>
</div>
);
}
+11 -7
View File
@@ -1,11 +1,11 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import React, { Suspense } from "react"; import type { ReactNode } from "react";
import authOptions from "@app/api/auth/[...nextauth]/options"; import authOptions from "@app/api/auth/[...nextauth]/options";
import { Header } from "@components/header"; import { Header } from "@components/header";
import { Title } from "@components/title"; import { Title } from "@components/title";
import { MapSkeleton } from "@components/loading/MapSkeleton"; import { AppSider } from "@components/sider/AppSider";
import { ThemedLayout } from "@refinedev/mui"; import { ThemedLayout } from "@refinedev/mui";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -19,7 +19,7 @@ export const metadata: Metadata = META_DATA;
export default async function MainLayout({ export default async function MainLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: ReactNode;
}>) { }>) {
const cookieStore = await cookies(); const cookieStore = await cookies();
const theme = cookieStore.get("theme"); const theme = cookieStore.get("theme");
@@ -35,16 +35,20 @@ export default async function MainLayout({
<ThemedLayout <ThemedLayout
Header={Header} Header={Header}
Title={Title} Title={Title}
Sider={AppSider}
childrenBoxProps={{ childrenBoxProps={{
sx: { height: "100vh", p: 0 }, sx: {
flex: 1,
minHeight: 0,
overflow: "auto",
p: 0,
},
}} }}
containerBoxProps={{ containerBoxProps={{
sx: { height: "100%" }, sx: { height: "100vh", overflow: "hidden" },
}} }}
> >
<Suspense fallback={<MapSkeleton />}>
{children} {children}
</Suspense>
</ThemedLayout> </ThemedLayout>
); );
} }
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,15 +0,0 @@
"use client";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import MonitoringPlaceOptimizationPanel from "@components/olmap/MonitoringPlaceOptimization/MonitoringPlaceOptimizationPanel";
export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar hiddenButtons={["style"]} />
<MonitoringPlaceOptimizationPanel />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,38 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import MapComponent from "@components/olmap/core/MapComponent";
import Timeline from "@components/olmap/core/Controls/Timeline";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const [panelVisible, setPanelVisible] = useState<boolean>(false);
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedDeviceIds(ids);
setPanelVisible(ids.length > 0);
}, []);
const handleDeviceClick = useCallback(() => {
setPanelVisible(true);
}, []);
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="realtime" />
<Timeline />
<SCADADeviceList
onDeviceClick={handleDeviceClick}
onSelectionChange={handleSelectionChange}
selectedDeviceIds={selectedDeviceIds}
/>
<SCADADataPanel deviceIds={selectedDeviceIds} visible={panelVisible} />
</MapComponent>
</div>
);
}
@@ -1,5 +0,0 @@
import { MapSkeleton } from "@components/loading/MapSkeleton";
export default function Loading() {
return <MapSkeleton />;
}
@@ -1,41 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import MapComponent from "@components/olmap/core/MapComponent";
import MapToolbar from "@components/olmap/core/Controls/Toolbar";
import SCADADeviceList from "@components/olmap/SCADA/SCADADeviceList";
import SCADADataPanel from "@components/olmap/SCADA/SCADADataPanel";
export default function Home() {
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const [panelVisible, setPanelVisible] = useState<boolean>(false);
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedDeviceIds(ids);
setPanelVisible(ids.length > 0);
}, []);
const handleDeviceClick = useCallback(() => {
setPanelVisible(true);
}, []);
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar hiddenButtons={["style"]} />
<SCADADeviceList
onDeviceClick={handleDeviceClick}
onSelectionChange={handleSelectionChange}
selectedDeviceIds={selectedDeviceIds}
showCleaning={true}
/>
<SCADADataPanel
deviceIds={selectedDeviceIds}
visible={panelVisible}
showCleaning={true}
/>
</MapComponent>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { SystemAdminPanel } from "@/components/admin/SystemAdminPanel";
export default function SystemAdminPage() {
return <SystemAdminPanel />;
}
+137
View File
@@ -0,0 +1,137 @@
import { render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
import { App } from "./RefineContext";
let mockSessionState: {
data: {
accessToken: string;
user: { id: string; name: string };
} | null;
status: "authenticated" | "unauthenticated";
} = {
data: {
accessToken: "expired-access-token",
user: { id: "user-1", name: "Test User" },
},
status: "authenticated",
};
jest.mock("next-auth/react", () => ({
SessionProvider: ({ children }: { children: ReactNode }) => children,
signIn: jest.fn().mockResolvedValue(undefined),
useSession: () => mockSessionState,
}));
jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation",
}));
jest.mock("@refinedev/core", () => ({
Refine: ({ children }: { children: ReactNode }) => children,
}));
jest.mock("@refinedev/kbar", () => ({
RefineKbar: () => null,
RefineKbarProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock("@refinedev/mui", () => ({
RefineSnackbarProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock("@refinedev/nextjs-router", () => ({}));
jest.mock("@providers/data-provider", () => ({ dataProvider: {} }));
jest.mock("@/providers/notification-provider/useAppNotificationProvider", () => ({
useAppNotificationProvider: {},
}));
jest.mock("@contexts/color-mode", () => ({
ColorModeContextProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock("@/contexts/ProjectContext", () => ({
ProjectProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock("@/lib/authToken", () => ({
getAccessToken: jest.fn().mockResolvedValue("expired-access-token"),
}));
describe("RefineContext access authentication", () => {
const originalFetch = global.fetch;
const originalRequest = global.Request;
beforeEach(() => {
mockSessionState = {
data: {
accessToken: "expired-access-token",
user: { id: "user-1", name: "Test User" },
},
status: "authenticated",
};
useAuthStore.setState({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
useAccessStore.setState({
context: null,
permissions: [],
loading: true,
});
global.Request = class TestRequest {} as unknown as typeof Request;
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 401,
headers: new Headers(),
} as Response);
});
afterEach(() => {
jest.restoreAllMocks();
global.fetch = originalFetch;
global.Request = originalRequest;
});
it("marks the session expired when access-context rejects an expired token", async () => {
render(
<App>
<div>应用内容</div>
</App>,
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/access-context"),
expect.not.objectContaining({ skipAuthRedirect: true }),
);
});
await waitFor(() => {
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
});
});
it("prioritizes an unauthenticated session over route permissions", async () => {
mockSessionState = { data: null, status: "unauthenticated" };
render(
<App>
<div>应用内容</div>
</App>,
);
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
await waitFor(() => {
expect(useAuthStore.getState()).toMatchObject({
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
});
});
});
+384
View File
@@ -0,0 +1,384 @@
"use client";
import {
Refine,
type AccessControlProvider,
type AuthProvider,
} from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import { RefineSnackbarProvider } from "@refinedev/mui";
import { SessionProvider, signIn, useSession } from "next-auth/react";
import { usePathname } from "next/navigation";
import React, { useEffect } from "react";
import routerProvider from "@refinedev/nextjs-router";
import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider";
import { ProjectProvider } from "@/contexts/ProjectContext";
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
import { SessionExpiryDialog } from "@/components/auth/SessionExpiryDialog";
import { useAuthStore } from "@/store/authStore";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch";
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
import { LiaNetworkWiredSolid } from "react-icons/lia";
import { TbActivity, TbDatabaseEdit, TbLocationPin } from "react-icons/tb";
import { LuReplace } from "react-icons/lu";
import { AiOutlineSecurityScan } from "react-icons/ai";
import { MdCleaningServices, MdOutlineWaterDrop } from "react-icons/md";
import {
FactCheck as FactCheckIcon,
ManageAccounts as ManageAccountsIcon,
MyLocation as MyLocationIcon,
Search as SearchIcon,
} from "@mui/icons-material";
type RefineContextProps = {
defaultMode?: string;
};
export const RefineContext = (
props: React.PropsWithChildren<RefineContextProps>,
) => (
<SessionProvider>
<App {...props} />
</SessionProvider>
);
type AppProps = {
defaultMode?: string;
};
export const App = (props: React.PropsWithChildren<AppProps>) => {
const { data, status } = useSession();
const to = usePathname();
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 permissions = useAccessStore((state) => state.permissions);
const setAccessContext = useAccessStore((state) => state.setContext);
const setAccessLoading = useAccessStore((state) => state.setLoading);
const resetAccess = useAccessStore((state) => state.reset);
const can = (permission: string) => permissions.includes(permission);
useEffect(() => {
setAccessToken(
typeof data?.accessToken === "string" ? data.accessToken : null,
);
}, [data?.accessToken, setAccessToken]);
useEffect(() => {
if (data?.error === "SessionExpired") {
markSessionExpired("session_max_age");
return;
}
if (data?.error === "RefreshAccessTokenError") {
markSessionExpired("refresh_failed");
return;
}
if (status === "unauthenticated") {
markSessionExpired("unauthorized");
return;
}
if (status === "authenticated") {
clearSessionExpired();
}
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
useEffect(() => {
if (status !== "authenticated") {
resetAccess();
return;
}
let cancelled = false;
setAccessLoading(true);
apiFetch(`${config.BACKEND_URL}/api/v1/access-context`, {
projectHeaderMode: currentProjectId ? "include" : "omit",
})
.then(async (response) => {
if (cancelled) return;
if (!response.ok) {
resetAccess();
return;
}
setAccessContext(await response.json());
})
.catch(() => {
if (!cancelled) resetAccess();
});
return () => {
cancelled = true;
};
}, [
currentProjectId,
resetAccess,
setAccessContext,
setAccessLoading,
status,
]);
useEffect(() => {
if (status !== "authenticated" || !data?.user?.id) return;
const auditKey = `tjwater-login-audit:${data.user.id}`;
if (sessionStorage.getItem(auditKey)) return;
apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "login" }),
projectHeaderMode: "omit",
skipAuthRedirect: true,
})
.then((response) => {
if (response.ok) sessionStorage.setItem(auditKey, "1");
})
.catch(() => undefined);
}, [data?.user?.id, status]);
if (status === "loading") {
return <span>loading...</span>;
}
const authProvider: AuthProvider = {
login: async () => {
signIn("keycloak", {
callbackUrl: to ? to.toString() : "/",
redirect: true,
});
return { success: true };
},
logout: () =>
completeLogout({
reportAudit: () =>
reportLogoutAudit({
endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
accessToken:
typeof data?.accessToken === "string"
? data.accessToken
: useAuthStore.getState().accessToken,
}),
clearLocalState: () => {
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
clearSessionRecoveryDrafts();
},
navigate: (path) => window.location.assign(path),
}),
onError: async (error) => {
return { error };
},
check: async () =>
status === "unauthenticated"
? { authenticated: false, redirectTo: "/login" }
: { authenticated: true },
getPermissions: async () => permissions,
getIdentity: async () => {
if (!data?.user) return null;
return {
id: data.user.id,
username: data.user.username,
name: data.user.name,
avatar: data.user.image,
};
},
};
const accessControlProvider: AccessControlProvider = {
can: async ({ resource }) => {
const requiredPermission = resource
? resourcePermissions[resource]
: undefined;
return {
can: !requiredPermission || permissions.includes(requiredPermission),
reason: requiredPermission
? `需要权限:${requiredPermission}`
: undefined,
};
},
};
const resources = [
...(can(permissionCodes.simulationView)
? [
{
name: "管网在线模拟",
list: "/network-simulation",
meta: {
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
label: "管网在线模拟",
},
},
]
: []),
...(can(permissionCodes.scadaClean)
? [
{
name: "SCADA 数据清洗",
list: "/scada-data-cleaning",
meta: {
icon: <TbDatabaseEdit className="w-6 h-6" />,
label: "SCADA 数据清洗",
},
},
]
: []),
...(can(permissionCodes.optimizationRun)
? [
{
name: "监测点优化布置",
list: "/monitoring-place-optimization",
meta: {
icon: <LuReplace className="w-6 h-6" />,
label: "监测点优化布置",
},
},
]
: []),
...(can(permissionCodes.riskRun)
? [
{
name: "健康风险分析",
list: "/health-risk-analysis",
meta: {
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
label: "健康风险分析",
},
},
]
: []),
...(can(permissionCodes.simulationRun) || can(permissionCodes.burstRun)
? [
{
name: "Hydraulic Simulation",
meta: { label: "事件模拟" },
},
]
: []),
...(can(permissionCodes.burstRun)
? [
{
name: "爆管模拟",
list: "/hydraulic-simulation/burst-simulation",
meta: {
parent: "Hydraulic Simulation",
icon: <TbLocationPin className="w-6 h-6" />,
label: "爆管模拟",
},
},
{
name: "爆管侦测",
list: "/hydraulic-simulation/burst-detection",
meta: {
parent: "Hydraulic Simulation",
icon: <TbActivity className="w-6 h-6" />,
label: "爆管侦测",
},
},
{
name: "爆管定位",
list: "/hydraulic-simulation/burst-location",
meta: {
parent: "Hydraulic Simulation",
icon: <MyLocationIcon className="w-6 h-6" />,
label: "爆管定位",
},
},
{
name: "DMA 漏损识别",
list: "/hydraulic-simulation/dma-leak-detection",
meta: {
parent: "Hydraulic Simulation",
icon: <SearchIcon className="w-6 h-6" />,
label: "DMA 漏损识别",
},
},
]
: []),
...(can(permissionCodes.simulationRun)
? [
{
name: "水质模拟",
list: "/hydraulic-simulation/contaminant-simulation",
meta: {
parent: "Hydraulic Simulation",
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
label: "水质模拟",
},
},
{
name: "管道冲洗",
list: "/hydraulic-simulation/flushing-analysis",
meta: {
parent: "Hydraulic Simulation",
icon: <MdCleaningServices className="w-6 h-6" />,
label: "管道冲洗",
},
},
]
: []),
...(can(permissionCodes.environmentManage)
? [
{
name: "系统管理",
list: "/system-admin",
meta: {
icon: <ManageAccountsIcon className="w-6 h-6" />,
label: "系统管理",
},
},
]
: []),
...(can(permissionCodes.auditView)
? [
{
name: "审计日志",
list: "/audit-logs",
meta: {
icon: <FactCheckIcon className="w-6 h-6" />,
label: "审计日志",
},
},
]
: []),
];
return (
<ProjectProvider>
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={props.defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useAppNotificationProvider}
authProvider={authProvider}
accessControlProvider={accessControlProvider}
resources={resources}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
>
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard authenticated={status === "authenticated"}>
{props.children}
</RoutePermissionGuard>
<RefineKbar />
</Refine>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</ProjectProvider>
);
};
-244
View File
@@ -1,244 +0,0 @@
"use client";
import { Refine, type AuthProvider } from "@refinedev/core";
import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
import {
RefineSnackbarProvider,
useNotificationProvider,
} from "@refinedev/mui";
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
import { usePathname } from "next/navigation";
import React, { useEffect } from "react";
import routerProvider from "@refinedev/nextjs-router";
import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider";
import { ProjectProvider } from "@/contexts/ProjectContext";
import { useAuthStore } from "@/store/authStore";
import { LiaNetworkWiredSolid } from "react-icons/lia";
import { TbDatabaseEdit, TbLocationPin, TbActivity } from "react-icons/tb";
import { LuReplace } from "react-icons/lu";
import { AiOutlineSecurityScan } from "react-icons/ai";
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
import {
MyLocation as MyLocationIcon,
Search as SearchIcon,
} from "@mui/icons-material";
type RefineContextProps = {
defaultMode?: string;
};
export const RefineContext = (
props: React.PropsWithChildren<RefineContextProps>
) => {
return (
<SessionProvider>
<ProjectProvider>
<App {...props} />
</ProjectProvider>
</SessionProvider>
);
};
type AppProps = {
defaultMode?: string;
};
const App = (props: React.PropsWithChildren<AppProps>) => {
const { data, status } = useSession();
const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken);
useEffect(() => {
setAccessToken(typeof data?.accessToken === "string" ? data.accessToken : null);
}, [data?.accessToken, setAccessToken]);
if (status === "loading") {
return <span>loading...</span>;
}
const authProvider: AuthProvider = {
login: async () => {
signIn("keycloak", {
callbackUrl: to ? to.toString() : "/",
redirect: true,
});
return {
success: true,
};
},
logout: async () => {
signOut({
redirect: true,
callbackUrl: "/login",
});
return {
success: true,
};
},
onError: async (error) => {
if (error.response?.status === 401) {
return {
logout: true,
};
}
return {
error,
};
},
check: async () => {
if (status === "unauthenticated") {
return {
authenticated: false,
redirectTo: "/login",
};
}
return {
authenticated: true,
};
},
getPermissions: async () => {
return null;
},
getIdentity: async () => {
if (data?.user) {
const { user } = data;
return {
id: user.id,
name: user.name,
avatar: user.image,
};
}
return null;
},
};
const defaultMode = props?.defaultMode;
return (
<>
<RefineKbarProvider>
<ColorModeContextProvider defaultMode={defaultMode}>
<RefineSnackbarProvider>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider}
notificationProvider={useNotificationProvider}
authProvider={authProvider}
resources={[
{
name: "管网在线模拟",
list: "/network-simulation",
meta: {
icon: <LiaNetworkWiredSolid className="w-6 h-6" />,
label: "管网在线模拟",
},
},
{
name: "SCADA 数据清洗",
list: "/scada-data-cleaning",
meta: {
icon: <TbDatabaseEdit className="w-6 h-6" />,
label: "SCADA 数据清洗",
},
},
{
name: "监测点优化布置",
list: "/monitoring-place-optimization",
meta: {
icon: <LuReplace className="w-6 h-6" />,
label: "监测点优化布置",
},
},
{
name: "健康风险分析",
list: "/health-risk-analysis",
meta: {
icon: <AiOutlineSecurityScan className="w-6 h-6" />,
label: "健康风险分析",
},
},
{
name: "Hydraulic Simulation",
meta: {
// icon: <MdWater className="w-6 h-6" />,
label: "事件模拟",
},
},
{
name: "爆管模拟",
list: "/hydraulic-simulation/burst-simulation",
meta: {
parent: "Hydraulic Simulation",
icon: <TbLocationPin className="w-6 h-6" />,
label: "爆管模拟",
},
},
{
name: "爆管侦测",
list: "/hydraulic-simulation/burst-detection",
meta: {
parent: "Hydraulic Simulation",
icon: <TbActivity className="w-6 h-6" />,
label: "爆管侦测",
},
},
{
name: "爆管定位",
list: "/hydraulic-simulation/burst-location",
meta: {
parent: "Hydraulic Simulation",
icon: <MyLocationIcon className="w-6 h-6" />,
label: "爆管定位",
},
},
{
name: "DMA 漏损识别",
list: "/hydraulic-simulation/dma-leak-detection",
meta: {
parent: "Hydraulic Simulation",
icon: <SearchIcon className="w-6 h-6" />,
label: "DMA 漏损识别",
},
},
{
name: "水质模拟",
list: "/hydraulic-simulation/contaminant-simulation",
meta: {
parent: "Hydraulic Simulation",
icon: <MdOutlineWaterDrop className="w-6 h-6" />,
label: "水质模拟",
},
},
{
name: "管道冲洗",
list: "/hydraulic-simulation/flushing-analysis",
meta: {
parent: "Hydraulic Simulation",
icon: <MdCleaningServices className="w-6 h-6" />,
label: "管道冲洗",
},
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
}}
>
{props.children}
<RefineKbar />
</Refine>
</RefineSnackbarProvider>
</ColorModeContextProvider>
</RefineKbarProvider>
</>
);
};
@@ -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;
};
+89 -13
View File
@@ -3,22 +3,35 @@ 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;
refresh_token?: string; refresh_token?: string;
}; };
const keycloakIssuer = process.env.KEYCLOAK_ISSUER!; const getKeycloakTokenEndpoint = () => {
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID!; const issuer = process.env.KEYCLOAK_ISSUER;
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET!; return issuer
const keycloakTokenEndpoint = `${keycloakIssuer.replace(/\/$/, "")}/protocol/openid-connect/token`; ? `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`
: undefined;
};
const refreshAccessToken = async (token: JWT): Promise<JWT> => { const refreshAccessToken = async (token: JWT): Promise<JWT> => {
if (!token.refreshToken) { if (!token.refreshToken) {
return { ...token, error: "RefreshAccessTokenError" }; return { ...token, error: "RefreshAccessTokenError" };
} }
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID;
const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
const keycloakTokenEndpoint = getKeycloakTokenEndpoint();
if (!keycloakClientId || !keycloakClientSecret || !keycloakTokenEndpoint) {
return { ...token, error: "RefreshAccessTokenError" };
}
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
client_id: keycloakClientId, client_id: keycloakClientId,
@@ -26,36 +39,56 @@ 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 = {
// Configure one or more authentication providers // Configure one or more authentication providers
providers: [ providers: [
KeycloakProvider({ KeycloakProvider({
clientId: keycloakClientId, clientId: process.env.KEYCLOAK_CLIENT_ID ?? "",
clientSecret: keycloakClientSecret, clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
issuer: keycloakIssuer, issuer: process.env.KEYCLOAK_ISSUER ?? "",
profile(profile) { profile(profile) {
return { return {
id: profile.sub, id: profile.sub,
username: profile.preferred_username,
name: profile.name ?? profile.preferred_username, name: profile.name ?? profile.preferred_username,
email: profile.email, email: profile.email,
image: Avatar.src, image: Avatar.src,
@@ -65,18 +98,29 @@ 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;
} }
const preferredUsername = (
profile as { preferred_username?: unknown } | undefined
)?.preferred_username;
if (typeof preferredUsername === "string") {
token.username = preferredUsername;
}
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;
} }
@@ -84,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;
} }
@@ -94,15 +157,28 @@ const authOptions: NextAuthOptions = {
if (session.user && token.sub) { if (session.user && token.sub) {
session.user.id = token.sub; session.user.id = token.sub;
} }
if (session.user && token.username) {
session.user.username = token.username;
}
if (token.accessToken) { if (token.accessToken) {
session.accessToken = token.accessToken; session.accessToken = token.accessToken;
} }
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;
};
+50
View File
@@ -0,0 +1,50 @@
/**
* @jest-environment node
*/
import { POST } from "./route";
const streamMock = jest.fn();
jest.mock("edge-tts-ts", () => ({
Communicate: jest.fn().mockImplementation(() => ({
stream: streamMock,
})),
}));
describe("POST /api/tts/edge", () => {
beforeEach(() => {
streamMock.mockReset();
});
it("returns synthesized mp3 audio", async () => {
streamMock.mockImplementation(async function* () {
yield { type: "audio", data: new Uint8Array([1, 2]) };
yield { type: "SentenceBoundary", offset: 0, duration: 1, text: "测试" };
yield { type: "audio", data: new Uint8Array([3]) };
});
const response = await POST(
new Request("http://localhost/api/tts/edge", {
method: "POST",
body: JSON.stringify({ text: "测试文本" }),
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("audio/mpeg");
expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([1, 2, 3]);
});
it("rejects empty text", async () => {
const response = await POST(
new Request("http://localhost/api/tts/edge", {
method: "POST",
body: JSON.stringify({ text: " " }),
}),
);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "text is required" });
});
});
+76
View File
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { Communicate } from "edge-tts-ts";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const DEFAULT_VOICE = process.env.EDGE_TTS_VOICE || "zh-CN-XiaoxiaoNeural";
const MAX_TEXT_LENGTH = 12000;
type EdgeTtsRequest = {
text?: unknown;
voice?: unknown;
};
const jsonError = (message: string, status: number) =>
NextResponse.json({ error: message }, { status });
export async function POST(request: Request) {
let payload: EdgeTtsRequest;
try {
payload = (await request.json()) as EdgeTtsRequest;
} catch {
return jsonError("Invalid JSON body", 400);
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
if (!text) {
return jsonError("text is required", 400);
}
if (text.length > MAX_TEXT_LENGTH) {
return jsonError(`text must be ${MAX_TEXT_LENGTH} characters or fewer`, 413);
}
const voice =
typeof payload.voice === "string" && payload.voice.trim()
? payload.voice.trim()
: DEFAULT_VOICE;
try {
const communicate = new Communicate(text, { voice });
const chunks: Uint8Array[] = [];
let byteLength = 0;
for await (const chunk of communicate.stream()) {
if (chunk.type !== "audio") continue;
chunks.push(chunk.data);
byteLength += chunk.data.byteLength;
}
if (byteLength === 0) {
return jsonError("Edge TTS returned empty audio", 502);
}
const audio = new Uint8Array(byteLength);
let offset = 0;
for (const chunk of chunks) {
audio.set(chunk, offset);
offset += chunk.byteLength;
}
const audioBuffer = audio.buffer.slice(
audio.byteOffset,
audio.byteOffset + audio.byteLength,
);
return new Response(audioBuffer, {
headers: {
"Content-Type": "audio/mpeg",
"Cache-Control": "no-store",
},
});
} catch (error) {
console.error("[EdgeTTS] Failed to synthesize speech:", error);
return jsonError("Failed to synthesize speech", 502);
}
}
+4 -2
View File
@@ -1,7 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import Script from "next/script";
import React, { Suspense } from "react"; import React, { Suspense } from "react";
import { RefineContext } from "./_refine_context"; import { RefineContext } from "./RefineContext";
import { META_DATA } from "@config/config"; import { META_DATA } from "@config/config";
export const metadata: Metadata = META_DATA; export const metadata: Metadata = META_DATA;
@@ -16,8 +17,9 @@ export default async function RootLayout({
const defaultMode = theme?.value === "dark" ? "dark" : "light"; const defaultMode = theme?.value === "dark" ? "dark" : "light";
return ( return (
<html lang="en"> <html lang="zh-CN">
<body> <body>
<Script src="/runtime-config.js" strategy="beforeInteractive" />
<Suspense> <Suspense>
<RefineContext defaultMode={defaultMode}>{children}</RefineContext> <RefineContext defaultMode={defaultMode}>{children}</RefineContext>
</Suspense> </Suspense>
+45
View File
@@ -0,0 +1,45 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import Login from "./page";
const mockLogin = jest.fn();
jest.mock("@refinedev/core", () => ({
useLogin: () => ({ mutate: mockLogin }),
}));
describe("Login", () => {
beforeEach(() => {
jest.useFakeTimers();
mockLogin.mockClear();
});
afterEach(() => {
jest.useRealTimers();
});
it("starts Keycloak login once when the page opens", () => {
const { rerender } = render(<Login />);
expect(mockLogin).toHaveBeenCalledTimes(1);
expect(mockLogin).toHaveBeenCalledWith({});
expect(screen.getByText("正在进入账号登录")).toBeInTheDocument();
rerender(<Login />);
expect(mockLogin).toHaveBeenCalledTimes(1);
});
it("shows a manual fallback when automatic redirect does not complete", () => {
render(<Login />);
expect(
screen.queryByRole("button", { name: "继续登录" }),
).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(4000);
});
fireEvent.click(screen.getByRole("button", { name: "继续登录" }));
expect(mockLogin).toHaveBeenCalledTimes(2);
});
});
+234 -29
View File
@@ -1,54 +1,259 @@
"use client"; "use client";
import Image from "next/image";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Container from "@mui/material/Container"; import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { useLogin } from "@refinedev/core"; import { useLogin } from "@refinedev/core";
import { Title } from "@components/title"; import { PROJECT_TITLE } from "@config/config";
import { useEffect, useRef, useState } from "react";
const FALLBACK_DELAY_MS = 4000;
export default function Login() { export default function Login() {
const { mutate: login } = useLogin(); const { mutate: login } = useLogin();
const loginStartedRef = useRef(false);
const [showFallback, setShowFallback] = useState(false);
useEffect(() => {
if (!loginStartedRef.current) {
loginStartedRef.current = true;
login({});
}
const fallbackTimer = window.setTimeout(
() => setShowFallback(true),
FALLBACK_DELAY_MS,
);
return () => window.clearTimeout(fallbackTimer);
}, [login]);
return ( return (
<Container <Box
style={{ component="main"
height: "100vh", sx={{
display: "flex", minHeight: "100vh",
justifyContent: "center", "@supports (height: 100svh)": {
alignItems: "center", minHeight: "100svh",
},
color: "oklch(0.3 0.055 215)",
bgcolor: "oklch(0.965 0.014 205)",
backgroundImage: `
linear-gradient(
90deg,
transparent 0%,
transparent 50%,
oklch(0.975 0.01 205 / 62%) 68%,
oklch(0.975 0.01 205 / 82%) 100%
),
url("/login-network-blueprint.svg")
`,
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
"@keyframes tjwaterEnter": {
from: { opacity: 0, transform: "translateY(12px)" },
to: { opacity: 1, transform: "translateY(0)" },
},
}} }}
> >
<Box <Box
display="flex" sx={{
gap="36px" width: "100%",
justifyContent="center" maxWidth: 1720,
flexDirection="column" minHeight: "100vh",
"@supports (height: 100svh)": {
minHeight: "100svh",
},
mx: "auto",
px: {
xs: 1.75,
sm: 3,
md: "clamp(40px, 5vw, 88px)",
},
py: {
xs: "max(28px, env(safe-area-inset-top))",
md: "clamp(32px, 4.5vw, 76px)",
},
display: "grid",
gridTemplateColumns: {
xs: "minmax(0, 1fr)",
md: "minmax(360px, 1fr) minmax(400px, 460px)",
},
gridTemplateAreas: {
xs: '"brand" "status"',
md: '"brand status"',
},
alignContent: { xs: "center", md: "stretch" },
alignItems: "center",
gap: { xs: 2.25, md: "clamp(64px, 8vw, 152px)" },
}}
> >
<Box display="flex" justifyContent="center"> <Stack
<Title collapsed={false} /> spacing={{ xs: 0, md: 3 }}
</Box> direction={{ xs: "row", md: "column" }}
alignItems={{ xs: "center", md: "flex-start" }}
sx={{
position: "relative",
isolation: "isolate",
gridArea: "brand",
width: "fit-content",
maxWidth: "100%",
animation:
"tjwaterEnter 480ms cubic-bezier(0.16, 1, 0.3, 1) both",
"&::before": {
position: "absolute",
zIndex: -1,
inset: { xs: "-24px -20px", md: "-54px -72px" },
background: {
xs: `radial-gradient(
ellipse at center,
oklch(0.965 0.014 205 / 98%) 0%,
oklch(0.965 0.014 205 / 88%) 58%,
transparent 84%
)`,
md: `radial-gradient(
ellipse at center,
oklch(0.925 0.018 205 / 98%) 0%,
oklch(0.925 0.018 205 / 94%) 48%,
oklch(0.925 0.018 205 / 62%) 65%,
transparent 82%
)`,
},
pointerEvents: "none",
content: '""',
},
}}
>
<Box
component="img"
src="/login-logo-mark.svg"
alt=""
width={56}
height={56}
sx={{
width: { xs: 42, md: 56 },
height: { xs: 42, md: 56 },
flex: { xs: "0 0 42px", md: "0 0 auto" },
mr: { xs: 1.5, md: 0 },
}}
/>
<Typography
component="h1"
sx={{
maxWidth: 680,
m: 0,
fontSize: { xs: 21, sm: 28, md: "clamp(34px, 3vw, 46px)" },
fontWeight: 720,
lineHeight: { xs: 1.4, md: 1.28 },
textWrap: "balance",
}}
>
{PROJECT_TITLE}
</Typography>
<Box
aria-hidden="true"
sx={{
display: { xs: "none", md: "block" },
width: 64,
height: 3,
borderRadius: 999,
bgcolor: "oklch(0.58 0.12 180)",
}}
/>
</Stack>
<Box
aria-live="polite"
sx={{
gridArea: "status",
width: "100%",
maxWidth: 460,
justifySelf: "stretch",
p: { xs: 2.75, sm: 4.5 },
overflow: "hidden",
borderRadius: { xs: 4, sm: 4.5 },
bgcolor: "oklch(0.995 0.004 205 / 97%)",
boxShadow:
"0 32px 80px rgb(22 65 75 / 16%), 0 5px 18px rgb(22 65 75 / 9%)",
animation:
"tjwaterEnter 520ms 70ms cubic-bezier(0.16, 1, 0.3, 1) both",
}}
>
<Stack spacing={3}>
<Stack direction="row" spacing={2} alignItems="center">
<CircularProgress
size={30}
thickness={4.5}
aria-label="正在连接统一身份认证"
sx={{
flex: "0 0 auto",
color: "oklch(0.57 0.16 242)",
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
"& .MuiCircularProgress-circle": {
animation: "none",
},
},
}}
/>
<Stack spacing={0.5}>
<Typography
component="h2"
sx={{ fontSize: { xs: 22, sm: 26 }, fontWeight: 720 }}
>
正在进入账号登录
</Typography>
<Typography
sx={{
color: "oklch(0.52 0.035 215)",
fontSize: { xs: 14, sm: 15 },
lineHeight: 1.7,
}}
>
正在连接统一身份认证服务,请稍候。
</Typography>
</Stack>
</Stack>
{showFallback ? (
<Button <Button
style={{ width: "240px" }} fullWidth
size="large" size="large"
variant="contained" variant="contained"
onClick={() => login({})} onClick={() => login({})}
sx={{
minHeight: 48,
borderRadius: 3,
fontSize: 16,
fontWeight: 700,
textTransform: "none",
boxShadow:
"0 8px 18px oklch(0.48 0.15 242 / 20%)",
transitionProperty:
"transform, background-color, box-shadow",
"&:active": { transform: "scale(0.96)" },
}}
> >
Sign in 继续登录
</Button> </Button>
<Typography align="center" color={"text.secondary"} fontSize="12px"> ) : null}
Powered by
<Image <Typography
style={{ padding: "0 5px" }} sx={{
alt="Keycloak" color: "oklch(0.52 0.035 215)",
src="https://refine.ams3.cdn.digitaloceanspaces.com/superplate-auth-icons%2Fkeycloak.svg" fontSize: { xs: 12, sm: 13 },
width={18} lineHeight: 1.7,
height={18} textAlign: "center",
/> }}
Keycloak >
账号与访问权限由统一身份认证服务管理
</Typography> </Typography>
</Stack>
</Box>
</Box>
</Box> </Box>
</Container>
); );
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
import { render, screen } from "@testing-library/react";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
import { RoutePermissionGuard } from "./RoutePermissionGuard";
jest.mock("next/navigation", () => ({
usePathname: () => "/network-simulation",
}));
describe("RoutePermissionGuard", () => {
beforeEach(() => {
useAccessStore.setState({
context: null,
permissions: [],
loading: false,
});
useAuthStore.setState({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("prioritizes an expired session over a missing route permission", () => {
useAuthStore.setState({
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
render(
<RoutePermissionGuard authenticated>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
expect(screen.getByText("正在跳转到登录页面…")).toBeInTheDocument();
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
expect(screen.queryByText(/simulation\.view/)).not.toBeInTheDocument();
expect(screen.queryByText("受保护内容")).not.toBeInTheDocument();
});
it("shows the permission error when the session is still valid", () => {
render(
<RoutePermissionGuard authenticated>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("无权访问此功能")).toBeInTheDocument();
expect(screen.getByText(/simulation\.view/)).toBeInTheDocument();
});
it("checks authentication before the expired state effect runs", () => {
render(
<RoutePermissionGuard authenticated={false}>
<div>受保护内容</div>
</RoutePermissionGuard>,
);
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,64 @@
"use client";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { permissionForPath } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useAuthStore } from "@/store/authStore";
export const RoutePermissionGuard = ({
children,
authenticated,
}: {
children: ReactNode;
authenticated: boolean;
}) => {
const pathname = usePathname();
const permissions = useAccessStore((state) => state.permissions);
const loading = useAccessStore((state) => state.loading);
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const requiredPermission = permissionForPath(pathname);
if (!authenticated || sessionExpired) {
return (
<Box sx={{ p: 3 }}>
<Alert severity="warning">
<Stack spacing={0.5}>
<Typography variant="body1">登录状态已失效</Typography>
<Typography variant="body2">
正在跳转到登录页面…
</Typography>
</Stack>
</Alert>
</Box>
);
}
if (requiredPermission && loading) {
return (
<Box sx={{ minHeight: 320, display: "grid", placeItems: "center" }}>
<CircularProgress size={28} />
</Box>
);
}
if (requiredPermission && !permissions.includes(requiredPermission)) {
return (
<Box sx={{ p: { xs: 2, md: 4 } }}>
<Alert severity="error" icon={<LockOutlinedIcon />}>
<Stack spacing={0.5}>
<Typography fontWeight={700}>无权访问此功能</Typography>
<Typography variant="body2">
当前项目角色缺少权限:{requiredPermission}
</Typography>
</Stack>
</Alert>
</Box>
);
}
return children;
};
@@ -0,0 +1,79 @@
import { createTheme, ThemeProvider } from "@mui/material/styles";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { signIn } from "next-auth/react";
import { useAuthStore } from "@/store/authStore";
import { SessionExpiryDialog } from "./SessionExpiryDialog";
jest.mock("next-auth/react", () => ({
signIn: jest.fn(),
}));
describe("SessionExpiryDialog", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.mocked(signIn).mockReset().mockResolvedValue(undefined);
useAuthStore.setState({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
});
afterEach(() => {
act(() => useAuthStore.getState().clearSessionExpired());
jest.useRealTimers();
});
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,
});
});
it("shows the expired state before automatically starting login", () => {
window.history.replaceState({}, "", "/network-simulation?tab=history");
render(<SessionExpiryDialog />);
expect(screen.getByText("登录已过期")).toBeInTheDocument();
expect(screen.getByText("即将自动跳转到登录页面。"))
.toBeInTheDocument();
expect(signIn).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(2_000);
});
expect(signIn).toHaveBeenCalledTimes(1);
expect(signIn).toHaveBeenCalledWith("keycloak", {
callbackUrl: "/network-simulation?tab=history",
redirect: true,
});
});
it("allows a retry when starting automatic login fails", async () => {
jest.mocked(signIn)
.mockRejectedValueOnce(new Error("network unavailable"))
.mockResolvedValueOnce(undefined);
render(<SessionExpiryDialog />);
await act(async () => {
jest.advanceTimersByTime(2_000);
});
fireEvent.click(screen.getByRole("button", { name: "重新认证" }));
expect(signIn).toHaveBeenCalledTimes(2);
});
});
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, 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;
const EXPIRED_REDIRECT_DELAY_MS = 2_000;
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);
const redirectStartedRef = useRef(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 = useCallback(() => {
if (redirectStartedRef.current) return;
redirectStartedRef.current = true;
const callbackUrl = `${window.location.pathname}${window.location.search}`;
void signIn("keycloak", { callbackUrl, redirect: true }).catch(() => {
redirectStartedRef.current = false;
});
}, []);
useEffect(() => {
if (!sessionExpired) {
redirectStartedRef.current = false;
return;
}
const timer = window.setTimeout(
handleReauthenticate,
EXPIRED_REDIRECT_DELAY_MS,
);
return () => window.clearTimeout(timer);
}, [handleReauthenticate, sessionExpired]);
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>
{sessionExpired && (
<Typography variant="body2" color="text.secondary">
即将自动跳转到登录页面。
</Typography>
)}
</Stack>
</DialogContent>
<DialogActions>
{!sessionExpired && (
<Button onClick={() => setWarningDismissed(true)}>稍后处理</Button>
)}
<Button variant="contained" onClick={handleReauthenticate}>
重新认证
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,384 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Collapse,
IconButton,
LinearProgress,
Stack,
Typography,
alpha,
useMediaQuery,
useTheme,
} from "@mui/material";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import ErrorOutlineRounded from "@mui/icons-material/ErrorOutlineRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
import StopCircleRounded from "@mui/icons-material/StopCircleRounded";
import type { AgentActivity, AgentActivityAction } from "@/lib/chatStream";
const activityAccent = "#0097a7";
type TimedActivityItem = {
status: "running" | "completed" | "error" | "cancelled";
startedAt: number;
endedAt?: number;
elapsedMs?: number;
elapsedSnapshotAt?: number;
durationMs?: number;
};
const formatDuration = (durationMs: number | undefined) => {
if (durationMs === undefined || !Number.isFinite(durationMs)) return undefined;
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
const seconds = Math.round(durationMs / 1000);
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
};
const getElapsedMs = (
item: TimedActivityItem,
now: number,
) => {
if (item.durationMs !== undefined) return item.durationMs;
if (item.status === "running") {
if (item.elapsedMs !== undefined && item.elapsedSnapshotAt !== undefined) {
return Math.max(0, item.elapsedMs + now - item.elapsedSnapshotAt);
}
return Math.max(0, now - item.startedAt);
}
return item.endedAt ? Math.max(0, item.endedAt - item.startedAt) : undefined;
};
const StatusIcon = ({
status,
size = 18,
}: {
status: AgentActivity["status"];
size?: number;
}) => {
if (status === "completed") {
return <CheckCircleRounded color="success" sx={{ fontSize: size }} />;
}
if (status === "error") {
return <ErrorOutlineRounded color="error" sx={{ fontSize: size }} />;
}
if (status === "cancelled") {
return <StopCircleRounded color="disabled" sx={{ fontSize: size }} />;
}
return <AutoAwesomeRounded sx={{ fontSize: size, color: activityAccent }} />;
};
const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] }) => {
if (status === "error") {
return <ErrorOutlineRounded sx={{ mt: "2px", fontSize: 14, color: "error.main" }} />;
}
if (status === "completed") {
return <CheckCircleRounded sx={{ mt: "2px", fontSize: 14, color: "success.main" }} />;
}
return (
<RadioButtonUncheckedRounded
sx={{ mt: "2px", fontSize: 14, color: activityAccent }}
/>
);
};
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
const elapsed = getElapsedMs(action, now);
return (
<Stack
direction="row"
spacing={1}
alignItems="flex-start"
sx={{ minWidth: 0, py: 0.55 }}
>
<ActionStatusIcon status={action.status} />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={{ xs: 0.2, sm: 1 }}
justifyContent="space-between"
>
<Typography variant="body2" fontWeight={650} sx={{ lineHeight: 1.45 }}>
{action.title}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
{formatDuration(elapsed)}
</Typography>
</Stack>
{action.target ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
mt: 0.2,
fontFamily: "monospace",
wordBreak: "break-word",
whiteSpace: "pre-wrap",
}}
>
{action.target}
</Typography>
) : null}
{action.error ? (
<Typography
variant="caption"
color="error.main"
sx={{ display: "block", mt: 0.2, wordBreak: "break-word" }}
>
{action.error}
</Typography>
) : null}
</Box>
</Stack>
);
};
export const AgentActivityTimeline = ({
activities,
}: {
activities: AgentActivity[];
}) => {
const theme = useTheme();
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const hasRunning = activities.some((activity) => activity.status === "running");
const hasError = activities.some((activity) => activity.status === "error");
const hasCancelled = activities.some((activity) => activity.status === "cancelled");
const [expanded, setExpanded] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!hasRunning) return;
const timer = window.setInterval(() => setNow(Date.now()), 500);
return () => window.clearInterval(timer);
}, [hasRunning]);
const current = [...activities]
.reverse()
.find((activity) => activity.status === "running") ?? activities.at(-1);
const totalDuration = useMemo(() => {
if (!activities.length) return undefined;
const start = Math.min(...activities.map((activity) => activity.startedAt));
const end = hasRunning
? now
: Math.max(
...activities.map((activity) => activity.endedAt ?? activity.startedAt),
);
return formatDuration(Math.max(0, end - start));
}, [activities, hasRunning, now]);
const overallStatus: AgentActivity["status"] = hasRunning
? "running"
: hasError
? "error"
: hasCancelled
? "cancelled"
: "completed";
const statusLabel = {
running: "进行中",
completed: "已完成",
error: "失败",
cancelled: "已停止",
}[overallStatus];
const statusColor = {
running: activityAccent,
completed: theme.palette.success.main,
error: theme.palette.error.main,
cancelled: theme.palette.text.secondary,
}[overallStatus];
const summary = hasRunning
? (current?.title ?? "正在分析")
: hasError
? "分析未完成"
: hasCancelled
? "分析已停止"
: `已完成 ${activities.length} 个阶段`;
return (
<Box
sx={{
overflow: "hidden",
borderRadius: 3,
border: `1px solid ${alpha(activityAccent, 0.16)}`,
bgcolor: alpha(activityAccent, 0.035),
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ px: 1.4, py: 1.05 }}
>
<Box
sx={{
width: 28,
height: 28,
flex: "0 0 auto",
display: "grid",
placeItems: "center",
borderRadius: 2,
color: activityAccent,
bgcolor: alpha(activityAccent, 0.1),
}}
>
<AutoAwesomeRounded sx={{ fontSize: 17 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" spacing={0.75} alignItems="center">
<Typography variant="body2" fontWeight={750}>
分析过程
</Typography>
<Stack
component="span"
direction="row"
spacing={0.45}
alignItems="center"
sx={{ color: statusColor }}
>
<Box
component="span"
sx={{
width: 5,
height: 5,
borderRadius: "50%",
bgcolor: "currentColor",
}}
/>
<Typography
component="span"
variant="caption"
fontWeight={700}
color="inherit"
>
{statusLabel}
</Typography>
</Stack>
</Stack>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{ display: "block", mt: 0.1 }}
>
{summary}
{totalDuration ? ` · ${totalDuration}` : ""}
</Typography>
</Box>
<IconButton
size="small"
aria-label={expanded ? "收起分析过程" : "展开分析过程"}
onClick={() => setExpanded((current) => !current)}
sx={{
width: 28,
height: 28,
flex: "0 0 auto",
color: "text.secondary",
bgcolor: alpha("#000", 0.035),
"&:hover": { bgcolor: alpha("#000", 0.07) },
}}
>
{expanded ? (
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
) : (
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
)}
</IconButton>
</Stack>
{hasRunning ? (
<LinearProgress
sx={{
height: 2,
bgcolor: alpha(activityAccent, 0.08),
"& .MuiLinearProgress-bar": { bgcolor: activityAccent },
}}
/>
) : null}
<Collapse in={expanded} timeout={reduceMotion ? 0 : 180}>
<Stack
spacing={1.1}
sx={{
px: 1.4,
py: 1.15,
}}
>
{activities.map((activity, index) => {
const elapsed = formatDuration(getElapsedMs(activity, now));
return (
<Stack
key={activity.id}
direction="row"
spacing={1}
sx={{
position: "relative",
minWidth: 0,
}}
>
<Box
sx={{
width: 18,
flex: "0 0 18px",
position: "relative",
pt: "2px",
}}
>
{index < activities.length - 1 ? (
<Box
sx={{
position: "absolute",
top: 19,
bottom: -14,
left: 8.5,
width: "1px",
bgcolor: alpha(activityAccent, 0.18),
}}
/>
) : null}
<StatusIcon status={activity.status} size={17} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" spacing={1} justifyContent="space-between">
<Typography variant="body2" fontWeight={700} sx={{ lineHeight: 1.45 }}>
{activity.title}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{elapsed}
</Typography>
</Stack>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 0.25, lineHeight: 1.5 }}
>
{activity.reason}
</Typography>
{activity.actions.length ? (
<Stack
spacing={0}
sx={{
mt: 0.6,
pl: 1,
borderLeft: `1px solid ${alpha(activityAccent, 0.2)}`,
}}
>
{activity.actions.map((action) => (
<ActionRow key={action.id} action={action} now={now} />
))}
</Stack>
) : null}
</Box>
</Stack>
);
})}
</Stack>
</Collapse>
</Box>
);
};
+111
View File
@@ -0,0 +1,111 @@
/* eslint-disable @next/next/no-img-element */
import React from "react";
import { render, screen } from "@testing-library/react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { AgentComposer } from "./AgentComposer";
jest.mock("next/image", () => ({
__esModule: true,
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img {...props} alt={props.alt ?? ""} />
),
}));
jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
motion: {
div: ({
children,
animate: _animate,
exit: _exit,
initial: _initial,
transition: _transition,
...props
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
<div {...props}>{children}</div>
),
},
}));
describe("AgentComposer", () => {
it("places voice input immediately before send without an attachment action", () => {
render(
<ThemeProvider theme={createTheme()}>
<AgentComposer
isStreaming={false}
isListening={false}
isSttSupported
presets={[]}
onSend={jest.fn()}
onAbort={jest.fn()}
onStartListening={jest.fn()}
onStopListening={jest.fn()}
modelOptions={[{ id: "test-model", label: "测试模型" }]}
selectedModel="test-model"
onModelChange={jest.fn()}
approvalMode="auto"
onApprovalModeChange={jest.fn()}
/>
</ThemeProvider>,
);
const voiceButton = screen.getByRole("button", { name: "语音输入" });
const sendButton = screen.getByRole("button", { name: "发送" });
expect(screen.queryByRole("button", { name: "上传附件" })).not.toBeInTheDocument();
expect(screen.getByTitle("快捷指令图标")).toBeInTheDocument();
expect(screen.queryByAltText("TJWater Agent")).not.toBeInTheDocument();
expect(screen.getByText("自动批准")).toBeInTheDocument();
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();
});
});
+143 -72
View File
@@ -23,11 +23,12 @@ import StopRounded from "@mui/icons-material/StopRounded";
import MicRounded from "@mui/icons-material/MicRounded"; import MicRounded from "@mui/icons-material/MicRounded";
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 AttachFileRounded from "@mui/icons-material/AttachFileRounded";
import BoltRounded from "@mui/icons-material/BoltRounded"; 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";
@@ -41,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;
@@ -56,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>,
@@ -68,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,
@@ -86,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,
@@ -103,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 }}>
@@ -125,15 +168,9 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
}} }}
> >
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<Image <AutoAwesomeRounded
src="/ai-agent.svg" titleAccess="快捷指令图标"
alt="TJWater Agent" sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
width={18}
height={18}
style={{
objectFit: "contain",
flexShrink: 0,
}}
/> />
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}> <Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
管网分析快捷指令 管网分析快捷指令
@@ -161,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);
@@ -216,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" },
@@ -230,61 +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">
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}> <FormControl size="small" sx={{ minWidth: 128 }}>
<AttachFileRounded fontSize="small" />
</IconButton>
{isSttSupported ? (
isListening ? (
<motion.div
animate={{ scale: [1, 1.14, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={onStopListening}
aria-label="停止语音输入"
size="small"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.15),
width: 36,
height: 36,
}}
>
<MicRounded fontSize="small" />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming || isHydrating}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton>
)
) : null}
<FormControl size="small" sx={{ minWidth: 96 }}>
<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" },
@@ -292,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)",
@@ -311,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" },
}, },
}, },
}, },
@@ -340,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>
@@ -471,6 +509,39 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
</Select> </Select>
</FormControl> </FormControl>
{isSttSupported ? (
isListening ? (
<motion.div
animate={{ scale: [1, 1.14, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={onStopListening}
aria-label="停止语音输入"
size="small"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.15),
width: 36,
height: 36,
}}
>
<MicRounded fontSize="small" />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming || isHydrating || !isRuntimeReady}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton>
)
) : null}
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{isStreaming ? ( {isStreaming ? (
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}> <motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
+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)}` },
+57 -24
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,25 +109,19 @@ 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
sx={{ sx={{
borderRadius: 3, borderRadius: 3,
overflow: "hidden", overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`, border: `1px solid ${alpha(accentColor, 0.18)}`,
bgcolor: alpha("#fff", 0.5), bgcolor: alpha(accentColor, 0.035),
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`, boxShadow: `0 6px 18px ${alpha("#000", 0.04)}`,
backdropFilter: "blur(20px)",
position: "relative", position: "relative",
"&::before": {
content: '""',
position: "absolute",
inset: "10px auto 10px 0",
width: 3,
borderRadius: "0 999px 999px 0",
bgcolor: accentColor,
},
}} }}
> >
<Stack <Stack
@@ -177,6 +171,22 @@ const PermissionRequestCard = ({
</Stack> </Stack>
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}> <Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
<Box
sx={{
px: 1.25,
py: 1,
borderRadius: 2.5,
bgcolor: alpha(accentColor, 0.055),
border: `1px solid ${alpha(accentColor, 0.12)}`,
}}
>
<Typography variant="caption" color="text.secondary" fontWeight={800}>
执行目的
</Typography>
<Typography variant="body2" sx={{ mt: 0.25, lineHeight: 1.55 }}>
{permission.reason?.trim() || "Agent 未提供执行目的"}
</Typography>
</Box>
<Box <Box
sx={{ sx={{
px: 1.25, px: 1.25,
@@ -203,6 +213,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 +297,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 +309,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 +356,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 +381,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) },
]; ];
+7 -6
View File
@@ -29,7 +29,9 @@ export const TodoPlanCard = ({
const theme = useTheme(); const theme = useTheme();
const total = todoUpdate.todos.length; const total = todoUpdate.todos.length;
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length; const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress"); const runningCount = todoUpdate.todos.filter(
(todo) => todo.status === "in_progress",
).length;
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length; const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length; const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
const progress = total > 0 ? Math.round((completed / total) * 100) : 0; const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
@@ -77,7 +79,7 @@ export const TodoPlanCard = ({
? `${completed} 完成 / ${cancelled} 中止` ? `${completed} 完成 / ${cancelled} 中止`
: [ : [
completed ? `${completed} 完成` : null, completed ? `${completed} 完成` : null,
running ? "1 进行中" : null, runningCount ? `${runningCount} 进行中` : null,
pending ? `${pending} 待办` : null, pending ? `${pending} 待办` : null,
cancelled ? `${cancelled} 中止` : null, cancelled ? `${cancelled} 中止` : null,
].filter(Boolean).join(" / ") || "等待任务"; ].filter(Boolean).join(" / ") || "等待任务";
@@ -221,14 +223,14 @@ export const TodoPlanCard = ({
</Typography> </Typography>
<Chip <Chip
size="small" size="small"
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"} label={runningCount ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
sx={{ sx={{
height: 20, height: 20,
borderRadius: "10px", borderRadius: "10px",
fontSize: "0.66rem", fontSize: "0.66rem",
fontWeight: 800, fontWeight: 800,
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f", color: runningCount ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08), bgcolor: alpha(runningCount ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
"& .MuiChip-label": { px: 0.75 }, "& .MuiChip-label": { px: 0.75 },
}} }}
/> />
@@ -305,4 +307,3 @@ export const TodoPlanCard = ({
); );
}; };
+351
View File
@@ -0,0 +1,351 @@
/* eslint-disable @next/next/no-img-element */
import "@testing-library/jest-dom";
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { AgentTurn } from "./AgentTurn";
jest.mock("next/image", () => ({
__esModule: true,
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<img {...props} alt={props.alt ?? ""} />
),
}));
jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useReducedMotion: () => false,
motion: {
div: ({
children,
animate: _animate,
exit: _exit,
initial: _initial,
transition: _transition,
...props
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
<div {...props}>{children}</div>
),
span: ({
children,
animate: _animate,
transition: _transition,
...props
}: React.HTMLAttributes<HTMLSpanElement> & Record<string, unknown>) => (
<span {...props}>{children}</span>
),
},
}));
jest.mock("./AgentMarkdownBlock", () => ({
MarkdownBlock: ({ children }: { children: string }) => (
<div>{children.split(/\n+/u).map((line) => <p key={line}>{line}</p>)}</div>
),
normalizeClipboardText: (value: string) => value.replace(/\s+$/u, ""),
}));
describe("AgentTurn speech selection", () => {
it("mounts the answer only after the complete response is available", () => {
const sharedProps = {
messageSpeechState: "idle" as const,
onSpeak: jest.fn(),
onPause: jest.fn(),
onResume: jest.fn(),
onStopSpeech: jest.fn(),
isTtsSupported: true,
onCreateBranch: jest.fn(),
onReplyPermission: jest.fn(),
onReplyQuestion: jest.fn(),
onRejectQuestion: jest.fn(),
};
const { rerender } = render(
<AgentTurn
{...sharedProps}
message={{ id: "assistant-buffered", role: "assistant", content: "" }}
isStreaming
/>,
);
expect(screen.queryByTestId("agent-answer-content")).not.toBeInTheDocument();
expect(screen.getByText("正在生成")).toBeInTheDocument();
rerender(
<AgentTurn
{...sharedProps}
message={{
id: "assistant-buffered",
role: "assistant",
content: "完整分析结果已生成。",
}}
isStreaming
/>,
);
expect(screen.getByTestId("agent-answer-content")).toHaveTextContent(
"完整分析结果已生成。",
);
});
it("shows a floating action and reads from the selected text", async () => {
const content = "第一段内容。\n\n第二段内容。";
const speechText = "第一段内容。\n第二段内容。";
const onSpeak = jest.fn();
const removeAllRanges = jest.fn();
render(
<AgentTurn
message={{ id: "assistant-1", role: "assistant", content }}
isStreaming={false}
messageSpeechState="idle"
onSpeak={onSpeak}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
const selectedParagraph = screen.getByText("第二段内容。");
const selectedTextNode = selectedParagraph.firstChild as Text;
const range = {
commonAncestorContainer: selectedTextNode,
getBoundingClientRect: () => ({
width: 72,
height: 20,
top: 120,
right: 172,
bottom: 140,
left: 100,
x: 100,
y: 120,
toJSON: () => ({}),
}),
} as unknown as Range;
const selection = {
rangeCount: 1,
isCollapsed: false,
getRangeAt: () => range,
toString: () => "第二段",
removeAllRanges,
} as unknown as Selection;
jest.spyOn(window, "getSelection").mockReturnValue(selection);
jest.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
fireEvent.pointerUp(selectedParagraph);
const speechAction = await screen.findByRole("button", { name: "从这里开始朗读" });
fireEvent.click(speechAction);
await waitFor(() => {
expect(onSpeak).toHaveBeenCalledWith("assistant-1", speechText, {
startOffset: speechText.indexOf("第二段"),
});
});
expect(removeAllRanges).toHaveBeenCalledTimes(1);
await waitFor(() => {
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",
reason: "需要运行测试确认本次改动没有引入回归。",
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.getByText("执行目的")).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();
});
it("groups concrete actions under a business activity", () => {
render(
<AgentTurn
message={{
id: "assistant-activity",
role: "assistant",
content: "",
activities: [
{
id: "activity-1",
title: "准备供水分区数据",
reason: "需要确认拓扑与水库属性完整,才能计算服务范围。",
status: "running",
startedAt: Date.now(),
actions: [
{
id: "action-1",
tool: "tjwater_cli",
title: "查询后端数据",
status: "completed",
target: "network get-all-reservoirs-properties",
startedAt: Date.now() - 100,
endedAt: Date.now(),
durationMs: 100,
},
],
},
],
}}
isStreaming
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByText("分析过程")).toBeInTheDocument();
expect(screen.getByText("进行中")).toBeInTheDocument();
expect(screen.getAllByText("准备供水分区数据").length).toBeGreaterThan(0);
expect(screen.getByTestId("KeyboardArrowDownRoundedIcon")).toBeInTheDocument();
expect(
screen.queryByText("需要确认拓扑与水库属性完整,才能计算服务范围。"),
).not.toBeVisible();
expect(screen.queryByText("查询后端数据")).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
expect(screen.getByTestId("KeyboardArrowUpRoundedIcon")).toBeInTheDocument();
expect(screen.getByText("需要确认拓扑与水库属性完整,才能计算服务范围。")).toBeVisible();
expect(screen.getByText("查询后端数据")).toBeVisible();
});
it("shows the actual number of in-progress session tasks", () => {
render(
<AgentTurn
message={{
id: "assistant-todos",
role: "assistant",
content: "",
todos: {
sessionId: "session-1",
createdAt: 1,
todos: [
{ id: "todo-1", content: "准备数据", status: "completed" },
{ id: "todo-2", content: "分析结果", status: "completed" },
{ id: "todo-3", content: "生成建议", status: "in_progress" },
{ id: "todo-4", content: "生成图表", status: "in_progress" },
],
},
}}
isStreaming
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByText(/2 完成 \/ 2 进行中/u)).toBeInTheDocument();
});
it("keeps a failed activity compact until the user expands it", () => {
render(
<AgentTurn
message={{
id: "assistant-activity-error",
role: "assistant",
content: "⚠️ **错误:** 模型请求失败",
activities: [
{
id: "activity-error",
title: "正在准备分析",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
status: "error",
startedAt: Date.now() - 4100,
endedAt: Date.now(),
durationMs: 4100,
actions: [],
},
],
}}
isStreaming={false}
messageSpeechState="idle"
onSpeak={jest.fn()}
onPause={jest.fn()}
onResume={jest.fn()}
onStopSpeech={jest.fn()}
isTtsSupported
onCreateBranch={jest.fn()}
onReplyPermission={jest.fn()}
onReplyQuestion={jest.fn()}
onRejectQuestion={jest.fn()}
/>,
);
expect(screen.getByText("失败")).toBeInTheDocument();
expect(screen.getByText("分析未完成 · 4.1s")).toBeInTheDocument();
expect(
screen.queryByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
).not.toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
expect(
screen.getByText("正在理解请求并确定本次分析需要完成的业务步骤。"),
).toBeInTheDocument();
});
});
+280 -101
View File
@@ -2,12 +2,16 @@
import Image from "next/image"; import Image from "next/image";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { import {
Avatar, Avatar,
Box, Box,
CircularProgress,
Button,
Grow,
IconButton, IconButton,
Paper, Paper,
Popper,
Stack, Stack,
Tooltip, Tooltip,
Typography, Typography,
@@ -16,15 +20,20 @@ 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,
type ContentSegment, type ContentSegment,
} from "./chatMessageSections"; } from "./chatMessageSections";
import type { Message, SpeechState } from "./GlobalChatbox.types"; import type {
import { stripMarkdown } from "./GlobalChatbox.utils"; Message,
SpeechState,
} from "./GlobalChatbox.types";
import { stripMarkdown } from "./globalChatboxUtils";
import { findSpeechSelectionStartOffset } from "./speechStartOptions";
import { AgentProgressTimeline } from "./AgentProgressTimeline"; import { AgentProgressTimeline } from "./AgentProgressTimeline";
import { AgentActivityTimeline } from "./AgentActivityTimeline";
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart"; import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
import { ChatToolCallBlock } from "./ChatToolCallBlock"; import { ChatToolCallBlock } from "./ChatToolCallBlock";
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock"; import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
@@ -36,17 +45,63 @@ import PauseRounded from "@mui/icons-material/PauseRounded";
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded"; import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
import StopRounded from "@mui/icons-material/StopRounded"; import StopRounded from "@mui/icons-material/StopRounded";
const floatingActionSurfaceSx = {
display: "flex",
gap: 0.5,
p: 0.5,
borderRadius: "16px",
bgcolor: alpha("#fff", 0.8),
backdropFilter: "blur(16px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
overflow: "hidden",
} as const;
const floatingActionTransitionTimeout = { enter: 150, exit: 120 } as const;
const floatingIconButtonSx = {
width: 28,
height: 28,
color: "text.secondary",
"&:hover": {
color: "#00acc1",
bgcolor: alpha("#00acc1", 0.1),
},
} as const;
const floatingSpeechButtonSx = {
minHeight: 34,
px: 1.25,
color: "text.primary",
fontSize: 13,
fontWeight: 700,
letterSpacing: 0,
whiteSpace: "nowrap",
borderRadius: "12px",
"&:hover": {
bgcolor: alpha("#00acc1", 0.1),
color: "#00acc1",
},
} as const;
type SpeechSelection = {
startOffset: number;
anchorRect: DOMRect;
};
type AgentTurnProps = { type AgentTurnProps = {
message: Message; message: Message;
isStreaming: boolean; isStreaming: boolean;
messageSpeechState: SpeechState; messageSpeechState: SpeechState;
onSpeak: (messageId: string, text: string) => void; onSpeak: (
messageId: string,
text: string,
options?: { startOffset?: number },
) => void;
onPause: () => void; onPause: () => void;
onResume: () => void; onResume: () => void;
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;
}; };
@@ -95,61 +150,6 @@ const StreamingStatus = () => {
); );
}; };
const StreamingMarkdownBlock = ({
text,
isStreaming,
segmentKey,
}: {
text: string;
isStreaming: boolean;
segmentKey: string;
}) => {
const [streamTextState, setStreamTextState] = React.useState<{
displayText: string;
animatedTailLength: number;
}>({
displayText: text,
animatedTailLength: 0,
});
React.useLayoutEffect(() => {
setStreamTextState((current) => {
if (current.displayText === text) {
return current;
}
if (!isStreaming) {
return {
displayText: text,
animatedTailLength: 0,
};
}
if (current.displayText === text) {
return current;
}
return {
displayText: text,
animatedTailLength:
text.length > current.displayText.length &&
text.startsWith(current.displayText)
? Math.min(48, text.length - current.displayText.length)
: 0,
};
});
}, [isStreaming, text]);
return (
<MarkdownBlock
streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`}
streamFadeLength={streamTextState.animatedTailLength}
>
{streamTextState.displayText}
</MarkdownBlock>
);
};
export const AgentTurn = React.memo( export const AgentTurn = React.memo(
({ ({
message, message,
@@ -166,15 +166,20 @@ export const AgentTurn = React.memo(
onRejectQuestion, onRejectQuestion,
}: AgentTurnProps) => { }: AgentTurnProps) => {
const theme = useTheme(); const theme = useTheme();
const reduceMotion = useReducedMotion();
const isUser = message.role === "user"; const isUser = message.role === "user";
const isErrorMessage = Boolean(message.isError); const isErrorMessage = Boolean(message.isError);
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming; const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
const hasFinalAnswer = message.content.trim().length > 0;
const [isHovered, setIsHovered] = React.useState(false); const [isHovered, setIsHovered] = React.useState(false);
const answerContentRef = React.useRef<HTMLDivElement | null>(null);
const [speechSelection, setSpeechSelection] = React.useState<SpeechSelection | null>(null);
const isProgressComplete = message.progress?.some( const isProgressComplete = message.progress?.some(
(item) => item.phase === "complete" && item.status === "completed", (item) => item.phase === "complete" && item.status === "completed",
) ?? false; ) ?? false;
const isProgressRunning = !isErrorMessage && !isProgressComplete && ( const isProgressRunning = !isErrorMessage && !isProgressComplete && (
message.progress?.some((item) => item.status === "running") ?? false (message.activities?.some((item) => item.status === "running") ?? false) ||
(message.progress?.some((item) => item.status === "running") ?? false)
); );
const parsedAssistantSections = useMemo( const parsedAssistantSections = useMemo(
@@ -185,6 +190,98 @@ export const AgentTurn = React.memo(
[isErrorMessage, isUser, message.content], [isErrorMessage, isUser, message.content],
); );
const answerContent = parsedAssistantSections?.answer ?? message.content; const answerContent = parsedAssistantSections?.answer ?? message.content;
const speechText = useMemo(
() => stripMarkdown(answerContent),
[answerContent],
);
const captureSpeechSelection = React.useCallback(() => {
if (!isTtsSupported || isStreamingAssistant) {
setSpeechSelection(null);
return;
}
const selection = window.getSelection();
const container = answerContentRef.current;
if (!selection || selection.rangeCount === 0 || selection.isCollapsed || !container) {
setSpeechSelection(null);
return;
}
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
setSpeechSelection(null);
return;
}
const selectedText = selection.toString();
const startOffset = findSpeechSelectionStartOffset(speechText, selectedText);
if (startOffset === null) {
setSpeechSelection(null);
return;
}
const anchorRect = range.getBoundingClientRect();
if (anchorRect.width === 0 && anchorRect.height === 0) {
setSpeechSelection(null);
return;
}
setSpeechSelection({ startOffset, anchorRect });
}, [isStreamingAssistant, isTtsSupported, speechText]);
const handleCaptureSpeechSelection = React.useCallback(() => {
window.requestAnimationFrame(captureSpeechSelection);
}, [captureSpeechSelection]);
React.useEffect(() => {
setSpeechSelection(null);
}, [message.id, speechText]);
React.useEffect(() => {
if (!speechSelection) return;
const closeSpeechSelection = () => setSpeechSelection(null);
const handleSelectionChange = () => {
if (window.getSelection()?.isCollapsed) {
closeSpeechSelection();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeSpeechSelection();
}
};
document.addEventListener("selectionchange", handleSelectionChange);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", closeSpeechSelection);
window.addEventListener("scroll", closeSpeechSelection, true);
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", closeSpeechSelection);
window.removeEventListener("scroll", closeSpeechSelection, true);
};
}, [speechSelection]);
const handleSpeakMessage = () => {
onSpeak(message.id, speechText);
};
const handleSpeakFromSelection = () => {
if (!speechSelection) return;
onSpeak(message.id, speechText, {
startOffset: speechSelection.startOffset,
});
window.getSelection()?.removeAllRanges();
setSpeechSelection(null);
};
const speechSelectionAnchor = React.useMemo(
() => speechSelection
? {
getBoundingClientRect: () => speechSelection.anchorRect,
}
: null,
[speechSelection],
);
const isSpeechSelectionOpen = Boolean(speechSelection);
const contentSegments: ContentSegment[] = useMemo( const contentSegments: ContentSegment[] = useMemo(
() => () =>
!isUser && !isErrorMessage !isUser && !isErrorMessage
@@ -308,7 +405,9 @@ export const AgentTurn = React.memo(
}} }}
> >
<Stack spacing={1.5}> <Stack spacing={1.5}>
{message.progress?.length ? ( {message.activities?.length ? (
<AgentActivityTimeline activities={message.activities} />
) : message.progress?.length ? (
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} /> <AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
) : null} ) : null}
@@ -333,6 +432,9 @@ export const AgentTurn = React.memo(
) : null} ) : null}
<Box <Box
ref={answerContentRef}
onPointerUp={handleCaptureSpeechSelection}
onKeyUp={handleCaptureSpeechSelection}
sx={{ sx={{
p: 1.5, p: 1.5,
borderRadius: 4, borderRadius: 4,
@@ -342,23 +444,45 @@ export const AgentTurn = React.memo(
}} }}
> >
<Stack spacing={1.2}> <Stack spacing={1.2}>
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}> <Stack
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}> direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
>
<Typography
variant="caption"
color="text.secondary"
fontWeight={800}
sx={{ letterSpacing: 0.5 }}
>
分析结果 分析结果
</Typography> </Typography>
{isStreamingAssistant ? <StreamingStatus /> : null} {isStreamingAssistant ? <StreamingStatus /> : null}
</Stack> </Stack>
{hasFinalAnswer || !isStreamingAssistant ? (
<motion.div
data-testid="agent-answer-content"
initial={
reduceMotion
? false
: { opacity: 0, y: 6, filter: "blur(2px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{
duration: reduceMotion ? 0 : 0.24,
ease: [0.16, 1, 0.3, 1],
}}
>
<Stack spacing={1.2}>
{contentSegments.map((segment, segIdx) => { {contentSegments.map((segment, segIdx) => {
if (segment.type === "text") { if (segment.type === "text") {
const text = segment.content.trim(); const text = segment.content.trim();
if (!text && contentSegments.length > 1) return null; if (!text && contentSegments.length > 1) return null;
return ( return (
<StreamingMarkdownBlock <MarkdownBlock key={segIdx}>
key={segIdx} {text || "..."}
text={text || "..."} </MarkdownBlock>
isStreaming={isStreamingAssistant}
segmentKey={`${message.id}-${segIdx}`}
/>
); );
} }
if (segment.type === "tool_call") { if (segment.type === "tool_call") {
@@ -372,12 +496,22 @@ export const AgentTurn = React.memo(
key={segment.toolCall.id} key={segment.toolCall.id}
title={(p.title as string) ?? undefined} title={(p.title as string) ?? undefined}
chart_type={ chart_type={
(p.chart_type as "line" | "bar" | "pie") ?? "line" (p.chart_type as "line" | "bar" | "pie") ??
"line"
}
x_data={
p.x_data ??
p.xData ??
p.labels ??
p.categories
} }
x_data={p.x_data ?? p.xData ?? p.labels ?? p.categories}
series={p.series} series={p.series}
x_axis_name={(p.x_axis_name as string) ?? undefined} x_axis_name={
y_axis_name={(p.y_axis_name as string) ?? undefined} (p.x_axis_name as string) ?? undefined
}
y_axis_name={
(p.y_axis_name as string) ?? undefined
}
isStreaming={isStreamingAssistant} isStreaming={isStreamingAssistant}
/> />
); );
@@ -400,8 +534,48 @@ export const AgentTurn = React.memo(
return null; return null;
})} })}
</Stack> </Stack>
</motion.div>
) : null}
</Stack>
</Box> </Box>
<Popper
open={isSpeechSelectionOpen}
anchorEl={speechSelectionAnchor}
placement="top"
transition
modifiers={[
{ name: "offset", options: { offset: [0, 8] } },
{ name: "flip", enabled: true },
{ name: "preventOverflow", options: { padding: 8 } },
]}
sx={{ zIndex: theme.zIndex.tooltip }}
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
timeout={floatingActionTransitionTimeout}
style={{
transformOrigin: placement.startsWith("bottom")
? "center top"
: "center bottom",
}}
>
<Paper elevation={4} sx={floatingActionSurfaceSx}>
<Button
size="small"
startIcon={<VolumeUpRounded sx={{ fontSize: 16 }} />}
onMouseDown={(event) => event.preventDefault()}
onClick={handleSpeakFromSelection}
sx={floatingSpeechButtonSx}
>
从这里开始朗读
</Button>
</Paper>
</Grow>
)}
</Popper>
{visibleChartArtifacts.map((artifact) => ( {visibleChartArtifacts.map((artifact) => (
<ChatInlineChart <ChatInlineChart
key={artifact.id} key={artifact.id}
@@ -424,26 +598,21 @@ export const AgentTurn = React.memo(
))} ))}
</Stack> </Stack>
<AnimatePresence> <Grow
{isHovered && !isStreaming && ( in={isHovered && !isStreaming}
<motion.div timeout={floatingActionTransitionTimeout}
initial={{ opacity: 0, scale: 0.9, y: 5 }} mountOnEnter
animate={{ opacity: 1, scale: 1, y: 0 }} unmountOnExit
exit={{ opacity: 0, scale: 0.9, y: 5 }} style={{ transformOrigin: "right bottom" }}
transition={{ duration: 0.15 }}
style={{ position: "absolute", top: -14, right: 12, zIndex: 10 }}
> >
<Paper <Paper
elevation={4} elevation={4}
sx={{ sx={{
display: "flex", ...floatingActionSurfaceSx,
gap: 0.5, position: "absolute",
p: 0.5, top: -14,
borderRadius: "16px", right: 12,
bgcolor: alpha("#fff", 0.8), zIndex: 10,
backdropFilter: "blur(16px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 4px 12px ${alpha("#000", 0.08)}`,
}} }}
> >
<Tooltip title="复制"> <Tooltip title="复制">
@@ -454,9 +623,8 @@ export const AgentTurn = React.memo(
navigator.clipboard.writeText( navigator.clipboard.writeText(
normalizeClipboardText(message.content), normalizeClipboardText(message.content),
); );
// Could add a toast here
}} }}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }} sx={floatingIconButtonSx}
> >
<ContentCopyRounded sx={{ fontSize: 16 }} /> <ContentCopyRounded sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
@@ -465,18 +633,14 @@ export const AgentTurn = React.memo(
<IconButton <IconButton
size="small" size="small"
aria-label="拆分为新会话" aria-label="拆分为新会话"
onClick={() => { onClick={() => onCreateBranch(message.id)}
onCreateBranch(message.id); sx={floatingIconButtonSx}
}}
sx={{ width: 28, height: 28, color: "text.secondary", "&:hover": { color: "#00acc1", bgcolor: alpha("#00acc1", 0.1) } }}
> >
<TbArrowsSplit2 size={16} /> <TbArrowsSplit2 size={16} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Paper> </Paper>
</motion.div> </Grow>
)}
</AnimatePresence>
</Paper> </Paper>
</Stack> </Stack>
@@ -487,13 +651,28 @@ export const AgentTurn = React.memo(
{messageSpeechState === "idle" ? ( {messageSpeechState === "idle" ? (
<IconButton <IconButton
size="small" size="small"
onClick={() => onSpeak(message.id, stripMarkdown(answerContent))} onClick={handleSpeakMessage}
aria-label="朗读消息" aria-label="朗读消息"
sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }} sx={{ color: "text.secondary", opacity: 0.68, p: 0.5 }}
> >
<VolumeUpRounded sx={{ fontSize: 16 }} /> <VolumeUpRounded sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
) : null} ) : null}
{messageSpeechState === "loading" ? (
<>
<IconButton
size="small"
disabled
aria-label="正在生成语音"
sx={{ color: "primary.main", p: 0.5 }}
>
<CircularProgress size={16} thickness={5} />
</IconButton>
<IconButton size="small" onClick={onStopSpeech} aria-label="停止朗读" sx={{ color: "error.main", p: 0.5 }}>
<StopRounded sx={{ fontSize: 16 }} />
</IconButton>
</>
) : null}
{messageSpeechState === "playing" ? ( {messageSpeechState === "playing" ? (
<> <>
<IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}> <IconButton size="small" onClick={onPause} aria-label="暂停朗读" sx={{ color: "primary.main", p: 0.5 }}>
+99 -5
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";
@@ -34,10 +34,6 @@ jest.mock("framer-motion", () => ({
}, },
})); }));
jest.mock("./GlobalChatbox.parts", () => ({
TypingIndicator: () => <div>typing</div>,
}));
jest.mock("./AgentTurn", () => ({ jest.mock("./AgentTurn", () => ({
AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => { AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => {
React.useEffect(() => { React.useEffect(() => {
@@ -89,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",
@@ -168,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);
});
}); });
+297 -61
View File
@@ -3,15 +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 { TypingIndicator } from "./GlobalChatbox.parts"; import type { AgentRuntimeState } from "@/lib/agentRuntime";
import type { PermissionReply } from "@/lib/chatStream"; import type { PermissionDecision } from "@/lib/chatStream";
import type { import type {
Message, Message,
SpeechState, SpeechState,
@@ -20,42 +21,59 @@ 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>;
onScrollStateChange?: (isNearBottom: boolean) => void; onScrollStateChange?: (isNearBottom: boolean) => void;
speakingMessageId: string | null; speakingMessageId: string | null;
speechState: SpeechState; speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void; onSpeak: (
messageId: string,
text: string,
options?: { startOffset?: number },
) => void;
onPauseSpeech: () => void; onPauseSpeech: () => void;
onResumeSpeech: () => void; onResumeSpeech: () => void;
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[];
isStreaming: boolean; scrollTop: number;
viewportHeight: number;
isAssistantStreaming: boolean;
streamingMessageId: string | null; streamingMessageId: string | null;
speakingMessageId: string | null; speakingMessageId: string | null;
speechState: SpeechState; speechState: SpeechState;
onSpeak: (messageId: string, text: string) => void; onSpeak: (
messageId: string,
text: string,
options?: { startOffset?: number },
) => void;
onPauseSpeech: () => void; onPauseSpeech: () => void;
onResumeSpeech: () => void; onResumeSpeech: () => void;
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 &&
@@ -65,7 +83,9 @@ const TurnItem = React.memo(AgentTurn);
const TurnListInner = ({ const TurnListInner = ({
messages, messages,
isStreaming, scrollTop,
viewportHeight,
isAssistantStreaming,
streamingMessageId, streamingMessageId,
speakingMessageId, speakingMessageId,
speechState, speechState,
@@ -79,13 +99,100 @@ 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} message={message}
isStreaming={isStreaming && message.id === streamingMessageId} measure={isWindowed}
onHeightChange={updateMeasuredHeight}
>
<TurnItem
message={message}
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"} messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
onSpeak={onSpeak} onSpeak={onSpeak}
onPause={onPauseSpeech} onPause={onPauseSpeech}
@@ -97,16 +204,75 @@ 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.isStreaming === nextProps.isStreaming && prevProps.scrollTop === nextProps.scrollTop &&
prevProps.viewportHeight === nextProps.viewportHeight &&
prevProps.isAssistantStreaming === nextProps.isAssistantStreaming &&
prevProps.streamingMessageId === nextProps.streamingMessageId && prevProps.streamingMessageId === nextProps.streamingMessageId &&
prevProps.speakingMessageId === nextProps.speakingMessageId && prevProps.speakingMessageId === nextProps.speakingMessageId &&
prevProps.speechState === nextProps.speechState && prevProps.speechState === nextProps.speechState &&
@@ -123,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: "异常状态预警" },
@@ -140,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,
@@ -163,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={{
@@ -187,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}>
@@ -232,6 +441,7 @@ const EmptyState = () => {
</Grid> </Grid>
))} ))}
</Grid> </Grid>
)}
</Paper> </Paper>
</motion.div> </motion.div>
); );
@@ -302,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,
@@ -318,23 +530,23 @@ export const AgentWorkspace = ({
onReplyQuestion, onReplyQuestion,
onRejectQuestion, onRejectQuestion,
}: AgentWorkspaceProps) => { }: AgentWorkspaceProps) => {
const theme = useTheme(); const localScrollContainerRef = React.useRef<HTMLDivElement>(null);
const latestAssistant = [...messages] const [scrollMetrics, setScrollMetrics] = React.useState({
.reverse() scrollTop: 0,
.find((message) => message.role === "assistant"); viewportHeight: DEFAULT_VIEWPORT_HEIGHT_PX,
const showTypingIndicator = });
isStreaming && const streamingMessageId =
(!latestAssistant ||
(latestAssistant.content.trim().length === 0 &&
!(latestAssistant.artifacts?.length)));
const streamingMessage =
isStreaming && messages.at(-1)?.role === "assistant" isStreaming && messages.at(-1)?.role === "assistant"
? messages.at(-1) ? messages.at(-1)?.id ?? null
: undefined; : 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(
@@ -345,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,
@@ -365,15 +605,32 @@ 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}
isStreaming={isStreaming} scrollTop={
streamingMessageId={streamingMessage?.id ?? null} messages.length > TURN_WINDOW_THRESHOLD
? scrollMetrics.scrollTop
: 0
}
viewportHeight={scrollMetrics.viewportHeight}
isAssistantStreaming={isStreaming}
streamingMessageId={streamingMessageId}
speakingMessageId={speakingMessageId} speakingMessageId={speakingMessageId}
speechState={speechState} speechState={speechState}
onSpeak={onSpeak} onSpeak={onSpeak}
@@ -391,27 +648,6 @@ export const AgentWorkspace = ({
</> </>
)} )}
{!isLoadingSession && showTypingIndicator ? (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 300 }}
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 44 }}
>
<Paper
elevation={0}
sx={{
p: 1.3,
borderRadius: 4,
bgcolor: alpha("#fff", 0.82),
boxShadow: `0 4px 12px ${alpha(theme.palette.common.black, 0.05)}`,
}}
>
<TypingIndicator />
</Paper>
</motion.div>
) : null}
<div <div
ref={bottomRef} ref={bottomRef}
style={{ style={{
+14 -23
View File
@@ -30,6 +30,7 @@ import {
describeApplyLayerStyle, describeApplyLayerStyle,
parseApplyLayerStylePayload, parseApplyLayerStylePayload,
} from "./toolCallStyleHelpers"; } from "./toolCallStyleHelpers";
import { buildViewHistoryAction } from "./historyToolAction";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Interactive card rendered inside a chat bubble for tool actions */ /* Interactive card rendered inside a chat bubble for tool actions */
@@ -45,12 +46,12 @@ type ToolMeta = {
const LOCATE_TOOL_TO_LAYER: Record<string, string> = { const LOCATE_TOOL_TO_LAYER: Record<string, string> = {
locate_features: "", locate_features: "",
locate_junctions: "geo_junctions_mat", locate_junctions: "junctions",
locate_pipes: "geo_pipes_mat", locate_pipes: "pipes",
locate_valves: "geo_valves", locate_valves: "valves",
locate_reservoirs: "geo_reservoirs", locate_reservoirs: "reservoirs",
locate_pumps: "geo_pumps", locate_pumps: "pumps",
locate_tanks: "geo_tanks", locate_tanks: "tanks",
}; };
const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]); const LOCATE_LINE_TOOLS = new Set<string>(["locate_pipes"]);
@@ -428,17 +429,7 @@ function buildAction(toolCall: ToolCall): ChatToolAction | null {
}; };
} }
case "view_history": { case "view_history": {
const historyRange = resolveTimeRange(); return buildViewHistoryAction(params);
return {
type: "view_history",
featureInfos:
(params.feature_infos as [string, string][] | undefined) ?? [],
dataType:
(params.data_type as "realtime" | "scheme" | "none" | undefined) ??
"realtime",
startTime: historyRange.startTime,
endTime: historyRange.endTime,
};
} }
case "view_scada": { case "view_scada": {
const scadaRange = resolveTimeRange(); const scadaRange = resolveTimeRange();
@@ -672,22 +663,22 @@ export const ChatToolCallBlock: React.FC<ChatToolCallBlockProps> = ({
switch (featureType) { switch (featureType) {
case "junction": case "junction":
case "junctions": case "junctions":
return { layer: "geo_junctions_mat", geometryKind: "point" }; return { layer: "junctions", geometryKind: "point" };
case "pipe": case "pipe":
case "pipes": case "pipes":
return { layer: "geo_pipes_mat", geometryKind: "line" }; return { layer: "pipes", geometryKind: "line" };
case "valve": case "valve":
case "valves": case "valves":
return { layer: "geo_valves", geometryKind: "point" }; return { layer: "valves", geometryKind: "point" };
case "reservoir": case "reservoir":
case "reservoirs": case "reservoirs":
return { layer: "geo_reservoirs", geometryKind: "point" }; return { layer: "reservoirs", geometryKind: "point" };
case "pump": case "pump":
case "pumps": case "pumps":
return { layer: "geo_pumps", geometryKind: "point" }; return { layer: "pumps", geometryKind: "point" };
case "tank": case "tank":
case "tanks": case "tanks":
return { layer: "geo_tanks", geometryKind: "point" }; return { layer: "tanks", geometryKind: "point" };
default: default:
return null; return null;
} }
+194
View File
@@ -0,0 +1,194 @@
import "@testing-library/jest-dom";
import React from "react";
import { act, render, screen } from "@testing-library/react";
import { GlobalChatbox } from "./GlobalChatbox";
const createSession = jest.fn();
const mockFetchAgentRuntimeHealth = jest.fn();
const mockFetchAgentModels = jest.fn();
let mockCurrentProjectId = "project-1";
jest.mock("@refinedev/core", () => ({
useNotification: () => ({ open: jest.fn() }),
}));
jest.mock("@/lib/chatModels", () => ({
fetchAgentModels: (...args: unknown[]) => mockFetchAgentModels(...args),
}));
jest.mock("@/lib/agentRuntime", () => ({
fetchAgentRuntimeHealth: (...args: unknown[]) =>
mockFetchAgentRuntimeHealth(...args),
}));
jest.mock("@/store/projectStore", () => ({
useProjectStore: (selector: (state: { currentProjectId: string }) => unknown) =>
selector({ currentProjectId: mockCurrentProjectId }),
}));
jest.mock("./globalChatboxVoice", () => ({
useSpeechSynthesis: () => ({
speechState: "idle",
speakingMessageId: null,
speak: jest.fn(),
pause: jest.fn(),
resume: jest.fn(),
stop: jest.fn(),
isSupported: true,
}),
useSpeechRecognition: () => ({
isListening: false,
start: jest.fn(),
stop: jest.fn(),
isSupported: true,
}),
}));
jest.mock("./hooks/useAgentToolActions", () => ({
useAgentToolActions: () => jest.fn(),
}));
jest.mock("./hooks/useAgentChatSession", () => ({
useAgentChatSession: () => ({
messages: [],
chatSessions: [],
activeSessionId: undefined,
isHydrating: false,
loadingSessionId: null,
isStreaming: false,
sessionTitle: "新会话",
sendPrompt: jest.fn(),
createBranch: jest.fn(),
abort: jest.fn(),
replyPermission: jest.fn(),
replyQuestion: jest.fn(),
rejectQuestion: jest.fn(),
createSession,
renameSession: jest.fn(),
removeSession: jest.fn(),
switchSession: jest.fn(),
}),
}));
jest.mock("./AgentHeader", () => ({
AgentHeader: () => <div>Agent header</div>,
}));
jest.mock("./AgentHistoryPanel", () => ({
AgentHistoryPanel: () => <div>History</div>,
}));
jest.mock("./AgentWorkspace", () => ({
AgentWorkspace: ({ runtimeState }: { runtimeState: string }) => (
<div data-testid="agent-workspace">Workspace state: {runtimeState}</div>
),
}));
jest.mock("./AgentComposer", () => ({
AgentComposer: React.forwardRef(function MockAgentComposer(props: { approvalMode: string }, _ref) {
return <div>Composer mode: {props.approvalMode}</div>;
}),
}));
jest.mock("./GlobalChatboxParts", () => ({
Blob: () => null,
}));
describe("GlobalChatbox lifecycle", () => {
beforeEach(() => {
jest.useFakeTimers();
createSession.mockClear();
mockFetchAgentRuntimeHealth.mockReset();
mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {}));
mockFetchAgentModels.mockReset();
mockFetchAgentModels.mockImplementation(() => new Promise(() => {}));
mockCurrentProjectId = "project-1";
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});
it("keeps content mounted and preserves the session across close and reopen", async () => {
const { rerender } = render(<GlobalChatbox open onClose={jest.fn()} />);
act(() => jest.runOnlyPendingTimers());
expect(createSession).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
rerender(<GlobalChatbox open={false} onClose={jest.fn()} />);
act(() => jest.advanceTimersByTime(300));
expect(screen.getByTestId("agent-workspace")).toBeInTheDocument();
rerender(<GlobalChatbox open onClose={jest.fn()} />);
act(() => jest.runOnlyPendingTimers());
expect(createSession).toHaveBeenCalledTimes(1);
mockCurrentProjectId = "project-2";
rerender(<GlobalChatbox open onClose={jest.fn()} />);
act(() => jest.runOnlyPendingTimers());
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);
});
});
+111 -40
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";
@@ -17,15 +21,18 @@ import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
import { AgentHeader } from "./AgentHeader"; import { AgentHeader } from "./AgentHeader";
import { AgentHistoryPanel } from "./AgentHistoryPanel"; import { AgentHistoryPanel } from "./AgentHistoryPanel";
import { AgentWorkspace } from "./AgentWorkspace"; import { AgentWorkspace } from "./AgentWorkspace";
import { Blob } from "./GlobalChatbox.parts"; import { Blob } from "./GlobalChatboxParts";
import type { Props } from "./GlobalChatbox.types"; import type { Props } from "./GlobalChatbox.types";
import { PRESET_PROMPTS } from "./GlobalChatbox.utils"; import { PRESET_PROMPTS } from "./globalChatboxUtils";
import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice"; import { useSpeechRecognition, useSpeechSynthesis } from "./globalChatboxVoice";
import { useAgentChatSession } from "./hooks/useAgentChatSession"; import { useAgentChatSession } from "./hooks/useAgentChatSession";
import { useAgentToolActions } from "./hooks/useAgentToolActions"; 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,15 +41,18 @@ 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);
const isNearBottomRef = useRef(true); const isNearBottomRef = useRef(true);
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 hasResetForOpenRef = useRef(false); 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 {
@@ -154,20 +216,36 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
isNearBottomRef.current = isNearBottom; isNearBottomRef.current = isNearBottom;
}, []); }, []);
const resetConversationView = useCallback(() => {
composerRef.current?.clear();
setIsHistoryOpen(false);
window.setTimeout(() => {
composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto");
}, 0);
}, [cancelStreamingScroll, scrollToBottom]);
useEffect(() => { useEffect(() => {
if (isStreaming) { if (isStreaming) {
const latestAssistant = [...messages]
.reverse()
.find((message) => message.role === "assistant");
if (latestAssistant?.content.trim()) {
cancelStreamingScroll();
return;
}
if (!isNearBottomRef.current) return; if (!isNearBottomRef.current) return;
scheduleStreamingScrollToBottom(); scheduleStreamingScrollToBottom();
return; return;
} }
cancelStreamingScroll(); cancelStreamingScroll();
scrollToBottom("smooth");
}, [ }, [
cancelStreamingScroll, cancelStreamingScroll,
isStreaming, isStreaming,
messages, messages,
scheduleStreamingScrollToBottom, scheduleStreamingScrollToBottom,
scrollToBottom,
]); ]);
useEffect( useEffect(
@@ -178,27 +256,21 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
); );
useEffect(() => { useEffect(() => {
if (!open) { if (
hasResetForOpenRef.current = false; !open ||
isHydrating ||
initializedProjectIdRef.current === currentProjectId
) {
return; return;
} }
if (hasResetForOpenRef.current || isHydrating) return;
hasResetForOpenRef.current = true;
const timer = window.setTimeout(() => { initializedProjectIdRef.current = currentProjectId;
createSession(); createSession();
composerRef.current?.clear(); resetConversationView();
setIsHistoryOpen(false); }, [createSession, currentProjectId, isHydrating, open, resetConversationView]);
composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto");
}, 0);
return () => window.clearTimeout(timer);
}, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
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 {
@@ -224,20 +296,14 @@ 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();
stopListening(); stopListening();
createSession(); createSession();
composerRef.current?.clear(); resetConversationView();
window.setTimeout(() => { }, [createSession, handleStopSpeech, resetConversationView, stopListening]);
composerRef.current?.focus();
isNearBottomRef.current = true;
cancelStreamingScroll();
scrollToBottom("auto");
}, 0);
}, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
const handleHistoryToggle = useCallback(() => { const handleHistoryToggle = useCallback(() => {
setIsHistoryOpen((prev) => !prev); setIsHistoryOpen((prev) => !prev);
@@ -311,6 +377,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
hideBackdrop hideBackdrop
disableScrollLock disableScrollLock
disableEnforceFocus disableEnforceFocus
ModalProps={{ keepMounted: true }}
sx={{ sx={{
zIndex: (muiTheme) => muiTheme.zIndex.modal + 100, zIndex: (muiTheme) => muiTheme.zIndex.modal + 100,
pointerEvents: "none", pointerEvents: "none",
@@ -373,6 +440,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 +498,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 +520,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}
+5 -1
View File
@@ -1,4 +1,5 @@
import type { import type {
AgentActivity,
AgentQuestionRequest, AgentQuestionRequest,
AgentTodoUpdate, AgentTodoUpdate,
} from "@/lib/chatStream"; } from "@/lib/chatStream";
@@ -42,6 +43,8 @@ export type AgentPermissionRequest = {
permission: string; permission: string;
patterns: string[]; patterns: string[];
target?: string; target?: string;
activityId?: string;
reason?: string;
always: string[]; always: string[];
tool?: { tool?: {
messageID: string; messageID: string;
@@ -59,6 +62,7 @@ export type Message = {
content: string; content: string;
isError?: boolean; isError?: boolean;
progress?: ChatProgress[]; progress?: ChatProgress[];
activities?: AgentActivity[];
artifacts?: AgentArtifact[]; artifacts?: AgentArtifact[];
permissions?: AgentPermissionRequest[]; permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[]; questions?: AgentQuestionRequest[];
@@ -70,7 +74,7 @@ export type Props = {
onClose: () => void; onClose: () => void;
}; };
export type SpeechState = "idle" | "playing" | "paused"; export type SpeechState = "idle" | "loading" | "playing" | "paused";
export type ChatSessionSummary = { export type ChatSessionSummary = {
id: string; id: string;
-647
View File
@@ -1,647 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import config from "@/config/config";
import type { SpeechState } from "./GlobalChatbox.types";
type AudioStreamStartResponse = {
stream_id?: string;
audio_url?: string;
status_url?: string;
result_url?: string;
sample_rate?: number;
channels?: number;
error?: string;
};
type AudioStreamStatusResponse = {
state?: "starting" | "running" | "done" | "failed" | "closed";
ready?: boolean;
failed?: boolean;
closed?: boolean;
status_text?: string;
error?: string;
};
type AudioStreamResultResponse = {
run_status?: string;
error?: string;
};
// WebKit Speech Recognition compatibility
interface SpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognition extends EventTarget {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: Event) => void) | null;
onend: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
declare global {
interface Window {
SpeechRecognition?: {
new (): SpeechRecognition;
prototype: SpeechRecognition;
};
webkitSpeechRecognition?: {
new (): SpeechRecognition;
prototype: SpeechRecognition;
};
webkitAudioContext?: typeof AudioContext;
}
}
export function useSpeechSynthesis() {
const [speechState, setSpeechState] = useState<SpeechState>("idle");
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const streamAbortControllerRef = useRef<AbortController | null>(null);
const activeSourceNodesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
const streamIdRef = useRef<string | null>(null);
const closeUrlRef = useRef<string | null>(null);
const statusUrlRef = useRef<string | null>(null);
const resultUrlRef = useRef<string | null>(null);
const statusPollTimeoutRef = useRef<number | null>(null);
const playbackTokenRef = useRef(0);
const isSupported =
typeof window !== "undefined" &&
typeof window.FormData !== "undefined" &&
(typeof window.AudioContext !== "undefined" ||
typeof window.webkitAudioContext !== "undefined");
const trimTrailingSlash = useCallback((value: string) => value.replace(/\/+$/, ""), []);
const buildServiceUrl = useCallback(
(path: string) => `${trimTrailingSlash(config.AUDIO_SERVICE_URL)}${path.startsWith("/") ? path : `/${path}`}`,
[trimTrailingSlash],
);
const resolveServiceUrl = useCallback(
(pathOrUrl: string) => {
if (/^https?:\/\//i.test(pathOrUrl)) {
return pathOrUrl;
}
return buildServiceUrl(pathOrUrl);
},
[buildServiceUrl],
);
const withQueryParams = useCallback(
(urlString: string, params: Record<string, string>) => {
const url = new URL(urlString);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
return url.toString();
},
[],
);
const readErrorMessage = useCallback(async (response: Response, fallback: string) => {
try {
const payload = (await response.json()) as { error?: string; message?: string };
return payload.error || payload.message || fallback;
} catch {
return fallback;
}
}, []);
const closeStream = useCallback(async (closeUrl: string) => {
const response = await fetch(closeUrl, {
method: "POST",
});
if (!response.ok) {
console.error("[GlobalChatbox] Failed to close audio stream:", closeUrl);
}
}, []);
const stopStatusPolling = useCallback(() => {
if (statusPollTimeoutRef.current !== null) {
window.clearTimeout(statusPollTimeoutRef.current);
statusPollTimeoutRef.current = null;
}
}, []);
const fetchStreamResult = useCallback(
async (resultUrl: string) => {
const response = await fetch(resultUrl);
if (response.status === 202) {
return false;
}
if (!response.ok) {
throw new Error(
await readErrorMessage(
response,
`Audio stream result failed with status ${response.status}`,
),
);
}
const payload = (await response.json()) as AudioStreamResultResponse;
if (payload.error) {
throw new Error(payload.error);
}
return true;
},
[readErrorMessage],
);
const clearAudio = useCallback(async () => {
const abortController = streamAbortControllerRef.current;
streamAbortControllerRef.current = null;
abortController?.abort();
activeSourceNodesRef.current.forEach((source) => {
try {
source.onended = null;
source.stop();
} catch {
// ignore stop errors when source already ended
}
source.disconnect();
});
activeSourceNodesRef.current.clear();
const audioContext = audioContextRef.current;
audioContextRef.current = null;
if (!audioContext) return;
try {
await audioContext.close();
} catch {
// ignore close errors when context already closed
}
}, []);
const playPcmStream = useCallback(
async ({
audioUrl,
sampleRate,
channels,
playbackToken,
}: {
audioUrl: string;
sampleRate: number;
channels: number;
playbackToken: number;
}) => {
const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext;
if (!AudioContextCtor) {
throw new Error("WebAudio AudioContext is not available in this browser");
}
const abortController = new AbortController();
streamAbortControllerRef.current = abortController;
const response = await fetch(withQueryParams(audioUrl, { format: "pcm" }), {
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(
await readErrorMessage(response, `Audio stream failed with status ${response.status}`),
);
}
if (!response.body) {
throw new Error("Audio stream response body is missing");
}
const audioContext = new AudioContextCtor({
sampleRate,
});
audioContextRef.current = audioContext;
const reader = response.body.getReader();
const bytesPerFrame = Math.max(1, channels) * 2;
let bufferedRemainder = new Uint8Array(0);
let nextStartTime = audioContext.currentTime + 0.05;
let activeSources = 0;
let streamEnded = false;
let resolvePlaybackDrain: (() => void) | null = null;
const playbackDrainPromise = new Promise<void>((resolve) => {
resolvePlaybackDrain = resolve;
});
const maybeResolvePlaybackDrain = () => {
if (streamEnded && activeSources === 0) {
resolvePlaybackDrain?.();
}
};
const schedulePcmChunk = (pcmBytes: Uint8Array) => {
const frameCount = pcmBytes.byteLength / bytesPerFrame;
if (frameCount <= 0) return;
const buffer = audioContext.createBuffer(Math.max(1, channels), frameCount, sampleRate);
const view = new DataView(pcmBytes.buffer, pcmBytes.byteOffset, pcmBytes.byteLength);
for (let frame = 0; frame < frameCount; frame += 1) {
for (let channel = 0; channel < Math.max(1, channels); channel += 1) {
const sampleIndex = frame * Math.max(1, channels) + channel;
const pcm = view.getInt16(sampleIndex * 2, true);
buffer.getChannelData(channel)[frame] = pcm / 32768;
}
}
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
const sourceStartTime = Math.max(nextStartTime, audioContext.currentTime + 0.01);
nextStartTime = sourceStartTime + buffer.duration;
activeSources += 1;
activeSourceNodesRef.current.add(source);
source.onended = () => {
activeSources -= 1;
activeSourceNodesRef.current.delete(source);
source.disconnect();
maybeResolvePlaybackDrain();
};
source.start(sourceStartTime);
};
const concatUint8Arrays = (a: Uint8Array, b: Uint8Array) => {
if (a.byteLength === 0) return b;
if (b.byteLength === 0) return a;
const merged = new Uint8Array(a.byteLength + b.byteLength);
merged.set(a);
merged.set(b, a.byteLength);
return merged;
};
while (true) {
if (playbackToken !== playbackTokenRef.current) {
throw new DOMException("PCM stream playback cancelled", "AbortError");
}
const { done, value } = await reader.read();
if (done) break;
if (!value || value.byteLength === 0) continue;
const merged = concatUint8Arrays(bufferedRemainder, value);
const alignedByteLength = merged.byteLength - (merged.byteLength % bytesPerFrame);
if (alignedByteLength === 0) {
bufferedRemainder = new Uint8Array(merged);
continue;
}
const alignedChunk = merged.slice(0, alignedByteLength);
bufferedRemainder = new Uint8Array(merged.slice(alignedByteLength));
schedulePcmChunk(alignedChunk);
}
streamEnded = true;
maybeResolvePlaybackDrain();
await playbackDrainPromise;
},
[readErrorMessage, withQueryParams],
);
const stopPlayback = useCallback(async () => {
await clearAudio();
stopStatusPolling();
const closeUrl = closeUrlRef.current;
streamIdRef.current = null;
closeUrlRef.current = null;
statusUrlRef.current = null;
resultUrlRef.current = null;
setSpeechState("idle");
setSpeakingMessageId(null);
if (closeUrl) {
try {
await closeStream(closeUrl);
} catch (error) {
console.error("[GlobalChatbox] Failed to close audio stream:", error);
}
}
}, [clearAudio, closeStream, stopStatusPolling]);
const pollStreamStatus = useCallback(
(playbackToken: number, statusUrl: string, resultUrl: string) => {
stopStatusPolling();
statusPollTimeoutRef.current = window.setTimeout(async () => {
if (
playbackToken !== playbackTokenRef.current ||
statusUrlRef.current !== statusUrl ||
resultUrlRef.current !== resultUrl
) {
return;
}
try {
const response = await fetch(statusUrl);
if (!response.ok) {
throw new Error(
await readErrorMessage(
response,
`Audio stream status failed with status ${response.status}`,
),
);
}
const payload = (await response.json()) as AudioStreamStatusResponse;
if (
playbackToken !== playbackTokenRef.current ||
statusUrlRef.current !== statusUrl ||
resultUrlRef.current !== resultUrl
) {
return;
}
if (payload.failed || payload.state === "failed") {
console.error(
"[GlobalChatbox] Audio stream failed:",
payload.error || payload.status_text || statusUrl,
);
playbackTokenRef.current += 1;
void stopPlayback();
return;
}
if (payload.closed || payload.state === "closed") {
stopStatusPolling();
return;
}
if (payload.ready || payload.state === "done") {
try {
const isResultReady = await fetchStreamResult(resultUrl);
if (isResultReady) {
stopStatusPolling();
return;
}
} catch (error) {
console.error("[GlobalChatbox] Failed to fetch audio stream result:", error);
}
}
pollStreamStatus(playbackToken, statusUrl, resultUrl);
} catch (error) {
if (
playbackToken === playbackTokenRef.current &&
statusUrlRef.current === statusUrl &&
resultUrlRef.current === resultUrl
) {
console.error("[GlobalChatbox] Failed to poll audio stream status:", error);
pollStreamStatus(playbackToken, statusUrl, resultUrl);
}
}
}, 1000);
},
[fetchStreamResult, readErrorMessage, stopPlayback, stopStatusPolling],
);
const stop = useCallback(() => {
playbackTokenRef.current += 1;
void stopPlayback();
}, [stopPlayback]);
const speak = useCallback(
async (messageId: string, text: string) => {
const normalizedText = text.trim();
if (!isSupported || !normalizedText) return;
const playbackToken = playbackTokenRef.current + 1;
playbackTokenRef.current = playbackToken;
await stopPlayback();
setSpeakingMessageId(messageId);
setSpeechState("playing");
try {
const formData = new FormData();
formData.append("text", normalizedText);
formData.append("demo_id", "demo-1");
const response = await fetch(buildServiceUrl("/api/generate-stream/start"), {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(
await readErrorMessage(
response,
`Audio stream start failed with status ${response.status}`,
),
);
}
const payload = (await response.json()) as AudioStreamStartResponse;
const streamId = payload.stream_id;
const sampleRate =
typeof payload.sample_rate === "number" && payload.sample_rate > 0
? payload.sample_rate
: 24000;
const channels =
typeof payload.channels === "number" && payload.channels > 0
? payload.channels
: 1;
const audioUrl = payload.audio_url
? resolveServiceUrl(payload.audio_url)
: buildServiceUrl(
`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/audio?format=pcm`,
);
const rawStatusUrl = payload.status_url
? resolveServiceUrl(payload.status_url)
: buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/status`);
const statusUrl = withQueryParams(rawStatusUrl, { compact: "1" });
const rawResultUrl = payload.result_url
? resolveServiceUrl(payload.result_url)
: buildServiceUrl(`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/result`);
const resultUrl = withQueryParams(rawResultUrl, {
compact: "1",
include_audio: "0",
});
const closeUrl = buildServiceUrl(
`/api/generate-stream/${encodeURIComponent(streamId ?? "")}/close`,
);
if (!streamId) {
throw new Error(payload.error || "Audio stream start response is missing stream_id");
}
if (playbackToken !== playbackTokenRef.current) {
await closeStream(closeUrl);
return;
}
streamIdRef.current = streamId;
closeUrlRef.current = closeUrl;
statusUrlRef.current = statusUrl;
resultUrlRef.current = resultUrl;
pollStreamStatus(playbackToken, statusUrl, resultUrl);
await playPcmStream({
audioUrl,
sampleRate,
channels,
playbackToken,
});
if (playbackToken !== playbackTokenRef.current) {
return;
}
await clearAudio();
if (streamIdRef.current === streamId) {
streamIdRef.current = null;
closeUrlRef.current = null;
statusUrlRef.current = null;
resultUrlRef.current = null;
setSpeechState("idle");
setSpeakingMessageId(null);
}
stopStatusPolling();
await fetchStreamResult(resultUrl).catch((error) => {
console.error("[GlobalChatbox] Failed to fetch audio stream result:", error);
});
await closeStream(closeUrl);
} catch (error) {
await clearAudio();
if (
error instanceof DOMException &&
error.name === "AbortError" &&
playbackToken !== playbackTokenRef.current
) {
return;
}
const closeUrl = closeUrlRef.current;
streamIdRef.current = null;
closeUrlRef.current = null;
statusUrlRef.current = null;
resultUrlRef.current = null;
setSpeechState("idle");
setSpeakingMessageId(null);
if (closeUrl) {
try {
await closeStream(closeUrl);
} catch (closeError) {
console.error("[GlobalChatbox] Failed to close audio stream:", closeError);
}
}
console.error("[GlobalChatbox] Failed to play audio stream:", error);
}
},
[
buildServiceUrl,
clearAudio,
closeStream,
fetchStreamResult,
isSupported,
playPcmStream,
readErrorMessage,
resolveServiceUrl,
pollStreamStatus,
stopPlayback,
stopStatusPolling,
withQueryParams,
],
);
const pause = useCallback(() => {
if (!isSupported || !audioContextRef.current) return;
void audioContextRef.current.suspend().then(
() => {
setSpeechState("paused");
},
(error) => {
console.error("[GlobalChatbox] Failed to pause PCM playback:", error);
},
);
}, [isSupported]);
const resume = useCallback(() => {
if (!isSupported || !audioContextRef.current) return;
void audioContextRef.current.resume().then(
() => {
setSpeechState("playing");
},
(error) => {
playbackTokenRef.current += 1;
void stopPlayback();
console.error("[GlobalChatbox] Failed to resume audio playback:", error);
},
);
}, [isSupported, stopPlayback]);
useEffect(() => {
return () => {
playbackTokenRef.current += 1;
void stopPlayback();
};
}, [stopPlayback]);
return { speechState, speakingMessageId, speak, pause, resume, stop, isSupported };
}
export function useSpeechRecognition(onResult: (text: string) => void) {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const onResultRef = useRef(onResult);
useEffect(() => {
onResultRef.current = onResult;
}, [onResult]);
const isSupported =
typeof window !== "undefined" &&
("SpeechRecognition" in window || "webkitSpeechRecognition" in window);
const start = useCallback(() => {
if (!isSupported || recognitionRef.current) return;
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
if (!Ctor) return;
const recognition = new Ctor();
recognition.lang = "zh-CN";
recognition.continuous = true;
recognition.interimResults = false;
recognition.onresult = (event: SpeechRecognitionEvent) => {
for (let i = event.resultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
onResultRef.current(event.results[i][0].transcript);
}
}
};
recognition.onerror = () => {
setIsListening(false);
recognitionRef.current = null;
};
recognition.onend = () => {
setIsListening(false);
recognitionRef.current = null;
};
recognitionRef.current = recognition;
recognition.start();
setIsListening(true);
}, [isSupported]);
const stop = useCallback(() => {
recognitionRef.current?.stop();
recognitionRef.current = null;
setIsListening(false);
}, []);
useEffect(() => {
return () => {
recognitionRef.current?.stop();
};
}, []);
return { isListening, start, stop, isSupported };
}
@@ -2,36 +2,6 @@
import React from "react"; import React from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Box, Stack } from "@mui/material";
export const TypingIndicator = () => {
return (
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ p: 1 }}>
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ y: 0 }}
animate={{ y: [-4, 4, -4] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
background: "linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%)",
}}
/>
</motion.div>
))}
</Stack>
);
};
export const Blob = ({ export const Blob = ({
color, color,
+2 -2
View File
@@ -84,7 +84,7 @@ describe("chatStorage backend session operations", () => {
sessionId: "session-1", sessionId: "session-1",
messages: [{ id: "message-1", role: "user", content: "查压力" }], messages: [{ id: "message-1", role: "user", content: "查压力" }],
}); });
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1"); expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" }); expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
}); });
@@ -98,7 +98,7 @@ describe("chatStorage backend session operations", () => {
isTitleManuallyEdited: true, isTitleManuallyEdited: true,
}); });
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title"); expect(String(apiFetch.mock.calls[0][0])).toContain("/sessions/session-1");
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" }); expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({ expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
title: "新标题", title: "新标题",
+5 -9
View File
@@ -6,7 +6,7 @@ import type {
LoadedChatState, LoadedChatState,
Message, Message,
} from "./GlobalChatbox.types"; } from "./GlobalChatbox.types";
import { cloneMessages } from "./GlobalChatbox.utils"; import { cloneMessages } from "./globalChatboxUtils";
type BackendSessionPayload = { type BackendSessionPayload = {
id?: string; id?: string;
@@ -46,10 +46,9 @@ const toMillis = (value: string | number | undefined) =>
const normalizeTitle = (value?: string) => value?.trim() || "新对话"; const normalizeTitle = (value?: string) => value?.trim() || "新对话";
const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => { const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, { const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/sessions`, {
method: "GET", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
}); });
if (!response.ok) { if (!response.ok) {
@@ -73,11 +72,10 @@ const fetchBackendChatSessions = async (): Promise<ChatSessionSummary[]> => {
const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => { const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatState> => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{ {
method: "GET", method: "GET",
projectHeaderMode: "include", projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
}, },
); );
@@ -112,7 +110,7 @@ const updateBackendChatSessionTitle = async (
isTitleManuallyEdited?: boolean, isTitleManuallyEdited?: boolean,
) => { ) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{ {
method: "PATCH", method: "PATCH",
headers: { headers: {
@@ -123,7 +121,6 @@ const updateBackendChatSessionTitle = async (
is_title_manually_edited: isTitleManuallyEdited, is_title_manually_edited: isTitleManuallyEdited,
}), }),
projectHeaderMode: "include", projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
}, },
); );
@@ -134,11 +131,10 @@ const updateBackendChatSessionTitle = async (
const deleteBackendChatSession = async (sessionId: string) => { const deleteBackendChatSession = async (sessionId: string) => {
const response = await apiFetch( const response = await apiFetch(
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, `${config.AGENT_URL}/api/v1/agent/sessions/${encodeURIComponent(sessionId)}`,
{ {
method: "DELETE", method: "DELETE",
projectHeaderMode: "include", projectHeaderMode: "include",
userHeaderMode: "include",
skipAuthRedirect: true, skipAuthRedirect: true,
}, },
); );
@@ -1,4 +1,4 @@
import { cloneMessage } from "./GlobalChatbox.utils"; import { cloneMessage } from "./globalChatboxUtils";
import type { Message } from "./GlobalChatbox.types"; import type { Message } from "./GlobalChatbox.types";
describe("cloneMessage", () => { describe("cloneMessage", () => {
+379
View File
@@ -0,0 +1,379 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { SpeechState } from "./GlobalChatbox.types";
import { splitSpeechTextIntoChunks } from "./speechStartOptions";
type SpeakOptions = {
startOffset?: number;
};
interface SpeechRecognitionEvent extends Event {
readonly resultIndex: number;
readonly results: SpeechRecognitionResultList;
}
interface SpeechRecognition extends EventTarget {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: Event) => void) | null;
onend: (() => void) | null;
start(): void;
stop(): void;
abort(): void;
}
declare global {
interface Window {
SpeechRecognition?: {
new (): SpeechRecognition;
prototype: SpeechRecognition;
};
webkitSpeechRecognition?: {
new (): SpeechRecognition;
prototype: SpeechRecognition;
};
}
}
export function useSpeechSynthesis() {
const [speechState, setSpeechState] = useState<SpeechState>("idle");
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentAudioUrlRef = useRef<string | null>(null);
const audioObjectUrlsRef = useRef<Set<string>>(new Set());
const fetchAbortControllersRef = useRef<Set<AbortController>>(new Set());
const chunkAudioUrlCacheRef = useRef<Map<number, string>>(new Map());
const chunkFetchPromisesRef = useRef<Map<number, Promise<string>>>(new Map());
const chunksRef = useRef<string[]>([]);
const currentChunkIndexRef = useRef(0);
const playChunkRef = useRef<(chunkIndex: number, playbackToken: number) => Promise<void>>(
async () => {},
);
const playbackTokenRef = useRef(0);
const activeMessageIdRef = useRef<string | null>(null);
const isSupported =
typeof window !== "undefined" &&
typeof window.Audio !== "undefined" &&
typeof window.URL !== "undefined" &&
typeof window.fetch !== "undefined";
const detachCurrentAudio = useCallback((revokeCurrentUrl: boolean) => {
const audio = audioRef.current;
audioRef.current = null;
if (audio) {
audio.pause();
audio.onended = null;
audio.onerror = null;
audio.removeAttribute("src");
audio.load();
}
const currentUrl = currentAudioUrlRef.current;
currentAudioUrlRef.current = null;
if (revokeCurrentUrl && currentUrl) {
URL.revokeObjectURL(currentUrl);
audioObjectUrlsRef.current.delete(currentUrl);
chunkAudioUrlCacheRef.current.delete(currentChunkIndexRef.current);
}
}, []);
const releaseAudio = useCallback(() => {
fetchAbortControllersRef.current.forEach((controller) => controller.abort());
fetchAbortControllersRef.current.clear();
chunkFetchPromisesRef.current.clear();
detachCurrentAudio(false);
audioObjectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
audioObjectUrlsRef.current.clear();
chunkAudioUrlCacheRef.current.clear();
chunksRef.current = [];
currentChunkIndexRef.current = 0;
activeMessageIdRef.current = null;
}, [detachCurrentAudio]);
const readErrorMessage = useCallback(async (response: Response, fallback: string) => {
try {
const payload = (await response.json()) as { error?: string; message?: string };
return payload.error || payload.message || fallback;
} catch {
return fallback;
}
}, []);
const fetchChunkAudio = useCallback(
(chunkIndex: number, playbackToken: number) => {
const cachedUrl = chunkAudioUrlCacheRef.current.get(chunkIndex);
if (cachedUrl) return Promise.resolve(cachedUrl);
const existingPromise = chunkFetchPromisesRef.current.get(chunkIndex);
if (existingPromise) return existingPromise;
const chunkText = chunksRef.current[chunkIndex];
if (!chunkText) {
return Promise.reject(new Error("Speech chunk is missing"));
}
const abortController = new AbortController();
fetchAbortControllersRef.current.add(abortController);
const promise = fetch("/api/tts/edge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text: chunkText }),
signal: abortController.signal,
})
.then(async (response) => {
if (!response.ok) {
throw new Error(
await readErrorMessage(response, `Edge TTS failed with status ${response.status}`),
);
}
const audioBlob = await response.blob();
if (!audioBlob.size) {
throw new Error("Edge TTS returned empty audio");
}
if (playbackToken !== playbackTokenRef.current) {
throw new DOMException("Edge TTS chunk cancelled", "AbortError");
}
const objectUrl = URL.createObjectURL(audioBlob);
audioObjectUrlsRef.current.add(objectUrl);
chunkAudioUrlCacheRef.current.set(chunkIndex, objectUrl);
return objectUrl;
})
.finally(() => {
fetchAbortControllersRef.current.delete(abortController);
chunkFetchPromisesRef.current.delete(chunkIndex);
});
chunkFetchPromisesRef.current.set(chunkIndex, promise);
return promise;
},
[readErrorMessage],
);
const prefetchChunk = useCallback(
(chunkIndex: number, playbackToken: number) => {
if (chunkIndex >= chunksRef.current.length) return;
void fetchChunkAudio(chunkIndex, playbackToken).catch((error) => {
if (
playbackToken === playbackTokenRef.current &&
!(error instanceof DOMException && error.name === "AbortError")
) {
console.error("[GlobalChatbox] Failed to prefetch Edge TTS chunk:", error);
}
});
},
[fetchChunkAudio],
);
const playChunk = useCallback(
async (chunkIndex: number, playbackToken: number) => {
setSpeechState("loading");
const objectUrl = await fetchChunkAudio(chunkIndex, playbackToken);
if (playbackToken !== playbackTokenRef.current) return;
detachCurrentAudio(true);
currentChunkIndexRef.current = chunkIndex;
const audio = new Audio(objectUrl);
audio.preload = "auto";
audioRef.current = audio;
currentAudioUrlRef.current = objectUrl;
audio.onended = () => {
if (playbackToken !== playbackTokenRef.current) return;
detachCurrentAudio(true);
const nextChunkIndex = chunkIndex + 1;
if (nextChunkIndex >= chunksRef.current.length) {
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
return;
}
currentChunkIndexRef.current = nextChunkIndex;
void playChunkRef.current(nextChunkIndex, playbackToken);
};
audio.onerror = () => {
if (playbackToken !== playbackTokenRef.current) return;
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
console.error("[GlobalChatbox] Edge TTS audio playback failed");
};
await audio.play();
if (playbackToken !== playbackTokenRef.current) return;
setSpeechState("playing");
prefetchChunk(chunkIndex + 1, playbackToken);
},
[detachCurrentAudio, fetchChunkAudio, prefetchChunk, releaseAudio],
);
useEffect(() => {
playChunkRef.current = playChunk;
}, [playChunk]);
const playExistingAudio = useCallback(async () => {
const audio = audioRef.current;
if (!audio) return false;
setSpeakingMessageId(activeMessageIdRef.current);
setSpeechState("playing");
try {
await audio.play();
prefetchChunk(currentChunkIndexRef.current + 1, playbackTokenRef.current);
return true;
} catch (error) {
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
console.error("[GlobalChatbox] Failed to resume Edge TTS playback:", error);
return false;
}
}, [prefetchChunk, releaseAudio]);
const speak = useCallback(
async (messageId: string, text: string, options: SpeakOptions = {}) => {
const normalizedText = text.trim();
if (!isSupported || !normalizedText) return;
const startOffset = Math.max(
0,
Math.min(options.startOffset ?? 0, normalizedText.length),
);
const textToSpeak = normalizedText.slice(startOffset).trim();
const chunks = splitSpeechTextIntoChunks(textToSpeak);
if (!chunks.length) return;
const playbackToken = playbackTokenRef.current + 1;
playbackTokenRef.current = playbackToken;
releaseAudio();
chunksRef.current = chunks;
currentChunkIndexRef.current = 0;
activeMessageIdRef.current = messageId;
setSpeakingMessageId(messageId);
setSpeechState("loading");
try {
await playChunk(0, playbackToken);
} catch (error) {
if (
error instanceof DOMException &&
error.name === "AbortError" &&
playbackToken !== playbackTokenRef.current
) {
return;
}
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
console.error("[GlobalChatbox] Failed to play Edge TTS audio:", error);
}
},
[isSupported, playChunk, releaseAudio],
);
const pause = useCallback(() => {
const audio = audioRef.current;
if (!isSupported || !audio || speechState !== "playing") return;
audio.pause();
setSpeechState("paused");
}, [isSupported, speechState]);
const resume = useCallback(() => {
if (!isSupported) return;
void playExistingAudio();
}, [isSupported, playExistingAudio]);
const stop = useCallback(() => {
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
}, [releaseAudio]);
useEffect(() => {
return () => {
playbackTokenRef.current += 1;
releaseAudio();
};
}, [releaseAudio]);
return {
speechState,
speakingMessageId,
speak,
pause,
resume,
stop,
isSupported,
};
}
export function useSpeechRecognition(onResult: (text: string) => void) {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const onResultRef = useRef(onResult);
useEffect(() => {
onResultRef.current = onResult;
}, [onResult]);
const isSupported =
typeof window !== "undefined" &&
("SpeechRecognition" in window || "webkitSpeechRecognition" in window);
const start = useCallback(() => {
if (!isSupported || recognitionRef.current) return;
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
if (!Ctor) return;
const recognition = new Ctor();
recognition.lang = "zh-CN";
recognition.continuous = true;
recognition.interimResults = false;
recognition.onresult = (event: SpeechRecognitionEvent) => {
for (let i = event.resultIndex; i < event.results.length; i++) {
if (event.results[i].isFinal) {
onResultRef.current(event.results[i][0].transcript);
}
}
};
recognition.onerror = () => {
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
recognitionRef.current = null;
};
recognitionRef.current = recognition;
setIsListening(true);
recognition.start();
}, [isSupported]);
const stop = useCallback(() => {
recognitionRef.current?.stop();
recognitionRef.current = null;
setIsListening(false);
}, []);
useEffect(() => {
return () => {
recognitionRef.current?.abort();
};
}, []);
return { isListening, start, stop, isSupported };
}
@@ -0,0 +1,36 @@
import { buildViewHistoryAction } from "./historyToolAction";
describe("buildViewHistoryAction", () => {
it("preserves the scheme run ID from Agent tool parameters", () => {
expect(
buildViewHistoryAction({
feature_infos: [["P-1", "pipe"]],
data_type: "scheme",
run_id: " 99dd4142-368b-54cb-bfca-d59ee48f6298 ",
}),
).toEqual(
expect.objectContaining({
featureInfos: [["P-1", "pipe"]],
dataType: "scheme",
runId: "99dd4142-368b-54cb-bfca-d59ee48f6298",
}),
);
});
it("normalizes invalid history parameters", () => {
expect(
buildViewHistoryAction({
feature_infos: [["", "pipe"], "invalid"],
data_type: "unexpected",
runId: " ",
}),
).toEqual({
type: "view_history",
featureInfos: [],
dataType: "realtime",
runId: undefined,
startTime: undefined,
endTime: undefined,
});
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { ChatToolAction } from "@/store/chatToolStore";
type ViewHistoryAction = Extract<ChatToolAction, { type: "view_history" }>;
const readOptionalString = (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : undefined;
export const buildViewHistoryAction = (
params: Record<string, unknown>,
): ViewHistoryAction => {
const rawFeatureInfos = Array.isArray(params.feature_infos)
? params.feature_infos
: [];
const featureInfos = rawFeatureInfos
.filter(
(item): item is [unknown, unknown] =>
Array.isArray(item) && item.length >= 2,
)
.map(
([id, type]) => [String(id).trim(), String(type).trim()] as [string, string],
)
.filter(([id, type]) => id.length > 0 && type.length > 0);
const rawDataType = params.data_type;
const dataType =
rawDataType === "realtime" ||
rawDataType === "scheme" ||
rawDataType === "none"
? rawDataType
: "realtime";
return {
type: "view_history",
featureInfos,
dataType,
runId: readOptionalString(params.run_id ?? params.runId),
startTime: readOptionalString(
params.start_time ?? params.startTime ?? params.from ?? params.start,
),
endTime: readOptionalString(
params.end_time ?? params.endTime ?? params.to ?? params.end,
),
};
};
@@ -1,4 +1,5 @@
import type { import type {
AgentActivity,
AgentQuestionRequest, AgentQuestionRequest,
AgentTodoUpdate, AgentTodoUpdate,
PermissionReply, PermissionReply,
@@ -9,7 +10,7 @@ import type {
ChatProgress, ChatProgress,
Message, Message,
} from "../GlobalChatbox.types"; } from "../GlobalChatbox.types";
import { createId } from "../GlobalChatbox.utils"; import { createId } from "../globalChatboxUtils";
export const upsertProgress = ( export const upsertProgress = (
progress: ChatProgress[] | undefined, progress: ChatProgress[] | undefined,
@@ -79,6 +80,48 @@ export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
}; };
}); });
export const upsertActivity = (
activities: AgentActivity[] | undefined,
event: StreamEvent & { type: "activity_update" },
) => {
const next = [...(activities ?? [])];
const index = next.findIndex((activity) => activity.id === event.activity.id);
if (index >= 0) next[index] = event.activity;
else next.push(event.activity);
return next;
};
export const completeRunningActivities = (
activities: AgentActivity[] | undefined,
status: "completed" | "error" | "cancelled" = "completed",
) => activities?.map((activity) => {
if (activity.status !== "running") return activity;
const endedAt = Date.now();
return {
...activity,
status,
actions: activity.actions.map((action) =>
action.status === "running"
? {
...action,
status: status === "error" ? "error" as const : "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - action.startedAt),
...(status === "error"
? { error: action.error ?? "活动执行失败" }
: {}),
}
: action,
),
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - activity.startedAt),
};
});
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) => export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
todoUpdate todoUpdate
? { ? {
@@ -107,6 +150,8 @@ export const upsertPermission = (
permission: event.permission, permission: event.permission,
patterns: event.patterns, patterns: event.patterns,
target: event.target, target: event.target,
activityId: event.activityId,
reason: event.reason,
always: event.always, always: event.always,
tool: event.tool, tool: event.tool,
createdAt: event.createdAt, createdAt: event.createdAt,
@@ -405,6 +450,7 @@ export const rejectOpenQuestionsAfterAbort = (
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => { export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
const completedProgress = completeRunningProgress(message.progress); const completedProgress = completeRunningProgress(message.progress);
const cancelledActivities = completeRunningActivities(message.activities, "cancelled");
const cancelledTodos = cancelRunningTodos(message.todos); const cancelledTodos = cancelRunningTodos(message.todos);
const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions); const abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions); const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
@@ -414,6 +460,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
Boolean(abortedPermissions?.length) || Boolean(abortedPermissions?.length) ||
Boolean(rejectedQuestions?.length) || Boolean(rejectedQuestions?.length) ||
Boolean(completedProgress?.length) || Boolean(completedProgress?.length) ||
Boolean(cancelledActivities?.length) ||
Boolean(cancelledTodos); Boolean(cancelledTodos);
if (!hasVisibleOutput) { if (!hasVisibleOutput) {
@@ -425,6 +472,7 @@ export const finalizeAssistantMessageAfterAbort = (message: Message): Message =>
content: message.content || "⚠️ **请求已中断**", content: message.content || "⚠️ **请求已中断**",
isError: true, isError: true,
progress: completedProgress, progress: completedProgress,
activities: cancelledActivities,
permissions: abortedPermissions, permissions: abortedPermissions,
questions: rejectedQuestions, questions: rejectedQuestions,
todos: cancelledTodos, todos: cancelledTodos,
@@ -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);
@@ -122,6 +132,80 @@ describe("useAgentChatSession actions", () => {
); );
}); });
it("applies an activity phase and todo snapshot atomically before revealing the final answer", async () => {
listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
onEvent({
type: "activity_update",
sessionId: "session-1",
activity: {
id: "activity-analyze",
title: "分析管网数据",
reason: "需要识别影响供水能力的关键管段。",
status: "running",
actions: [],
startedAt: 1000,
},
todos: [
{
id: "todo-data",
content: "准备管网数据",
status: "completed",
priority: "high",
},
{
id: "todo-analysis",
content: "识别瓶颈管段",
status: "in_progress",
priority: "high",
},
],
todosCreatedAt: 1001,
});
onEvent({
type: "final_answer",
sessionId: "session-1",
content: "已识别关键瓶颈管段。",
});
onEvent({ type: "done", sessionId: "session-1" });
});
const { result } = renderHook(() =>
useAgentChatSession({
projectId: "project-1",
onToolCall: jest.fn(),
}),
);
await waitFor(() => expect(result.current.isHydrating).toBe(false));
await act(async () => {
await result.current.sendPrompt("分析管网瓶颈");
});
const assistantMessage = result.current.messages.at(-1);
expect(assistantMessage).toMatchObject({
role: "assistant",
content: "已识别关键瓶颈管段。",
todos: {
sessionId: "session-1",
createdAt: 1001,
todos: [
expect.objectContaining({ id: "todo-data", status: "completed" }),
expect.objectContaining({ id: "todo-analysis", status: "completed" }),
],
},
});
expect(
assistantMessage?.activities?.find(
(activity) => activity.id === "activity-analyze",
),
).toMatchObject({
title: "分析管网数据",
status: "completed",
});
});
it("finalizes running progress when aborting an active prompt", async () => { it("finalizes running progress when aborting an active prompt", async () => {
listChatSessions.mockResolvedValue([]); listChatSessions.mockResolvedValue([]);
jest.mocked(streamAgentChat).mockImplementationOnce( jest.mocked(streamAgentChat).mockImplementationOnce(
@@ -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);
@@ -330,7 +340,7 @@ describe("useAgentChatSession lifecycle and resume", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: "todo-2", id: "todo-2",
status: "in_progress", status: "completed",
}), }),
], ],
}), }),
@@ -1,2 +0,0 @@
// Tests for useAgentChatSession are split by behavior boundary.
// See useAgentChatSession.lifecycle.test.tsx and useAgentChatSession.actions.test.tsx.
+244 -168
View File
@@ -1,77 +1,70 @@
"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,
import { cloneMessages } from "../GlobalChatbox.utils"; rejectAgentQuestion,
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage"; replyAgentCredentialRefresh,
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState"; replyAgentPermission,
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types"; 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 {
createEmptyChatState,
deleteChatSession,
listChatSessions,
loadChatSessionById,
updateChatSessionTitle,
} from "../chatStorage";
import {
applyQuestionResponse,
cancelRunningTodos,
completeRunningActivities,
completeRunningProgress,
createAssistantMessage,
createTodoUpdateFromEvent,
createUserMessage,
dedupeQuestionsAcrossMessages,
finalizeAssistantMessageAfterAbort,
normalizeSessionTodos,
toPermissionStatus,
upsertPermission,
upsertActivity,
upsertProgress,
upsertQuestionAcrossMessages,
} from "./agentChatSessionState";
import type {
PromptRunOptions,
UseAgentChatSessionOptions,
} from "./useAgentChatSession.types";
const TOKEN_PLAYBACK_INTERVAL_MS = 16; const completeTodos = (todoUpdate: Message["todos"]) =>
const TOKEN_PLAYBACK_BASE_CHARS = 28; todoUpdate
const TOKEN_PLAYBACK_MAX_CHARS = 160; ? {
...todoUpdate,
const sliceCodePoints = (value: string, count: number) => todos: todoUpdate.todos.map((todo) =>
Array.from(value).slice(0, count).join(""); todo.status === "pending" || todo.status === "in_progress"
? {
let cachedSegmenter: Intl.Segmenter | null | undefined; ...todo,
status: "completed" as const,
const getSegmenter = () => { updatedAt: Date.now(),
if (cachedSegmenter !== undefined) return cachedSegmenter;
cachedSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter("zh", { granularity: "word" })
: null;
return cachedSegmenter;
};
const getPlaybackChunkSize = (bufferLength: number) => {
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
if (bufferLength >= 300) return 112;
if (bufferLength >= 140) return 72;
if (bufferLength >= 64) return 44;
return TOKEN_PLAYBACK_BASE_CHARS;
};
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
if (content.length <= maxChars) return content;
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
const segmenter = getSegmenter();
if (segmenter) {
let chunk = "";
for (const segment of segmenter.segment(content)) {
chunk += segment.segment;
if (
chunk.length >= maxChars ||
(chunk.length >= targetChars &&
/[\s,。!?、;:,.!?;:]/u.test(segment.segment))
) {
return chunk;
} }
: todo,
),
} }
} : undefined;
const phrase = content.match(/^.{1,12}?[\s,。!?、;:,.!?;:]+/u)?.[0];
if (phrase) return phrase;
const cjkChunk = content.match(
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
)?.[0];
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
const wordChunk = content.match(/^\S+\s*/u)?.[0];
if (wordChunk) {
return wordChunk.length <= maxChars
? wordChunk
: sliceCodePoints(wordChunk, maxChars);
}
return sliceCodePoints(content, Math.min(maxChars, 12));
};
export const useAgentChatSession = ({ export const useAgentChatSession = ({
projectId, projectId,
@@ -80,6 +73,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[]>([]);
@@ -97,11 +91,7 @@ export const useAgentChatSession = ({
const isSessionTitleManuallyEditedRef = useRef(false); const isSessionTitleManuallyEditedRef = useRef(false);
const cancelPromiseRef = useRef<Promise<void> | null>(null); const cancelPromiseRef = useRef<Promise<void> | null>(null);
const titleUpdateNonceRef = useRef(0); const titleUpdateNonceRef = useRef(0);
const pendingTokenRef = useRef<{ const credentialRefreshRequestIdsRef = useRef(new Set<string>());
assistantMessageId: string;
content: string;
} | null>(null);
const tokenPlaybackIntervalRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
sessionIdRef.current = sessionId; sessionIdRef.current = sessionId;
@@ -128,83 +118,6 @@ export const useAgentChatSession = ({
}); });
}, []); }, []);
const cancelTokenPlayback = useCallback(() => {
const intervalId = tokenPlaybackIntervalRef.current;
if (intervalId === null) return;
window.clearInterval(intervalId);
tokenPlaybackIntervalRef.current = null;
}, []);
const flushPendingTokens = useCallback(() => {
const pending = pendingTokenRef.current;
pendingTokenRef.current = null;
cancelTokenPlayback();
if (!pending) return;
applyTokenContent(pending.assistantMessageId, pending.content);
}, [applyTokenContent, cancelTokenPlayback]);
const scheduleTokenPlayback = useCallback(() => {
if (tokenPlaybackIntervalRef.current !== null) return;
const id = window.setInterval(() => {
const pending = pendingTokenRef.current;
if (!pending) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
return;
}
const chunk = takeNextTokenPlaybackChunk(
pending.content,
getPlaybackChunkSize(pending.content.length),
);
if (!chunk) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
pendingTokenRef.current = null;
return;
}
const remaining = pending.content.slice(chunk.length);
pendingTokenRef.current = remaining
? { assistantMessageId: pending.assistantMessageId, content: remaining }
: null;
applyTokenContent(pending.assistantMessageId, chunk);
if (!remaining) {
window.clearInterval(id);
tokenPlaybackIntervalRef.current = null;
}
}, TOKEN_PLAYBACK_INTERVAL_MS);
tokenPlaybackIntervalRef.current = id;
}, [applyTokenContent]);
const queueTokenContent = useCallback(
(assistantMessageId: string, content: string) => {
const pending = pendingTokenRef.current;
if (pending && pending.assistantMessageId !== assistantMessageId) {
flushPendingTokens();
}
pendingTokenRef.current = {
assistantMessageId,
content:
pending?.assistantMessageId === assistantMessageId
? pending.content + content
: content,
};
scheduleTokenPlayback();
},
[flushPendingTokens, scheduleTokenPlayback],
);
useEffect(
() => () => {
pendingTokenRef.current = null;
cancelTokenPlayback();
},
[cancelTokenPlayback],
);
useEffect(() => { useEffect(() => {
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited; isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
}, [isSessionTitleManuallyEdited]); }, [isSessionTitleManuallyEdited]);
@@ -289,6 +202,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,
@@ -296,10 +264,6 @@ export const useAgentChatSession = ({
assistantMessageId?: string; assistantMessageId?: string;
}, },
) => { ) => {
if (event.type !== "token") {
flushPendingTokens();
}
if ( if (
event.type !== "session_title" && event.type !== "session_title" &&
"sessionId" in event && "sessionId" in event &&
@@ -352,7 +316,36 @@ export const useAgentChatSession = ({
} }
if (event.type === "token") { if (event.type === "token") {
queueTokenContent(assistantMessageId, event.content); applyTokenContent(assistantMessageId, event.content);
} else if (event.type === "final_answer") {
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? { ...message, content: event.content, isError: false }
: message,
);
messagesRef.current = next;
return next;
});
} else if (event.type === "activity_update") {
setMessages((prev) => {
const next = prev.map((message) =>
message.id === assistantMessageId
? { ...message, activities: upsertActivity(message.activities, event) }
: message,
);
return event.todos
? normalizeSessionTodos(
next,
{
sessionId: event.sessionId,
todos: event.todos,
createdAt: event.todosCreatedAt ?? Date.now(),
},
assistantMessageId,
)
: next;
});
} else if (event.type === "progress") { } else if (event.type === "progress") {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => prev.map((message) =>
@@ -421,11 +414,74 @@ 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) => {
if (message.id !== assistantMessageId) return message; if (message.id !== assistantMessageId) return message;
const completedProgress = completeRunningProgress(message.progress); const completedProgress = completeRunningProgress(message.progress);
const completedActivities = completeRunningActivities(message.activities);
if ( if (
message.content.trim().length === 0 && message.content.trim().length === 0 &&
!(message.artifacts?.length) !(message.artifacts?.length)
@@ -435,9 +491,16 @@ export const useAgentChatSession = ({
content: content:
"Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。", "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completedProgress, progress: completedProgress,
activities: completedActivities,
todos: completeTodos(message.todos),
}; };
} }
return { ...message, progress: completedProgress }; return {
...message,
progress: completedProgress,
activities: completedActivities,
todos: completeTodos(message.todos),
};
}), }),
); );
setIsStreaming(false); setIsStreaming(false);
@@ -450,6 +513,24 @@ export const useAgentChatSession = ({
content: message.content || `⚠️ **错误:** ${event.message}`, content: message.content || `⚠️ **错误:** ${event.message}`,
isError: true, isError: true,
progress: completeRunningProgress(message.progress), progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
todos: cancelRunningTodos(message.todos),
}
: message,
),
);
setIsStreaming(false);
} else if (event.type === "auth_required") {
useAuthStore.getState().markSessionExpired("unauthorized");
setMessages((prev) =>
prev.map((message) =>
message.id === assistantMessageId
? {
...message,
content: message.content || `⚠️ **${event.message}**`,
isError: true,
progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
todos: cancelRunningTodos(message.todos), todos: cancelRunningTodos(message.todos),
} }
: message, : message,
@@ -459,11 +540,11 @@ export const useAgentChatSession = ({
} }
}, },
[ [
applyTokenContent,
appendArtifact, appendArtifact,
flushPendingTokens,
getLastAssistantMessageId, getLastAssistantMessageId,
handleCredentialRefresh,
onToolCall, onToolCall,
queueTokenContent,
], ],
); );
@@ -480,20 +561,18 @@ export const useAgentChatSession = ({
onEvent: (event) => applyStreamEvent(event), onEvent: (event) => applyStreamEvent(event),
}) })
.catch((error) => { .catch((error) => {
flushPendingTokens();
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
console.error("[GlobalChatbox] Failed to resume chat stream:", error); console.error("[GlobalChatbox] Failed to resume chat stream:", error);
setIsStreaming(false); setIsStreaming(false);
} }
}) })
.finally(() => { .finally(() => {
flushPendingTokens();
if (abortRef.current === controller) { if (abortRef.current === controller) {
abortRef.current = null; abortRef.current = null;
} }
}); });
}, },
[applyStreamEvent, flushPendingTokens], [applyStreamEvent],
); );
resumeStreamingSessionRef.current = resumeStreamingSession; resumeStreamingSessionRef.current = resumeStreamingSession;
@@ -542,7 +621,6 @@ export const useAgentChatSession = ({
}), }),
}); });
} catch (error) { } catch (error) {
flushPendingTokens();
if (controller.signal.aborted) { if (controller.signal.aborted) {
setMessages((prev) => setMessages((prev) =>
prev prev
@@ -559,6 +637,7 @@ export const useAgentChatSession = ({
message.content.trim().length === 0 && message.content.trim().length === 0 &&
!(message.artifacts?.length) && !(message.artifacts?.length) &&
!(message.progress?.length) && !(message.progress?.length) &&
!(message.activities?.length) &&
!message.todos !message.todos
), ),
), ),
@@ -573,20 +652,19 @@ export const useAgentChatSession = ({
content: `⚠️ **错误:** ${String(error)}`, content: `⚠️ **错误:** ${String(error)}`,
isError: true, isError: true,
progress: completeRunningProgress(message.progress), progress: completeRunningProgress(message.progress),
activities: completeRunningActivities(message.activities, "error"),
} }
: message, : message,
), ),
); );
setIsStreaming(false); setIsStreaming(false);
} finally { } finally {
flushPendingTokens();
abortRef.current = null; abortRef.current = null;
setIsStreaming(false); setIsStreaming(false);
} }
}, },
[ [
applyStreamEvent, applyStreamEvent,
flushPendingTokens,
getApprovalMode, getApprovalMode,
getModel, getModel,
isHydrating, isHydrating,
@@ -599,7 +677,6 @@ export const useAgentChatSession = ({
const abort = useCallback(() => { const abort = useCallback(() => {
const controller = abortRef.current; const controller = abortRef.current;
controller?.abort(); controller?.abort();
flushPendingTokens();
setIsStreaming(false); setIsStreaming(false);
const assistantMessageId = getLastAssistantMessageId(); const assistantMessageId = getLastAssistantMessageId();
@@ -622,10 +699,10 @@ export const useAgentChatSession = ({
} }
}); });
cancelPromiseRef.current = trackedCancelPromise; cancelPromiseRef.current = trackedCancelPromise;
}, [flushPendingTokens, getLastAssistantMessageId]); }, [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);
@@ -835,7 +912,6 @@ export const useAgentChatSession = ({
const createSession = useCallback(() => { const createSession = useCallback(() => {
if (isHydrating || isStreaming) return; if (isHydrating || isStreaming) return;
flushPendingTokens();
const controller = abortRef.current; const controller = abortRef.current;
controller?.abort(); controller?.abort();
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
@@ -846,7 +922,7 @@ export const useAgentChatSession = ({
setIsSessionTitleManuallyEdited(false); setIsSessionTitleManuallyEdited(false);
setSessionId(undefined); setSessionId(undefined);
setIsStreaming(false); setIsStreaming(false);
}, [flushPendingTokens, isHydrating, isStreaming]); }, [isHydrating, isStreaming]);
const switchSession = useCallback( const switchSession = useCallback(
async (nextSessionId: string, optimisticTitle?: string) => { async (nextSessionId: string, optimisticTitle?: string) => {

Some files were not shown because too many files have changed in this diff Show More