diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 9bc7f369..f18d72c9 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -36,7 +36,7 @@ uv run ruff check . --exclude "code/" --force-exclude echo -e "${YELLOW}📝 Python: mypy${NC}" uv run mypy . --exclude "code/" -if printf '%s\n' "$STAGED" | grep -Eq '^(apps/undefined-console/|src/Undefined/webui/static/js/|biome\.json$|\.github/workflows/(ci|release)\.yml$)'; then +if printf '%s\n' "$STAGED" | grep -Eq '^(apps/undefined-console/|apps/undefined-chat/|src/Undefined/webui/static/js/|biome\.json$|\.github/workflows/(ci|release)\.yml$)'; then echo -e "${YELLOW}📝 检测到 JS/Tauri 相关改动,运行前端与 Tauri 检查...${NC}" if ! command -v npm >/dev/null 2>&1; then @@ -44,12 +44,21 @@ if printf '%s\n' "$STAGED" | grep -Eq '^(apps/undefined-console/|src/Undefined/w exit 1 fi - if [ ! -x "apps/undefined-console/node_modules/.bin/biome" ]; then - echo -e "${RED}❌ 缺少 apps/undefined-console/node_modules,请先运行 cd apps/undefined-console && npm install${NC}" - exit 1 + APP_CHECKS=() + if printf '%s\n' "$STAGED" | grep -Eq '^(apps/undefined-console/|src/Undefined/webui/static/js/|biome\.json$|\.github/workflows/(ci|release)\.yml$)'; then + APP_CHECKS+=("apps/undefined-console") + fi + if printf '%s\n' "$STAGED" | grep -Eq '^(apps/undefined-chat/|\.github/workflows/(ci|release)\.yml$)'; then + APP_CHECKS+=("apps/undefined-chat") fi - npm --prefix apps/undefined-console run check + for app_dir in "${APP_CHECKS[@]}"; do + if [ ! -x "$app_dir/node_modules/.bin/biome" ]; then + echo -e "${RED}❌ 缺少 $app_dir/node_modules,请先运行 cd $app_dir && npm install${NC}" + exit 1 + fi + npm --prefix "$app_dir" run check + done fi echo -e "${GREEN}✅ pre-commit 检查通过${NC}" diff --git a/.githooks/pre-tag b/.githooks/pre-tag index 398cdab7..1f06d525 100755 --- a/.githooks/pre-tag +++ b/.githooks/pre-tag @@ -2,7 +2,6 @@ set -euo pipefail TAG_NAME="${1:-}" -TAG_VERSION="${TAG_NAME#v}" RED='\033[0;31m' GREEN='\033[0;32m' @@ -11,23 +10,17 @@ NC='\033[0m' cd "$(git rev-parse --show-toplevel)" -get_pyproject_version() { - grep -E '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/' -} - -get_init_version() { - grep -E '^__version__' src/Undefined/__init__.py | sed -E "s/__version__ = '([^']+)'/\1/" | sed -E 's/__version__ = "([^"]+)"/\1/' -} - -PYPROJECT_VERSION=$(get_pyproject_version) -INIT_VERSION=$(get_init_version) +if [ -z "$TAG_NAME" ]; then + echo -e "${RED}❌ 缺少 tag 名称${NC}" + exit 1 +fi -if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ] || [ "$TAG_VERSION" != "$INIT_VERSION" ]; then - echo -e "${RED}❌ Tag 版本与仓库版本号不一致${NC}" - echo -e "tag: ${YELLOW}$TAG_VERSION${NC}" - echo -e "pyproject.toml: ${YELLOW}$PYPROJECT_VERSION${NC}" - echo -e "src/Undefined/__init__.py: ${YELLOW}$INIT_VERSION${NC}" +if ! command -v uv >/dev/null 2>&1; then + echo -e "${RED}❌ 未找到 uv,请先安装 uv${NC}" exit 1 fi -echo -e "${GREEN}✅ 版本检查通过: $TAG_VERSION${NC}" +echo -e "${YELLOW}🔖 校验 release 版本一致性: $TAG_NAME${NC}" +uv run python scripts/release_notes.py validate --tag "$TAG_NAME" + +echo -e "${GREEN}✅ 版本检查通过: ${TAG_NAME#v}${NC}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82c53ca0..69002615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,14 @@ name: CI Code Quality on: + workflow_dispatch: push: - branches: [ "main" ] + branches: [ "main", "develop" ] pull_request: - branches: [ "main" ] + branches: [ "main", "develop" ] + +env: + NODE_VERSION: "22" jobs: quality-check: @@ -57,8 +61,17 @@ jobs: run: | uv run python -c "import glob, zipfile; whl=glob.glob('dist/*.whl')[0]; z=zipfile.ZipFile(whl); names=set(z.namelist()); required={'config.toml.example','res/prompts/undefined.xml','img/xlwy.jpg'}; missing=sorted([p for p in required if p not in names]); assert not missing, f'missing in wheel: {missing}'" - console-quality-check: + native-app-quality-check: + name: Native app quality (${{ matrix.app_dir }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - product: console + app_dir: apps/undefined-console + - product: chat + app_dir: apps/undefined-chat steps: - name: Checkout code uses: actions/checkout@v4 @@ -66,16 +79,19 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "22" + node-version: ${{ env.NODE_VERSION }} cache: "npm" - cache-dependency-path: apps/undefined-console/package-lock.json + cache-dependency-path: ${{ matrix.app_dir }}/package-lock.json - name: Cache node_modules id: cache-node-modules uses: actions/cache@v4 with: - path: apps/undefined-console/node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('apps/undefined-console/package-lock.json') }} + path: ${{ matrix.app_dir }}/node_modules + key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules-${{ matrix.product }}-${{ hashFiles(format('{0}/package-lock.json', matrix.app_dir)) }} + restore-keys: | + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules-${{ matrix.product }}- + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules- - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -83,8 +99,9 @@ jobs: - name: Cache cargo registry and target uses: Swatinem/rust-cache@v2 with: + key: ci-${{ matrix.product }}-${{ hashFiles(format('{0}/src-tauri/Cargo.lock', matrix.app_dir), format('{0}/package-lock.json', matrix.app_dir)) }} workspaces: | - apps/undefined-console/src-tauri -> target + ${{ matrix.app_dir }}/src-tauri -> target - name: Install Linux system dependencies run: | @@ -98,9 +115,9 @@ jobs: - name: Install app dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' - working-directory: apps/undefined-console + working-directory: ${{ matrix.app_dir }} run: npm ci - - name: Run console checks - working-directory: apps/undefined-console + - name: Run native app checks + working-directory: ${{ matrix.app_dir }} run: npm run check diff --git a/.github/workflows/manual-native-artifacts.yml b/.github/workflows/manual-native-artifacts.yml new file mode 100644 index 00000000..260383a5 --- /dev/null +++ b/.github/workflows/manual-native-artifacts.yml @@ -0,0 +1,501 @@ +name: Manual Native Artifacts + +on: + workflow_dispatch: + inputs: + source_ref: + description: "Branch, tag, or SHA to build. Leave empty to use the selected workflow ref." + required: false + type: string + product: + description: "Native app product to build." + required: true + default: chat + type: choice + options: + - chat + - console + - all + build_desktop: + description: "Build desktop installers." + required: true + default: true + type: boolean + desktop_platform: + description: "Desktop platform to build." + required: true + default: all + type: choice + options: + - all + - linux + - windows + - macos + build_android_debug: + description: "Build unsigned/debug Android APK artifacts." + required: true + default: true + type: boolean + android_abi: + description: "Android ABI to build." + required: true + default: arm64-v8a + type: choice + options: + - arm64-v8a + - armeabi-v7a + - x86 + - x86_64 + - all + +run-name: Manual native artifacts for ${{ inputs.product }} from ${{ inputs.source_ref || github.ref_name }} + +permissions: + contents: read + +concurrency: + group: manual-native-artifacts-${{ github.run_id }} + cancel-in-progress: false + +env: + NODE_VERSION: "22" + ANDROID_NDK_VERSION: "27.2.12479018" + +jobs: + build-desktop: + name: Desktop ${{ matrix.product }} ${{ matrix.label }} + runs-on: ${{ matrix.os }} + if: ${{ inputs.build_desktop }} + strategy: + fail-fast: false + matrix: + include: + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + os: ubuntu-latest + platform: linux + label: linux-x64 + bundles: appimage,deb + rust_target: "" + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + os: windows-latest + platform: windows + label: windows-x64 + bundles: nsis,msi + rust_target: "" + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + os: macos-15 + platform: macos + label: macos-x64 + bundles: dmg + rust_target: x86_64-apple-darwin + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + os: macos-15 + platform: macos + label: macos-arm64 + bundles: dmg + rust_target: "" + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + os: ubuntu-latest + platform: linux + label: linux-x64 + bundles: appimage,deb + rust_target: "" + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + os: windows-latest + platform: windows + label: windows-x64 + bundles: nsis,msi + rust_target: "" + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + os: macos-15 + platform: macos + label: macos-x64 + bundles: dmg + rust_target: x86_64-apple-darwin + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + os: macos-15 + platform: macos + label: macos-arm64 + bundles: dmg + rust_target: "" + steps: + - name: Decide whether to build this desktop target + id: select + shell: bash + run: | + selected=true + if [ "${{ inputs.product }}" != "all" ] && [ "${{ inputs.product }}" != "${{ matrix.product }}" ]; then + selected=false + fi + if [ "${{ inputs.desktop_platform }}" != "all" ] && [ "${{ inputs.desktop_platform }}" != "${{ matrix.platform }}" ]; then + selected=false + fi + echo "selected=$selected" >> "$GITHUB_OUTPUT" + + - name: Checkout code + if: steps.select.outputs.selected == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_ref || github.ref }} + + - name: Resolve artifact suffix + if: steps.select.outputs.selected == 'true' + id: artifact + shell: bash + run: | + short_sha="$(git rev-parse --short HEAD)" + ref_name="${{ inputs.source_ref || github.ref_name }}" + safe_ref="$(printf '%s' "$ref_name" | tr '/: ' '---' | tr -cd '[:alnum:]._-' | cut -c1-80)" + echo "suffix=${safe_ref}-${short_sha}-run${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + if: steps.select.outputs.selected == 'true' + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + cache-dependency-path: ${{ matrix.app_dir }}/package-lock.json + + - name: Cache node_modules + if: steps.select.outputs.selected == 'true' + id: cache-node-modules + uses: actions/cache@v4 + with: + path: ${{ matrix.app_dir }}/node_modules + key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-manual-node-modules-${{ matrix.product }}-${{ hashFiles(format('{0}/package-lock.json', matrix.app_dir)) }} + restore-keys: | + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-manual-node-modules-${{ matrix.product }}- + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules-${{ matrix.product }}- + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules- + + - name: Install Rust toolchain + if: steps.select.outputs.selected == 'true' && matrix.rust_target == '' + uses: dtolnay/rust-toolchain@stable + + - name: Install Rust toolchain with target + if: steps.select.outputs.selected == 'true' && matrix.rust_target != '' + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_target }} + + - name: Cache cargo registry and target + if: steps.select.outputs.selected == 'true' + uses: Swatinem/rust-cache@v2 + with: + key: manual-desktop-${{ matrix.product }}-${{ matrix.label }}-${{ hashFiles(format('{0}/src-tauri/Cargo.lock', matrix.app_dir), format('{0}/package-lock.json', matrix.app_dir)) }} + workspaces: | + ${{ matrix.app_dir }}/src-tauri -> target + + - name: Install Linux system dependencies + if: steps.select.outputs.selected == 'true' && runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Install app dependencies + if: steps.select.outputs.selected == 'true' && steps.cache-node-modules.outputs.cache-hit != 'true' + working-directory: ${{ matrix.app_dir }} + run: npm ci + + - name: Build Tauri bundles + if: steps.select.outputs.selected == 'true' + working-directory: ${{ matrix.app_dir }} + shell: bash + run: | + TAURI_ARGS="--ci --bundles ${{ matrix.bundles }}" + if [ -n "${{ matrix.rust_target }}" ]; then + TAURI_ARGS="$TAURI_ARGS --target ${{ matrix.rust_target }}" + fi + if [ "${{ runner.os }}" = "Linux" ]; then + NO_STRIP=true npm run tauri:build -- $TAURI_ARGS + else + npm run tauri:build -- $TAURI_ARGS + fi + + - name: Collect desktop artifacts + if: steps.select.outputs.selected == 'true' + shell: bash + env: + APP_DIR: ${{ matrix.app_dir }} + ARTIFACT_PREFIX: ${{ matrix.artifact_prefix }} + LABEL: ${{ matrix.label }} + RUST_TARGET: ${{ matrix.rust_target }} + SUFFIX: ${{ steps.artifact.outputs.suffix }} + run: | + copy_single_match() { + local search_root="$1" + local pattern="$2" + local destination="$3" + local matches=() + while IFS= read -r line; do + matches+=("$line") + done < <(find "$search_root" -type f -name "$pattern" | sort) + if [ "${#matches[@]}" -ne 1 ]; then + echo "Expected exactly one match for $pattern under $search_root, found ${#matches[@]}" >&2 + printf 'Matches:\n%s\n' "${matches[*]}" >&2 + exit 1 + fi + cp "${matches[0]}" "$destination" + } + + if [ -n "$RUST_TARGET" ]; then + BUNDLE_ROOT="$APP_DIR/src-tauri/target/$RUST_TARGET/release/bundle" + else + BUNDLE_ROOT="$APP_DIR/src-tauri/target/release/bundle" + fi + + mkdir -p manual-artifacts + case "$LABEL" in + linux-x64) + copy_single_match "$BUNDLE_ROOT" '*.AppImage' "manual-artifacts/${ARTIFACT_PREFIX}-${SUFFIX}-${LABEL}.AppImage" + copy_single_match "$BUNDLE_ROOT" '*.deb' "manual-artifacts/${ARTIFACT_PREFIX}-${SUFFIX}-${LABEL}.deb" + ;; + windows-x64) + copy_single_match "$BUNDLE_ROOT" '*.exe' "manual-artifacts/${ARTIFACT_PREFIX}-${SUFFIX}-${LABEL}-setup.exe" + copy_single_match "$BUNDLE_ROOT" '*.msi' "manual-artifacts/${ARTIFACT_PREFIX}-${SUFFIX}-${LABEL}.msi" + ;; + macos-x64|macos-arm64) + copy_single_match "$BUNDLE_ROOT" '*.dmg' "manual-artifacts/${ARTIFACT_PREFIX}-${SUFFIX}-${LABEL}.dmg" + ;; + esac + + - name: Upload desktop artifacts + if: steps.select.outputs.selected == 'true' + uses: actions/upload-artifact@v4 + with: + name: manual-${{ matrix.product }}-${{ matrix.label }} + path: manual-artifacts/* + if-no-files-found: error + retention-days: 14 + + build-android-debug: + name: Android debug ${{ matrix.product }} ${{ matrix.abi_label }} + runs-on: ubuntu-latest + if: ${{ inputs.build_android_debug }} + strategy: + fail-fast: false + matrix: + include: + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + abi_label: arm64-v8a + tauri_target: aarch64 + rust_target: aarch64-linux-android + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + abi_label: armeabi-v7a + tauri_target: armv7 + rust_target: armv7-linux-androideabi + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + abi_label: x86 + tauri_target: i686 + rust_target: i686-linux-android + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + abi_label: x86_64 + tauri_target: x86_64 + rust_target: x86_64-linux-android + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + abi_label: arm64-v8a + tauri_target: aarch64 + rust_target: aarch64-linux-android + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + abi_label: armeabi-v7a + tauri_target: armv7 + rust_target: armv7-linux-androideabi + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + abi_label: x86 + tauri_target: i686 + rust_target: i686-linux-android + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + abi_label: x86_64 + tauri_target: x86_64 + rust_target: x86_64-linux-android + steps: + - name: Decide whether to build this Android target + id: select + shell: bash + run: | + selected=true + if [ "${{ inputs.product }}" != "all" ] && [ "${{ inputs.product }}" != "${{ matrix.product }}" ]; then + selected=false + fi + if [ "${{ inputs.android_abi }}" != "all" ] && [ "${{ inputs.android_abi }}" != "${{ matrix.abi_label }}" ]; then + selected=false + fi + echo "selected=$selected" >> "$GITHUB_OUTPUT" + + - name: Checkout code + if: steps.select.outputs.selected == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_ref || github.ref }} + + - name: Resolve artifact suffix + if: steps.select.outputs.selected == 'true' + id: artifact + shell: bash + run: | + short_sha="$(git rev-parse --short HEAD)" + ref_name="${{ inputs.source_ref || github.ref_name }}" + safe_ref="$(printf '%s' "$ref_name" | tr '/: ' '---' | tr -cd '[:alnum:]._-' | cut -c1-80)" + echo "suffix=${safe_ref}-${short_sha}-run${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + if: steps.select.outputs.selected == 'true' + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + cache-dependency-path: ${{ matrix.app_dir }}/package-lock.json + + - name: Cache node_modules + if: steps.select.outputs.selected == 'true' + id: cache-node-modules + uses: actions/cache@v4 + with: + path: ${{ matrix.app_dir }}/node_modules + key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-manual-android-node-modules-${{ matrix.product }}-${{ hashFiles(format('{0}/package-lock.json', matrix.app_dir)) }} + restore-keys: | + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-manual-android-node-modules-${{ matrix.product }}- + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules-${{ matrix.product }}- + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules- + + - name: Install Rust toolchain + if: steps.select.outputs.selected == 'true' + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_target }} + + - name: Cache cargo registry and target + if: steps.select.outputs.selected == 'true' + uses: Swatinem/rust-cache@v2 + with: + key: manual-android-${{ matrix.product }}-${{ matrix.abi_label }}-${{ hashFiles(format('{0}/src-tauri/Cargo.lock', matrix.app_dir), format('{0}/package-lock.json', matrix.app_dir)) }} + workspaces: | + ${{ matrix.app_dir }}/src-tauri -> target + + - name: Setup Java 17 + if: steps.select.outputs.selected == 'true' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Setup Android SDK + if: steps.select.outputs.selected == 'true' + uses: android-actions/setup-android@v3 + + - name: Ensure Android platform packages + if: steps.select.outputs.selected == 'true' + run: sdkmanager --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;${{ env.ANDROID_NDK_VERSION }}" + + - name: Configure Android NDK + if: steps.select.outputs.selected == 'true' + run: | + NDK_HOME="$ANDROID_HOME/ndk/${ANDROID_NDK_VERSION}" + if [ ! -d "$NDK_HOME" ]; then + echo "Expected Android NDK at $NDK_HOME" >&2 + exit 1 + fi + echo "NDK_HOME=$NDK_HOME" >> "$GITHUB_ENV" + + - name: Cache Gradle + if: steps.select.outputs.selected == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-manual-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-manual-gradle- + ${{ runner.os }}-gradle- + + - name: Install app dependencies + if: steps.select.outputs.selected == 'true' && steps.cache-node-modules.outputs.cache-hit != 'true' + working-directory: ${{ matrix.app_dir }} + run: npm ci + + - name: Initialize Android project for CI + if: steps.select.outputs.selected == 'true' + working-directory: ${{ matrix.app_dir }} + run: npm run tauri:android:init + + - name: Verify Chat Android native patches + if: steps.select.outputs.selected == 'true' && matrix.product == 'chat' + working-directory: ${{ matrix.app_dir }} + run: npm run tauri:android:prepare:check + + - name: Build Android debug APK + if: steps.select.outputs.selected == 'true' + working-directory: ${{ matrix.app_dir }} + run: npm run tauri:android:debug -- --ci --apk --target ${{ matrix.tauri_target }} + + - name: Collect Android debug artifacts + if: steps.select.outputs.selected == 'true' + shell: bash + env: + APP_DIR: ${{ matrix.app_dir }} + ARTIFACT_PREFIX: ${{ matrix.artifact_prefix }} + ABI_LABEL: ${{ matrix.abi_label }} + SUFFIX: ${{ steps.artifact.outputs.suffix }} + run: | + mkdir -p manual-artifacts + matches=() + while IFS= read -r line; do + matches+=("$line") + done < <(find "$APP_DIR/src-tauri" -type f -name '*.apk' ! -name '*-unsigned.apk' | sort) + + if [ "${#matches[@]}" -lt 1 ]; then + echo "Expected at least one Android APK under $APP_DIR/src-tauri" >&2 + exit 1 + fi + + for apk in "${matches[@]}"; do + apk_name="$(basename "$apk")" + cp "$apk" "manual-artifacts/${ARTIFACT_PREFIX}-${SUFFIX}-android-${ABI_LABEL}-${apk_name}" + done + + - name: Upload Android debug artifacts + if: steps.select.outputs.selected == 'true' + uses: actions/upload-artifact@v4 + with: + name: manual-android-debug-${{ matrix.product }}-${{ matrix.abi_label }} + path: manual-artifacts/* + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47206723..96d7c4f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ concurrency: env: PYTHON_VERSION: "3.12" NODE_VERSION: "22" - APP_DIR: apps/undefined-console + ANDROID_NDK_VERSION: "27.2.12479018" jobs: verify-python: @@ -85,10 +85,18 @@ jobs: path: dist/* if-no-files-found: error - verify-console: - name: Verify console app + verify-native-app: + name: Verify ${{ matrix.product }} app runs-on: ubuntu-latest environment: release + strategy: + fail-fast: false + matrix: + include: + - product: console + app_dir: apps/undefined-console + - product: chat + app_dir: apps/undefined-chat steps: - name: Checkout code uses: actions/checkout@v4 @@ -98,14 +106,17 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} cache: "npm" - cache-dependency-path: ${{ env.APP_DIR }}/package-lock.json + cache-dependency-path: ${{ matrix.app_dir }}/package-lock.json - name: Cache node_modules id: cache-node-modules uses: actions/cache@v4 with: - path: ${{ env.APP_DIR }}/node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('apps/undefined-console/package-lock.json') }} + path: ${{ matrix.app_dir }}/node_modules + key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules-${{ matrix.product }}-${{ hashFiles(format('{0}/package-lock.json', matrix.app_dir)) }} + restore-keys: | + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules-${{ matrix.product }}- + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules- - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -113,8 +124,9 @@ jobs: - name: Cache cargo registry and target uses: Swatinem/rust-cache@v2 with: + key: verify-${{ matrix.product }}-${{ hashFiles(format('{0}/src-tauri/Cargo.lock', matrix.app_dir), format('{0}/package-lock.json', matrix.app_dir)) }} workspaces: | - apps/undefined-console/src-tauri -> target + ${{ matrix.app_dir }}/src-tauri -> target - name: Install Linux system dependencies run: | @@ -128,37 +140,80 @@ jobs: - name: Install app dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' - working-directory: ${{ env.APP_DIR }} + working-directory: ${{ matrix.app_dir }} run: npm ci - - name: Run console checks - working-directory: ${{ env.APP_DIR }} + - name: Run native app checks + working-directory: ${{ matrix.app_dir }} run: npm run check build-tauri-desktop: - name: Build Tauri desktop (${{ matrix.label }}) + name: Build Tauri desktop (${{ matrix.product }} ${{ matrix.label }}) runs-on: ${{ matrix.os }} environment: release needs: - verify-python - - verify-console + - verify-native-app strategy: fail-fast: false matrix: include: - - os: ubuntu-latest + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + os: ubuntu-latest + label: linux-x64 + bundles: appimage,deb + rust_target: "" + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + os: windows-latest + label: windows-x64 + bundles: nsis,msi + rust_target: "" + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + os: macos-15 + label: macos-x64 + bundles: dmg + rust_target: x86_64-apple-darwin + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + os: macos-15 + label: macos-arm64 + bundles: dmg + rust_target: "" + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + os: ubuntu-latest label: linux-x64 bundles: appimage,deb - - os: windows-latest + rust_target: "" + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + os: windows-latest label: windows-x64 bundles: nsis,msi - - os: macos-15 + rust_target: "" + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + os: macos-15 label: macos-x64 bundles: dmg - rust-target: x86_64-apple-darwin - - os: macos-15 + rust_target: x86_64-apple-darwin + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + os: macos-15 label: macos-arm64 bundles: dmg + rust_target: "" steps: - name: Checkout code uses: actions/checkout@v4 @@ -168,26 +223,34 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} cache: "npm" - cache-dependency-path: ${{ env.APP_DIR }}/package-lock.json + cache-dependency-path: ${{ matrix.app_dir }}/package-lock.json - name: Cache node_modules id: cache-node-modules uses: actions/cache@v4 with: - path: ${{ env.APP_DIR }}/node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('apps/undefined-console/package-lock.json') }} + path: ${{ matrix.app_dir }}/node_modules + key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules-${{ matrix.product }}-${{ hashFiles(format('{0}/package-lock.json', matrix.app_dir)) }} + restore-keys: | + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules-${{ matrix.product }}- + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules- - name: Install Rust toolchain + if: matrix.rust_target == '' + uses: dtolnay/rust-toolchain@stable + + - name: Install Rust toolchain with target + if: matrix.rust_target != '' uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.rust-target }} + targets: ${{ matrix.rust_target }} - name: Cache cargo registry and target uses: Swatinem/rust-cache@v2 with: - key: ${{ matrix.label }} + key: desktop-${{ matrix.product }}-${{ matrix.label }}-${{ hashFiles(format('{0}/src-tauri/Cargo.lock', matrix.app_dir), format('{0}/package-lock.json', matrix.app_dir)) }} workspaces: | - apps/undefined-console/src-tauri -> target + ${{ matrix.app_dir }}/src-tauri -> target - name: Install Linux system dependencies if: runner.os == 'Linux' @@ -202,16 +265,16 @@ jobs: - name: Install app dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' - working-directory: ${{ env.APP_DIR }} + working-directory: ${{ matrix.app_dir }} run: npm ci - name: Build Tauri bundles - working-directory: ${{ env.APP_DIR }} + working-directory: ${{ matrix.app_dir }} shell: bash run: | TAURI_ARGS="--ci --bundles ${{ matrix.bundles }}" - if [ -n "${{ matrix.rust-target }}" ]; then - TAURI_ARGS="$TAURI_ARGS --target ${{ matrix.rust-target }}" + if [ -n "${{ matrix.rust_target }}" ]; then + TAURI_ARGS="$TAURI_ARGS --target ${{ matrix.rust_target }}" fi if [ "${{ runner.os }}" = "Linux" ]; then NO_STRIP=true npm run tauri:build -- $TAURI_ARGS @@ -224,7 +287,9 @@ jobs: env: TAG: ${{ github.ref_name }} LABEL: ${{ matrix.label }} - RUST_TARGET: ${{ matrix.rust-target }} + APP_DIR: ${{ matrix.app_dir }} + ARTIFACT_PREFIX: ${{ matrix.artifact_prefix }} + RUST_TARGET: ${{ matrix.rust_target }} run: | copy_single_match() { local search_root="$1" @@ -251,46 +316,82 @@ jobs: mkdir -p release-artifacts case "$LABEL" in linux-x64) - copy_single_match "$BUNDLE_ROOT" '*.AppImage' "release-artifacts/Undefined-Console-${TAG}-${LABEL}.AppImage" - copy_single_match "$BUNDLE_ROOT" '*.deb' "release-artifacts/Undefined-Console-${TAG}-${LABEL}.deb" + copy_single_match "$BUNDLE_ROOT" '*.AppImage' "release-artifacts/${ARTIFACT_PREFIX}-${TAG}-${LABEL}.AppImage" + copy_single_match "$BUNDLE_ROOT" '*.deb' "release-artifacts/${ARTIFACT_PREFIX}-${TAG}-${LABEL}.deb" ;; windows-x64) - copy_single_match "$BUNDLE_ROOT" '*.exe' "release-artifacts/Undefined-Console-${TAG}-${LABEL}-setup.exe" - copy_single_match "$BUNDLE_ROOT" '*.msi' "release-artifacts/Undefined-Console-${TAG}-${LABEL}.msi" + copy_single_match "$BUNDLE_ROOT" '*.exe' "release-artifacts/${ARTIFACT_PREFIX}-${TAG}-${LABEL}-setup.exe" + copy_single_match "$BUNDLE_ROOT" '*.msi' "release-artifacts/${ARTIFACT_PREFIX}-${TAG}-${LABEL}.msi" ;; macos-x64|macos-arm64) - copy_single_match "$BUNDLE_ROOT" '*.dmg' "release-artifacts/Undefined-Console-${TAG}-${LABEL}.dmg" + copy_single_match "$BUNDLE_ROOT" '*.dmg' "release-artifacts/${ARTIFACT_PREFIX}-${TAG}-${LABEL}.dmg" ;; esac - name: Upload desktop artifacts uses: actions/upload-artifact@v4 with: - name: tauri-${{ matrix.label }} + name: tauri-${{ matrix.product }}-${{ matrix.label }} path: release-artifacts/* if-no-files-found: error build-tauri-android: - name: Build Tauri Android (${{ matrix.abi_label }}) + name: Build Tauri Android (${{ matrix.product }} ${{ matrix.abi_label }}) runs-on: ubuntu-latest environment: release needs: - verify-python - - verify-console + - verify-native-app strategy: fail-fast: false matrix: include: - - abi_label: arm64-v8a + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + abi_label: arm64-v8a tauri_target: aarch64 rust_target: aarch64-linux-android - - abi_label: armeabi-v7a + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + abi_label: armeabi-v7a tauri_target: armv7 rust_target: armv7-linux-androideabi - - abi_label: x86 + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + abi_label: x86 tauri_target: i686 rust_target: i686-linux-android - - abi_label: x86_64 + - product: console + app_dir: apps/undefined-console + artifact_prefix: Undefined-Console + abi_label: x86_64 + tauri_target: x86_64 + rust_target: x86_64-linux-android + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + abi_label: arm64-v8a + tauri_target: aarch64 + rust_target: aarch64-linux-android + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + abi_label: armeabi-v7a + tauri_target: armv7 + rust_target: armv7-linux-androideabi + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + abi_label: x86 + tauri_target: i686 + rust_target: i686-linux-android + - product: chat + app_dir: apps/undefined-chat + artifact_prefix: Undefined-Chat + abi_label: x86_64 tauri_target: x86_64 rust_target: x86_64-linux-android steps: @@ -302,14 +403,17 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} cache: "npm" - cache-dependency-path: ${{ env.APP_DIR }}/package-lock.json + cache-dependency-path: ${{ matrix.app_dir }}/package-lock.json - name: Cache node_modules id: cache-node-modules uses: actions/cache@v4 with: - path: ${{ env.APP_DIR }}/node_modules - key: ${{ runner.os }}-node-modules-${{ hashFiles('apps/undefined-console/package-lock.json') }} + path: ${{ matrix.app_dir }}/node_modules + key: ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules-${{ matrix.product }}-${{ hashFiles(format('{0}/package-lock.json', matrix.app_dir)) }} + restore-keys: | + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules-${{ matrix.product }}- + ${{ runner.os }}-node-${{ env.NODE_VERSION }}-node-modules- - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -319,9 +423,9 @@ jobs: - name: Cache cargo registry and target uses: Swatinem/rust-cache@v2 with: - key: android-${{ matrix.abi_label }} + key: android-${{ matrix.product }}-${{ matrix.abi_label }}-${{ hashFiles(format('{0}/src-tauri/Cargo.lock', matrix.app_dir), format('{0}/package-lock.json', matrix.app_dir)) }} workspaces: | - apps/undefined-console/src-tauri -> target + ${{ matrix.app_dir }}/src-tauri -> target - name: Setup Java 17 uses: actions/setup-java@v4 @@ -333,7 +437,16 @@ jobs: uses: android-actions/setup-android@v3 - name: Ensure Android platform packages - run: sdkmanager --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" + run: sdkmanager --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;${{ env.ANDROID_NDK_VERSION }}" + + - name: Configure Android NDK + run: | + NDK_HOME="$ANDROID_HOME/ndk/${ANDROID_NDK_VERSION}" + if [ ! -d "$NDK_HOME" ]; then + echo "Expected Android NDK at $NDK_HOME" >&2 + exit 1 + fi + echo "NDK_HOME=$NDK_HOME" >> "$GITHUB_ENV" - name: Cache Gradle uses: actions/cache@v4 @@ -347,13 +460,18 @@ jobs: - name: Install app dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' - working-directory: ${{ env.APP_DIR }} + working-directory: ${{ matrix.app_dir }} run: npm ci - name: Initialize Android project for CI - working-directory: ${{ env.APP_DIR }} + working-directory: ${{ matrix.app_dir }} run: npm run tauri:android:init + - name: Verify Chat Android native patches + if: matrix.product == 'chat' + working-directory: ${{ matrix.app_dir }} + run: npm run tauri:android:prepare:check + - name: Validate Android signing secrets env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} @@ -373,15 +491,16 @@ jobs: fi - name: Configure Android release signing - working-directory: ${{ env.APP_DIR }} + working-directory: ${{ matrix.app_dir }} env: + APP_PRODUCT: ${{ matrix.product }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} shell: bash run: | - KEYSTORE_PATH="$RUNNER_TEMP/undefined-console-release.jks" + KEYSTORE_PATH="$RUNNER_TEMP/undefined-${APP_PRODUCT}-release.jks" KEYSTORE_PROPERTIES_PATH="src-tauri/gen/android/keystore.properties" APP_GRADLE_PATH=$(find "src-tauri/gen/android" -type f \( -path '*/app/build.gradle.kts' -o -path '*/app/build.gradle' \) | sort | head -n 1) @@ -487,7 +606,7 @@ jobs: PY - name: Build Android release APK - working-directory: ${{ env.APP_DIR }} + working-directory: ${{ matrix.app_dir }} run: npm run tauri:android -- --ci --apk --target ${{ matrix.tauri_target }} - name: Collect Android release artifact @@ -495,6 +614,8 @@ jobs: env: TAG: ${{ github.ref_name }} ABI_LABEL: ${{ matrix.abi_label }} + APP_DIR: ${{ matrix.app_dir }} + ARTIFACT_PREFIX: ${{ matrix.artifact_prefix }} run: | copy_single_match() { local search_root="$1" @@ -513,12 +634,12 @@ jobs: } mkdir -p release-artifacts - copy_single_match "$APP_DIR/src-tauri" '*.apk' "release-artifacts/Undefined-Console-${TAG}-android-${ABI_LABEL}-release.apk" + copy_single_match "$APP_DIR/src-tauri" '*.apk' "release-artifacts/${ARTIFACT_PREFIX}-${TAG}-android-${ABI_LABEL}-release.apk" - name: Upload Android artifact uses: actions/upload-artifact@v4 with: - name: tauri-android-${{ matrix.abi_label }} + name: tauri-android-${{ matrix.product }}-${{ matrix.abi_label }} path: release-artifacts/* if-no-files-found: error diff --git a/.gitignore b/.gitignore index 7173eef9..e19c4346 100644 --- a/.gitignore +++ b/.gitignore @@ -53,10 +53,17 @@ config.local.json dist/ apps/**/dist/ .cache/ +.superpowers/ apps/**/src-tauri/target/ apps/**/src-tauri/gen/ apps/**/src-tauri/.cargo/ +# Android signing secrets and local SDK config +*.jks +*.keystore +keystore.properties +local.properties + # MCP 配置 config/mcp.json diff --git a/AGENTS.md b/AGENTS.md index 683ecca5..3834c1f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Repository Guidelines ## Project Structure & Module Organization -`src/Undefined/` contains the main runtime package. Core areas include `ai/`, `services/`, `skills/`, `cognitive/`, `memes/`, `knowledge/`, `api/`, `webui/`, `config/`, and `mcp/`; media-facing integrations live in `arxiv/`, `bilibili/`, `github/`, and `attachments.py`. `tests/` holds the pytest suite. `apps/undefined-console/` is the primary Tauri + Vite management client, while `code/NagaAgent/` remains a git submodule and should be updated deliberately, with upstream syncs kept separate from repo-local changes. Runtime and generated state primarily lives under `data/`, `logs/`, and `dist/`; the root `knowledge/` directory stores knowledge-base data rather than application code. Prefer editing source files and docs over generated outputs unless the task is explicitly about runtime state. +`src/Undefined/` contains the main runtime package. Core areas include `ai/`, `services/`, `skills/`, `cognitive/`, `memes/`, `knowledge/`, `api/`, `webui/`, `config/`, and `mcp/`; media-facing integrations live in `arxiv/`, `bilibili/`, `github/`, and `attachments.py`. `tests/` holds the pytest suite. `apps/undefined-console/` is the Tauri + Vite management client and `apps/undefined-chat/` is the native-first Tauri + React 19 chat client (both connect to the same Management/Runtime services), while `code/NagaAgent/` remains a git submodule and should be updated deliberately, with upstream syncs kept separate from repo-local changes. Runtime and generated state primarily lives under `data/`, `logs/`, and `dist/`; the root `knowledge/` directory stores knowledge-base data rather than application code. Prefer editing source files and docs over generated outputs unless the task is explicitly about runtime state. ## Build, Test, and Development Commands Use `uv` for the root project: @@ -18,6 +18,8 @@ Use `uv` for the root project: For the console app, run `cd apps/undefined-console && npm ci && npm run check`. Use `npm run dev` for the Vite shell and `npm run tauri:dev` for the desktop shell. +For the native chat client, run `cd apps/undefined-chat && npm ci && npm run check` (Biome + TypeScript + Vitest unit/e2e + `cargo fmt --check`/`cargo check`/`cargo test`). Use `npm run tauri:dev` for the desktop shell. + ## Coding Style & Naming Conventions Use 4-space indentation. Python code must be fully type-annotated and pass strict mypy checks. Disk I/O should go through `src/Undefined/utils/io.py` so writes stay async-safe and atomic. Follow `snake_case` for modules and functions, `PascalCase` for classes, and prefer extending existing services/helpers over introducing one-off abstractions. Skills handlers must not import repo-local modules outside `skills/`; pass dependencies through the execution context instead. WebUI JavaScript in `src/Undefined/webui/static/js/` is formatted with Biome, and `apps/undefined-console/` changes must satisfy Biome, TypeScript, and Cargo checks. @@ -43,7 +45,7 @@ Consecutive messages from the same sender within `[message_batcher].window_secon The system prompt now includes a rule: **recognize and address users by their QQ ID (`sender_id`)** because nicknames can change. When needing to address a user, use the latest nickname obtained via `group.get_member_info(brief=true)`. Observations recorded in cognitive memory should always include the QQ ID, e.g., “QQ号12345678(昵称张三)做了某事”. ## Testing Guidelines -Write tests as `tests/test_.py`. Async tests use `pytest-asyncio`. Add or update coverage for behavior changes in APIs, config loading/hot reload, cognitive memory, meme or knowledge flows, and WebUI/runtime routes. If you touch `apps/undefined-console/` or `src/Undefined/webui/static/js/`, run `npm run check` in `apps/undefined-console/` in addition to the Python checks. No fixed coverage threshold is configured, so cover touched paths well. +Write tests as `tests/test_.py`. Async tests use `pytest-asyncio`. Add or update coverage for behavior changes in APIs, config loading/hot reload, cognitive memory, meme or knowledge flows, and WebUI/runtime routes. If you touch `apps/undefined-console/` or `src/Undefined/webui/static/js/`, run `npm run check` in `apps/undefined-console/` in addition to the Python checks; if you touch `apps/undefined-chat/`, run `npm run check` in `apps/undefined-chat/` (it bundles Vitest unit/e2e suites, so cover changed behavior there). No fixed coverage threshold is configured, so cover touched paths well. ## Commit & Pull Request Guidelines -Recent history follows Conventional Commits with optional scopes, for example `fix(webui): refine launcher return flow` and `feat(commands): add /version (/v) slash command`. Keep commit subjects imperative and concise. Keep `code/NagaAgent/` syncs separate from local feature work when possible. If you are bumping release versions, prefer `uv run python scripts/bump_version.py ` so `pyproject.toml`, `src/Undefined/__init__.py`, `apps/undefined-console/package.json`, and the Tauri config stay in sync. For pull requests, include a short impact summary, linked issues, and the commands you ran; attach screenshots for WebUI or Tauri UI changes. +Recent history follows Conventional Commits with optional scopes, for example `fix(webui): refine launcher return flow` and `feat(commands): add /version (/v) slash command`. Keep commit subjects imperative and concise. Keep `code/NagaAgent/` syncs separate from local feature work when possible. If you are bumping release versions, prefer `uv run python scripts/bump_version.py ` so `pyproject.toml`, `src/Undefined/__init__.py`, and the `package.json` + Tauri config of both `apps/undefined-console/` and `apps/undefined-chat/` stay in sync. For pull requests, include a short impact summary, linked issues, and the commands you ran; attach screenshots for WebUI or Tauri UI changes. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a7661901..df0e3b9e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -112,11 +112,12 @@ graph TB TS_Memes["memes.*
• search_memes
• send_meme_by_uid"] end - subgraph IntelligentAgents["智能体 Agents (skills/agents/, 6个)"] + subgraph IntelligentAgents["智能体 Agents (skills/agents/, 7个)"] A_Info["info_agent
信息查询助手
(18个工具)
• weather_query
• *hot 热搜
• bilibili_*
• arxiv_search
• whois"] A_Web["web_agent
网络搜索助手
(3个工具 + MCP)
• web_search
• crawl_webpage
• Playwright MCP"] A_File["file_analysis_agent
文件分析助手
(14个工具)
• extract_* (PDF/Word/Excel/PPT)
• analyze_code
• analyze_multimodal"] A_Naga["naga_code_analysis_agent
NagaAgent 代码分析
(7个工具)
• read_file / glob
• search_file_content"] + A_Self["undefined_self_code_agent
Undefined 自身代码查阅
(4个工具)
• read_file / list_directory
• glob / search_file_content"] A_Entertainment["entertainment_agent
娱乐助手
(9个工具)
• ai_draw_one
• horoscope
• video_random_recommend"] A_Code["code_delivery_agent
代码交付助手
(13个工具)
• Docker 容器隔离
• Git 仓库克隆
• 代码编写验证
• 打包上传"] end @@ -331,7 +332,7 @@ graph TB class Dir_History,Dir_FAQ,Dir_TokenUsage,Dir_Cognitive,File_Memory,File_EndSummary,File_ScheduledTasks,Dir_Logs,File_Config persistence class Prompts,Intros resource class QueueManager,ModelQueues,DispatcherLoop queue - class A_Info,A_Web,A_File,A_Naga,A_Entertainment,A_Code agent + class A_Info,A_Web,A_File,A_Naga,A_Self,A_Entertainment,A_Code agent ``` ## 二、数据流向图 @@ -493,6 +494,7 @@ graph TB WebAgent["web_agent
网络搜索
• MCP Playwright"] FileAgent["file_analysis_agent
文件分析"] NagaAgent["naga_code_analysis_agent
代码分析"] + SelfCodeAgent["undefined_self_code_agent
自身代码查阅"] EntAgent["entertainment_agent
娱乐"] CodeAgent["code_delivery_agent
代码交付"] end @@ -532,6 +534,7 @@ graph TB AgentToolReg --> WebAgent AgentToolReg --> FileAgent AgentToolReg --> NagaAgent + AgentToolReg --> SelfCodeAgent AgentToolReg --> EntAgent WebAgent --> MCPAgent @@ -839,7 +842,7 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 | **认知记忆** | `cognitive.enabled`, `cognitive.query.*`, `models.embedding.*` | 事件检索、时间衰减加权、侧写与后台史官 | | **Bilibili** | `bilibili.auto_extract_enabled`, `bilibili.cookie`, `bilibili.prefer_quality` | B站视频自动提取与下载 | | **arXiv** | `arxiv.auto_extract_enabled`, `arxiv.max_file_size`, `arxiv.auto_extract_max_items` | arXiv 论文自动提取、搜索与 PDF 发送 | -| **GitHub** | `github.auto_extract_enabled`, `github.request_timeout_seconds`, `github.auto_extract_max_items` | GitHub public 仓库自动提取与图片卡片发送 | +| **GitHub** | `github.auto_extract_enabled`, `github.request_timeout_seconds`, `github.request_retries`, `github.auto_extract_max_items` | GitHub public 仓库自动提取与图片卡片发送 | | **思考链** | `*.thinking_enabled` | 思维链支持 | | **思维链兼容** | `*.thinking_tool_call_compat` | 思维链 + 工具调用兼容 | | **WebUI** | `webui.url`, `webui.port`, `webui.password` | 配置控制台 | @@ -854,7 +857,7 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 自动提取由 `PipelineRegistry` 并行检测、并行处理全部命中的管线;发送结果写入历史后继续进入 AI 自动回复。 4. **AI 核心能力层**:AIClient (ai/client/ + client.py shim)、PromptBuilder (ai/prompts/ + prompts.py shim)、ModelRequester (ai/llm/ + llm.py shim)、ToolManager (tooling.py)、MultimodalAnalyzer (ai/multimodal/ + multimodal.py shim)、SummaryService (summaries.py)、TokenCounter (tokens.py) 5. **存储与上下文层**:MessageHistoryManager (utils/history.py, 10000条限制)、MemoryStorage (memory.py, 置顶备忘录, 500条上限)、EndSummaryStorage、CognitiveService + JobQueue + HistorianWorker + VectorStore + ProfileStorage、MemeService + MemeWorker + MemeStore + MemeVectorStore (表情包库)、FAQStorage、ScheduledTaskStorage、TokenUsageStorage (自动归档) -6. **技能系统层**:ToolRegistry (registry.py)、AgentRegistry、6个 Agents、11类 Toolsets +6. **技能系统层**:ToolRegistry (registry.py)、AgentRegistry、7个 Agents、11类 Toolsets 7. **异步 IO 层**:统一 IO 工具 (utils/io.py),包含 write_json、read_json、append_line、跨平台文件锁 (flock/msvcrt) 8. **数据持久化层**:历史数据目录、FAQ 目录、Token 归档目录、记忆文件、总结文件、定时任务文件 @@ -868,7 +871,7 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 * **优先级管理**:支持四级优先级(超级管理员 > 私聊 > 群聊@ > 群聊普通),确保重要消息优先响应。 * **关停收敛**:`MessageHandler.close()` 会先 flush `MessageBatcher`,再调用 `QueueManager.drain()` 等待已入队请求和在途请求自然完成,最后才停止队列处理器,避免缓冲消息只入队未执行。 -### 6个智能体 Agent +### 7个智能体 Agent | Agent | 功能定位 | 工具数量 | 核心能力 | |-------|---------|---------|---------| @@ -876,6 +879,7 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 | **web_agent** | 网络搜索助手 | 3个 + MCP | 网页搜索、爬虫、Playwright MCP | | **file_analysis_agent** | 文件分析助手 | 14个 | PDF/Word/Excel/PPT解析、代码分析、多模态分析 | | **naga_code_analysis_agent** | NagaAgent 代码分析 | 7个 | 代码库浏览、文件搜索、目录遍历 | +| **undefined_self_code_agent** | Undefined 自身代码查阅 | 4个 | 受限读取源码、测试、文档、资源、脚本与 App | | **entertainment_agent** | 娱乐助手 | 9个 | AI 绘图、星座运势、小说搜索、随机视频推荐等 | | **code_delivery_agent** | 代码交付助手 | 13个 | Docker 隔离、仓库克隆、代码验证、打包上传 | diff --git a/CHANGELOG.md b/CHANGELOG.md index f63f2b15..5ec6d285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## v3.6.0 原生 Chat、WebChat 多会话与运行时管理增强 + +本版本把 Undefined 的“管理控制台内聊天”扩展为一套更完整的跨端聊天与运行时管理体系:一边新增面向桌面端和 Android 的原生 Chat 客户端,一边把 WebUI WebChat 升级为可长期使用的多会话工作台;底层则补齐 Runtime / Management API、任务续接、附件、命令、定时任务和发布构建能力。围绕这些入口,v3.6.0 也整理了 Agent 路由、认知记忆、附件标签和工程验证,让 WebUI、原生客户端和 QQ 侧共享更一致的运行时语义。 + +- 建立原生 Chat 产品线。新增 `apps/undefined-chat/`,以 Runtime 作为会话、历史、任务、附件和事件真源,提供多会话、历史分页、Markdown / HTML 渲染、代码高亮、附件上传下载、图片预览、命令面板、消息引用、主题、i18n、快捷键与移动端布局;桌面端和 Android 侧同步接入受控请求、密钥保存、文件上传、生命周期恢复和 HTML 预览等原生能力。 +- 升级 WebUI WebChat 的长期使用体验。WebChat 从单一调试入口升级为多会话聊天工作台,支持持久化会话、旧历史迁移、标题生成、后台 job、事件续接、任务取消、重试复用、工具 / Agent timeline 回放、附件与引用体验,以及更完整的 Markdown、代码块、安全 HTML 和图片展示能力。 +- 补齐 Runtime 与 Management API 的客户端合同。Runtime 新增聊天会话、后台任务、附件、命令元数据和定时任务等接口;Management API 对这些运行态能力提供统一代理,使 WebUI、桌面端和 Android 客户端可以只连接一个管理入口,并安全复用后端注入的 Runtime 鉴权。 +- 扩展运行时管理能力。WebUI 新增 Schedules 页,可管理单工具、多工具和 AI 自我督办任务;日志读取、Runtime 探针、OpenAPI、配置摘要和 WebUI 文案同步覆盖新的聊天、命令、附件、定时任务与原生客户端能力,`[webui].autostart_bot` 也可用于启动 WebUI 后自动拉起 Bot。 +- 整理 Agent 路由与项目自查能力。新增 `undefined_self_code_agent`,用于只读查询 Undefined 当前仓库允许范围内的源码、测试、文档、资源、脚本和 App 实现;提示词同步明确 Undefined / NagaAgent / 文件分析 / 代码交付的职责边界,减少项目问题被错误路由或凭记忆回答。 +- 加固认知记忆、附件和消息边界。认知向量库新增优先级调度,当前输入批次解析覆盖全部 `` 并避免把本轮消息重复当作历史;`end.observations` 改为只记录当前批次新事实,并按 QQ 号、群号或 WebUI/system 会话稳定归档;附件占位统一为 ``,WebChat 输出中的 CQ 图片、base64、file URL 和合并转发附件会先注册再写入历史,降低上下文污染和重复回复风险。 +- 修复和优化若干日常稳定性问题。GitHub 自动卡片增加 API 重试并修正 watchers 展示,WebUI 日志尾部读取上限提升到 10000 行,WebChat 取消 / 重试流程避免重复消息,消息发送状态通过统一上下文回调标记,提示词进一步收紧“你 / AI / bot / 机器人”等泛称导致的误触发。 +- 收敛构建、发布、文档与测试。新增 native app 构建、Android 准备和 release app 元数据脚本;CI 与 Release workflow 覆盖 Console / Chat 的桌面端和 Android 矩阵,并校验所有版本源与最新 changelog 一致;新增 `docs/undefined-chat.md`、Chat README、平台能力与 HTML 预览文档,补充 Runtime Chat、WebUI Chat、原生 Chat、定时任务、代码自查 Agent、Chroma 调度器和构建脚本等测试。 + +--- + ## v3.5.1 安全回复、群聊边界与配置表单优化 本版本聚焦三个实际使用中的细节问题:一是群聊里只出现「你/我/他」等人称时,Undefined 更容易误判成在和自己说话;二是面对 prompt 注入或强行改人设的消息时,防御性回复有时过于模板化、攻击性过强,甚至在生成失败时仍会发送兜底脏话;三是 WebUI 配置页对枚举型字段的输入约束不足,容易让用户手填出不合法或不直观的配置值。v3.5.1 因此收紧对话归属判断,强化人设自洽与防注入边界,并把更多配置项改为下拉选择,降低误配置概率。 diff --git a/CLAUDE.md b/CLAUDE.md index 13424c98..3427862b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,11 @@ uv run playwright install # 页面截图等能力依赖的浏览器运行 uv run Undefined-webui # 启动 Management-first WebUI(推荐入口) uv run Undefined # 直接启动 Bot +# WebUI 自动启动 Bot +# 配置 [webui].autostart_bot = true 后,运行 uv run Undefined-webui 会自动拉起 bot 进程 +# 默认为 false,保持传统手动启动行为 +# 注意:该配置仅在 WebUI 启动时生效,运行时修改需重启 WebUI + # 代码质量(提交前必须全部通过) uv run ruff format . uv run ruff check . @@ -26,11 +31,14 @@ uv run pytest tests/test_xxx.py uv run pytest tests/test_xxx.py::test_func -v uv build --wheel # 校验打包与资源包含 -# 前端 / 桌面端(仅改动 apps/undefined-console/ 或 webui/static/js/ 时需要) +# 前端 / 桌面端(仅改动 apps/undefined-console/ 或 apps/undefined-chat/ 或 webui/static/js/ 时需要) cd apps/undefined-console && npm ci && npm run check cd apps/undefined-console && npm run dev cd apps/undefined-console && npm run tauri:dev +cd apps/undefined-chat && npm ci && npm run check +cd apps/undefined-chat && npm run tauri:dev + # Git hooks 安装 bash scripts/install_git_hooks.sh # pre-commit 自动执行:ruff format --check + ruff check + mypy @@ -43,8 +51,8 @@ bash scripts/install_git_hooks.sh - **异步 IO**:磁盘读写必须走 `utils/io.py`(`asyncio.to_thread` + 跨平台文件锁 + 原子写入),禁止在事件循环中直接阻塞 IO - **Python 版本**:`>=3.11, <3.14`(推荐 3.12) - **测试**:pytest + pytest-asyncio,`asyncio_mode = "auto"` -- **前端格式化**:`src/Undefined/webui/static/js/` 由根目录 `biome.json` 管理;`apps/undefined-console/` 走 Biome + TypeScript + Cargo 检查 -- **版本号同步**:发布版本时优先使用 `uv run python scripts/bump_version.py `,统一同步 Python 包、console 与 Tauri 版本号 +- **前端格式化**:`src/Undefined/webui/static/js/` 由根目录 `biome.json` 管理;`apps/undefined-console/` 和 `apps/undefined-chat/` 走 Biome + TypeScript + Cargo 检查 +- **版本号同步**:发布版本时优先使用 `uv run python scripts/bump_version.py `,统一同步 Python 包、console、chat 与 Tauri 版本号 - **Git hooks**:优先通过 `scripts/install_git_hooks.sh` 安装,不要手动维护 `core.hooksPath` ## 架构分层 @@ -157,3 +165,5 @@ Management / Runtime 请求 → webui/app.py 或 api/app.py → routes/* ## 跨平台控制台 `apps/undefined-console/` 是基于 Tauri v2 + TypeScript + Vite 的管理客户端,支持 Windows / macOS / Linux / Android,连接同一套 Management API 与 Runtime API。 + +`apps/undefined-chat/` 是原生优先的 WebChat 客户端,基于 Tauri v2 + React,直接连接 Runtime API,面向长期挂起、桌面/移动端聊天使用场景。采用莫兰迪橙色系(Morandi Orange)设计,与 WebUI 保持视觉一致性。它移植 WebUI webchat 的核心聊天功能,并在事件流(SSE 优先 + JSON fallback 双通道)、桌面快捷键、图片查看(缩放/旋转/全屏)、安全存储(系统 keyring / Android Keystore)等方面做了原生增强;HTML 采用 sanitize 内联渲染 + 独立预览窗口隔离运行的双层策略。详见 [docs/undefined-chat.md](docs/undefined-chat.md)。 diff --git a/README.md b/README.md index 2043a552..a722db1b 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ - **置顶备忘录**(`memory.*`):AI 自身的置顶提醒(自我约束、待办事项),每轮固定注入,支持增删改查 详见 [认知记忆文档](docs/cognitive-memory.md)。 - **Management-first WebUI**:继续保留 `uv run Undefined-webui` 一键入口;即使 `config.toml` 缺失或未配完,也能先进入管理态补配置、看日志、校验并启动 Bot。 -- **远程管理 + 多端客户端**:浏览器版 WebUI 与新的跨平台控制台共享同一管理面,支持远程管理,并覆盖 `Windows / macOS / Linux / Android` 发布链路。 +- **远程管理 + 多端客户端**:浏览器版 WebUI、跨平台 Console(管理客户端)和原生优先 Undefined Chat(聊天客户端)共享同一套 Management / Runtime 服务,支持远程管理,并覆盖 `Windows / macOS / Linux / Android` 发布链路。 + - **Undefined Console**:基于 Tauri v2 的管理客户端,完整管理功能 + - **Undefined Chat**:基于 Tauri v2 + React 19 的原生优先聊天客户端,采用莫兰迪橙色系设计,移植 WebUI webchat 的核心聊天能力并做原生增强:中英双语运行时切换(i18n)、平台抽象层(按真实平台区分桌面/移动布局)、桌面快捷键、系统凭据存储、HTML 正文 sanitize 内联渲染 + 独立预览窗口隔离运行、Android(非 iOS)横屏/平板适配。iOS 暂不作为发布平台 - **Management API + Runtime API 分层**:配置、日志、Bot 启停和管理探针由 Management API 提供;主进程 Runtime API 则专注探针、记忆只读查询、认知侧写检索和 WebUI AI Chat;内部探针的技能统计覆盖可调用工具、工具集、Agent、自动处理管线、斜杠命令与 Anthropic Skills。详见 [docs/management-api.md](docs/management-api.md) 与 [docs/openapi.md](docs/openapi.md)。 - **多模型池**:支持配置多个 AI 模型,可轮询、随机选择或用户指定;支持多模型并发比较,选择最佳结果继续对话。详见 [多模型功能文档](docs/multi-model.md)。 - **本地知识库**:将纯文本文件向量化存入 ChromaDB,AI 可通过关键词搜索或语义搜索查询领域知识;支持增量嵌入与自动扫描。详见 [知识库文档](docs/knowledge.md)。 @@ -91,6 +93,7 @@ Undefined 的功能极为丰富,为了让本页面不过于臃肿,我们将 - 🔄 **[多模型并发竞技](docs/multi-model.md)**:配置多个异构模型,让它们并行运算、同台 PK,从中择优响应。 - ⌨️ **[命令系统与斜杠指令](docs/slash-commands.md)**:查阅所有斜杠指令(`/*`)的详细用法,并学习如何轻松扩展你自己的指令系统。 - 🌐 **[Runtime API 与 OpenAPI](docs/openapi.md)**:主进程 Runtime API、鉴权、探针、记忆/侧写查询和运行态集成说明。 +- 💬 **[Undefined Chat](docs/undefined-chat.md)**:原生优先 WebChat 客户端说明——莫兰迪橙色系设计、功能对等表、平台差异(桌面快捷键/独立窗口、Android 生命周期)、Runtime 真源、SSE/JSON fallback、安全存储、附件上传和 HTML 预览隔离。 - 🏗️ **[构建指南](docs/build.md)**:Python 包、WebUI、跨平台 App、Android 与 Release 工作流的构建说明。 - 🔧 **[运维脚本](scripts/README.md)**:嵌入模型更换后的向量库重嵌入等维护工具。 - 👨‍💻 **[开发者与拓展中心](docs/development.md)**:代码结构剖析、模块拆分后的目录树、开发新 Agent 的流程参考及自检命令。 @@ -157,7 +160,7 @@ set_config(cfg) # opt-in 注入全局单例;CLI 启动链不会调用 # 自动扫描 skills/:tools + toolsets(end / group.* / cognitive.* …) tools = ToolRegistry() -# 自动扫描 skills/agents/:web_agent、code_delivery_agent … +# 自动扫描 skills/agents/:web_agent、undefined_self_code_agent、code_delivery_agent … agents = AgentRegistry() async def main() -> None: diff --git a/apps/undefined-chat/README.md b/apps/undefined-chat/README.md new file mode 100644 index 00000000..8109dee8 --- /dev/null +++ b/apps/undefined-chat/README.md @@ -0,0 +1,71 @@ +# Undefined Chat + +Undefined Chat 是 `apps/undefined-chat/` 下的 Tauri v2 + React 19 原生聊天客户端。它直接连接 Runtime API;会话、历史、任务、附件和事件以 Runtime 为真源,本地只保存连接配置、API Key 状态、草稿和 UI 游标。 + +完整产品说明见 [docs/undefined-chat.md](../../docs/undefined-chat.md)。 + +## 当前能力 + +- 会话管理:创建、删除、重命名、切换会话。当前没有置顶字段/API。 +- 消息历史:初始加载、cursor-based 加载更早消息、滚动锚点保持和长历史窗口化渲染。 +- 消息渲染:Markdown、表格、任务列表、引用块、代码高亮、代码块复制;正文 HTML 经 sanitize 内联渲染,HTML 代码块可在独立预览窗口中运行。 +- 附件:系统文件选择器、上传状态队列、Tauri 流式上传、下载和预览。当前显示上传中/成功/失败状态,不显示百分比进度。 +- 图片:正文 `` 内联图、附件缩略图、blob 缓存、全屏查看、缩放、旋转和可滚动查看区域。 +- 命令:斜杠命令、子命令提示、方向键导航、Tab/Enter 补全;空态反馈(加载中/无匹配/不可用);窗口聚焦按 TTL 刷新命令列表。 +- 引用:引用 bot 消息、划词引用选中文本、发送引用、引用芯片跳转当前已加载源消息;源消息不在当前页时会自动加载一页更早历史,仍未命中需手动继续加载更早历史。 +- 工具调用块:层级展示、状态、运行中实时计时、阶段明细(stage detail)、与 WebUI 一致的输入/输出预览(JSON/Python 风格结构化、Markdown 输出、附件图片预览);历史回放支持 timeline/calls/events 多级回退。 +- 自动滚动:智能跟随底部,可在设置面板开关,偏好持久化到 localStorage。 +- 事件:SSE 优先,断开时 JSON fallback(指数退避重连,活跃任务持续重试)。 +- 快捷键:Ctrl/Cmd+N 新会话,Ctrl/Cmd+K 聚焦输入并打开命令模式,Ctrl/Cmd+/ 切换侧栏,Ctrl/Cmd+, 打开设置,Escape 关闭当前弹层/抽屉。 +- i18n:中英双语运行时切换,提供语言切换 UI,并按系统语言自动检测默认语言。 + +## 平台状态 + +桌面端: + +- HTML 渲染对齐 WebUI 基线:正文 HTML 经 sanitize 内联渲染;HTML 代码块在独立预览窗口/Activity 中打开,预览窗口内允许运行脚本,同时保留 `connect-src 'none'`、导航守卫、IPC 隔离、临时 file URL、1 MB 限制和临时文件清理等安全边界。 +- API Key 优先保存在系统凭据管理器/Stronghold。macOS 使用 Keychain,Windows 使用系统凭据存储,Linux 依赖 Secret Service;不可用时必须显式确认不安全文件降级。 +- Tauri fs/http 插件仅由 Rust commands 内部使用;前端 JS 没有直接读取本地文件或发起带密钥请求的权限。 + +Android: + +- 文件选择器返回的 `content://` URI 通过 Tauri fs plugin 打开,上传链路仍需要真机/模拟器 smoke 覆盖。 +- 生命周期监听使用 Tauri `tauri://suspended` / `tauri://resumed` 事件;恢复前台时重新 bootstrap 并恢复 Runtime 订阅。逐 job 主动补齐仍依赖 store 的 SSE 断线 fallback 行为。 +- Android 通过生成工程注入的 `SecretPlugin` 使用 Android Keystore + AES-GCM 保存 API Key:密钥在 AndroidKeyStore 中生成且不可导出,加密后的密文存放在 `MODE_PRIVATE` 的 SharedPreferences 中。Android 生成插件已接入并有脚本/Rust 测试覆盖,真实设备仍需 smoke 验证;只有平台安全存储不可用且用户显式确认时,才允许不安全文件降级。 + +iOS: + +- 仅 `keyring` 库的 `apple-native` backend 在代码层兼容 Keychain,未纳入构建/发布(无 iOS 工程/CI/真机路径),不是受支持的发布平台(见 [docs/build.md](../../docs/build.md) “Release 不含 iOS”)。 + +## 快速开始 + +```bash +npm install +npm run check +npm run tauri:dev +``` + +`npm run check` 当前包含 Biome、TypeScript、unit/jsdom integration tests、cargo fmt、cargo check 和 cargo test。 + +Runtime 默认连接 `http://127.0.0.1:8788`,受保护请求由 Tauri Rust command 注入 `X-Undefined-API-Key`。React 侧不直接持有 API Key。 + +## Android smoke checklist + +桌面检查通过后再运行: + +```bash +npm run tauri:android:init +npm run tauri:android:prepare:check +npm run tauri:android:debug -- --apk +``` + +`tauri:android:init` 会在 Tauri 生成 Android 工程后运行仓库脚本,注入 HTML preview 专用的 `HtmlPreviewActivity` 和 Android Keystore 安全存储用的 `SecretPlugin`。`src-tauri/gen/` 是生成目录,不需要提交。 + +真机或模拟器至少验证: + +- 主界面渲染、移动端会话抽屉、软键盘弹出时输入区和命令面板不被遮挡。 +- Runtime LAN 连接、`/health`、发送消息、SSE/JSON fallback。 +- 后台运行中 job,回到前台后历史和 active jobs 能恢复。 +- 系统相册、Downloads、云盘 provider 返回的 `content://` 文件可以上传。 +- HTML 预览 Activity 打开、关闭后不会残留可见窗口或暴露外部导航。 +- 安全存储状态;Android 应能保存、重启后读取并删除 API Key。 diff --git a/apps/undefined-chat/biome.json b/apps/undefined-chat/biome.json new file mode 100644 index 00000000..eb9b067c --- /dev/null +++ b/apps/undefined-chat/biome.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "package.json", + "tsconfig.json", + "vite.config.ts", + "src-tauri/tauri.conf.json", + "src-tauri/capabilities/**/*.json" + ] + }, + "linter": { + "rules": { + "a11y": { + "useSemanticElements": "off" + }, + "correctness": { + "noUnusedVariables": "warn" + } + } + } +} diff --git a/apps/undefined-chat/docs/html-preview.md b/apps/undefined-chat/docs/html-preview.md new file mode 100644 index 00000000..650bf74d --- /dev/null +++ b/apps/undefined-chat/docs/html-preview.md @@ -0,0 +1,113 @@ +# HTML 预览 + +Undefined Chat 处理 HTML 的方式与 WebUI 基线对齐,分为两条互相独立的路径: + +- **正文内联渲染**:消息正文里的原始 HTML 通过 `rehype-raw` 解析进 hast 树,再经 `rehype-sanitize` 按白名单清洗后内联渲染(见 `src/rendering/sanitize.ts`)。该路径**绝不执行脚本**——`script`、`on*` 事件属性、`style` 属性会被剥离,协议限制为 `http/https/mailto` 等安全协议,并以 `hast-util-sanitize` 的 `defaultSchema` 为安全基线,仅最小化放开 `className` 等纯展示属性。 +- **独立预览窗口**:HTML/HTM 代码块右上角显示“预览 HTML”。预览由 Tauri Rust command 创建独立 WebView 窗口或 Android Activity,主 React 应用只负责传入 `{ title, html }`。**预览窗口内可运行脚本**(详见下文 CSP)。 + +下文主要描述独立预览窗口路径。 + +## 运行路径 + +```text +MarkdownContent / CodeBlock + -> onPreviewHtml({ title, html }) + -> runtime-client/tauri.openHtmlPreview + -> invoke("open_html_preview") + -> src-tauri/src/preview.rs +``` + +Rust 侧流程: + +1. `preview_document_checked` 校验标题 + HTML 总长度不超过 1 MB。 +2. 生成带 CSP meta 的完整 HTML 文档。 +3. 写入系统临时目录下的 `html-preview-*.html`。 +4. 用 `Url::from_file_path` 生成初始 `file://` URL。 +5. 创建独立预览窗口;Android 指定 `HtmlPreviewActivity`。 +6. 导航守卫只允许初始 URL 和 `about:blank`。 +7. 窗口 close/destroy 时删除对应临时文件;每次打开前也会清理旧的 `html-preview-*.html` 残留。 + +`build_preview_data_url` 仍保留为测试/未来回退 helper,但不是当前运行路径。 + +## CSP + +当前 CSP(见 `src-tauri/src/preview.rs` 的 `PREVIEW_CSP`): + +```text +default-src 'none'; +connect-src 'none'; +form-action 'none'; +object-src 'none'; +base-uri 'none'; +frame-ancestors 'none'; +img-src data: blob:; +media-src data: blob:; +style-src 'unsafe-inline'; +font-src data:; +script-src 'unsafe-inline'; +``` + +这意味着: + +- **允许内联脚本(`script-src 'unsafe-inline'`)**:与 WebUI HTML 预览基线对齐,放开内联脚本以支持图表/动画等工具产物。不放开 `unsafe-eval`。 +- 允许内联 CSS。 +- 图片和媒体只允许 `data:` / `blob:`。 +- `connect-src 'none'` 禁止一切外联请求,防止脚本把内容外泄。 +- 禁止表单提交(`form-action 'none'`)、插件对象(`object-src 'none'`)和 `base` 改写(`base-uri 'none'`)。 +- 预览是隔离容器,不是 HTML 净化器;传入内容会按原样渲染。 + +### 放开脚本后如何维持隔离 + +放开 `script-src` 意味着预览窗口可执行任意脚本,但其无法访问 Tauri IPC/invoke,也无法外联或导航出去。多重边界共同生效: + +- **IPC 隔离靠 capability 缺失(而非 `withGlobalTauri`)**:预览窗口 label 形如 `html-preview-{uuid}`,不匹配 `capabilities/default.json`(`main-capability`,仅授权 `windows: ["main"]`)。Tauri v2 ACL 模型下,未匹配任何 capability 的 webview 完全没有 IPC 访问权,因此即便脚本被放开,也无法回调 Rust 命令或读取主窗口数据。底层 `__TAURI_INTERNALS__` 无论 `withGlobalTauri` 取值都会注入,故隔离不能寄托于此;`preview.rs` 的 `test_preview_window_has_no_ipc_capability` 会在有人误把 capability 放宽到 `*` 或 `html-preview-*` 时失败。 +- **外联阻断**:`connect-src 'none'` 切断脚本的网络出口。 +- **导航防护不靠 CSP**:由 Rust 侧 `on_navigation` 守卫(`preview_navigation_allowed`)只允许初始 URL 和 `about:blank`。 +- **防嵌入靠窗口隔离**:预览是独立 OS 窗口而非 iframe,配合 `on_new_window` Deny 覆盖。`frame-ancestors 'none'` 通过 `` 交付时被浏览器忽略(仅 HTTP 响应头有效),保留该指令仅为未来改用 header 交付时即可生效。 +- 曾用的 `navigate-to 'none'` 已移除——该指令已从 CSP 规范删除、浏览器从不实现,是零防护的死指令。 + +## Android + +`npm run tauri:android:init` 会在生成 Android 工程后运行 `scripts/prepare_tauri_android.py`,注册 `HtmlPreviewActivity`: + +```kotlin +package com.undefined.chat + +class HtmlPreviewActivity : TauriActivity() +``` + +验证命令: + +```bash +npm run tauri:android:prepare:check +npm run tauri:android:debug -- --apk +``` + +真实设备需要确认 Activity 能打开、返回后主窗口仍可用,并且预览窗口不能导航到外部站点。 + +## 限制 + +- 单次预览标题 + HTML 最大 1 MB。 +- 可运行内联脚本(`script-src 'unsafe-inline'`),但不放开 `unsafe-eval`。 +- 不加载外部 CSS、图片、字体或网络资源(`connect-src 'none'`)。 +- 不支持表单提交和 iframe 外部嵌入。 +- 临时文件位于系统 temp 目录,关闭窗口和下次打开前会清理 `html-preview-*.html`。 + +## 测试 + +```bash +cd apps/undefined-chat +npm run test:unit -- src/rendering/HtmlPreview.test.tsx src/rendering/CodeBlock.test.tsx +npm run tauri:test +``` + +Rust 测试覆盖: + +- CSP 策略:放开内联脚本但禁止 `unsafe-eval`,保留 `default-src`/`connect-src`/`form-action`/`object-src`/`base-uri` 隔离指令,且不含已移除的 `navigate-to`。 +- IPC 隔离回归:预览窗口 label 不被任何 capability 的 `windows` 作用域命中(防止误放宽到 `*` / `html-preview-*`)。 +- 标题转义。 +- 1 MB 限制。 +- 初始 URL/`about:blank` 导航守卫。 +- 临时文件识别和残留清理 helper。 + +真实 Tauri smoke 仍需要覆盖窗口打开、关闭清理、Windows/空格/非 ASCII 路径和 Android Activity。 diff --git a/apps/undefined-chat/docs/platform-features.md b/apps/undefined-chat/docs/platform-features.md new file mode 100644 index 00000000..27fc63cb --- /dev/null +++ b/apps/undefined-chat/docs/platform-features.md @@ -0,0 +1,66 @@ +# 平台能力说明 + +本文档记录 `apps/undefined-chat/src/platform/` 当前已接入的能力和限制。 + +## 快捷键 + +`KeybindingManager` 已在 `App.tsx` 接入。它把 macOS `Cmd` 映射为 `Ctrl`,并只在命中已注册快捷键时阻止默认行为。 + +当前默认绑定: + +- `Ctrl/Cmd+N`:新建会话。 +- `Ctrl/Cmd+K`:聚焦输入框;若草稿不是命令,则填入 `/` 并打开命令模式。 +- `Ctrl/Cmd+/`:桌面切换侧栏折叠;移动端切换会话抽屉。 +- `Ctrl/Cmd+,`:打开 Runtime 设置。 +- `Escape`:关闭删除确认、设置面板或移动端抽屉。 + +`Enter` 发送、`Shift+Enter` 换行和命令面板内的方向键/Tab/Enter 由 `MessageComposer` 自己处理。 + +## 移动端布局 + +移动端使用 `useMediaQuery("(max-width: 768px)")` 切换布局: + +- 会话列表作为抽屉打开,菜单按钮带 `aria-expanded` / `aria-controls`。 +- 打开抽屉后焦点移入抽屉,关闭后尽量恢复到原触发元素。 +- 输入区使用 `env(safe-area-inset-bottom)` 和 `window.visualViewport` 计算出的 `--keyboard-inset` 避让软键盘。 +- 主要移动端点击控件在 CSS 中提升到 44px 最小触控目标。 + +jsdom 测试覆盖抽屉打开/关闭、ARIA 状态和 Escape 行为;软键盘、安全区和 Tab 顺序仍需要真实浏览器或设备验证。 + +## Android 生命周期 + +`setupAndroidLifecycle` 仅在 Android UA 下由 `App.tsx` 注册,监听 Tauri v2 官方事件: + +- `tauri://suspended` +- `tauri://resumed` + +恢复前台时会执行 `store.bootstrap()`,刷新 Runtime 配置、会话、当前历史页、active jobs,并重新建立事件订阅。SSE 断开或关闭后的事件补齐仍由 store 内部 JSON fallback 处理;当前没有单独暴露“resume 后逐 job 主动补齐”的公开接口。 + +## 文件与附件 + +附件上传通过 Tauri dialog 获取路径,然后调用 Rust command 流式上传: + +- 桌面路径和 `file://` URL 会做本地 regular file 校验。 +- Android `content://` URI 交给 `tauri-plugin-fs` 打开,不强制做本地 `metadata().is_file()` 校验,避免 content provider 兼容性问题。 +- 前端只持有上传状态队列,不直接读取本地文件内容。 + +真实 Android content provider 读取仍必须通过设备 smoke 覆盖。 + +## 安全存储 + +`supports_system_keyring_target` 在代码层支持 `linux`、`macos`、`windows` 和 `ios`。其中 iOS 仅 `keyring` 库的 `apple-native` backend 在代码层兼容系统 Keychain,未纳入构建/发布(无 iOS 工程/CI/真机路径),不是受支持的发布平台。Android 通过生成工程注入的 `SecretPlugin` 使用 Android Keystore + AES-GCM 保存 API Key:密钥在 AndroidKeyStore 中生成且不可导出,加密后的密文存放在 `MODE_PRIVATE` 的 SharedPreferences 中。 + +`get_platform_info` 区分 `supportsSystemKeyring` 和 `supportsSecureApiKeyStorage`:Android 的系统 keyring 能力仍为 false,但安全 API Key 存储能力为 true。 + +只有平台安全存储不可用且用户显式确认时,才允许降级写入不安全本地文件。Android Keystore 写入、重启后读取和删除仍需要真实设备 smoke 覆盖。 + +## 测试 + +常用命令: + +```bash +npm run test:unit -- src/platform/KeybindingManager.test.ts src/platform/AndroidLifecycle.test.ts src/App.test.tsx +npm run check +``` + +`npm run check` 还会运行 TypeScript、Biome、jsdom integration tests、cargo fmt/check/test。真实 Tauri WebView、Android content URI、软键盘和后台恢复不在 jsdom 覆盖范围内。 diff --git a/apps/undefined-chat/index.html b/apps/undefined-chat/index.html new file mode 100644 index 00000000..29cb5346 --- /dev/null +++ b/apps/undefined-chat/index.html @@ -0,0 +1,14 @@ + + + + + + + + Undefined Chat + + +
+ + + diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json new file mode 100644 index 00000000..b167d07b --- /dev/null +++ b/apps/undefined-chat/package-lock.json @@ -0,0 +1,5128 @@ +{ + "name": "undefined-chat", + "version": "3.6.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "undefined-chat", + "version": "3.6.0", + "dependencies": { + "@tauri-apps/api": "^2.3.0", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-stronghold": "^2.3.0", + "highlight.js": "^11.11.1", + "marked": "^18.0.5", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@tauri-apps/cli": "^2.3.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", + "@types/marked": "^5.0.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.0.0", + "typescript": "^5.7.3", + "vite": "^6.2.1", + "vitest": "^4.1.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", + "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.2", + "@tauri-apps/cli-darwin-x64": "2.11.2", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", + "@tauri-apps/cli-linux-arm64-musl": "2.11.2", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-gnu": "2.11.2", + "@tauri-apps/cli-linux-x64-musl": "2.11.2", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", + "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", + "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", + "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", + "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", + "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", + "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", + "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", + "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", + "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", + "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", + "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", + "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-stronghold": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-stronghold/-/plugin-stronghold-2.3.1.tgz", + "integrity": "sha512-zFbD1Apk/VFdWaoGaoKcouRrZnzLFiNY9b1KDeBaN47sMaMHRYIa+ZDhvbzMOyH314+OHCQBXfe8I/ph59Lp9g==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/marked": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", + "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/apps/undefined-chat/package.json b/apps/undefined-chat/package.json new file mode 100644 index 00000000..b1176d1e --- /dev/null +++ b/apps/undefined-chat/package.json @@ -0,0 +1,60 @@ +{ + "name": "undefined-chat", + "private": true, + "version": "3.6.0", + "type": "module", + "scripts": { + "tauri": "tauri", + "dev": "vite --host 0.0.0.0 --port 1430", + "lint": "biome check src package.json tsconfig.json vite.config.ts src-tauri/tauri.conf.json src-tauri/capabilities/default.json", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:unit": "vitest run", + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:all": "npm run test:unit && npm run test:e2e", + "test:watch": "vitest watch", + "test:e2e:watch": "vitest watch --config vitest.e2e.config.ts", + "tauri:fmt:check": "cargo fmt --manifest-path src-tauri/Cargo.toml --all --check", + "tauri:check": "cargo check --manifest-path src-tauri/Cargo.toml", + "tauri:test": "cargo test --manifest-path src-tauri/Cargo.toml", + "check": "npm run lint && npm run typecheck && npm run test:all && npm run tauri:fmt:check && npm run tauri:check && npm run tauri:test", + "build": "npm run typecheck && vite build", + "preview": "vite preview --host 0.0.0.0 --port 4183", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build", + "tauri:android:init": "tauri android init --ci && python3 ../../scripts/prepare_tauri_android.py .", + "tauri:android:prepare": "python3 ../../scripts/prepare_tauri_android.py .", + "tauri:android:prepare:check": "python3 ../../scripts/prepare_tauri_android.py . --check", + "tauri:android": "tauri android build", + "tauri:android:debug": "tauri android build --debug" + }, + "dependencies": { + "@tauri-apps/api": "^2.3.0", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-stronghold": "^2.3.0", + "highlight.js": "^11.11.1", + "marked": "^18.0.5", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@tauri-apps/cli": "^2.3.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", + "@types/marked": "^5.0.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.0.0", + "typescript": "^5.7.3", + "vite": "^6.2.1", + "vitest": "^4.1.0" + } +} diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock new file mode 100644 index 00000000..4c74e676 --- /dev/null +++ b/apps/undefined-chat/src-tauri/Cargo.lock @@ -0,0 +1,6912 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq 0.4.2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-zebra" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775765289f7c6336c18d3d66127527820dd45ffd9eb3b6b8ee4708590e6c20f5" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset 0.9.1", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "iota-crypto" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a38db844c910d78825e173c083f2ef416b69cb091bba8ac1055763c6db065b" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "autocfg", + "base64 0.21.7", + "blake2", + "chacha20poly1305", + "cipher", + "curve25519-dalek", + "digest", + "ed25519-zebra", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "hmac", + "iterator-sorted", + "k256", + "pbkdf2", + "rand 0.8.6", + "scrypt", + "serde", + "sha2", + "tiny-keccak", + "unicode-normalization", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "iota_stronghold" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0d301c7edbc31494d183b7d24c1bb51d3fb10fce2f3793df1baf45b6988e10" +dependencies = [ + "bincode", + "hkdf", + "iota-crypto", + "rust-argon2 1.0.0", + "serde", + "stronghold-derive", + "stronghold-utils", + "stronghold_engine", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iterator-sorted" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d101775d2bc8f99f4ac18bf29b9ed70c0dd138b9a1e88d7b80179470cbbe8bd2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libflate" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd96e993e5f3368b0cb8497dae6c860c22af8ff18388c61c6c0b86c58d86b5df" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "libsodium-sys-stable" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b04bf6da2c98b727af37ab62cb505f4d751b975b034a9b9ad491d333b0564e" +dependencies = [ + "cc", + "libc", + "libflate", + "minisign-verify", + "pkg-config", + "tar", + "ureq", + "vcpkg", + "zip", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset 0.9.1", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "rust-argon2" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50162d19404029c1ceca6f6980fe40d45c8b369f6f44446fa14bb39573b5bb9" +dependencies = [ + "base64 0.13.1", + "blake2b_simd", + "constant_time_eq 0.1.5", + "crossbeam-utils", +] + +[[package]] +name = "rust-argon2" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d9848531d60c9cbbcf9d166c885316c24bc0e2a9d3eba0956bb6cbbd79bc6e8" +dependencies = [ + "base64 0.21.7", + "blake2b_simd", + "constant_time_eq 0.3.1", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.0", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stronghold-derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2835db23c4724c05a2f85b81c4681f4aa8ea158edc8a7f4ad791c916fb766c2e" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "stronghold-runtime" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18db7cc51450cefdab5f4990e128dd02c98da6d2992b93ffef8992ac0d2f3ddf" +dependencies = [ + "dirs 4.0.0", + "iota-crypto", + "libc", + "libsodium-sys-stable", + "log", + "nix 0.24.3", + "rand 0.8.6", + "serde", + "thiserror 1.0.69", + "windows 0.36.1", + "zeroize", +] + +[[package]] +name = "stronghold-utils" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8300214898af5e153e7f66e49dbd1c6a21585f2d592d9f24f58b969792475ed6" +dependencies = [ + "rand 0.8.6", + "stronghold-derive", +] + +[[package]] +name = "stronghold_engine" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd7371c42e557dd71a7f860bb2ec6b6fdb32f97a97987ccc2435fdd1f3a8615" +dependencies = [ + "anyhow", + "dirs-next", + "hex", + "iota-crypto", + "once_cell", + "paste", + "serde", + "stronghold-runtime", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.0", + "block2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "data-url", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.61.3", +] + +[[package]] +name = "tauri-build" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-http" +version = "2.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067" +dependencies = [ + "bytes", + "cookie_store", + "data-url", + "http", + "regex", + "reqwest 0.12.28", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "tokio", + "url", + "urlpattern", +] + +[[package]] +name = "tauri-plugin-stronghold" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6307f47fcf548531743f6f12dbde075014bd8a22c5f8c14201e571de52cbd057" +dependencies = [ + "hex", + "iota-crypto", + "iota_stronghold", + "log", + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "rust-argon2 2.1.0", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.61.3", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "undefined_chat" +version = "3.6.0" +dependencies = [ + "futures-util", + "keyring", + "serde", + "serde_json", + "sha2", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-http", + "tauri-plugin-stronghold", + "tokio", + "tokio-util", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53b97a83176b369b0eb2fd8158d4ae215357d02df9d40c1e1bf1879c5482c80" +dependencies = [ + "windows_aarch64_msvc 0.36.1", + "windows_i686_gnu 0.36.1", + "windows_i686_msvc 0.36.1", + "windows_x86_64_gnu 0.36.1", + "windows_x86_64_msvc 0.36.1", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "zeroize", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml new file mode 100644 index 00000000..399f3d1d --- /dev/null +++ b/apps/undefined-chat/src-tauri/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "undefined_chat" +version = "3.6.0" +description = "Undefined native chat client" +authors = ["Undefined contributors"] +license = "MIT" +edition = "2021" +rust-version = "1.77" + +[lib] +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.0.2", features = [] } + +[dependencies] +futures-util = "0.3" +keyring = { version = "3", features = ["apple-native", "crypto-rust", "sync-secret-service", "windows-native"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tauri = { version = "2.2.5", features = ["webview-data-url"] } +tauri-plugin-dialog = "2" +tauri-plugin-fs = "2" +tauri-plugin-http = { version = "2", features = ["stream", "multipart"] } +tauri-plugin-stronghold = "2" +tokio = { version = "1", features = ["fs", "io-util"] } +tokio-util = { version = "0.7", features = ["io"] } +url = "2" +urlencoding = "2" +uuid = { version = "1", features = ["v4"] } diff --git a/apps/undefined-chat/src-tauri/build.rs b/apps/undefined-chat/src-tauri/build.rs new file mode 100644 index 00000000..261851f6 --- /dev/null +++ b/apps/undefined-chat/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build(); +} diff --git a/apps/undefined-chat/src-tauri/capabilities/default.json b/apps/undefined-chat/src-tauri/capabilities/default.json new file mode 100644 index 00000000..a82fa72d --- /dev/null +++ b/apps/undefined-chat/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main-capability", + "description": "Default capability for the Undefined Chat main window.", + "windows": ["main"], + "permissions": ["core:default", "dialog:default", "stronghold:default"] +} diff --git a/apps/undefined-chat/src-tauri/icons/128x128.png b/apps/undefined-chat/src-tauri/icons/128x128.png new file mode 100644 index 00000000..0a855d8e Binary files /dev/null and b/apps/undefined-chat/src-tauri/icons/128x128.png differ diff --git a/apps/undefined-chat/src-tauri/icons/128x128@2x.png b/apps/undefined-chat/src-tauri/icons/128x128@2x.png new file mode 100644 index 00000000..b33b74fd Binary files /dev/null and b/apps/undefined-chat/src-tauri/icons/128x128@2x.png differ diff --git a/apps/undefined-chat/src-tauri/icons/32x32.png b/apps/undefined-chat/src-tauri/icons/32x32.png new file mode 100644 index 00000000..fd21f865 Binary files /dev/null and b/apps/undefined-chat/src-tauri/icons/32x32.png differ diff --git a/apps/undefined-chat/src-tauri/icons/icon.icns b/apps/undefined-chat/src-tauri/icons/icon.icns new file mode 100644 index 00000000..fed478c3 Binary files /dev/null and b/apps/undefined-chat/src-tauri/icons/icon.icns differ diff --git a/apps/undefined-chat/src-tauri/icons/icon.ico b/apps/undefined-chat/src-tauri/icons/icon.ico new file mode 100644 index 00000000..842eef18 Binary files /dev/null and b/apps/undefined-chat/src-tauri/icons/icon.ico differ diff --git a/apps/undefined-chat/src-tauri/icons/icon.png b/apps/undefined-chat/src-tauri/icons/icon.png new file mode 100644 index 00000000..17af5db8 Binary files /dev/null and b/apps/undefined-chat/src-tauri/icons/icon.png differ diff --git a/apps/undefined-chat/src-tauri/src/config.rs b/apps/undefined-chat/src-tauri/src/config.rs new file mode 100644 index 00000000..959360d6 --- /dev/null +++ b/apps/undefined-chat/src-tauri/src/config.rs @@ -0,0 +1,32 @@ +use url::Url; + +pub fn normalize_runtime_url(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("runtime_url is required".to_string()); + } + + let parsed = Url::parse(trimmed).map_err(|err| format!("invalid runtime_url: {err}"))?; + match parsed.scheme() { + "http" | "https" => {} + scheme => return Err(format!("unsupported runtime_url scheme: {scheme}")), + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("runtime_url must not include credentials".to_string()); + } + if !parsed.path().trim_matches('/').is_empty() { + return Err("runtime_url must be an origin without a path".to_string()); + } + if parsed.query().is_some() { + return Err("runtime_url must not include a query".to_string()); + } + if parsed.fragment().is_some() { + return Err("runtime_url must not include a fragment".to_string()); + } + + let mut normalized = parsed.to_string(); + while normalized.ends_with('/') { + normalized.pop(); + } + Ok(normalized) +} diff --git a/apps/undefined-chat/src-tauri/src/download.rs b/apps/undefined-chat/src-tauri/src/download.rs new file mode 100644 index 00000000..f8611267 --- /dev/null +++ b/apps/undefined-chat/src-tauri/src/download.rs @@ -0,0 +1,258 @@ +use crate::{ + secret::require_api_key, + state::{require_runtime_config, NativeState, RuntimeRequestPath}, +}; +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tauri::{AppHandle, Manager, State}; +use tauri_plugin_http::reqwest::Response; +use tokio::io::AsyncWriteExt; + +const MAX_PREVIEW_BYTES: usize = 10 * 1024 * 1024; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub struct DownloadAttachmentInput { + pub attachment_id: String, + #[serde(default)] + pub file_name: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadAttachmentResult { + pub status: u16, + pub ok: bool, + pub saved_file_name: Option, + pub bytes_written: u64, + pub media_type: Option, + pub body: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub struct PreviewAttachmentInput { + pub attachment_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewAttachmentResult { + pub status: u16, + pub ok: bool, + pub media_type: Option, + pub bytes: Vec, + pub body: Option, +} + +fn validate_attachment_id(value: &str) -> Result<(), String> { + if value.is_empty() { + return Err("attachment_id is required".to_string()); + } + if value.len() > 160 { + return Err("attachment_id is too long".to_string()); + } + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) + { + return Err("attachment_id contains unsupported characters".to_string()); + } + if value == "." || value == ".." || value.contains("..") { + return Err("attachment_id must not include path traversal".to_string()); + } + Ok(()) +} + +fn attachment_path(attachment_id: &str, preview: bool) -> Result { + validate_attachment_id(attachment_id)?; + let encoded = urlencoding::encode(attachment_id); + let path = if preview { + format!("/api/v1/chat/attachments/{encoded}/preview") + } else { + format!("/api/v1/chat/attachments/{encoded}") + }; + RuntimeRequestPath::new(&path) +} + +fn safe_file_name(input: Option<&str>, attachment_id: &str) -> String { + let candidate = input + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(attachment_id); + let mut value = String::with_capacity(candidate.len()); + for ch in candidate.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ' ') { + value.push(ch); + } else { + value.push('_'); + } + } + let trimmed = value.trim_matches(['.', ' ']).trim().to_string(); + if trimmed.is_empty() { + "attachment".to_string() + } else { + trimmed + } +} + +async fn attachment_response( + app: &AppHandle, + state: &NativeState, + attachment_id: &str, + preview: bool, +) -> Result { + let config = require_runtime_config(app, state).await?; + let api_key = require_api_key(app).await?; + let path = attachment_path(attachment_id, preview)?; + let url = format!("{}{}", config.runtime_url, path.as_str()); + state + .http_client()? + .get(url) + .header("X-Undefined-API-Key", api_key) + .send() + .await + .map_err(|err| format!("attachment request failed: {err}")) +} + +fn media_type_from_response(response: &Response) -> Option { + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string) +} + +#[tauri::command] +pub async fn save_attachment( + app: AppHandle, + state: State<'_, NativeState>, + input: DownloadAttachmentInput, +) -> Result { + let response = attachment_response(&app, &state, &input.attachment_id, false).await?; + let status = response.status(); + let media_type = media_type_from_response(&response); + if !status.is_success() { + return Ok(DownloadAttachmentResult { + status: status.as_u16(), + ok: false, + saved_file_name: None, + bytes_written: 0, + media_type, + body: Some(response.text().await.unwrap_or_default()), + }); + } + + let download_dir = app + .path() + .download_dir() + .map_err(|err| format!("download directory unavailable: {err}"))?; + tokio::fs::create_dir_all(&download_dir) + .await + .map_err(|err| format!("download directory create failed: {err}"))?; + let mut path = download_dir.join(safe_file_name( + input.file_name.as_deref(), + &input.attachment_id, + )); + path = unique_path(path).await; + let mut file = tokio::fs::File::create(&path) + .await + .map_err(|err| format!("attachment output create failed: {err}"))?; + let mut bytes_written = 0u64; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| format!("attachment stream read failed: {err}"))?; + bytes_written = bytes_written.saturating_add(chunk.len() as u64); + file.write_all(&chunk) + .await + .map_err(|err| format!("attachment output write failed: {err}"))?; + } + file.flush() + .await + .map_err(|err| format!("attachment output flush failed: {err}"))?; + + Ok(DownloadAttachmentResult { + status: status.as_u16(), + ok: true, + saved_file_name: path + .file_name() + .and_then(|value| value.to_str()) + .map(ToString::to_string), + bytes_written, + media_type, + body: None, + }) +} + +#[tauri::command] +pub async fn preview_attachment_bytes( + app: AppHandle, + state: State<'_, NativeState>, + input: PreviewAttachmentInput, +) -> Result { + let response = attachment_response(&app, &state, &input.attachment_id, true).await?; + let status = response.status(); + let media_type = media_type_from_response(&response); + let bytes = response + .bytes() + .await + .map_err(|err| format!("attachment preview read failed: {err}"))?; + if !status.is_success() { + return Ok(PreviewAttachmentResult { + status: status.as_u16(), + ok: false, + media_type, + body: Some(String::from_utf8_lossy(&bytes).to_string()), + bytes: Vec::new(), + }); + } + if bytes.len() > MAX_PREVIEW_BYTES { + return Err(format!( + "attachment preview is too large; max {MAX_PREVIEW_BYTES} bytes" + )); + } + + Ok(PreviewAttachmentResult { + status: status.as_u16(), + ok: true, + media_type, + bytes: bytes.to_vec(), + body: None, + }) +} + +async fn unique_path(path: PathBuf) -> PathBuf { + if tokio::fs::metadata(&path).await.is_err() { + return path; + } + + let parent = path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("attachment"); + let extension = path.extension().and_then(|value| value.to_str()); + for index in 1..1000 { + let file_name = if let Some(extension) = extension { + format!("{stem} ({index}).{extension}") + } else { + format!("{stem} ({index})") + }; + let candidate = parent.join(file_name); + if tokio::fs::metadata(&candidate).await.is_err() { + return candidate; + } + } + path +} + +#[cfg(test)] +pub(crate) fn test_safe_file_name(input: Option<&str>, attachment_id: &str) -> String { + safe_file_name(input, attachment_id) +} diff --git a/apps/undefined-chat/src-tauri/src/lib.rs b/apps/undefined-chat/src-tauri/src/lib.rs new file mode 100644 index 00000000..340e7a4a --- /dev/null +++ b/apps/undefined-chat/src-tauri/src/lib.rs @@ -0,0 +1,52 @@ +pub mod config; +mod download; +mod mobile_secret; +mod platform; +mod preview; +mod runtime_client; +mod secret; +pub mod state; +mod upload; + +#[cfg(test)] +mod native_tests; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .manage(state::NativeState::default()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_stronghold::Builder::new(secret::derive_stronghold_key).build()) + .plugin(mobile_secret::init()) + .invoke_handler(tauri::generate_handler![ + state::get_runtime_config, + state::save_runtime_config, + state::clear_runtime_config, + secret::save_api_key, + secret::load_api_key_status, + secret::delete_api_key, + secret::unlock_vault, + secret::confirm_insecure_storage_fallback, + runtime_client::probe_runtime, + runtime_client::runtime_request, + runtime_client::list_conversations, + runtime_client::get_history, + runtime_client::get_active_jobs, + runtime_client::send_message, + runtime_client::cancel_job, + runtime_client::list_commands, + runtime_client::fetch_job_events_json, + runtime_client::start_job_event_stream, + runtime_client::stop_job_event_stream, + download::save_attachment, + download::preview_attachment_bytes, + preview::open_html_preview, + secret::probe_secret_storage, + upload::upload_attachment_streaming, + platform::get_platform_info, + ]) + .run(tauri::generate_context!()) + .expect("failed to run Undefined Chat app"); +} diff --git a/apps/undefined-chat/src-tauri/src/main.rs b/apps/undefined-chat/src-tauri/src/main.rs new file mode 100644 index 00000000..ec3e9e8d --- /dev/null +++ b/apps/undefined-chat/src-tauri/src/main.rs @@ -0,0 +1,5 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + undefined_chat::run(); +} diff --git a/apps/undefined-chat/src-tauri/src/mobile_secret.rs b/apps/undefined-chat/src-tauri/src/mobile_secret.rs new file mode 100644 index 00000000..49c44c0d --- /dev/null +++ b/apps/undefined-chat/src-tauri/src/mobile_secret.rs @@ -0,0 +1,125 @@ +#[cfg(target_os = "android")] +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +#[cfg(target_os = "android")] +use tauri::Manager; + +#[cfg(target_os = "android")] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SecretPayload<'a> { + key: &'a str, +} + +#[cfg(target_os = "android")] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SetSecretPayload<'a> { + key: &'a str, + value: &'a str, +} + +#[cfg(target_os = "android")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecretResponse { + value: Option, +} + +#[cfg(target_os = "android")] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AvailabilityResponse { + available: bool, +} + +pub fn supports_android_secure_store_target(target_os: &str) -> bool { + target_os == "android" +} + +#[cfg(any(target_os = "android", test))] +pub(crate) fn android_secret_plugin_identifier(app_identifier: &str) -> &str { + app_identifier +} + +#[cfg(target_os = "android")] +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("undefined-chat-secret") + .setup(|app, api| { + let handle = api.register_android_plugin( + android_secret_plugin_identifier(&app.config().identifier), + "SecretPlugin", + )?; + app.manage(handle); + Ok(()) + }) + .build() +} + +#[cfg(not(target_os = "android"))] +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("undefined-chat-secret").build() +} + +#[cfg(target_os = "android")] +fn plugin(app: &AppHandle) -> Result, String> { + app.try_state::>() + .map(|state| state.inner().clone()) + .ok_or_else(|| "Android secure storage plugin is not initialized".to_string()) +} + +#[cfg(target_os = "android")] +pub async fn is_available(app: &AppHandle) -> Result { + let response = plugin(app)? + .run_mobile_plugin_async::("isAvailable", serde_json::json!({})) + .await + .map_err(|err| format!("Android secure storage probe failed: {err}"))?; + Ok(response.available) +} + +#[cfg(not(target_os = "android"))] +pub async fn is_available(_app: &AppHandle) -> Result { + Ok(false) +} + +#[cfg(target_os = "android")] +pub async fn get_secret(app: &AppHandle, key: &str) -> Result, String> { + let response = plugin(app)? + .run_mobile_plugin_async::("getSecret", SecretPayload { key }) + .await + .map_err(|err| format!("Android secure storage read failed: {err}"))?; + Ok(response.value) +} + +#[cfg(not(target_os = "android"))] +pub async fn get_secret(_app: &AppHandle, _key: &str) -> Result, String> { + Ok(None) +} + +#[cfg(target_os = "android")] +pub async fn set_secret(app: &AppHandle, key: &str, value: &str) -> Result<(), String> { + plugin(app)? + .run_mobile_plugin_async::("setSecret", SetSecretPayload { key, value }) + .await + .map_err(|err| format!("Android secure storage write failed: {err}"))?; + Ok(()) +} + +#[cfg(not(target_os = "android"))] +pub async fn set_secret(_app: &AppHandle, _key: &str, _value: &str) -> Result<(), String> { + Err("Android secure storage is unavailable on this platform".to_string()) +} + +#[cfg(target_os = "android")] +pub async fn delete_secret(app: &AppHandle, key: &str) -> Result<(), String> { + plugin(app)? + .run_mobile_plugin_async::("deleteSecret", SecretPayload { key }) + .await + .map_err(|err| format!("Android secure storage delete failed: {err}"))?; + Ok(()) +} + +#[cfg(not(target_os = "android"))] +pub async fn delete_secret(_app: &AppHandle, _key: &str) -> Result<(), String> { + Ok(()) +} diff --git a/apps/undefined-chat/src-tauri/src/native_tests.rs b/apps/undefined-chat/src-tauri/src/native_tests.rs new file mode 100644 index 00000000..9c81f7f4 --- /dev/null +++ b/apps/undefined-chat/src-tauri/src/native_tests.rs @@ -0,0 +1,522 @@ +use crate::config::normalize_runtime_url; +use crate::mobile_secret::android_secret_plugin_identifier; +use crate::platform::{ + platform_info_for_target, supports_html_preview_target, supports_sse_target, +}; +use crate::preview::{ + build_preview_data_url, preview_document, preview_document_checked, preview_navigation_allowed, + MAX_PREVIEW_HTML_BYTES, +}; +use crate::runtime_client::{ + build_runtime_url, job_events_url, parse_sse_chunks, runtime_health_from_body_result, + RuntimeRequestInput, StartJobEventStreamInput, Utf8ChunkDecoder, +}; +use crate::secret::{ + api_key_status_from_storage, classify_secret_storage, derive_stronghold_key, + empty_api_key_status_for_target, load_api_key_status_from_value, + supports_secure_api_key_target, supports_system_keyring_target, +}; +use crate::state::{ + AppRuntimeConfig, RuntimeConfigInput, RuntimeRequestPath, APP_CONFIG_FILE_NAME, +}; +use crate::{ + download::test_safe_file_name, + upload::{ + attachment_file_name, attachments_url, parse_file_path, requires_regular_file_check, + upload_uses_streaming_body, UploadAttachmentInput, + }, +}; + +#[test] +fn normalize_runtime_url_removes_trailing_slashes() { + let value = normalize_runtime_url("http://127.0.0.1:8788///").unwrap(); + assert_eq!(value, "http://127.0.0.1:8788"); +} + +#[test] +fn normalize_runtime_url_rejects_empty_input() { + let err = normalize_runtime_url(" ").unwrap_err(); + assert!(err.contains("runtime_url is required")); +} + +#[test] +fn normalize_runtime_url_rejects_query() { + let err = normalize_runtime_url("http://127.0.0.1:8788?debug=true").unwrap_err(); + assert!(err.contains("runtime_url must not include a query")); +} + +#[test] +fn normalize_runtime_url_rejects_fragment() { + let err = normalize_runtime_url("http://127.0.0.1:8788#runtime").unwrap_err(); + assert!(err.contains("runtime_url must not include a fragment")); +} + +#[test] +fn normalize_runtime_url_rejects_path_and_credentials() { + let path_err = normalize_runtime_url("http://127.0.0.1:8788/api").unwrap_err(); + assert!(path_err.contains("runtime_url must be an origin")); + + let credentials_err = normalize_runtime_url("http://user:pass@127.0.0.1:8788").unwrap_err(); + assert!(credentials_err.contains("runtime_url must not include credentials")); +} + +#[test] +fn normalize_runtime_url_rejects_non_http_origins() { + for value in [ + "file:///tmp/runtime.sock", + "data:text/plain,hello", + "javascript:alert(1)", + "ws://127.0.0.1:8788", + ] { + let err = normalize_runtime_url(value).unwrap_err(); + assert!(err.contains("unsupported runtime_url scheme")); + } +} + +#[test] +fn runtime_config_input_normalizes_runtime_origin() { + let config = AppRuntimeConfig::from_input(RuntimeConfigInput { + runtime_url: " http://127.0.0.1:8788/// ".to_string(), + }) + .unwrap(); + + assert_eq!(config.runtime_url, "http://127.0.0.1:8788"); +} + +#[test] +fn runtime_config_file_name_is_stable() { + assert_eq!(APP_CONFIG_FILE_NAME, "runtime-config.json"); +} + +#[test] +fn job_events_url_uses_normalized_runtime_base_and_after_sequence() { + let value = job_events_url("http://127.0.0.1:8788///", "job-123", 42).unwrap(); + assert_eq!( + value, + "http://127.0.0.1:8788/api/v1/chat/jobs/job-123/events?after=42" + ); +} + +#[test] +fn job_events_url_rejects_empty_runtime_url() { + let err = job_events_url(" ", "job-123", 0).unwrap_err(); + assert!(err.contains("runtime_url is required")); +} + +#[test] +fn job_events_url_encodes_job_id_path_segment() { + let value = job_events_url("http://127.0.0.1:8788", "job /secret", 7).unwrap(); + assert_eq!( + value, + "http://127.0.0.1:8788/api/v1/chat/jobs/job%20%2Fsecret/events?after=7" + ); +} + +#[test] +fn runtime_url_builder_accepts_only_relative_runtime_api_paths() { + let base = AppRuntimeConfig { + runtime_url: "http://127.0.0.1:8788".to_string(), + }; + let input = RuntimeRequestPath::new("/api/v1/chat/history?conversation_id=abc").unwrap(); + let value = build_runtime_url(&base, &input).unwrap(); + + assert_eq!( + value, + "http://127.0.0.1:8788/api/v1/chat/history?conversation_id=abc" + ); +} + +#[test] +fn runtime_url_builder_rejects_absolute_and_non_api_paths() { + let base = AppRuntimeConfig { + runtime_url: "http://127.0.0.1:8788".to_string(), + }; + + for path in [ + "https://evil.example/api/v1/chat/history", + "//evil.example/api/v1/chat/history", + "/admin", + "api/v1/chat/history", + "/api/../secret", + ] { + assert!(RuntimeRequestPath::new(path).is_err()); + } + + let path = RuntimeRequestPath::new("/api/v1/chat/history").unwrap(); + let built = build_runtime_url(&base, &path).unwrap(); + assert!(built.starts_with("http://127.0.0.1:8788/")); +} + +#[test] +fn runtime_request_input_rejects_secret_headers_from_react() { + let input = RuntimeRequestInput { + method: "GET".to_string(), + path: "/api/v1/chat/history".to_string(), + body: None, + headers: vec![("X-Undefined-API-Key".to_string(), "leak".to_string())], + }; + + let err = input.validate().unwrap_err(); + assert!(err.contains("reserved header")); +} + +#[test] +fn attachments_url_uses_normalized_runtime_base() { + let value = attachments_url("http://127.0.0.1:8788///").unwrap(); + assert_eq!(value, "http://127.0.0.1:8788/api/v1/chat/attachments"); +} + +#[test] +fn attachments_url_rejects_query_and_fragment() { + let query_err = attachments_url("http://127.0.0.1:8788?debug=true").unwrap_err(); + assert!(query_err.contains("runtime_url must not include a query")); + + let fragment_err = attachments_url("http://127.0.0.1:8788#runtime").unwrap_err(); + assert!(fragment_err.contains("runtime_url must not include a fragment")); +} + +#[test] +fn html_preview_csp_allows_inline_scripts_but_keeps_isolation() { + // 与 WebUI 基线对齐:放开内联脚本(图表/动画),但维持外联与提交隔离、禁止 eval。 + // 仅断言运行时真正生效的隔离指令,不把已被移除/在 meta 交付下被忽略的指令当作隔离证据。 + let document = preview_document("Report", "

Hello

"); + + assert!(document.contains("default-src 'none'")); + assert!(document.contains("connect-src 'none'")); + assert!(document.contains("form-action 'none'")); + assert!(document.contains("object-src 'none'")); + assert!(document.contains("base-uri 'none'")); + assert!(document.contains("img-src data: blob:")); + assert!(document.contains("media-src data: blob:")); + assert!(document.contains("style-src 'unsafe-inline'")); + // 放开内联脚本以支持工具产物中的图表/动画。 + assert!(document.contains("script-src 'unsafe-inline'")); + // 仍禁止 eval,且不存在残留的 script-src 'none'。 + assert!(!document.contains("unsafe-eval")); + assert!(!document.contains("script-src 'none'")); + // 已从 CSP 规范移除、浏览器从不实现的死指令不得再充当隔离证据。 + assert!(!document.contains("navigate-to")); + // 导航防护的真实证据是 on_navigation 守卫单测(见 + // html_preview_navigation_guard_allows_only_initial_url),而非任何 CSP 指令。 +} + +#[test] +fn html_preview_navigation_guard_allows_only_initial_url() { + let initial_url = build_preview_data_url("Report", "

Hello

").unwrap(); + + assert!(preview_navigation_allowed(&initial_url, &initial_url)); + assert!(preview_navigation_allowed( + &url::Url::parse("about:blank").unwrap(), + &initial_url + )); + assert!(!preview_navigation_allowed( + &url::Url::parse("data:text/html;charset=utf-8,%3Cscript%3Ealert(1)%3C%2Fscript%3E") + .unwrap(), + &initial_url + )); + assert!(!preview_navigation_allowed( + &url::Url::parse("https://example.com").unwrap(), + &initial_url + )); + let initial_file_url = url::Url::parse("file:///tmp/html-preview-safe.html").unwrap(); + assert!(preview_navigation_allowed( + &initial_file_url, + &initial_file_url + )); + assert!(!preview_navigation_allowed( + &url::Url::parse("file:///tmp/other-preview.html").unwrap(), + &initial_file_url + )); +} + +#[test] +fn html_preview_rejects_oversized_html() { + let html = "a".repeat(MAX_PREVIEW_HTML_BYTES + 1); + let err = build_preview_data_url("Too large", &html).unwrap_err(); + + assert!(err.contains("html preview content is too large")); + let err = preview_document_checked("Too large", &html).unwrap_err(); + assert!(err.contains("html preview content is too large")); +} + +#[test] +fn html_preview_escapes_title() { + let document = preview_document("A&B ", "

Hello

"); + let title = document + .split_once("") + .and_then(|(_, rest)| rest.split_once("")) + .map(|(value, _)| value) + .expect("preview document should include a title element"); + + assert_eq!(title, "A&B <C>"); + assert!(!title.contains("")); +} + +#[test] +fn attachment_file_name_uses_file_name_or_fallback() { + assert_eq!( + attachment_file_name(std::path::Path::new("/tmp/report.txt")), + "report.txt" + ); + assert_eq!( + attachment_file_name(std::path::Path::new("/")), + "attachment" + ); +} + +#[test] +fn attachment_save_file_name_sanitizes_path_like_input() { + assert_eq!( + test_safe_file_name(Some("..\\evil\"/photo.png"), "attachment123"), + "_evil__photo.png" + ); + assert_eq!( + test_safe_file_name(Some(".."), "attachment123"), + "attachment" + ); +} + +#[test] +fn upload_file_path_parser_keeps_android_content_uri_as_url() { + let path = parse_file_path("content://media/external/images/media/42").unwrap(); + assert!(path.as_path().is_none()); + assert_eq!(path.to_string(), "content://media/external/images/media/42"); +} + +#[test] +fn upload_file_path_parser_accepts_file_uri_and_plain_path() { + let file_uri = parse_file_path("file:///tmp/report.txt").unwrap(); + assert!(file_uri.as_path().is_none()); + assert_eq!( + file_uri.clone().into_path().unwrap(), + std::path::PathBuf::from("/tmp/report.txt") + ); + + let plain_path = parse_file_path("/tmp/report.txt").unwrap(); + assert_eq!( + plain_path.as_path(), + Some(std::path::Path::new("/tmp/report.txt")) + ); +} + +#[test] +fn upload_input_rejects_runtime_url_and_api_key_from_react() { + let err = serde_json::from_value::(serde_json::json!({ + "runtimeUrl": "http://127.0.0.1:8788", + "apiKey": "secret-api-key", + "filePath": "/tmp/report.txt" + })) + .unwrap_err(); + + assert!(err.to_string().contains("unknown field")); + assert!(!err.to_string().contains("secret-api-key")); +} + +#[test] +fn sse_input_rejects_runtime_url_and_api_key_from_react() { + let err = serde_json::from_value::(serde_json::json!({ + "runtimeUrl": "http://127.0.0.1:8788", + "apiKey": "secret-api-key", + "jobId": "job-123", + "afterSeq": 0 + })) + .unwrap_err(); + + assert!(err.to_string().contains("unknown field")); + assert!(!err.to_string().contains("secret-api-key")); +} + +#[test] +fn utf8_chunk_decoder_preserves_split_multibyte_sequence() { + let mut decoder = Utf8ChunkDecoder::default(); + let value = "data: 中文\n\n".as_bytes(); + + assert_eq!( + decoder.decode_chunk(&value[..7]).unwrap(), + Some("data: ".to_string()) + ); + assert_eq!(decoder.decode_chunk(&value[7..8]).unwrap(), None); + assert_eq!( + decoder.decode_chunk(&value[8..]).unwrap(), + Some("中文\n\n".to_string()) + ); +} + +#[test] +fn runtime_health_preserves_status_when_body_read_fails() { + let health = runtime_health_from_body_result(503, false, Err("body read failed".to_string())); + + assert!(!health.ok); + assert_eq!(health.status, 503); + assert_eq!(health.body, ""); +} + +#[test] +fn sse_parser_emits_typed_events_and_tracks_last_sequence() { + let parsed = parse_sse_chunks(&[ + "id: 2\nevent: progress\ndata: {\"stage\":\"thinking\"}\n\n", + ": keepalive\n\nid: 3\ndata: {\"message\":\"done\"}\n\n", + ]) + .unwrap(); + + assert_eq!(parsed.last_seq, 3); + assert_eq!(parsed.events.len(), 2); + assert_eq!(parsed.events[0].seq, 2); + assert_eq!(parsed.events[0].event_type.as_deref(), Some("progress")); + assert_eq!(parsed.events[0].payload["stage"], "thinking"); + assert_eq!(parsed.events[1].seq, 3); + assert_eq!(parsed.events[1].payload["message"], "done"); +} + +#[test] +fn sse_parser_buffers_incomplete_frames_across_chunks() { + let parsed = parse_sse_chunks(&[ + "id: 9\ndata: {\"message\":\"hel", + "lo\"}\n\nid: 10\ndata: {\"message\":\"next\"}\n\n", + ]) + .unwrap(); + + assert_eq!(parsed.last_seq, 10); + assert_eq!(parsed.events.len(), 2); + assert_eq!(parsed.events[0].payload["message"], "hello"); + assert_eq!(parsed.events[1].payload["message"], "next"); +} + +#[test] +fn secret_status_marks_degraded_detail() { + let status = classify_secret_storage(false, "no native store"); + assert!(!status.available); + assert!(status.degraded); + assert_eq!(status.detail, "no native store"); +} + +#[test] +fn stronghold_key_derivation_returns_32_bytes() { + let derived = derive_stronghold_key("vault-password"); + assert_eq!(derived.len(), 32); + assert_ne!(derived, b"vault-password".to_vec()); +} + +#[test] +fn system_keyring_guard_allows_supported_desktop_targets() { + assert!(supports_system_keyring_target("linux")); + assert!(supports_system_keyring_target("macos")); + assert!(supports_system_keyring_target("windows")); +} + +#[test] +fn system_keyring_guard_allows_ios_keychain_target() { + assert!(supports_system_keyring_target("ios")); +} + +#[test] +fn system_keyring_guard_rejects_android_without_secure_storage_support() { + assert!(!supports_system_keyring_target("android")); +} + +#[test] +fn secure_api_key_target_includes_android_secure_store() { + assert!(supports_secure_api_key_target("android")); + assert!(supports_secure_api_key_target("ios")); + assert!(supports_secure_api_key_target("linux")); +} + +#[test] +fn android_secret_plugin_identifier_uses_app_identifier() { + assert_eq!( + android_secret_plugin_identifier("com.example.custom"), + "com.example.custom" + ); +} + +#[test] +fn platform_info_keeps_system_keyring_and_secure_api_key_storage_separate() { + let platform = platform_info_for_target("android", "unix", "aarch64"); + assert!(!platform.supports_system_keyring); + assert!(platform.supports_secure_api_key_storage); +} + +#[test] +fn html_preview_support_covers_desktop_and_android_but_not_ios() { + // 桌面端与 Android(HtmlPreviewActivity)支持原生 HTML 预览窗口。 + for os in ["windows", "macos", "linux", "android"] { + assert!( + supports_html_preview_target(os), + "{os} should support html preview" + ); + } + // iOS 缺少承载预览窗口的 Activity,诚实降级为不支持。 + assert!(!supports_html_preview_target("ios")); +} + +#[test] +fn sse_support_is_available_on_every_platform() { + for os in ["windows", "macos", "linux", "android", "ios"] { + assert!(supports_sse_target(os), "{os} should support SSE"); + } +} + +#[test] +fn platform_info_reports_real_html_preview_and_sse_capabilities() { + let ios = platform_info_for_target("ios", "unix", "aarch64"); + assert!(!ios.supports_html_preview); + assert!(ios.supports_sse); + + let android = platform_info_for_target("android", "unix", "aarch64"); + assert!(android.supports_html_preview); + assert!(android.supports_sse); + + let desktop = platform_info_for_target("linux", "unix", "x86_64"); + assert!(desktop.supports_html_preview); + assert!(desktop.supports_sse); +} + +#[test] +fn api_key_status_does_not_return_secret_material() { + let status = load_api_key_status_from_value(Some("runtime-secret-key")); + + assert!(status.available); + assert_eq!(status.key_preview.as_deref(), Some("runt...-key")); + let serialized = serde_json::to_string(&status).unwrap(); + assert!(!serialized.contains("runtime-secret-key")); +} + +#[test] +fn empty_api_key_status_uses_android_secure_store_label_on_android() { + let status = empty_api_key_status_for_target("android"); + assert!(!status.available); + assert!(!status.degraded); + assert_eq!(status.storage, "android-secure-store"); +} + +#[test] +fn insecure_api_key_status_marks_degraded_without_returning_secret_material() { + let status = api_key_status_from_storage( + Some("runtime-secret-key"), + "insecure-file", + true, + "local plaintext fallback", + ); + + assert!(status.available); + assert!(status.degraded); + assert_eq!(status.storage, "insecure-file"); + assert_eq!(status.detail, "local plaintext fallback"); + assert_eq!(status.key_preview.as_deref(), Some("runt...-key")); + let serialized = serde_json::to_string(&status).unwrap(); + assert!(!serialized.contains("runtime-secret-key")); +} + +#[test] +fn upload_skips_regular_file_metadata_check_for_android_content_uri() { + let content_uri = parse_file_path("content://media/external/images/media/42").unwrap(); + assert!(!requires_regular_file_check(&content_uri)); + + let plain_path = parse_file_path("/tmp/report.txt").unwrap(); + assert!(requires_regular_file_check(&plain_path)); +} + +#[test] +fn upload_command_uses_streaming_body() { + assert!(upload_uses_streaming_body()); +} diff --git a/apps/undefined-chat/src-tauri/src/platform.rs b/apps/undefined-chat/src-tauri/src/platform.rs new file mode 100644 index 00000000..e02ecd9d --- /dev/null +++ b/apps/undefined-chat/src-tauri/src/platform.rs @@ -0,0 +1,51 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlatformInfo { + pub os: String, + pub family: String, + pub arch: String, + pub debug: bool, + pub supports_system_keyring: bool, + pub supports_secure_api_key_storage: bool, + pub supports_sse: bool, + pub supports_html_preview: bool, +} + +/// 各平台是否支持原生 HTML 预览窗口。 +/// +/// - 桌面端(windows/macos/linux):通过 `WebviewWindowBuilder` 弹出独立预览窗口。 +/// - android:有专用的 `HtmlPreviewActivity` 承载预览窗口(见 `preview::open_html_preview`)。 +/// - ios:缺少对应 Activity,无法承载外部预览窗口,诚实降级为不支持。 +pub(crate) fn supports_html_preview_target(os: &str) -> bool { + matches!(os, "windows" | "macos" | "linux" | "android") +} + +/// 各平台是否支持 SSE 流式(基于 HTTP 流,全平台可用)。 +pub(crate) fn supports_sse_target(os: &str) -> bool { + let _ = os; + true +} + +pub(crate) fn platform_info_for_target(os: &str, family: &str, arch: &str) -> PlatformInfo { + PlatformInfo { + supports_system_keyring: crate::secret::supports_system_keyring_target(os), + supports_secure_api_key_storage: crate::secret::supports_secure_api_key_target(os), + os: os.to_string(), + family: family.to_string(), + arch: arch.to_string(), + debug: cfg!(debug_assertions), + supports_sse: supports_sse_target(os), + supports_html_preview: supports_html_preview_target(os), + } +} + +#[tauri::command] +pub fn get_platform_info() -> PlatformInfo { + platform_info_for_target( + std::env::consts::OS, + std::env::consts::FAMILY, + std::env::consts::ARCH, + ) +} diff --git a/apps/undefined-chat/src-tauri/src/preview.rs b/apps/undefined-chat/src-tauri/src/preview.rs new file mode 100644 index 00000000..4af02b22 --- /dev/null +++ b/apps/undefined-chat/src-tauri/src/preview.rs @@ -0,0 +1,638 @@ +use serde::Deserialize; +use std::{ + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; +use tauri::{ + webview::NewWindowResponse, AppHandle, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent, +}; +use url::Url; +use uuid::Uuid; + +// CSP 策略:与 WebUI HTML 预览基线对齐——放开内联脚本以支持图表/动画等工具产物, +// 但通过运行时真正生效的指令维持隔离:禁止一切外联(connect-src 'none'),禁止表单提交、 +// 插件对象、base 改写,仅允许 data:/blob: 内联资源与内联样式。 +// +// 安全说明:放开 script-src 后,预览窗口可执行任意脚本,但其无法访问 Tauri IPC/invoke。 +// IPC 隔离的唯一屏障是 capability 缺失:预览窗口 label 形如 `html-preview-*`,不匹配 +// `capabilities/default.json`(`main-capability`,仅 `windows: ["main"]`);在 Tauri v2 ACL 模型下 +// 未匹配任何 capability 的 webview 完全没有 IPC 访问权(permission 缺失)。 +// 注意:底层 `__TAURI_INTERNALS__` 无论 `withGlobalTauri` 取值都会注入,并非 IPC 隔离的依据; +// `withGlobalTauri` 未启用仅移除便利全局 `window.__TAURI__`,命令调用仍然只由 capability 把关。 +// connect-src 'none' 进一步阻断脚本外联,防止内容外泄。 +// 导航防护不依赖 CSP,而由 Rust 侧 on_navigation 守卫(`preview_navigation_allowed`)提供; +// 防嵌入由窗口隔离(预览为独立 OS 窗口而非 iframe)与 on_new_window Deny 覆盖。 +// 详见 `open_html_preview` 注释与 native_tests 中的隔离断言。 +const PREVIEW_CSP: &str = concat!( + "default-src 'none'; ", + "connect-src 'none'; ", + "form-action 'none'; ", + "object-src 'none'; ", + "base-uri 'none'; ", + // frame-ancestors 通过 交付时被浏览器忽略(仅 HTTP 响应头有效)。 + // 预览为独立 OS 窗口而非 iframe,防嵌入实际由窗口隔离 + on_new_window Deny 覆盖; + // 保留此指令以便未来若改为 header 交付时即可生效。 + "frame-ancestors 'none'; ", + // 注:曾用的 `navigate-to 'none'` 已移除——该指令已从 CSP 规范删除、浏览器从不实现, + // 是零防护的死指令。导航防护改由上文所述的 on_navigation 守卫提供。 + "img-src data: blob:; ", + "media-src data: blob:; ", + "style-src 'unsafe-inline'; ", + "font-src data:; ", + "script-src 'unsafe-inline';" +); + +#[derive(Debug, Clone, Deserialize)] +pub struct HtmlPreviewInput { + pub title: String, + pub html: String, +} + +fn escape_html_text(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +pub(crate) fn preview_document(title: &str, html: &str) -> String { + let escaped_title = escape_html_text(title); + + format!( + concat!( + "", + "", + "", + "", + "", + "", + "", + "{}", + "", + "", + "{}", + "" + ), + PREVIEW_CSP, + escaped_title, + if html.trim().is_empty() { "empty" } else { "" }, + html + ) +} + +pub(crate) fn preview_navigation_allowed(url: &Url, initial_url: &Url) -> bool { + url == initial_url || url.as_str() == "about:blank" +} + +pub(crate) const MAX_PREVIEW_HTML_BYTES: usize = 1024 * 1024; +/// 预览窗口的 label 前缀(label 形如 `html-preview-{uuid}`)。 +/// 该前缀同时用作临时文件名前缀,并且是 IPC 隔离的安全锚点: +/// `capabilities/default.json` 仅授权 `windows: ["main"]`,此前缀的窗口不匹配任何 capability。 +pub(crate) const PREVIEW_WINDOW_LABEL_PREFIX: &str = "html-preview-"; +const PREVIEW_TEMP_PREFIX: &str = PREVIEW_WINDOW_LABEL_PREFIX; +const PREVIEW_TEMP_EXTENSION: &str = "html"; +const PREVIEW_TEMP_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +pub(crate) fn validate_preview_input(title: &str, html: &str) -> Result<(), String> { + if title.len().saturating_add(html.len()) > MAX_PREVIEW_HTML_BYTES { + return Err(format!( + "html preview content is too large; max {MAX_PREVIEW_HTML_BYTES} bytes" + )); + } + Ok(()) +} + +pub(crate) fn preview_document_checked(title: &str, html: &str) -> Result { + validate_preview_input(title, html)?; + // This renders Runtime/tool HTML as-is. It is containment, not sanitization. + Ok(preview_document(title, html)) +} + +pub(crate) fn is_preview_temp_file(path: &Path) -> bool { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + path.extension().and_then(|ext| ext.to_str()) == Some(PREVIEW_TEMP_EXTENSION) + && file_name.starts_with(PREVIEW_TEMP_PREFIX) +} + +fn remove_preview_temp_file(path: &Path) { + if !is_preview_temp_file(path) { + return; + } + // 临时文件清理失败是非致命的:TTL 过期扫描(cleanup_stale_preview_temp_files)会作为兜底, + // 这里静默忽略以避免生产环境 stderr 噪音,且不暴露临时路径。 + let _ = std::fs::remove_file(path); +} + +struct PreviewTempFile { + path: PathBuf, + cleanup_on_drop: bool, +} + +impl PreviewTempFile { + fn new(path: PathBuf) -> Self { + Self { + path, + cleanup_on_drop: true, + } + } + + fn path(&self) -> &Path { + &self.path + } + + #[cfg(test)] + fn persist_for_test(mut self) -> PathBuf { + self.cleanup_on_drop = false; + self.path.clone() + } + + fn into_window_cleanup(mut self) -> PathBuf { + self.cleanup_on_drop = false; + self.path.clone() + } +} + +impl Drop for PreviewTempFile { + fn drop(&mut self) { + if self.cleanup_on_drop { + remove_preview_temp_file(&self.path); + } + } +} + +fn is_stale_preview_temp_file(path: &Path, now: SystemTime) -> bool { + if !is_preview_temp_file(path) { + return false; + } + let Ok(metadata) = std::fs::metadata(path) else { + return true; + }; + let Ok(modified) = metadata.modified() else { + return true; + }; + now.duration_since(modified) + .is_ok_and(|age| age > PREVIEW_TEMP_TTL) +} + +pub(crate) fn cleanup_stale_preview_temp_files(temp_dir: &Path) -> Result { + cleanup_preview_temp_files_before(temp_dir, SystemTime::now()) +} + +fn cleanup_preview_temp_files_before(temp_dir: &Path, now: SystemTime) -> Result { + let entries = match std::fs::read_dir(temp_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(err) => return Err(format!("Failed to scan temp dir: {err}")), + }; + + let mut removed = 0; + for entry in entries { + let entry = entry.map_err(|err| format!("Failed to read temp dir entry: {err}"))?; + let path = entry.path(); + if !is_stale_preview_temp_file(&path, now) { + continue; + } + match std::fs::remove_file(&path) { + Ok(()) => removed += 1, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(format!( + "Failed to remove temp preview file {path:?}: {err}" + )) + } + } + } + + Ok(removed) +} + +pub(crate) fn preview_temp_path(temp_dir: &Path, label: &str) -> PathBuf { + temp_dir.join(format!("{label}.{PREVIEW_TEMP_EXTENSION}")) +} + +// 保留用于测试和未来可能的 data URL 回退 +#[allow(dead_code)] +pub(crate) fn build_preview_data_url(title: &str, html: &str) -> Result { + let document = preview_document_checked(title, html)?; + let encoded_document = urlencoding::encode(&document); + Url::parse(&format!("data:text/html;charset=utf-8,{encoded_document}")) + .map_err(|err| format!("html preview URL build failed: {err}")) +} + +#[tauri::command] +pub async fn open_html_preview(app: AppHandle, input: HtmlPreviewInput) -> Result<(), String> { + use std::fs; + + let document = preview_document_checked(&input.title, &input.html)?; + let label = format!("{PREVIEW_WINDOW_LABEL_PREFIX}{}", Uuid::new_v4()); + + // 写入临时 HTML 文件 + let temp_dir = app + .path() + .temp_dir() + .map_err(|e| format!("Failed to get temp dir: {}", e))?; + // 过期临时文件清理失败不影响本次预览:非致命,静默忽略(下次预览会再次尝试清理)。 + let _ = cleanup_stale_preview_temp_files(&temp_dir); + let html_file = preview_temp_path(&temp_dir, &label); + + fs::write(&html_file, document).map_err(|e| format!("Failed to write HTML file: {}", e))?; + let temp_file = PreviewTempFile::new(html_file); + + let url = Url::from_file_path(temp_file.path()).map_err(|_| { + format!( + "Failed to convert HTML preview path to file URL: {:?}", + temp_file.path() + ) + })?; + let initial_url = url.clone(); + + // 尝试获取主窗口 + let main_window = app + .get_webview_window("main") + .or_else(|| app.webview_windows().into_values().next()) + .ok_or_else(|| "no parent window is available for html preview".to_string())?; + + // 安全:预览窗口加载外部 file:// URL,label 形如 `html-preview-*`,不匹配任何 capability + // (`capabilities/default.json` 即 `main-capability`,仅授权 `windows: ["main"]`),因此该 + // webview 没有任何 Tauri IPC/invoke 权限——这正是 IPC 隔离的唯一屏障,即便 CSP 放开脚本, + // 也因 capability 缺失而无法回调 Rust 命令或读取主窗口数据。 + // (`withGlobalTauri` 未启用只是移除便利全局 `window.__TAURI__`;底层 `__TAURI_INTERNALS__` + // 无论如何都会注入,故隔离不能寄托于此。导航防护由下方 on_navigation 守卫提供。) + let builder = WebviewWindowBuilder::new(&main_window, label, WebviewUrl::External(url)) + .title(input.title) + .inner_size(900.0, 700.0) + .resizable(true) + .on_navigation(move |url| preview_navigation_allowed(url, &initial_url)) + .on_new_window(|_, _| NewWindowResponse::Deny); + + #[cfg(target_os = "android")] + let builder = builder.activity_name("HtmlPreviewActivity"); + + let window = builder + .build() + .map_err(|err| format!("html preview window open failed: {err}"))?; + let html_file_for_cleanup = temp_file.into_window_cleanup(); + window.on_window_event(move |event| { + if matches!( + event, + WindowEvent::CloseRequested { .. } | WindowEvent::Destroyed + ) { + remove_preview_temp_file(&html_file_for_cleanup); + } + }); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_escape_html_text() { + assert_eq!(escape_html_text("hello"), "hello"); + assert_eq!(escape_html_text("", "

safe

"); + assert!(doc.contains("<script>")); + assert!(!doc.contains("\n```', + attachments: [ + { + id: "att-1", + name: "report.png", + size: 2048, + mediaType: "image/png", + kind: "image", + discarded: false, + downloadUrl: "/download/report.png", + previewUrl: "/preview/report.png", + }, + ], + references: [ + { + messageId: "u-1", + quote: "参考这条消息", + }, + ], + }), + ]} + onPreviewAttachment={vi.fn()} + onPreviewHtml={onPreviewHtml} + onSaveAttachment={vi.fn()} + />, + ); + + // 历史消息内容(user 正文 + bot 引用块均含该文本) + expect(screen.getAllByText("参考这条消息").length).toBeGreaterThanOrEqual( + 1, + ); + // 附件图片经 blob 异步加载后渲染 + expect(await screen.findByAltText("report.png")).toBeTruthy(); + // 引用块(runtime-quote-block,对齐 WebUI 结构) + const quoteBlock = document.querySelector(".runtime-quote-block"); + expect(quoteBlock).toBeTruthy(); + expect(quoteBlock?.textContent).toContain("参考这条消息"); + + // 流式 bot 消息 + expect(screen.getByTestId("streaming-message")).toBeTruthy(); + expect(screen.getByText("正在处理您的请求...")).toBeTruthy(); + expect(screen.getByText("group.get_member_info")).toBeTruthy(); + + // HTML 预览不在流式消息中渲染 + expect(screen.queryByRole("heading", { name: "报告" })).toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: "预览 HTML" })); + + expect(onPreviewHtml).toHaveBeenCalledWith({ + title: "HTML 预览", + html: '

报告

', + }); + }); + + test("uses windowed rendering for large histories", () => { + const manyItems = Array.from({ length: 180 }, (_, index) => + historyItem({ + messageId: `msg-${index}`, + content: `消息 ${index}`, + timestamp: `2026-06-08T10:${String(index).padStart(2, "0")}:00`, + }), + ); + + renderTimeline( + , + ); + + const timeline = screen.getByRole("log", { name: "消息" }); + expect(within(timeline).queryByText("消息 0")).toBeNull(); + expect(within(timeline).getByText("消息 179")).toBeTruthy(); + expect(within(timeline).getAllByTestId("message-row").length).toBeLessThan( + 80, + ); + }); + + test("renders load-more control and invokes history pagination", async () => { + const onLoadMoreHistory = vi.fn(async () => undefined); + renderTimeline( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "加载更早消息" })); + + expect(onLoadMoreHistory).toHaveBeenCalledOnce(); + }); + + test("windows large histories even when pagination is connected", () => { + const manyItems = Array.from({ length: 260 }, (_, index) => + historyItem({ + messageId: `msg-${index}`, + content: `已加载消息 ${index}`, + }), + ); + + renderTimeline( + , + ); + + const timeline = screen.getByRole("log", { name: "消息" }); + // 始终窗口化:即使提供了 onLoadMoreHistory,初始也只渲染最近一窗,最新消息可见、最早被裁。 + expect(within(timeline).queryByText("已加载消息 0")).toBeNull(); + expect(within(timeline).getByText("已加载消息 259")).toBeTruthy(); + expect(within(timeline).getAllByTestId("message-row").length).toBeLessThan( + 80, + ); + }); + + test("expands the window to reveal earlier locally-loaded messages", async () => { + const manyItems = Array.from({ length: 200 }, (_, index) => + historyItem({ + messageId: `msg-${index}`, + content: `已加载消息 ${index}`, + }), + ); + + renderTimeline( + , + ); + + const timeline = screen.getByRole("log", { name: "消息" }); + const initialRows = within(timeline).getAllByTestId("message-row").length; + // 本地仍有未展开的更早消息:显示"加载更早消息"且不调用后端 + const loadMore = screen.getByRole("button", { name: "加载更早消息" }); + await userEvent.click(loadMore); + expect( + within(timeline).getAllByTestId("message-row").length, + ).toBeGreaterThan(initialRows); + }); + + test("loads more history automatically when scrolled near the top", async () => { + const onLoadMoreHistory = vi.fn(async () => undefined); + renderTimeline( + , + ); + + const timeline = screen.getByRole("log", { name: "消息" }); + Object.defineProperty(timeline, "scrollHeight", { + configurable: true, + value: 1000, + }); + Object.defineProperty(timeline, "clientHeight", { + configurable: true, + value: 400, + }); + timeline.scrollTop = 24; + fireEvent.scroll(timeline); + + await waitFor(() => { + expect(onLoadMoreHistory).toHaveBeenCalledOnce(); + }); + }); + + test("keeps scroll position anchored after loading older history", async () => { + const rafCallbacks: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }); + const onLoadMoreHistory = vi.fn(async () => undefined); + renderTimeline( + , + ); + + const timeline = screen.getByRole("log", { name: "消息" }); + Object.defineProperty(timeline, "scrollHeight", { + configurable: true, + value: 1000, + }); + Object.defineProperty(timeline, "clientHeight", { + configurable: true, + value: 400, + }); + timeline.scrollTop = 20; + + await userEvent.click(screen.getByRole("button", { name: "加载更早消息" })); + + Object.defineProperty(timeline, "scrollHeight", { + configurable: true, + value: 1400, + }); + await Promise.resolve(); + for (const callback of rafCallbacks.splice(0)) { + callback(0); + } + + expect(timeline.scrollTop).toBe(420); + expect(onLoadMoreHistory).toHaveBeenCalledOnce(); + }); + + test("scrolls to bottom when an external signal changes", () => { + vi.useFakeTimers(); + const rafCallbacks: FrameRequestCallback[] = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }); + const { rerender } = renderTimeline( + , + ); + const timeline = screen.getByRole("log", { name: "消息" }); + Object.defineProperty(timeline, "scrollHeight", { + configurable: true, + value: 1800, + }); + timeline.scrollTop = 120; + + rerender( + ({ + status: 200, + ok: true, + mediaType: "image/png", + bytes: [137, 80, 78, 71], + body: null, + })), + }} + > + + , + ); + + expect(timeline.scrollTop).toBe(1800); + Object.defineProperty(timeline, "scrollHeight", { + configurable: true, + value: 2200, + }); + for (const callback of rafCallbacks.splice(0)) { + callback(0); + } + for (const callback of rafCallbacks.splice(0)) { + callback(0); + } + expect(timeline.scrollTop).toBe(2200); + Object.defineProperty(timeline, "scrollHeight", { + configurable: true, + value: 2400, + }); + vi.advanceTimersByTime(280); + expect(timeline.scrollTop).toBe(2400); + vi.useRealTimers(); + }); + + test("点击附件图片以已加载的 blob URL 打开查看器", async () => { + const onOpenImage = vi.fn(); + renderTimeline( + , + ); + + await userEvent.click(await screen.findByAltText("chart.png")); + + expect(onOpenImage).toHaveBeenCalledWith( + expect.stringMatching(/^blob:/), + "chart.png", + ); + }); + + test("正文 引用的图片只内联渲染,不在附件区重复", async () => { + renderTimeline( + ', + attachments: [ + { + id: "img-1", + name: "chart.png", + size: 2048, + mediaType: "image/png", + kind: "image", + downloadUrl: "/api/v1/chat/attachments/img-1", + previewUrl: "/api/v1/chat/attachments/img-1/preview", + discarded: false, + }, + ], + }), + ]} + onPreviewAttachment={vi.fn()} + onPreviewHtml={vi.fn()} + onSaveAttachment={vi.fn()} + />, + ); + + // 正文已内联该图,附件区被过滤,全局只应有一个对应图片 + const imgs = await screen.findAllByAltText("chart.png"); + expect(imgs).toHaveLength(1); + }); + + test("未被正文引用的图片(如上传)仍在附件区展示", async () => { + renderTimeline( + , + ); + + // content 未引用该 uid,附件区保留展示 + const imgs = await screen.findAllByAltText("photo.png"); + expect(imgs).toHaveLength(1); + }); + + test("timeline 缺失时从 calls 回退重建工具块", () => { + const item = historyItem({ + messageId: "b-calls", + role: "bot", + content: "调用了工具", + }); + item.webchat = { + displayOnly: true, + jobId: "job-x", + mode: "chat", + status: "done", + createdAt: null, + finishedAt: null, + durationMs: 1200, + events: [], + timeline: [], + calls: [ + { + webchat_call_id: "c1", + name: "group.get_member_info", + is_agent: false, + status: "done", + arguments_preview: '{"user_id":"123"}', + result_preview: "ok", + children: [], + timeline: [], + }, + ], + }; + + renderTimeline( + , + ); + + expect(screen.getByText("group.get_member_info")).toBeTruthy(); + }); + + test("timeline 与 calls 缺失时从 events 回退重建工具块", () => { + const item = historyItem({ + messageId: "b-events", + role: "bot", + content: "事件回放", + }); + item.webchat = { + displayOnly: true, + jobId: "job-y", + mode: "chat", + status: "done", + createdAt: null, + finishedAt: null, + durationMs: 800, + calls: [], + timeline: [], + events: [ + { + seq: 1, + event: "tool_start", + payload: { + webchat_call_id: "e1", + name: "web.search", + arguments_preview: '{"q":"hi"}', + }, + }, + { + seq: 2, + event: "tool_end", + payload: { + webchat_call_id: "e1", + status: "done", + result_preview: "结果", + duration_ms: 500, + }, + }, + ] as unknown as ChatEvent[], + }; + + renderTimeline( + , + ); + + expect(screen.getByText("web.search")).toBeTruthy(); + }); + + test("划词引用:选中正文文本后点击浮层调用 onAddSelectionReference", async () => { + const onAddSelectionReference = vi.fn(); + renderTimeline( + , + ); + + const timeline = screen.getByRole("log", { name: "消息" }); + const target = screen.getByText("可被划词引用的正文"); + // 模拟一个落在时间线内的非空文本选区 + const range = document.createRange(); + range.selectNodeContents(target); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + fireEvent.mouseUp(timeline); + + const quoteButton = await screen.findByRole("button", { name: "引用" }); + await userEvent.click(quoteButton); + + expect(onAddSelectionReference).toHaveBeenCalledWith("可被划词引用的正文"); + }); +}); diff --git a/apps/undefined-chat/src/message-timeline/MessageTimeline.tsx b/apps/undefined-chat/src/message-timeline/MessageTimeline.tsx new file mode 100644 index 00000000..eba06c6e --- /dev/null +++ b/apps/undefined-chat/src/message-timeline/MessageTimeline.tsx @@ -0,0 +1,966 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { isJobRunning } from "../chat-store/store"; +import { useTranslation } from "../i18n"; +import { extractAttachmentTags } from "../rendering/AttachmentProcessor"; +import { + type HtmlPreviewRequest, + MarkdownContent, +} from "../rendering/MarkdownContent"; +import type { + Attachment, + ChatJob, + ConnectionState, + HistoryItem, + ToolCallSnapshot, +} from "../runtime-client/types"; +import { isImageAttachment } from "../utils/attachment"; +import { AttachmentCard } from "./AttachmentCard"; +import { ChatStageLabel } from "./ChatStageLabel"; +import { MessageQuoteButton } from "./MessageQuoteButton"; +import { + MessageTimelineContent, + hasRenderableTimeline, +} from "./MessageTimelineContent"; + +export type MessageTimelineProps = { + activeJob: ChatJob | null; + connectionState: ConnectionState; + /** 选中会话的历史尚未加载或加载中:显示加载态而非欢迎页 */ + historyLoading?: boolean; + /** 历史加载失败的错误信息(单会话级,非全局致命) */ + historyError?: string | null; + /** 当前会话是否还有更早的历史可加载 */ + hasMoreHistory?: boolean; + /** 重试加载当前会话历史 */ + onRetryHistory?: () => void; + /** 加载更早历史;提供时渲染所有已加载消息,避免旧页被窗口化裁掉 */ + onLoadMoreHistory?: () => Promise | void; + /** + * 是否启用自动滚动到底部。false 时流式/历史加载完成均不自动滚底, + * 尊重用户上滑查看历史的意图(跨分区契约 1:由 App 传入)。默认 true。 + */ + autoScrollEnabled?: boolean; + /** + * 外部显式请求滚到底部的信号值。用于输入框聚焦、发送消息等用户主动动作; + * 与 autoScrollEnabled 区分,避免把手动动作误判为被动流式跟随。 + */ + scrollToBottomSignal?: number; + items: HistoryItem[]; + onPreviewHtml: (input: HtmlPreviewRequest) => void; + onPreviewAttachment: (attachment: Attachment) => void; + onSaveAttachment: (attachment: Attachment) => void; + onShortcutClick?: (prompt: string) => void; + onAddReference?: (messageId: string) => void; + /** + * 划词引用回调(跨分区契约 2):用户在消息正文区选中文本并点击"引用"浮层时触发, + * App 端转调 store.addReferenceFromSelection(conversationId, text)。 + */ + onAddSelectionReference?: (text: string) => void; + onOpenImage?: (src: string, alt: string) => void; + onCancelJob?: (jobId: string) => void; +}; + +const WINDOW_SIZE = 64; +type HistoryToolCall = { + id?: string; + name: string; + is_agent?: boolean; + status: string; + arguments_preview?: string; + result_preview?: string; + ui_hint?: string; + duration_ms?: number; + current_stage?: string; + current_stage_detail?: string; + children?: HistoryToolCall[]; + timeline?: unknown[]; +}; + +type HistoryTimelineEntry = { + type: string; + content?: string; + stage?: string; + detail?: string; + call?: HistoryToolCall; +}; + +function convertToolCallSnapshot(snap: ToolCallSnapshot): HistoryToolCall { + return { + id: snap.id, + name: snap.name, + is_agent: snap.isAgent, + status: snap.status, + arguments_preview: snap.argumentsPreview, + result_preview: snap.resultPreview, + ui_hint: snap.uiHint, + duration_ms: snap.durationMs ?? snap.elapsedMs ?? undefined, + current_stage: snap.currentStage, + children: snap.children?.map(convertToolCallSnapshot), + timeline: snap.timeline, + }; +} + +function buildStreamingTimeline(job: ChatJob): HistoryTimelineEntry[] { + // 优先用 currentTimeline:按事件到达顺序,message 段与 call 交错(对齐 WebUI append 顺序) + if (job.currentTimeline.length > 0) { + const entries: HistoryTimelineEntry[] = []; + for (const item of job.currentTimeline) { + if (item.type === "message") { + entries.push({ type: "message", content: item.content }); + } else { + const snap = job.currentToolCalls.find( + (s) => s.id === item.callId || (!s.id && s.name === item.callId), + ); + if (snap) { + entries.push({ type: "call", call: convertToolCallSnapshot(snap) }); + } + } + } + return entries; + } + // 回退(无 currentTimeline,如旧数据) + const timeline: HistoryTimelineEntry[] = []; + if (job.reply.trim()) { + timeline.push({ type: "message", content: job.reply }); + } + for (const toolCall of job.currentToolCalls) { + timeline.push({ + type: "call", + call: convertToolCallSnapshot(toolCall), + }); + } + return timeline; +} + +/** + * 历史 webchat 记录的原始事件条目(对齐后端 _finalize_webchat_history_events)。 + * 仅在 timeline 缺失需从 calls/events 回退重建时使用。 + */ +type HistoryEvent = { + seq?: number; + event?: string; + payload?: { + content?: string; + message?: string; + name?: string; + api_name?: string; + is_agent?: boolean; + status?: string; + ok?: boolean; + arguments_preview?: string; + result_preview?: string; + ui_hint?: string; + duration_ms?: number; + current_stage?: string; + current_stage_detail?: string; + webchat_call_id?: string; + parent_webchat_call_id?: string; + }; +}; + +/** + * 后端 calls 树节点(_call_preview_node)以 webchat_call_id 标识、含 current_stage_detail, + * 统一归一化为 MessageTimelineContent 可消费的 HistoryToolCall(id/current_stage_detail 兼容)。 + */ +function normalizeCallNode(node: Record): HistoryToolCall { + const children = Array.isArray(node.children) + ? (node.children as Record[]).map(normalizeCallNode) + : undefined; + return { + id: String(node.webchat_call_id ?? node.id ?? "") || undefined, + name: String(node.name ?? "--"), + is_agent: Boolean(node.is_agent), + status: String(node.status ?? "done"), + arguments_preview: node.arguments_preview + ? String(node.arguments_preview) + : undefined, + result_preview: node.result_preview + ? String(node.result_preview) + : undefined, + ui_hint: node.ui_hint ? String(node.ui_hint) : undefined, + duration_ms: + typeof node.duration_ms === "number" ? node.duration_ms : undefined, + current_stage: node.current_stage ? String(node.current_stage) : undefined, + current_stage_detail: node.current_stage_detail + ? String(node.current_stage_detail) + : undefined, + children, + timeline: Array.isArray(node.timeline) ? node.timeline : undefined, + }; +} + +/** + * 从原始 events 重建顶层时间线:顺序提取顶层 message 文本与 tool/agent 调用。 + * 简化处理——仅重建顶层(parent 为空)的 call/message,子调用经 tool_end 的 + * result_preview/status 落到对应节点;不深挖嵌套层级(calls 缺失通常意味着旧/简单记录)。 + */ +function buildTimelineFromEvents( + events: HistoryEvent[], +): HistoryTimelineEntry[] { + const entries: HistoryTimelineEntry[] = []; + const callIndexById = new Map(); + for (const item of events) { + const event = String(item.event ?? ""); + const payload = item.payload ?? {}; + if (event === "message") { + if (String(payload.parent_webchat_call_id ?? "").trim()) { + continue; // 仅顶层文本 + } + const content = String(payload.content ?? payload.message ?? ""); + if (content.trim()) { + entries.push({ type: "message", content }); + } + continue; + } + if (event === "tool_start" || event === "agent_start") { + if (String(payload.parent_webchat_call_id ?? "").trim()) { + continue; // 仅重建顶层调用 + } + const callId = String(payload.webchat_call_id ?? ""); + const call: HistoryToolCall = { + id: callId || undefined, + name: String(payload.name ?? payload.api_name ?? "--"), + is_agent: Boolean(payload.is_agent), + status: "running", + arguments_preview: payload.arguments_preview, + ui_hint: payload.ui_hint, + current_stage: payload.current_stage, + current_stage_detail: payload.current_stage_detail, + }; + if (callId) { + callIndexById.set(callId, entries.length); + } + entries.push({ type: "call", call }); + continue; + } + if (event === "tool_end" || event === "agent_end") { + const callId = String(payload.webchat_call_id ?? ""); + const index = callId ? callIndexById.get(callId) : undefined; + if (index === undefined) { + continue; + } + const existing = entries[index]?.call; + if (!existing) { + continue; + } + existing.status = String( + payload.status ?? (payload.ok === false ? "error" : "done"), + ); + if (payload.result_preview) { + existing.result_preview = String(payload.result_preview); + } + if (typeof payload.duration_ms === "number") { + existing.duration_ms = payload.duration_ms; + } + if (payload.ui_hint) { + existing.ui_hint = String(payload.ui_hint); + } + } + } + return entries; +} + +/** + * 历史 bot 消息的可渲染时间线,按可用性回退: + * 1. webchat.timeline(首选,后端已交错好的完整时间线); + * 2. webchat.calls(仅有调用树时,逐根节点包成 call 条目,正文走 fallbackContent 兜底); + * 3. webchat.events(最原始,重建顶层 message + call)。 + * 三者皆空时返回 null,由调用方走普通正文渲染。 + */ +function buildHistoryTimeline( + webchat: HistoryItem["webchat"], +): unknown[] | null { + if (!webchat) { + return null; + } + if (hasRenderableTimeline(webchat.timeline)) { + return webchat.timeline ?? null; + } + const calls = Array.isArray(webchat.calls) ? webchat.calls : []; + if (calls.length > 0) { + return calls.map((node) => ({ + type: "call", + call: normalizeCallNode(node as Record), + })); + } + const events = Array.isArray(webchat.events) + ? (webchat.events as HistoryEvent[]) + : []; + if (events.length > 0) { + const rebuilt = buildTimelineFromEvents(events); + if (rebuilt.some((entry) => entry.type === "call")) { + return rebuilt; + } + } + return null; +} + +/** + * 格式化消息时间戳为 "HH:MM"。HistoryItem.timestamp 为 string, + * 兼容 Unix 秒/毫秒数字字符串与 ISO 字符串。 + * WebUI 无显式时间戳元素,此函数仅用于保留用户要求的轻量时间戳。 + */ +function formatMessageTime(timestamp: string): string { + if (!timestamp) return ""; + const trimmed = timestamp.trim(); + // 纯数字字符串:Unix 时间戳(秒或毫秒) + if (/^\d+$/.test(trimmed)) { + const n = Number(trimmed); + const ms = n > 1e12 ? n : n * 1000; + const d = new Date(ms); + return Number.isNaN(d.getTime()) + ? "" + : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + // ISO 字符串 + const d = new Date(timestamp); + return Number.isNaN(d.getTime()) + ? "" + : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +export function MessageTimeline({ + activeJob, + historyLoading, + historyError, + hasMoreHistory = false, + onRetryHistory, + onLoadMoreHistory, + autoScrollEnabled = true, + scrollToBottomSignal, + items, + onPreviewAttachment, + onPreviewHtml, + onSaveAttachment, + onShortcutClick, + onAddReference, + onAddSelectionReference, + onOpenImage, + onCancelJob, +}: MessageTimelineProps) { + const { t } = useTranslation(); + // 始终窗口化:仅渲染最近 visibleCount 条已加载消息,避免长历史全量渲染卡顿。 + // 用户上滑/点击加载更早时同时增大 visibleCount 展开更多本地已加载项。 + const [visibleCount, setVisibleCount] = useState(WINDOW_SIZE); + const visibleItems = + items.length > visibleCount ? items.slice(-visibleCount) : items; + const timelineRef = useRef(null); + // 是否贴附底部:用户向上滚动查看历史时暂停自动滚动(智能暂停) + const stickToBottomRef = useRef(true); + // 程序化滚动标志:避免程序化滚动触发 handleTimelineScroll 重置 stickToBottom + const isProgrammaticScrollRef = useRef(false); + // 历史加载状态:用于检测"加载完成"时刻(true → false)触发滚底 + const prevHistoryLoadingRef = useRef(false); + const loadingMoreRef = useRef(false); + + const isCurrentlyThinking = isJobRunning(activeJob); + // 本地是否还有已加载但被窗口裁掉、未展示的更早消息 + const hasHiddenLocalItems = items.length > visibleItems.length; + // 加载更早控件可用:本地有未展开项,或后端还有更早历史 + const canLoadMore = hasHiddenLocalItems || hasMoreHistory; + + // 锚定滚动位置:在 mutate(展开 visibleCount / 拉取后端)前后保持顶部锚点不跳动 + function withScrollAnchor(mutate: () => void): void { + const el = timelineRef.current; + const previousScrollHeight = el?.scrollHeight ?? 0; + const previousScrollTop = el?.scrollTop ?? 0; + mutate(); + requestAnimationFrame(() => { + const nextEl = timelineRef.current; + if (nextEl) { + nextEl.scrollTop = + nextEl.scrollHeight - previousScrollHeight + previousScrollTop; + } + }); + } + + async function loadMoreHistory(): Promise { + // 优先展开本地已加载项(无需访问后端) + if (hasHiddenLocalItems) { + withScrollAnchor(() => { + setVisibleCount((count) => count + WINDOW_SIZE); + }); + return; + } + if (!hasMoreHistory || historyLoading || !onLoadMoreHistory) { + return; + } + const el = timelineRef.current; + const previousScrollHeight = el?.scrollHeight ?? 0; + const previousScrollTop = el?.scrollTop ?? 0; + loadingMoreRef.current = true; + try { + await onLoadMoreHistory(); + } finally { + requestAnimationFrame(() => { + // 拉取到的更早消息已 prepend 到 items,同步扩大窗口让其可见 + setVisibleCount((count) => count + WINDOW_SIZE); + const nextEl = timelineRef.current; + if (nextEl) { + nextEl.scrollTop = + nextEl.scrollHeight - previousScrollHeight + previousScrollTop; + } + loadingMoreRef.current = false; + }); + } + } + + const scrollToBottom = useCallback((): void => { + const el = timelineRef.current; + if (!el) return; + isProgrammaticScrollRef.current = true; + el.scrollTop = el.scrollHeight; + requestAnimationFrame(() => { + isProgrammaticScrollRef.current = false; + }); + }, []); + + const scheduleScrollToBottom = useCallback((): (() => void) => { + const frameIds: number[] = []; + const timeoutIds: number[] = []; + const run = (): void => { + scrollToBottom(); + }; + run(); + frameIds.push( + requestAnimationFrame(() => { + run(); + frameIds.push(requestAnimationFrame(run)); + }), + ); + timeoutIds.push(window.setTimeout(run, 120)); + timeoutIds.push(window.setTimeout(run, 280)); + return () => { + for (const frameId of frameIds) { + cancelAnimationFrame(frameId); + } + for (const timeoutId of timeoutIds) { + window.clearTimeout(timeoutId); + } + }; + }, [scrollToBottom]); + + function handleTimelineScroll(): void { + // 滚动后选区浮层位置失效(fixed 视口坐标),随滚动关闭 + if (selectionRef) { + setSelectionRef(null); + } + if (isProgrammaticScrollRef.current) return; // 程序化滚动不更新 stickToBottom + const el = timelineRef.current; + if (!el) return; + stickToBottomRef.current = + el.scrollHeight - el.scrollTop - el.clientHeight < 80; + if ( + el.scrollTop < 72 && + canLoadMore && + !historyLoading && + !loadingMoreRef.current + ) { + void loadMoreHistory(); + } + } + + // 流式滚动:新工具调用/文本变化时滚底(发送后也由此滚底,无需单独"发送滚顶") + const streamSignature = activeJob + ? [ + activeJob.jobId, + activeJob.reply.length, + activeJob.currentStage, + activeJob.currentStageDetail, + activeJob.currentToolCalls.length, + activeJob.currentAgentStages.length, + ].join(":") + : ""; + const prevSigRef = useRef<{ jobId: string | null; toolCount: number }>({ + jobId: null, + toolCount: 0, + }); + // biome-ignore lint/correctness/useExhaustiveDependencies: streamSignature + activeJob 作为流式信号 + useEffect(() => { + if (!activeJob) return; + const jobId = activeJob.jobId; + const toolCount = activeJob.currentToolCalls.length; + const prev = prevSigRef.current; + const isNewJob = jobId !== prev.jobId; + if (isNewJob) { + // 新 job:重置基线 + prev.jobId = jobId; + prev.toolCount = toolCount; + } + // 尊重外部自动滚动开关(契约 1):关闭时仅更新基线,不自动滚底 + if (!autoScrollEnabled) { + prev.toolCount = toolCount; + return; + } + const raf = requestAnimationFrame(() => { + if (isNewJob || toolCount > prev.toolCount) { + // 新 job 或新工具调用:强制滚底 + 恢复跟随 + stickToBottomRef.current = true; + scrollToBottom(); + } else if (stickToBottomRef.current) { + // 文本/阶段变化:贴底跟随 + scrollToBottom(); + } + prev.toolCount = toolCount; + }); + return () => cancelAnimationFrame(raf); + }, [streamSignature, activeJob, autoScrollEnabled]); + + const prevScrollToBottomSignalRef = useRef(scrollToBottomSignal); + useEffect(() => { + if (scrollToBottomSignal === undefined) { + return; + } + if (prevScrollToBottomSignalRef.current === scrollToBottomSignal) { + return; + } + prevScrollToBottomSignalRef.current = scrollToBottomSignal; + stickToBottomRef.current = true; + return scheduleScrollToBottom(); + }, [scrollToBottomSignal, scheduleScrollToBottom]); + + // 初次加载历史/切换会话完成:滚到底部(监听 historyLoading 从 true → false) + // biome-ignore lint/correctness/useExhaustiveDependencies: historyLoading 作为加载完成信号 + useEffect(() => { + const prev = prevHistoryLoadingRef.current; + const current = Boolean(historyLoading); + prevHistoryLoadingRef.current = current; + if (prev && !current && visibleItems.length > 0) { + if (loadingMoreRef.current || !autoScrollEnabled) { + return; + } + // 历史加载完成:滚底 + 恢复跟随 + stickToBottomRef.current = true; + const raf = requestAnimationFrame(scrollToBottom); + return () => cancelAnimationFrame(raf); + } + }, [historyLoading]); + + // 切换会话:滚到底部(覆盖"点击进入有缓存的对话"场景,此时 historyLoading 无 true→false 转换) + // biome-ignore lint/correctness/useExhaustiveDependencies: key 作为切换信号(MessageTimeline 用 key={conversationId}) + useEffect(() => { + if (visibleItems.length > 0 && autoScrollEnabled) { + stickToBottomRef.current = true; + const raf = requestAnimationFrame(scrollToBottom); + return () => cancelAnimationFrame(raf); + } + }, []); + + // 快捷模板:稳定 id 作 React key(不随 locale 变化),title/desc/prompt 走 i18n + const shortcuts = [ + { + id: "shortcut.news", + icon: ( + + ), + title: t("shortcut.news.title"), + desc: t("shortcut.news.desc"), + prompt: t("shortcut.news.prompt"), + }, + { + id: "shortcut.joke", + icon: ( + + ), + title: t("shortcut.joke.title"), + desc: t("shortcut.joke.desc"), + prompt: t("shortcut.joke.prompt"), + }, + { + id: "shortcut.polish", + icon: ( + + ), + title: t("shortcut.polish.title"), + desc: t("shortcut.polish.desc"), + prompt: t("shortcut.polish.prompt"), + }, + { + id: "shortcut.code", + icon: ( + + ), + title: t("shortcut.code.title"), + desc: t("shortcut.code.desc"), + prompt: t("shortcut.code.prompt"), + }, + ]; + + // 划词引用浮层(契约 2):选中正文文本时显示"引用"按钮,点击回传选区文本。 + // 仅在 App 提供 onAddSelectionReference 时启用。 + const [selectionRef, setSelectionRef] = useState<{ + text: string; + top: number; + left: number; + } | null>(null); + + function clearSelectionRef(): void { + setSelectionRef(null); + } + + function handleSelectionPointerUp(): void { + if (!onAddSelectionReference) { + return; + } + const selection = window.getSelection(); + const text = selection?.toString().trim() ?? ""; + const container = timelineRef.current; + if (!selection || selection.rangeCount === 0 || !text || !container) { + clearSelectionRef(); + return; + } + const range = selection.getRangeAt(0); + // 选区必须落在时间线容器内(避免选到输入框/侧栏等区域) + if (!container.contains(range.commonAncestorContainer)) { + clearSelectionRef(); + return; + } + // 视口坐标 + position:fixed,不依赖容器是否为定位上下文(CSS 归 WF1/其他分区)。 + // getBoundingClientRect 在部分环境(如 jsdom)未实现,缺失时退化为左上角定位。 + const rect = + typeof range.getBoundingClientRect === "function" + ? range.getBoundingClientRect() + : null; + setSelectionRef({ + text, + top: rect?.top ?? 0, // 浮层显示在选区上方 + left: (rect?.left ?? 0) + (rect?.width ?? 0) / 2, + }); + } + + function confirmSelectionRef(): void { + if (selectionRef) { + onAddSelectionReference?.(selectionRef.text); + } + window.getSelection()?.removeAllRanges(); + clearSelectionRef(); + } + + return ( +
+
+ {historyError && visibleItems.length === 0 && !activeJob ? ( +
+

{historyError}

+ {onRetryHistory ? ( + + ) : null} +
+ ) : historyLoading && visibleItems.length === 0 && !activeJob ? ( +
+
+ ) : visibleItems.length === 0 && !activeJob ? ( +
+
+ +
+
+

{t("timeline.welcomeTitle")}

+

{t("timeline.welcomeSubtitle")}

+
+
+ {shortcuts.map((card) => ( + + ))} +
+
+ ) : null} + + {visibleItems.length === 0 && activeJob ? ( +
+ {t("timeline.noMessages")} +
+ ) : null} + + {visibleItems.length > 0 && (canLoadMore || historyLoading) ? ( +
+ +
+ ) : null} + + {visibleItems.map((item) => { + const isBot = item.role === "bot"; + const durationMs = item.webchat?.durationMs ?? null; + const hasDuration = + typeof durationMs === "number" && + Number.isFinite(durationMs) && + durationMs >= 0; + const timeText = formatMessageTime(item.timestamp); + return ( +
+
+ + {isBot ? t("timeline.roleAi") : t("timeline.roleYou")} + + {isBot && hasDuration ? ( + + ) : null} + {onAddReference && isBot ? ( + + ) : null} + {timeText ? ( + {timeText} + ) : null} +
+
+ {item.references.length > 0 + ? item.references.map((reference) => ( +
+ {reference.quote} +
+ )) + : null} + {(() => { + // bot 消息含工具调用/分段文本时,按统一时间线渲染(正文与工具块按序穿插,避免正文重复)。 + // timeline 缺失时回退 calls/events 重建(buildHistoryTimeline 内部已按优先级处理)。 + const timeline = isBot + ? buildHistoryTimeline(item.webchat) + : null; + if (timeline) { + return ( + + ); + } + // 普通消息:直接渲染正文 + return ( + + ); + })()} + {(() => { + // 正文已内联渲染的图片 uid(避免与附件区重复); + // 未在正文引用的图片(如用户上传)仍在附件区展示。 + const inlinedUids = new Set( + extractAttachmentTags(item.content).attachmentUids, + ); + return item.attachments + .filter( + (attachment) => + !( + isImageAttachment(attachment) && + inlinedUids.has(attachment.id) + ), + ) + .map((attachment) => ( + + )); + })()} +
+
+ ); + })} + + {/* 流式 bot 气泡:activeJob 存在且运行中时立即显示 */} + {activeJob && isCurrentlyThinking ? ( +
+
+ + {t("timeline.roleAi")} + + + {onCancelJob ? ( + + ) : null} + {onAddReference ? ( + + ) : null} +
+
+ +
+
+ ) : null} +
+ + {/* 划词引用浮层(契约 2):选中正文文本后浮现的"引用"按钮 */} + {selectionRef ? ( + + ) : null} +
+ ); +} diff --git a/apps/undefined-chat/src/message-timeline/MessageTimelineContent.test.tsx b/apps/undefined-chat/src/message-timeline/MessageTimelineContent.test.tsx new file mode 100644 index 00000000..2f1ddb67 --- /dev/null +++ b/apps/undefined-chat/src/message-timeline/MessageTimelineContent.test.tsx @@ -0,0 +1,120 @@ +import { screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { describe, expect, test, vi } from "vitest"; +import { renderWithProviders } from "../test-utils"; +import { + MessageTimelineContent, + hasRenderableTimeline, +} from "./MessageTimelineContent"; + +/** MessageTimelineContent 内部经 ToolBlock 使用 useTranslation,需 LanguageProvider。 */ +function renderContent(ui: ReactElement) { + return renderWithProviders(ui); +} + +describe("hasRenderableTimeline", () => { + test("returns false for empty or undefined timeline", () => { + expect(hasRenderableTimeline(undefined)).toBe(false); + expect(hasRenderableTimeline([])).toBe(false); + }); + + test("returns false when timeline has only blank message entries", () => { + expect(hasRenderableTimeline([{ type: "message", content: " " }])).toBe( + false, + ); + }); + + test("returns true when timeline contains a tool call", () => { + expect( + hasRenderableTimeline([ + { type: "call", call: { name: "search", status: "done" } }, + ]), + ).toBe(true); + }); + + test("returns true when timeline contains a non-empty message", () => { + expect(hasRenderableTimeline([{ type: "message", content: "hello" }])).toBe( + true, + ); + }); +}); + +describe("MessageTimelineContent", () => { + test("renders message text and tool calls in order without duplicating content", () => { + renderContent( + , + ); + + // 两段文本各渲染一次,不因 fallbackContent 而重复 + expect(screen.getAllByText("第一段回答内容")).toHaveLength(1); + expect(screen.getAllByText("第二段补充内容")).toHaveLength(1); + expect(screen.getByText("send_message")).toBeTruthy(); + expect(screen.getByText("完成")).toBeTruthy(); + }); + + test("falls back to full-text content when timeline has no message entries", () => { + renderContent( + , + ); + + expect(screen.getByText("这是完整正文兜底")).toBeTruthy(); + expect(screen.getByText("Agent")).toBeTruthy(); + }); + + test("renders nested child calls inside a tool block", () => { + renderContent( + , + ); + + expect(screen.getByText("Agent")).toBeTruthy(); + expect(screen.getByText("search")).toBeTruthy(); + }); +}); diff --git a/apps/undefined-chat/src/message-timeline/MessageTimelineContent.tsx b/apps/undefined-chat/src/message-timeline/MessageTimelineContent.tsx new file mode 100644 index 00000000..8fd6228e --- /dev/null +++ b/apps/undefined-chat/src/message-timeline/MessageTimelineContent.tsx @@ -0,0 +1,217 @@ +import { useState } from "react"; +import type { ToolBlock as ToolBlockType } from "../chat-store/types"; +import { + type HtmlPreviewRequest, + MarkdownContent, +} from "../rendering/MarkdownContent"; +import type { Attachment } from "../runtime-client/types"; +import { ImagePreview } from "./ImagePreview"; +import { ToolBlock } from "./ToolBlock"; +import "./ToolBlock.css"; + +type HistoryToolCall = { + id?: string; + name: string; + is_agent?: boolean; + status: string; + arguments_preview?: string; + result_preview?: string; + ui_hint?: string; + duration_ms?: number; + current_stage?: string; + current_stage_detail?: string; + children?: HistoryToolCall[]; + timeline?: HistoryTimelineEntry[]; +}; + +type HistoryTimelineEntry = { + type: string; + content?: string; + stage?: string; + detail?: string; + call?: HistoryToolCall; +}; + +export type MessageTimelineContentProps = { + timeline: unknown[]; + /** 完整正文,timeline 中无任何顶层 message 文本时作为兜底渲染 */ + fallbackContent?: string; + attachments?: Attachment[]; + onPreviewHtml: (input: HtmlPreviewRequest) => void; + /** + * 外部图片点击回调。提供时点击图片交由外部处理(如全局图片查看器); + * 未提供时由本组件内置的 ImagePreview 本地预览。 + */ + onImageClick?: (src: string, alt: string) => void; +}; + +function isToolCall(node: unknown): node is HistoryToolCall { + return ( + node !== null && + typeof node === "object" && + "name" in (node as Record) + ); +} + +function normalizeEntries(timeline: unknown[]): HistoryTimelineEntry[] { + if (!Array.isArray(timeline)) return []; + return timeline.filter( + (e): e is HistoryTimelineEntry => + e !== null && typeof e === "object" && "type" in e, + ); +} + +/** + * 判断 timeline 是否包含可渲染的工具调用或消息片段。 + * 仅在为真时才走统一时间线渲染模式。 + */ +export function hasRenderableTimeline( + timeline: unknown[] | undefined, +): boolean { + const entries = normalizeEntries(timeline ?? []); + return entries.some( + (e) => + (e.type === "call" && isToolCall(e.call)) || + (e.type === "message" && Boolean(e.content?.trim())), + ); +} + +/** + * 将 HistoryToolCall 转换为 ToolBlock 组件所需的格式 + */ +function convertHistoryToolCallToToolBlock( + call: HistoryToolCall, + index: number, +): ToolBlockType { + const status = call.status || "done"; + const mappedStatus: ToolBlockType["status"] = + status === "error" + ? "error" + : status === "running" + ? "running" + : status === "cancelled" + ? "cancelled" + : "done"; + + // 转换子工具调用(优先从 timeline 中提取,兼容旧的 children 字段) + const children = new Map(); + + // 从 timeline 中提取子调用(新格式) + if (Array.isArray(call.timeline)) { + const childCalls = call.timeline.filter( + (e): e is HistoryTimelineEntry & { call: HistoryToolCall } => + e.type === "call" && isToolCall(e.call), + ); + + childCalls.forEach((entry, idx) => { + const childBlock = convertHistoryToolCallToToolBlock(entry.call, idx); + children.set(childBlock.webchatCallId, childBlock); + }); + } + + // 兼容旧格式:直接的 children 数组 + if (Array.isArray(call.children)) { + call.children.forEach((child, idx) => { + const childBlock = convertHistoryToolCallToToolBlock(child, idx); + children.set(childBlock.webchatCallId, childBlock); + }); + } + + const now = Date.now(); + const startTime = now - (call.duration_ms || 0); + const endTime = mappedStatus === "running" ? undefined : now; + + return { + webchatCallId: call.id || `call-${index}-${call.name}`, + toolName: call.name || "--", + status: mappedStatus, + isAgent: call.is_agent, + uiHint: call.ui_hint, + argumentsPreview: call.arguments_preview, + resultPreview: call.result_preview, + currentStage: call.current_stage, + stageDetail: call.current_stage_detail, + children, + timeline: [], + startTime, + endTime, + }; +} + +/** + * 渲染一条 bot 消息的完整时间线:按顺序穿插顶层文本片段(message)与工具调用(call)。 + * 顶层文本用完整 Markdown 渲染(图片/代码/附件/引用);工具调用用折叠块展示。 + */ +export function MessageTimelineContent({ + timeline, + fallbackContent, + attachments = [], + onPreviewHtml, + onImageClick, +}: MessageTimelineContentProps) { + const entries = normalizeEntries(timeline); + const hasMessageText = entries.some( + (e) => e.type === "message" && Boolean(e.content?.trim()), + ); + + // 本地图片预览状态:仅在外部未提供 onImageClick 时启用 + const [previewImage, setPreviewImage] = useState<{ + src: string; + alt: string; + } | null>(null); + + // 统一的图片点击处理:优先外部回调,否则走本地预览 + const handleImageClick = + onImageClick ?? + ((src: string, alt: string) => setPreviewImage({ src, alt })); + + return ( +
+ {entries.map((entry, idx) => { + if (entry.type === "call" && isToolCall(entry.call)) { + const toolBlock = convertHistoryToolCallToToolBlock(entry.call, idx); + return ( + + ); + } + if (entry.type === "message" && entry.content?.trim()) { + return ( + + ); + } + return null; + })} + + {/* timeline 中无任何顶层文本时,用完整正文兜底 */} + {!hasMessageText && fallbackContent?.trim() ? ( + + ) : null} + + {/* 内置图片预览:仅外部未接管时由本地 state 驱动 */} + setPreviewImage(null)} + /> +
+ ); +} diff --git a/apps/undefined-chat/src/message-timeline/ToolBlock.README.md b/apps/undefined-chat/src/message-timeline/ToolBlock.README.md new file mode 100644 index 00000000..800ea810 --- /dev/null +++ b/apps/undefined-chat/src/message-timeline/ToolBlock.README.md @@ -0,0 +1,185 @@ +# ToolBlock 组件 + +完整的运行时工具块展示组件,用于可视化 AI 工具调用的执行过程。 + +## 功能特性 + +- ✅ **可折叠展开** - 使用 `
` 元素实现原生折叠/展开(running 自动展开,done/error 后 2s 自动折叠,用户交互后不再自动折叠) +- ✅ **状态指示** - 支持 running / done / error / cancelled 状态,带视觉反馈 +- ✅ **时间线展示** - 显示工具执行的输入、输出、错误历史 +- ✅ **嵌套渲染** - 支持递归渲染子工具调用 +- ✅ **实时计时** - 运行中通过统一时钟 `useChatClock` 每 500ms 刷新用时,结束后定格;自动格式化(ms/s/m) +- ✅ **Agent 阶段明细** - Agent 运行中在状态位展示阶段标签(`currentStage`),并可附加阶段明细 `stageDetail`(如模型名/子步骤) +- ✅ **WebUI 一致预览** - 输入按纯文本展示;输出支持 JSON/Python 风格结构化、Markdown 渲染和附件图片预览 +- ✅ **深浅模式** - 完全适配项目的 Morandi 配色主题 + +## 类型定义 + +```typescript +export type ToolBlockStatus = "running" | "done" | "error" | "cancelled"; + +export type ToolBlock = { + webchatCallId: string; // 唯一标识 + toolName: string; // 工具名称 + status: ToolBlockStatus; + isAgent?: boolean; // 是否为 Agent(决定 kind 标签与阶段展示) + uiHint?: string; // UI 提示,附加为容器修饰类名 + argumentsPreview?: string; // 输入参数预览 + resultPreview?: string; // 结果预览(结构化/Markdown/附件图片) + currentStage?: string; // Agent 当前阶段(运行中展示标签) + stageDetail?: string; // 阶段明细(如模型名/子步骤) + children: Map; // 嵌套子工具 + timeline: TimelineEntry[]; // 时间线事件 + startTime: number; // 开始时间戳 + endTime?: number; // 结束时间戳(可选) +}; + +export type TimelineEntry = + | { type: "input"; timestamp: number; content: string } + | { type: "output"; timestamp: number; content: string } + | { type: "error"; timestamp: number; message: string }; +``` + +## 使用示例 + +### 基本用法 + +```tsx +import { ToolBlock } from "./message-timeline/ToolBlock"; + +const toolBlock = { + webchatCallId: "call-123", + toolName: "search", + status: "done", + children: new Map(), + timeline: [ + { type: "input", timestamp: Date.now() - 2000, content: "搜索内容" }, + { type: "output", timestamp: Date.now(), content: "找到 5 个结果" } + ], + startTime: Date.now() - 2000, + endTime: Date.now() +}; + + +``` + +### 嵌套工具调用 + +```tsx +const childTool = { /* ... */ }; +const parentTool = { + // ... + children: new Map([["child-id", childTool]]) +}; + + +``` + +### 在 MessageTimeline 中集成 + +```tsx +// 从 store 获取工具块 +const toolBlocks = useStore(state => state.toolBlocksByJob[jobId]); + +// 渲染 +{Array.from(toolBlocks.values()).map(block => ( + +))} +``` + +### 历史回放(calls / events 多级回退) + +实时运行的工具块由 store 维护;历史消息则由 `MessageTimelineContent` 消费一条已交错好的 +`timeline`,其中 `type === "call"` 的条目经 `convertHistoryToolCallToToolBlock` 转换为本组件所需的 +`ToolBlock`(映射 `arguments_preview`/`result_preview`/`is_agent`/`current_stage`/`current_stage_detail` +等字段,并按 `duration_ms` 反推 `startTime`)。 + +`timeline` 由 `MessageTimeline` 的 `buildHistoryTimeline` 按可用性多级回退构建: + +1. `webchat.timeline`:首选,后端已交错好的完整时间线; +2. `webchat.calls`:仅有调用树时,逐根节点包成 `call` 条目,正文走 `fallbackContent` 兜底; +3. `webchat.events`:最原始,重建顶层 message + call。 + +三者皆空时回退普通正文渲染。 + +## 视觉设计 + +### 状态颜色 + +- **running** - 橙色边框 + 加载动画 +- **done** - 绿色边框 +- **error** - 红色边框 + +### 布局结构 + +``` +┌─────────────────────────────────────┐ +│ ▶ tool_name 1.2s 完成 Tool │ ← summary (可点击;Agent 运行中显示阶段标签) +├─────────────────────────────────────┤ +│ 输入 │ +│ {"query": "..."} │ ← argumentsPreview (
)
+├─────────────────────────────────────┤
+├─────────────────────────────────────┤
+│ 时间线 + 嵌套子工具 (递归渲染)      │
+│ 12:34:56 [输入] {"query": "..."}    │
+│   ┌───────────────────────────┐    │
+│   │ ▶ child_tool    500ms ... │    │
+│   └───────────────────────────┘    │
+├─────────────────────────────────────┤
+│ 输出                                │
+│   key: value  ...                   │ ← resultPreview (结构化/Markdown/附件图片)
+└─────────────────────────────────────┘
+```
+
+## 样式类名
+
+- `.runtime-tool-block` - 主容器
+  - `.running` / `.done` / `.error` - 状态修饰符
+  - `.is-agent` / `.is-tool` - 类型修饰符(按 `isAgent` 区分)
+  - `uiHint` 经下划线转连字符后追加为修饰类名(如 `runtime-tool-block ... agent-task`)
+- `.runtime-tool-name` - 工具名称
+- `.runtime-tool-duration` - 执行时长
+- `.runtime-tool-status` - 状态/阶段文本(Agent 运行中显示阶段标签)
+- `.runtime-tool-kind` - 类型标签(Agent / Tool)
+- `.runtime-tool-preview` - 输入/输出预览容器
+  - `.runtime-tool-preview-label` / `.runtime-tool-preview-body` - 预览标题 / 内容
+  - `.runtime-tool-preview-body.is-structured` - 结构化结果内容容器
+- `.runtime-tool-structured-list` - WebUI 同名结构化结果根
+  - `.runtime-tool-structured-row` - 结构化结果行
+  - `.runtime-tool-key` / `.runtime-tool-value` - 键 / 值
+  - `.runtime-tool-value.string` / `.number` / `.boolean` / `.muted` - 标量类型修饰类
+- `.runtime-tool-children` - 嵌套子工具与时间线容器
+- `.timeline-entry` - 时间线条目
+  - `.timeline-entry-input` / `.timeline-entry-output` / `.timeline-entry-error`
+
+## 测试覆盖
+
+- 基本渲染(名称、状态)
+- 状态样式(running / done / error)
+- 时长格式化(ms / s / m)
+- 折叠展开交互
+- 时间线渲染(input / output / error)
+- 嵌套子工具递归渲染(单个 / 多个)
+- 空状态处理(无时间线、无子工具)
+- 结果预览:JSON/Python 风格结构化、非结构化 Markdown、附件图片标签 fallback
+- Agent 运行中在阶段标签后展示 `stageDetail`
+
+## 文件结构
+
+```
+src/message-timeline/
+├── ToolBlock.tsx              # 组件实现
+├── ToolBlock.css              # 样式文件
+└── ToolBlock.test.tsx         # 单元测试
+```
+
+## 样式来源
+
+样式从 `webui/static/css/components.css` 的 `.runtime-tool-*` 规则迁移并适配,保持与 WebUI 一致的视觉体验。
+
+## 相关类型
+
+类型定义位于 `src/chat-store/types.ts`:
+- `ToolBlock`
+- `ToolBlockStatus`
+- `TimelineEntry`
diff --git a/apps/undefined-chat/src/message-timeline/ToolBlock.css b/apps/undefined-chat/src/message-timeline/ToolBlock.css
new file mode 100644
index 00000000..1d8f7290
--- /dev/null
+++ b/apps/undefined-chat/src/message-timeline/ToolBlock.css
@@ -0,0 +1,409 @@
+/* ToolBlock 组件样式 - 从 webui components.css 迁移 */
+
+.runtime-tool-block {
+	--tool-accent: var(--accent-color);
+	min-width: 0;
+	max-width: 100%;
+	border: 1px solid var(--border-color);
+	border-radius: var(--radius-sm);
+	background: color-mix(in srgb, var(--bg-app) 92%, var(--bg-card));
+	overflow: hidden;
+	position: relative;
+	transition:
+		border-color 0.18s ease,
+		background 0.18s ease,
+		transform 0.18s ease,
+		box-shadow 0.18s ease;
+	margin: 0.5em 0;
+}
+
+.runtime-tool-block::before {
+	content: "";
+	position: absolute;
+	inset: 0 auto 0 0;
+	width: 3px;
+	background: var(--tool-accent);
+	opacity: 0.65;
+}
+
+.runtime-tool-block.is-tool {
+	--tool-accent: color-mix(in srgb, var(--success) 76%, var(--accent-color));
+}
+
+.runtime-tool-block.is-agent {
+	--tool-accent: var(--accent-color);
+	background: color-mix(in srgb, var(--bg-card) 60%, var(--bg-app));
+}
+
+.runtime-tool-block.running {
+	--tool-accent: color-mix(in srgb, var(--warning) 82%, var(--accent-color));
+}
+
+.runtime-tool-block.done {
+	--tool-accent: var(--success);
+}
+
+.runtime-tool-block.error {
+	--tool-accent: var(--error);
+}
+
+.runtime-tool-block.cancelled {
+	--tool-accent: var(--warning);
+}
+
+.runtime-tool-block:hover {
+	border-color: color-mix(in srgb, var(--tool-accent) 38%, var(--border-color));
+	box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
+}
+
+.runtime-tool-block summary {
+	display: grid;
+	grid-template-columns: auto minmax(0, 1fr) auto auto;
+	align-items: center;
+	gap: 8px;
+	cursor: pointer;
+	min-height: 32px;
+	padding: 3px 10px 3px 13px;
+	font-size: 12px;
+	line-height: 1.2;
+	color: var(--text-secondary);
+	list-style: none;
+}
+
+.runtime-tool-block summary::-webkit-details-marker {
+	display: none;
+}
+
+.runtime-tool-block summary::before {
+	content: "";
+	width: 6px;
+	height: 6px;
+	border-right: 1.5px solid currentColor;
+	border-bottom: 1.5px solid currentColor;
+	transform: rotate(-45deg);
+	transition: transform 0.18s ease;
+	opacity: 0.7;
+}
+
+.runtime-tool-block[open] summary::before {
+	transform: rotate(45deg);
+}
+
+.runtime-tool-block summary .runtime-tool-summary-main {
+	min-width: 0;
+	overflow: hidden;
+}
+
+.runtime-tool-block summary .runtime-tool-title {
+	display: inline-flex;
+	align-items: baseline;
+	gap: 7px;
+	min-width: 0;
+	max-width: 100%;
+	vertical-align: middle;
+}
+
+.runtime-tool-block summary .runtime-tool-name {
+	min-width: 0;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	border: 0;
+	background: transparent;
+	padding: 0;
+	color: var(--text-primary);
+	font-size: 12px;
+	font-weight: 650;
+}
+
+.runtime-tool-block summary .runtime-tool-duration {
+	flex: 0 0 auto;
+	padding: 1px 6px;
+	border-radius: 999px;
+	background: color-mix(in srgb, var(--tool-accent) 10%, transparent);
+	color: color-mix(in srgb, var(--tool-accent) 84%, var(--text-primary));
+	font-family: var(--font-mono);
+	font-size: 10.5px;
+	line-height: 1.4;
+	white-space: nowrap;
+}
+
+.runtime-tool-block summary .runtime-tool-status {
+	min-width: 0;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	font-style: normal;
+	white-space: nowrap;
+	color: var(--text-tertiary);
+	font-size: 11px;
+}
+
+.runtime-tool-block summary .runtime-tool-kind {
+	min-width: 44px;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	text-align: right;
+	white-space: nowrap;
+	color: var(--text-secondary);
+	font-size: 11px;
+}
+
+.runtime-tool-block.webchat-private-send,
+.runtime-tool-block.webchat-end {
+	background: color-mix(in srgb, var(--bg-app) 72%, var(--bg-card));
+}
+
+.runtime-tool-block.webchat-private-send summary,
+.runtime-tool-block.webchat-end summary {
+	min-height: 32px;
+	padding-block: 3px;
+}
+
+.runtime-tool-block.running summary .runtime-tool-status {
+	color: var(--warning);
+}
+
+.runtime-tool-block.done summary .runtime-tool-status {
+	color: var(--success);
+}
+
+.runtime-tool-block.error summary .runtime-tool-status {
+	color: var(--error);
+}
+
+.runtime-tool-block.cancelled summary .runtime-tool-status {
+	color: var(--warning);
+}
+
+.runtime-tool-preview {
+	min-width: 0;
+	max-width: 100%;
+	border-top: 1px solid var(--border-color);
+	padding: 8px 10px 10px;
+	animation: runtime-tool-reveal 0.18s ease-out;
+}
+
+.runtime-tool-preview + .runtime-tool-preview {
+	border-top-style: dashed;
+}
+
+.runtime-tool-preview-label {
+	margin-bottom: 6px;
+	color: var(--text-tertiary);
+	font-size: 11px;
+	font-weight: 600;
+}
+
+.runtime-tool-preview-body {
+	min-width: 0;
+	max-width: 100%;
+	color: var(--text-secondary);
+	font-size: 12px;
+	line-height: 1.5;
+	white-space: pre-wrap;
+	word-break: break-word;
+	max-height: min(34vh, 260px);
+	overflow: auto;
+}
+
+.runtime-tool-preview-body > *:first-child {
+	margin-top: 0;
+}
+
+.runtime-tool-preview-body > *:last-child {
+	margin-bottom: 0;
+}
+
+.runtime-tool-preview-body.is-structured {
+	padding: 8px 10px;
+	border: 1px solid var(--border-color);
+	border-radius: var(--radius-sm);
+	background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-app));
+	white-space: normal;
+}
+
+.runtime-tool-structured-list {
+	display: grid;
+	gap: 6px;
+	min-width: 0;
+}
+
+.runtime-tool-structured-list .runtime-tool-structured-list {
+	margin-top: 5px;
+	padding-left: 10px;
+	border-left: 1px solid var(--border-color);
+}
+
+.runtime-tool-structured-row {
+	display: grid;
+	grid-template-columns: minmax(64px, min(34%, 180px)) minmax(0, 1fr);
+	align-items: start;
+	gap: 8px;
+	min-width: 0;
+}
+
+.runtime-tool-key {
+	min-width: 0;
+	color: var(--accent-color);
+	font-family: var(--font-mono);
+	font-size: 11px;
+	overflow-wrap: anywhere;
+}
+
+.runtime-tool-value {
+	min-width: 0;
+	color: var(--text-secondary);
+	overflow-wrap: anywhere;
+}
+
+.runtime-tool-value.string {
+	color: var(--text-primary);
+}
+
+.runtime-tool-value.number {
+	color: var(--warning);
+	font-family: var(--font-mono);
+}
+
+.runtime-tool-value.boolean {
+	color: var(--success);
+	font-family: var(--font-mono);
+}
+
+.runtime-tool-value.muted {
+	color: var(--text-tertiary);
+	font-family: var(--font-mono);
+}
+
+.runtime-tool-preview-body .runtime-chat-image {
+	max-width: min(420px, 100%);
+}
+
+.runtime-tool-preview-body .runtime-chat-file-card {
+	max-width: min(340px, 100%);
+}
+
+/* 时间线条目样式 */
+.timeline-entry {
+	display: grid;
+	grid-template-columns: auto 1fr;
+	gap: 8px;
+	padding: 6px 0;
+	border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent);
+}
+
+.timeline-entry:last-child {
+	border-bottom: none;
+}
+
+.timeline-label {
+	font-size: 10px;
+	font-weight: 600;
+	text-transform: uppercase;
+	padding: 0 6px;
+	border-radius: 4px;
+	white-space: nowrap;
+}
+
+.timeline-entry-input .timeline-label {
+	background: color-mix(in srgb, var(--accent) 12%, transparent);
+	color: var(--accent);
+}
+
+.timeline-entry-output .timeline-label {
+	background: color-mix(in srgb, var(--success) 12%, transparent);
+	color: var(--success);
+}
+
+.timeline-entry-error .timeline-label {
+	background: color-mix(in srgb, var(--error) 12%, transparent);
+	color: var(--error);
+}
+
+.timeline-content {
+	grid-column: 2;
+	margin: 0;
+	padding: 4px 8px;
+	border-radius: 4px;
+	background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-app));
+	font-family: var(--font-mono);
+	font-size: 11px;
+	line-height: 1.4;
+	overflow-wrap: anywhere;
+}
+
+/* 嵌套工具块 */
+.runtime-tool-children {
+	display: grid;
+	gap: 8px;
+	margin-left: 10px;
+	padding: 8px 10px 10px 12px;
+	border-top: 1px dashed var(--border-color);
+	border-left: 1px solid color-mix(in srgb, var(--tool-accent) 30%, var(--border-color));
+	background: color-mix(in srgb, var(--bg-app) 84%, var(--bg-card));
+	animation: runtime-tool-reveal 0.18s ease-out;
+}
+
+.runtime-tool-children .runtime-tool-block {
+	background: var(--bg-card);
+}
+
+/* ========================================
+   统一消息时间线(正文 + 工具块按序穿插)
+   ======================================== */
+.message-timeline-content {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	min-width: 0;
+	max-width: 100%;
+}
+
+/* 工具块内部的子消息(轻量纯文本展示) */
+.runtime-tool-message {
+	min-width: 0;
+	max-width: 100%;
+	border: 1px solid color-mix(in srgb, var(--tool-accent, var(--accent-color)) 18%, var(--border-color));
+	border-left: 3px solid color-mix(in srgb, var(--tool-accent, var(--accent-color)) 48%, var(--border-color));
+	border-radius: var(--radius-sm);
+	padding: 8px 10px;
+	background: color-mix(in srgb, var(--bg-card) 72%, var(--bg-app));
+	color: var(--text-secondary);
+	font-size: 12.5px;
+	line-height: 1.5;
+	white-space: pre-wrap;
+	overflow-wrap: anywhere;
+	animation: runtime-tool-reveal 0.18s ease-out;
+}
+
+.runtime-tool-message > *:first-child {
+	margin-top: 0;
+}
+
+.runtime-tool-message > *:last-child {
+	margin-bottom: 0;
+}
+
+@keyframes runtime-tool-reveal {
+	from {
+		opacity: 0;
+		transform: translateY(-4px);
+	}
+	to {
+		opacity: 1;
+		transform: translateY(0);
+	}
+}
+
+@media (prefers-reduced-motion: reduce) {
+	.runtime-tool-preview,
+	.runtime-tool-children,
+	.runtime-tool-message {
+		animation: none;
+	}
+
+	.runtime-tool-block,
+	.runtime-tool-block summary::before {
+		transition: none;
+	}
+}
diff --git a/apps/undefined-chat/src/message-timeline/ToolBlock.test.tsx b/apps/undefined-chat/src/message-timeline/ToolBlock.test.tsx
new file mode 100644
index 00000000..7531414f
--- /dev/null
+++ b/apps/undefined-chat/src/message-timeline/ToolBlock.test.tsx
@@ -0,0 +1,454 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ReactElement } from "react";
+import { describe, expect, it, vi } from "vitest";
+import type { ToolBlock as ToolBlockType } from "../chat-store/types";
+import { AttachmentImageProvider } from "../rendering/AttachmentImageContext";
+import type {
+	Attachment,
+	AttachmentPreviewResult,
+} from "../runtime-client/types";
+import { renderWithProviders } from "../test-utils";
+import { ToolBlock } from "./ToolBlock";
+
+/** 在固定 zh-CN i18n 上下文中渲染(ToolBlock 内部使用 useTranslation)。 */
+function renderToolBlock(ui: ReactElement) {
+	return renderWithProviders(ui);
+}
+
+function imagePreviewResult(): AttachmentPreviewResult {
+	return {
+		status: 200,
+		ok: true,
+		mediaType: "image/png",
+		bytes: [137, 80, 78, 71],
+		body: null,
+	};
+}
+
+function renderToolBlockWithAttachmentProvider(ui: ReactElement) {
+	const previewAttachment = vi.fn(async () => imagePreviewResult());
+	return {
+		previewAttachment,
+		...renderWithProviders(
+			
+				{ui}
+			,
+		),
+	};
+}
+
+describe("ToolBlock", () => {
+	it("renders tool name and status", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-1",
+			toolName: "test_tool",
+			status: "done",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		renderToolBlock();
+
+		expect(screen.getByText("test_tool")).toBeInTheDocument();
+		expect(screen.getByText("完成")).toBeInTheDocument();
+	});
+
+	it("displays running status with correct styling", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-2",
+			toolName: "running_tool",
+			status: "running",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 500,
+		};
+
+		const { container } = renderToolBlock();
+
+		expect(screen.getByText("运行中")).toBeInTheDocument();
+		const details = container.querySelector(".runtime-tool-block");
+		expect(details).toHaveClass("running");
+	});
+
+	it("displays error status with correct styling", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-3",
+			toolName: "failed_tool",
+			status: "error",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 2000,
+			endTime: Date.now(),
+		};
+
+		const { container } = renderToolBlock();
+
+		expect(screen.getByText("失败")).toBeInTheDocument();
+		const details = container.querySelector(".runtime-tool-block");
+		expect(details).toHaveClass("error");
+	});
+
+	it("displays cancelled status with correct styling", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-cancelled",
+			toolName: "cancelled_tool",
+			status: "cancelled",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 2000,
+			endTime: Date.now(),
+		};
+
+		const { container } = renderToolBlock();
+
+		expect(screen.getByText("已取消")).toBeInTheDocument();
+		const details = container.querySelector(".runtime-tool-block");
+		expect(details).toHaveClass("cancelled");
+	});
+
+	it("formats duration correctly", () => {
+		const startTime = Date.now() - 1500;
+		const endTime = Date.now();
+
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-4",
+			toolName: "timed_tool",
+			status: "done",
+			children: new Map(),
+			timeline: [],
+			startTime,
+			endTime,
+		};
+
+		renderToolBlock();
+
+		// Should display duration in seconds
+		const durationElement = screen.getByText(/1\.[0-9]s/);
+		expect(durationElement).toBeInTheDocument();
+	});
+
+	it("toggles expansion when clicked", async () => {
+		const user = userEvent.setup();
+
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-5",
+			toolName: "expandable_tool",
+			status: "done",
+			children: new Map(),
+			timeline: [
+				{
+					type: "input",
+					timestamp: Date.now() - 1000,
+					content: "test input",
+				},
+			],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		const { container } = renderToolBlock();
+
+		const details = container.querySelector("details");
+		expect(details).not.toHaveAttribute("open");
+
+		const summary = screen.getByText("expandable_tool").closest("summary");
+		if (summary) {
+			await user.click(summary);
+		}
+
+		expect(details).toHaveAttribute("open");
+	});
+
+	it("renders timeline entries", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-6",
+			toolName: "tool_with_timeline",
+			status: "done",
+			children: new Map(),
+			timeline: [
+				{
+					type: "input",
+					timestamp: Date.now() - 2000,
+					content: "input content",
+				},
+				{
+					type: "output",
+					timestamp: Date.now() - 1000,
+					content: "output content",
+				},
+				{
+					type: "error",
+					timestamp: Date.now() - 500,
+					message: "error message",
+				},
+			],
+			startTime: Date.now() - 2000,
+			endTime: Date.now(),
+		};
+
+		renderToolBlock();
+
+		expect(screen.getByText("input content")).toBeInTheDocument();
+		expect(screen.getByText("output content")).toBeInTheDocument();
+		expect(screen.getByText("error message")).toBeInTheDocument();
+	});
+
+	it("renders nested children recursively", () => {
+		const childBlock: ToolBlockType = {
+			webchatCallId: "call-child",
+			toolName: "child_tool",
+			status: "done",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 500,
+			endTime: Date.now(),
+		};
+
+		const parentBlock: ToolBlockType = {
+			webchatCallId: "call-parent",
+			toolName: "parent_tool",
+			status: "done",
+			children: new Map([["call-child", childBlock]]),
+			timeline: [],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		renderToolBlock();
+
+		expect(screen.getByText("parent_tool")).toBeInTheDocument();
+		expect(screen.getByText("child_tool")).toBeInTheDocument();
+	});
+
+	it("renders multiple nested children", () => {
+		const child1: ToolBlockType = {
+			webchatCallId: "call-child-1",
+			toolName: "child_tool_1",
+			status: "done",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 500,
+			endTime: Date.now(),
+		};
+
+		const child2: ToolBlockType = {
+			webchatCallId: "call-child-2",
+			toolName: "child_tool_2",
+			status: "running",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 300,
+		};
+
+		const parentBlock: ToolBlockType = {
+			webchatCallId: "call-parent",
+			toolName: "parent_tool",
+			status: "running",
+			children: new Map([
+				["call-child-1", child1],
+				["call-child-2", child2],
+			]),
+			timeline: [],
+			startTime: Date.now() - 1000,
+		};
+
+		renderToolBlock();
+
+		expect(screen.getByText("parent_tool")).toBeInTheDocument();
+		expect(screen.getByText("child_tool_1")).toBeInTheDocument();
+		expect(screen.getByText("child_tool_2")).toBeInTheDocument();
+	});
+
+	it("does not render timeline section when timeline is empty", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-7",
+			toolName: "tool_no_timeline",
+			status: "done",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		const { container } = renderToolBlock();
+
+		expect(
+			container.querySelector(".runtime-tool-preview"),
+		).not.toBeInTheDocument();
+	});
+
+	it("does not render children section when no children", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-8",
+			toolName: "tool_no_children",
+			status: "done",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		const { container } = renderToolBlock();
+
+		expect(
+			container.querySelector(".runtime-tool-children"),
+		).not.toBeInTheDocument();
+	});
+
+	it("结构化渲染可解析为 JSON 的工具结果", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-json",
+			toolName: "json_tool",
+			status: "done",
+			resultPreview: '{"name":"小明","age":18}',
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		const { container } = renderToolBlock();
+
+		// 结构化展示:对齐 WebUI 的 runtime-tool-structured-* 类名
+		expect(
+			container.querySelector(".runtime-tool-structured-list"),
+		).toBeTruthy();
+		expect(screen.getByText("name")).toBeInTheDocument();
+		expect(screen.getByText("小明")).toBeInTheDocument();
+		expect(screen.getByText("age")).toBeInTheDocument();
+	});
+
+	it("兼容 Python 风格工具预览并按 WebUI 结构化类名渲染", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-pythonish",
+			toolName: "pythonish_tool",
+			status: "done",
+			resultPreview: "{'ok': True, 'items': [None, 'done']}",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		const { container } = renderToolBlock();
+
+		expect(
+			container.querySelector(".runtime-tool-structured-list"),
+		).toBeTruthy();
+		expect(screen.getByText("ok")).toBeInTheDocument();
+		expect(screen.getByText("true")).toHaveClass("boolean");
+		expect(screen.getByText("null")).toHaveClass("muted");
+	});
+
+	it("非结构化工具输出按 Markdown 渲染", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-markdown",
+			toolName: "markdown_tool",
+			status: "done",
+			resultPreview: "**加粗结果**",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		renderToolBlock();
+
+		expect(screen.getByText("加粗结果").closest("strong")).toBeInTheDocument();
+	});
+
+	it("工具输出附件标签复用 MarkdownContent 图片预览链路", async () => {
+		const imageAttachment: Attachment = {
+			id: "pic_tool",
+			name: "tool.png",
+			size: 2048,
+			mediaType: "image/png",
+			kind: "image",
+			downloadUrl: null,
+			previewUrl: null,
+			discarded: false,
+		};
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-image",
+			toolName: "image_tool",
+			status: "done",
+			resultPreview: '结果',
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		const { previewAttachment } = renderToolBlockWithAttachmentProvider(
+			,
+		);
+
+		expect(await screen.findByAltText("tool.png")).toBeInTheDocument();
+		expect(previewAttachment).toHaveBeenCalledWith({
+			attachmentId: "pic_tool",
+		});
+	});
+
+	it("未知 pic_ 附件标签按 WebUI 规则 fallback 为图片预览", async () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-pic-fallback",
+			toolName: "image_tool",
+			status: "done",
+			resultPreview: '',
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		const { previewAttachment } = renderToolBlockWithAttachmentProvider(
+			,
+		);
+
+		expect(await screen.findByRole("img")).toBeInTheDocument();
+		expect(previewAttachment).toHaveBeenCalledWith({
+			attachmentId: "pic_missing",
+		});
+	});
+
+	it("非 JSON 的工具结果回退为普通预览文本", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-text",
+			toolName: "text_tool",
+			status: "done",
+			resultPreview: "这是一段纯文本结果",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 1000,
+			endTime: Date.now(),
+		};
+
+		const { container } = renderToolBlock();
+
+		expect(container.querySelector(".runtime-tool-structured-list")).toBeNull();
+		expect(screen.getByText("这是一段纯文本结果")).toBeInTheDocument();
+	});
+
+	it("agent 运行中在阶段标签后展示 stageDetail", () => {
+		const toolBlock: ToolBlockType = {
+			webchatCallId: "call-agent",
+			toolName: "web_agent",
+			status: "running",
+			isAgent: true,
+			currentStage: "waiting_model",
+			stageDetail: "Claude Opus 4.8",
+			children: new Map(),
+			timeline: [],
+			startTime: Date.now() - 500,
+		};
+
+		renderToolBlock();
+
+		// "等待模型 · Claude Opus 4.8"(zh-CN 阶段标签 + detail)
+		expect(screen.getByText(/Claude Opus 4\.8/)).toBeInTheDocument();
+		expect(screen.getByText(/等待模型/)).toBeInTheDocument();
+	});
+});
diff --git a/apps/undefined-chat/src/message-timeline/ToolBlock.tsx b/apps/undefined-chat/src/message-timeline/ToolBlock.tsx
new file mode 100644
index 00000000..0529acf6
--- /dev/null
+++ b/apps/undefined-chat/src/message-timeline/ToolBlock.tsx
@@ -0,0 +1,378 @@
+import type React from "react";
+import { useEffect, useRef } from "react";
+import type { ToolBlock as ToolBlockType } from "../chat-store/types";
+import { useChatClock } from "../hooks/useChatClock";
+import { type TranslateFn, useTranslation } from "../i18n";
+import { getChatStageLabel } from "../i18n/zh-CN";
+import {
+	type HtmlPreviewRequest,
+	MarkdownContent,
+} from "../rendering/MarkdownContent";
+import type { Attachment } from "../runtime-client/types";
+import "./ToolBlock.css";
+
+const noopPreviewHtml = (_input: HtmlPreviewRequest): void => {};
+
+export type ToolBlockProps = ToolBlockType & {
+	attachments?: Attachment[];
+	onPreviewHtml?: (input: HtmlPreviewRequest) => void;
+	onImageClick?: (src: string, alt: string) => void;
+};
+
+function formatDuration(
+	startTime: number,
+	now: number,
+	endTime?: number,
+): string {
+	const duration = endTime ? endTime - startTime : now - startTime;
+	if (!Number.isFinite(duration) || duration <= 0) {
+		return "";
+	}
+	if (duration < 1000) {
+		return `${Math.round(duration)}ms`;
+	}
+	if (duration < 60000) {
+		return `${(duration / 1000).toFixed(1)}s`;
+	}
+	return `${(duration / 60000).toFixed(1)}m`;
+}
+
+function getStatusText(
+	status: ToolBlockType["status"],
+	t: TranslateFn,
+): string {
+	switch (status) {
+		case "running":
+			return t("tool.statusRunning");
+		case "done":
+			return t("tool.statusDone");
+		case "error":
+			return t("tool.statusError");
+		case "cancelled":
+			return t("tool.statusCancelled");
+		default:
+			return status;
+	}
+}
+
+type ToolPreview = {
+	text: string;
+	isStructured: boolean;
+	value: unknown;
+};
+
+function parseStructuredPreview(text: string): unknown {
+	const trimmed = text.trim();
+	if (!trimmed) {
+		return null;
+	}
+	try {
+		const parsed: unknown = JSON.parse(trimmed);
+		return parsed !== null && typeof parsed === "object" ? parsed : null;
+	} catch {
+		const normalized = trimmed
+			.replace(/([{,]\s*)'([^'\\]*(?:\\.[^'\\]*)*)'\s*:/g, '$1"$2":')
+			.replace(/:\s*'([^'\\]*(?:\\.[^'\\]*)*)'(?=\s*[,}])/g, ':"$1"')
+			.replace(/([[,]\s*)'([^'\\]*(?:\\.[^'\\]*)*)'(?=\s*[\],])/g, '$1"$2"')
+			.replace(/\bNone\b/g, "null")
+			.replace(/\bTrue\b/g, "true")
+			.replace(/\bFalse\b/g, "false");
+		try {
+			const parsed: unknown = JSON.parse(normalized);
+			return parsed !== null && typeof parsed === "object" ? parsed : null;
+		} catch {
+			return null;
+		}
+	}
+}
+
+function formatToolPreview(raw: string | undefined): ToolPreview {
+	const text = String(raw || "").trim();
+	if (!text) {
+		return { text: "", isStructured: false, value: null };
+	}
+	const value = parseStructuredPreview(text);
+	return { text, isStructured: value !== null, value };
+}
+
+function renderStructuredToolValue(value: unknown): React.ReactElement {
+	if (Array.isArray(value)) {
+		if (value.length === 0) {
+			return [];
+		}
+		return (
+			
+ {value.map((item, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: 只读结果,顺序稳定 +
+ {index} +
+ {renderStructuredToolValue(item)} +
+
+ ))} +
+ ); + } + if (value !== null && typeof value === "object") { + const entries = Object.entries(value as Record); + if (entries.length === 0) { + return {"{}"}; + } + return ( +
+ {entries.map(([key, item]) => ( +
+ {key} +
+ {renderStructuredToolValue(item)} +
+
+ ))} +
+ ); + } + if (typeof value === "boolean") { + return ( + + {value ? "true" : "false"} + + ); + } + if (typeof value === "number") { + return {String(value)}; + } + if (value === null || value === undefined) { + return null; + } + return {String(value)}; +} + +function renderPreviewSection({ + label, + raw, + markdown, + attachments, + onPreviewHtml, + onImageClick, +}: { + label: string; + raw: string | undefined; + markdown: boolean; + attachments: Attachment[]; + onPreviewHtml: (input: HtmlPreviewRequest) => void; + onImageClick?: (src: string, alt: string) => void; +}): React.ReactElement | null { + const preview = formatToolPreview(raw); + if (!preview.text) { + return null; + } + return ( +
+
{label}
+
+ {preview.isStructured ? ( + renderStructuredToolValue(preview.value) + ) : markdown ? ( + + ) : ( + preview.text + )} +
+
+ ); +} + +function renderTimelineEntry( + entry: ToolBlockType["timeline"][number], + index: number, + t: TranslateFn, +): React.ReactElement { + const content = entry.type === "error" ? entry.message : entry.content; + const label = + entry.type === "input" + ? t("tool.input") + : entry.type === "output" + ? t("tool.output") + : t("tool.error"); + const className = + entry.type === "input" + ? "timeline-entry timeline-entry-input" + : entry.type === "output" + ? "timeline-entry timeline-entry-output" + : "timeline-entry timeline-entry-error"; + return ( +
+ {label} +
{content}
+
+ ); +} + +/** + * 工具调用块(对齐 WebUI renderToolBlock,runtime.js:1100-1142) + * - running 时显示,tool_end 后变 done/error/cancelled,2s 后自动折叠 + * - agent 运行中显示阶段标签(metaLabel),对齐 WebUI + */ +export function ToolBlock({ + webchatCallId, + toolName, + status, + isAgent, + uiHint, + argumentsPreview, + resultPreview, + currentStage, + stageDetail, + children, + timeline, + startTime, + endTime, + attachments = [], + onPreviewHtml = noopPreviewHtml, + onImageClick, +}: ToolBlockProps) { + const { t, locale } = useTranslation(); + const detailsRef = useRef(null); + const userInteractedRef = useRef(false); + const collapseTimerRef = useRef(null); + + // 初始展开:running 时 open=true(对齐 WebUI autoOpen: isStart ? true) + useEffect(() => { + if (detailsRef.current && status === "running") { + detailsRef.current.open = true; + } + }, [status]); + + // 自动折叠:终态后 2s 直接操作 DOM(对齐 WebUI scheduleToolAutoCollapse) + useEffect(() => { + if (collapseTimerRef.current !== null) { + clearTimeout(collapseTimerRef.current); + collapseTimerRef.current = null; + } + + if ( + (status === "done" || status === "error" || status === "cancelled") && + !userInteractedRef.current + ) { + collapseTimerRef.current = window.setTimeout(() => { + if (detailsRef.current) detailsRef.current.open = false; + }, 2000); + } + + return () => { + if (collapseTimerRef.current !== null) { + clearTimeout(collapseTimerRef.current); + } + }; + }, [status]); + + // 用户手动 toggle:仅记录交互(阻止后续自动折叠),绝不翻转 state + // (翻转会与折叠 timer 的 open 变化形成 toggle→onToggle→toggle 无限循环 = 闪烁) + const handleToggle = () => { + userInteractedRef.current = true; + }; + + // 运行中用时实时刷新:复用统一时钟,每 500ms 推进 now;非运行态停止定时器。 + const clockNow = useChatClock(status === "running"); + const duration = formatDuration(startTime, clockNow, endTime); + const statusText = getStatusText(status, t); + const childrenArray = Array.from(children.values()); + + // agent 运行中且有阶段时,显示阶段标签(对齐 WebUI metaLabel) + const showLiveAgentStage = + isAgent && Boolean(currentStage) && status === "running"; + const stageLabel = getChatStageLabel(currentStage ?? "", locale); + // 阶段 detail:有则在阶段标签后补充展示(如模型名 / 子步骤) + const stageDetailText = (stageDetail ?? "").trim(); + const metaLabel = showLiveAgentStage + ? stageDetailText + ? `${stageLabel} · ${stageDetailText}` + : stageLabel + : statusText; + const metaTitle = + showLiveAgentStage && stageDetailText + ? `${stageLabel} · ${stageDetailText}` + : undefined; + const hintClass = uiHint ? ` ${uiHint.replace(/_/g, "-")}` : ""; + const kindClass = isAgent ? " is-agent" : " is-tool"; + const kindLabel = isAgent ? "Agent" : "Tool"; + const argsPreview = renderPreviewSection({ + label: t("tool.input"), + raw: argumentsPreview, + markdown: false, + attachments, + onPreviewHtml, + onImageClick, + }); + const resultPreviewNode = renderPreviewSection({ + label: t("tool.output"), + raw: resultPreview, + markdown: true, + attachments, + onPreviewHtml, + onImageClick, + }); + + return ( +
+ + + + {toolName} + + + + + {metaLabel} + + {kindLabel} + + + {argsPreview} + + {childrenArray.length > 0 || timeline.length > 0 ? ( +
+ {timeline.map((entry, index) => renderTimelineEntry(entry, index, t))} + {childrenArray.map((child) => ( + + ))} +
+ ) : null} + + {resultPreviewNode} +
+ ); +} diff --git a/apps/undefined-chat/src/platform/AndroidLifecycle.test.ts b/apps/undefined-chat/src/platform/AndroidLifecycle.test.ts new file mode 100644 index 00000000..9e044827 --- /dev/null +++ b/apps/undefined-chat/src/platform/AndroidLifecycle.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { isAndroid, setupAndroidLifecycle } from "./AndroidLifecycle"; + +const listeners = new Map void | Promise>(); +const unlisten = vi.fn(); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ + listen: vi.fn( + async (event: string, callback: () => void | Promise) => { + listeners.set(event, callback); + return unlisten; + }, + ), + }), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + TauriEvent: { + WINDOW_RESUMED: "tauri://resumed", + WINDOW_SUSPENDED: "tauri://suspended", + }, +})); + +describe("AndroidLifecycle", () => { + beforeEach(() => { + listeners.clear(); + unlisten.mockClear(); + }); + + describe("isAndroid", () => { + it("detects Android platform from user agent", () => { + const originalUserAgent = navigator.userAgent; + + // Mock Android user agent + Object.defineProperty(navigator, "userAgent", { + value: + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36", + configurable: true, + }); + + expect(isAndroid()).toBe(true); + + // Restore original user agent + Object.defineProperty(navigator, "userAgent", { + value: originalUserAgent, + configurable: true, + }); + }); + + it("returns false for non-Android platforms", () => { + const originalUserAgent = navigator.userAgent; + + // Mock desktop user agent + Object.defineProperty(navigator, "userAgent", { + value: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + configurable: true, + }); + + expect(isAndroid()).toBe(false); + + // Restore original user agent + Object.defineProperty(navigator, "userAgent", { + value: originalUserAgent, + configurable: true, + }); + }); + }); + + describe("setupAndroidLifecycle", () => { + it("re-bootstraps on android resume and cleans listeners", async () => { + const mockStore = { + bootstrap: vi.fn(async () => undefined), + getSnapshot: vi.fn(() => ({ + activeJobsByConversation: {}, + eventCursorByJob: {}, + })), + }; + + const cleanup = setupAndroidLifecycle( + mockStore as unknown as Parameters[0], + ); + await vi.waitFor(() => { + expect(listeners.has("tauri://suspended")).toBe(true); + expect(listeners.has("tauri://resumed")).toBe(true); + }); + + await listeners.get("tauri://resumed")?.(); + + expect(mockStore.bootstrap).toHaveBeenCalledOnce(); + cleanup(); + expect(unlisten).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/apps/undefined-chat/src/platform/AndroidLifecycle.ts b/apps/undefined-chat/src/platform/AndroidLifecycle.ts new file mode 100644 index 00000000..0bcf2865 --- /dev/null +++ b/apps/undefined-chat/src/platform/AndroidLifecycle.ts @@ -0,0 +1,85 @@ +import { TauriEvent } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { ChatStore } from "../chat-store/store"; + +/** + * Android 生命周期管理 + * - 监听应用暂停/恢复事件 + * - 恢复时重新 bootstrap 并恢复 Runtime 事件订阅 + */ +export function setupAndroidLifecycle(store: ChatStore): () => void { + const unlisteners: Array<() => void> = []; + const appWindow = getCurrentWindow(); + + // 监听应用暂停事件 + appWindow + .listen(TauriEvent.WINDOW_SUSPENDED, () => { + console.log("[Lifecycle] App paused"); + // 可以在这里保存状态或清理资源 + }) + .then((unlisten: () => void) => { + unlisteners.push(unlisten); + }) + .catch((err: unknown) => { + console.error("[Lifecycle] Failed to listen android-pause:", err); + }); + + // 监听应用恢复事件 + appWindow + .listen(TauriEvent.WINDOW_RESUMED, async () => { + console.log("[Lifecycle] App resumed, re-bootstrapping..."); + + try { + // 重新初始化连接:保留当前选中会话,避免切后台返回后丢失正在查看的会话; + // 标记为续接(resuming)以反映真实连接状态 + const previousSelection = + store.getSnapshot().selectedConversationId ?? undefined; + await store.bootstrap({ + preserveSelectionId: previousSelection, + resuming: true, + }); + + // 获取当前活跃任务 + const state = store.getSnapshot(); + const activeJobs = Object.values(state.activeJobsByConversation); + + console.log( + `[Lifecycle] Reconnected, ${activeJobs.length} active jobs found`, + ); + + // bootstrap 会重新订阅事件流;断线期间的事件补齐仍由 + // store 内部的 SSE error/closed fallback 处理。 + if (activeJobs.length > 0) { + console.log( + "[Lifecycle] Event streams will be resumed automatically", + ); + } + } catch (err) { + console.error("[Lifecycle] Failed to reconnect:", err); + } + }) + .then((unlisten: () => void) => { + unlisteners.push(unlisten); + }) + .catch((err: unknown) => { + console.error("[Lifecycle] Failed to listen android-resume:", err); + }); + + // 返回清理函数 + return () => { + for (const unlisten of unlisteners) { + unlisten(); + } + }; +} + +/** + * 检测是否在 Android 平台 + */ +export function isAndroid(): boolean { + if (typeof window === "undefined") { + return false; + } + // Tauri Android 会在 navigator.userAgent 中包含 "Android" + return /Android/i.test(navigator.userAgent); +} diff --git a/apps/undefined-chat/src/platform/ConnectionSetup.test.tsx b/apps/undefined-chat/src/platform/ConnectionSetup.test.tsx new file mode 100644 index 00000000..183b8390 --- /dev/null +++ b/apps/undefined-chat/src/platform/ConnectionSetup.test.tsx @@ -0,0 +1,222 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LOCALE_STORAGE_KEY, LanguageProvider } from "../i18n"; +import { ConnectionSetup } from "./ConnectionSetup"; + +/** 在 LanguageProvider 下渲染(组件依赖 useTranslation) */ +function renderWithI18n(ui: ReactElement) { + return render({ui}); +} + +describe("ConnectionSetup", () => { + beforeEach(() => { + // 固定 locale 为 zh-CN,避免随测试环境 navigator.language 漂移 + localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("renders connection form in setup mode", () => { + const onConnect = vi.fn(); + renderWithI18n(); + + expect(screen.getByText("连接到 Runtime")).toBeInTheDocument(); + expect(screen.getByLabelText("Runtime URL")).toBeInTheDocument(); + expect(screen.getByLabelText("API Key")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "保存并连接" }), + ).toBeInTheDocument(); + }); + + it("submits connection with valid inputs (url, key, allowInsecure)", async () => { + const onConnect = vi.fn(); + renderWithI18n(); + + const urlInput = screen.getByLabelText("Runtime URL"); + const keyInput = screen.getByLabelText("API Key"); + const submitButton = screen.getByRole("button", { name: "保存并连接" }); + + fireEvent.change(urlInput, { + target: { value: "http://192.168.1.100:8788" }, + }); + fireEvent.change(keyInput, { target: { value: "test-api-key" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith( + "http://192.168.1.100:8788", + "test-api-key", + false, + ); + }); + }); + + it("validates URL format", async () => { + const onConnect = vi.fn(); + renderWithI18n(); + + const urlInput = screen.getByLabelText("Runtime URL"); + const keyInput = screen.getByLabelText("API Key"); + const submitButton = screen.getByRole("button", { name: "保存并连接" }); + + // type="url" + required 会触发浏览器原生校验,jsdom 下不阻断提交逻辑, + // 这里直接验证组件内的 URL 解析校验分支 + fireEvent.change(urlInput, { target: { value: "not-a-valid-url" } }); + fireEvent.change(keyInput, { target: { value: "test-key" } }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText("URL 格式不正确")).toBeInTheDocument(); + expect(onConnect).not.toHaveBeenCalled(); + }); + }); + + it("requires API key in setup mode", async () => { + const onConnect = vi.fn(); + renderWithI18n(); + + const urlInput = screen.getByLabelText("Runtime URL"); + const submitButton = screen.getByRole("button", { name: "保存并连接" }); + + fireEvent.change(urlInput, { + target: { value: "http://192.168.1.100:8788" }, + }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByText("请输入 API Key")).toBeInTheDocument(); + expect(onConnect).not.toHaveBeenCalled(); + }); + }); + + it("allows empty API key in settings mode (keeps existing key)", async () => { + const onConnect = vi.fn(); + renderWithI18n( + , + ); + + const submitButton = screen.getByRole("button", { name: "保存并连接" }); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith( + "http://192.168.1.100:8788", + "", + false, + ); + }); + }); + + it("forwards allowInsecure when checkbox is checked", async () => { + const onConnect = vi.fn(); + renderWithI18n(); + + const urlInput = screen.getByLabelText("Runtime URL"); + const keyInput = screen.getByLabelText("API Key"); + const checkbox = screen.getByRole("checkbox"); + const submitButton = screen.getByRole("button", { name: "保存并连接" }); + + fireEvent.change(urlInput, { + target: { value: "http://192.168.1.100:8788" }, + }); + fireEvent.change(keyInput, { target: { value: "k" } }); + fireEvent.click(checkbox); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(onConnect).toHaveBeenCalledWith( + "http://192.168.1.100:8788", + "k", + true, + ); + }); + }); + + it("shows close button only in settings mode", () => { + const onConnect = vi.fn(); + const onClose = vi.fn(); + const { rerender } = renderWithI18n( + , + ); + expect( + screen.queryByRole("button", { name: "关闭" }), + ).not.toBeInTheDocument(); + + rerender( + + + , + ); + const closeButton = screen.getByRole("button", { name: "关闭" }); + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalled(); + }); + + it("loads and displays saved configs", async () => { + const mockConfigs = [ + { runtimeUrl: "http://192.168.1.100:8788", usedAt: Date.now() }, + { runtimeUrl: "http://192.168.1.101:8788", usedAt: Date.now() - 1000 }, + ]; + localStorage.setItem( + "undefined-runtime-history", + JSON.stringify(mockConfigs), + ); + + const onConnect = vi.fn(); + renderWithI18n(); + + await waitFor(() => { + expect(screen.getByText("最近使用")).toBeInTheDocument(); + expect(screen.getByText("http://192.168.1.100:8788")).toBeInTheDocument(); + expect(screen.getByText("http://192.168.1.101:8788")).toBeInTheDocument(); + }); + }); + + it("selects config from recent list", async () => { + const mockConfigs = [ + { runtimeUrl: "http://192.168.1.100:8788", usedAt: Date.now() }, + ]; + localStorage.setItem( + "undefined-runtime-history", + JSON.stringify(mockConfigs), + ); + + const onConnect = vi.fn(); + renderWithI18n(); + + await waitFor(() => { + expect(screen.getByText("http://192.168.1.100:8788")).toBeInTheDocument(); + }); + + const configButton = screen.getByText("http://192.168.1.100:8788"); + fireEvent.click(configButton); + + const urlInput = screen.getByLabelText("Runtime URL") as HTMLInputElement; + expect(urlInput.value).toBe("http://192.168.1.100:8788"); + }); + + it("uses currentUrl prop as default", () => { + const onConnect = vi.fn(); + renderWithI18n( + , + ); + + const urlInput = screen.getByLabelText("Runtime URL") as HTMLInputElement; + expect(urlInput.value).toBe("http://custom.example.com:8788"); + }); +}); diff --git a/apps/undefined-chat/src/platform/ConnectionSetup.tsx b/apps/undefined-chat/src/platform/ConnectionSetup.tsx new file mode 100644 index 00000000..210eaf10 --- /dev/null +++ b/apps/undefined-chat/src/platform/ConnectionSetup.tsx @@ -0,0 +1,247 @@ +import { type FormEvent, type ReactNode, useEffect, useState } from "react"; +import { useTranslation } from "../i18n"; + +export type RuntimeConfig = { + runtimeUrl: string; + usedAt: number; +}; + +export type ConnectionSetupProps = { + /** + * 模式: + * - "setup":首次连接(需要 URL + API Key,无关闭按钮) + * - "settings":已连接后修改配置(API Key 可留空保持原值,可关闭) + */ + mode: "setup" | "settings"; + /** 当前已配置的 Runtime URL(用于回填输入框) */ + currentUrl?: string; + /** 连接/保存回调;由调用方负责 saveRuntimeConfig→保存密钥→bootstrap 等持久化逻辑 */ + onConnect: (url: string, apiKey: string, allowInsecure: boolean) => void; + /** settings 模式下的关闭回调(setup 模式不提供) */ + onClose?: () => void; + /** 调用方(如 bootstrap)返回的错误信息,与本组件本地校验错误合并显示 */ + error?: string | null; + /** 额外的设置项(如自动滚动开关),渲染在面板底部;通常仅在 settings 模式使用 */ + children?: ReactNode; +}; + +const RUNTIME_HISTORY_KEY = "undefined-runtime-history"; +const DEFAULT_SETUP_URL = "http://127.0.0.1:8788"; + +/** 从 localStorage 读取最近使用的 Runtime 配置(按时间倒序,最多 5 条) */ +function loadSavedConfigs(): RuntimeConfig[] { + try { + const stored = localStorage.getItem(RUNTIME_HISTORY_KEY); + if (!stored) { + return []; + } + const parsed = JSON.parse(stored) as RuntimeConfig[]; + return parsed.sort((a, b) => b.usedAt - a.usedAt).slice(0, 5); + } catch { + return []; + } +} + +/** 将一个 Runtime URL 写入最近使用历史(去重 + 置顶 + 截断到 5 条) */ +function saveConfigToHistory(runtimeUrl: string): void { + try { + const configs = loadSavedConfigs(); + const updated = [ + { runtimeUrl, usedAt: Date.now() }, + ...configs.filter((item) => item.runtimeUrl !== runtimeUrl), + ].slice(0, 5); + localStorage.setItem(RUNTIME_HISTORY_KEY, JSON.stringify(updated)); + } catch { + // 忽略存储异常(隐私模式 / 配额) + } +} + +/** + * 统一连接 / 配置组件 + * + * 同时承担首次连接(setup)与运行期配置修改(settings)两种场景: + * - Runtime URL / API Key 输入、"允许不安全存储降级" 复选框 + * - 最近使用历史(点击回填) + * - 本地校验(URL 必填且格式合法、setup 模式 API Key 必填) + * - 全部文案走 i18n + * + * 持久化逻辑(保存配置、保存密钥、bootstrap)由调用方在 onConnect 中完成。 + */ +export function ConnectionSetup({ + mode, + currentUrl, + onConnect, + onClose, + error, + children, +}: ConnectionSetupProps) { + const { t } = useTranslation(); + const isSetup = mode === "setup"; + const [url, setUrl] = useState(currentUrl ?? DEFAULT_SETUP_URL); + const [apiKey, setApiKey] = useState(""); + const [allowInsecure, setAllowInsecure] = useState(false); + const [savedConfigs, setSavedConfigs] = useState([]); + const [localError, setLocalError] = useState(null); + + useEffect(() => { + setSavedConfigs(loadSavedConfigs()); + }, []); + + // 调用方回填的 URL 变化时同步(如 bootstrap 后拿到已存配置) + useEffect(() => { + if (currentUrl) { + setUrl(currentUrl); + } + }, [currentUrl]); + + function handleSubmit(event: FormEvent): void { + event.preventDefault(); + setLocalError(null); + + const trimmedUrl = url.trim(); + const trimmedKey = apiKey.trim(); + + if (!trimmedUrl) { + setLocalError(t("setup.error.needUrl")); + return; + } + // setup 模式必须填写 API Key;settings 模式留空表示沿用原密钥 + if (isSetup && !trimmedKey) { + setLocalError(t("setup.error.needApiKey")); + return; + } + try { + new URL(trimmedUrl); + } catch { + setLocalError(t("setup.error.invalidUrl")); + return; + } + + saveConfigToHistory(trimmedUrl); + setSavedConfigs(loadSavedConfigs()); + onConnect(trimmedUrl, trimmedKey, allowInsecure); + setApiKey(""); + } + + function selectConfig(config: RuntimeConfig): void { + setUrl(config.runtimeUrl); + setLocalError(null); + } + + const displayError = localError ?? error ?? null; + + return ( +
+
+
+

+ {isSetup + ? t("setup.dialog.connectTitle") + : t("setup.dialog.configTitle")} +

+ {!isSetup && onClose ? ( + + ) : null} +
+ + {savedConfigs.length > 0 ? ( +
+

{t("setup.recent")}

+
+ {savedConfigs.map((config) => ( + + ))} +
+
+ ) : null} + + + + + +

{t("setup.insecureHint")}

+ {displayError ? ( + + {displayError} + + ) : null} + {children} +
+
+ ); +} diff --git a/apps/undefined-chat/src/platform/DesktopLayout.tsx b/apps/undefined-chat/src/platform/DesktopLayout.tsx new file mode 100644 index 00000000..5c55ac81 --- /dev/null +++ b/apps/undefined-chat/src/platform/DesktopLayout.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; + +/** + * DesktopLayout - 桌面端特定布局包装器 + * + * 提供桌面端特有的布局增强,包括: + * - 窗口拖拽区域(未来) + * - 原生菜单栏集成(未来) + * - 平台特定样式调整 + * + * 当前版本保持简单包装,为未来扩展预留结构。 + */ + +export interface DesktopLayoutProps { + children: ReactNode; + /** 是否启用桌面端特定样式(如自定义标题栏) */ + enableCustomTitleBar?: boolean; +} + +export function DesktopLayout({ + children, + enableCustomTitleBar = false, +}: DesktopLayoutProps) { + // 未来可在此处添加: + // - 自定义标题栏(data-tauri-drag-region) + // - 原生菜单栏触发器 + // - 平台特定的 CSS 类名 + // + // 当前作为透明语义包装:用 display:contents 让自身盒子从布局中消失, + // 子元素直接参与父容器(.chat-workspace)的 flex 布局,避免破坏现有列布局。 + + return ( +
+ {children} +
+ ); +} diff --git a/apps/undefined-chat/src/platform/KeybindingManager.test.ts b/apps/undefined-chat/src/platform/KeybindingManager.test.ts new file mode 100644 index 00000000..4be313bd --- /dev/null +++ b/apps/undefined-chat/src/platform/KeybindingManager.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { KeybindingHandler } from "./KeybindingManager"; +import { KeybindingManager } from "./KeybindingManager"; + +describe("KeybindingManager", () => { + let manager: KeybindingManager; + let handler: KeybindingHandler; + + beforeEach(() => { + manager = new KeybindingManager(); + handler = vi.fn(); + }); + + afterEach(() => { + manager.stopListening(); + }); + + describe("register", () => { + it("应该注册单个快捷键", () => { + manager.register("Escape", handler); + expect(manager.getRegisteredKeys()).toContain("Escape"); + }); + + it("应该注册组合快捷键", () => { + manager.register("Ctrl+N", handler); + manager.register("Ctrl+Shift+K", handler); + const keys = manager.getRegisteredKeys(); + expect(keys).toContain("Ctrl+N"); + expect(keys).toContain("Ctrl+Shift+K"); + }); + + it("应该允许覆盖已有绑定", () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + manager.register("Ctrl+N", handler1); + manager.register("Ctrl+N", handler2); + expect(manager.getRegisteredKeys()).toHaveLength(1); + }); + }); + + describe("unregister", () => { + it("应该注销指定快捷键", () => { + manager.register("Ctrl+N", handler); + manager.unregister("Ctrl+N"); + expect(manager.getRegisteredKeys()).not.toContain("Ctrl+N"); + }); + }); + + describe("clear", () => { + it("应该清空所有绑定", () => { + manager.register("Ctrl+N", handler); + manager.register("Ctrl+K", handler); + manager.clear(); + expect(manager.getRegisteredKeys()).toHaveLength(0); + }); + }); + + describe("handleKeyDown", () => { + beforeEach(() => { + manager.register("Ctrl+N", handler); + manager.startListening(); + }); + + it("应该在按下注册的快捷键时触发回调", () => { + const event = new KeyboardEvent("keydown", { + key: "n", + ctrlKey: true, + bubbles: true, + }); + window.dispatchEvent(event); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("应该忽略未注册的快捷键", () => { + const event = new KeyboardEvent("keydown", { + key: "m", + ctrlKey: true, + bubbles: true, + }); + window.dispatchEvent(event); + expect(handler).not.toHaveBeenCalled(); + }); + + it("应该处理 Escape 键", () => { + const escHandler = vi.fn(); + manager.register("Escape", escHandler); + const event = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + }); + window.dispatchEvent(event); + expect(escHandler).toHaveBeenCalledTimes(1); + }); + + it("应该处理组合键 Ctrl+Alt+Shift", () => { + const comboHandler = vi.fn(); + manager.register("Ctrl+Alt+Shift+K", comboHandler); + const event = new KeyboardEvent("keydown", { + key: "k", + ctrlKey: true, + altKey: true, + shiftKey: true, + bubbles: true, + }); + window.dispatchEvent(event); + expect(comboHandler).toHaveBeenCalledTimes(1); + }); + + it("应该在输入框内触发已注册的组合快捷键", () => { + const input = document.createElement("input"); + document.body.appendChild(input); + + const event = new KeyboardEvent("keydown", { + key: "n", + ctrlKey: true, + bubbles: true, + }); + Object.defineProperty(event, "target", { + value: input, + writable: false, + }); + window.dispatchEvent(event); + expect(handler).toHaveBeenCalledOnce(); + + document.body.removeChild(input); + }); + + it("应该忽略输入框内未注册的组合快捷键", () => { + const input = document.createElement("input"); + document.body.appendChild(input); + + const event = new KeyboardEvent("keydown", { + key: "m", + ctrlKey: true, + bubbles: true, + }); + Object.defineProperty(event, "target", { + value: input, + writable: false, + }); + window.dispatchEvent(event); + expect(handler).not.toHaveBeenCalled(); + + document.body.removeChild(input); + }); + + it("应该在输入框内也能触发 Escape", () => { + const escHandler = vi.fn(); + manager.register("Escape", escHandler); + + const input = document.createElement("input"); + document.body.appendChild(input); + + const event = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + }); + Object.defineProperty(event, "target", { + value: input, + writable: false, + }); + window.dispatchEvent(event); + expect(escHandler).toHaveBeenCalledTimes(1); + + document.body.removeChild(input); + }); + }); + + describe("startListening / stopListening", () => { + it("应该开始和停止监听", () => { + manager.register("Ctrl+N", handler); + manager.startListening(); + + let event = new KeyboardEvent("keydown", { + key: "n", + ctrlKey: true, + bubbles: true, + }); + window.dispatchEvent(event); + expect(handler).toHaveBeenCalledTimes(1); + + manager.stopListening(); + + event = new KeyboardEvent("keydown", { + key: "n", + ctrlKey: true, + bubbles: true, + }); + window.dispatchEvent(event); + // 应该还是只调用一次(停止监听后不再触发) + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("应该防止重复监听", () => { + manager.startListening(); + manager.startListening(); + // 不应该抛出错误,且只注册一次监听器 + }); + }); + + describe("normalizeKey", () => { + it("应该标准化 Ctrl+字母", () => { + manager.register("Ctrl+N", handler); + manager.startListening(); + + const event = new KeyboardEvent("keydown", { + key: "N", + ctrlKey: true, + bubbles: true, + }); + window.dispatchEvent(event); + expect(handler).toHaveBeenCalled(); + }); + + it("应该标准化小写字母为大写", () => { + manager.register("Ctrl+K", handler); + manager.startListening(); + + const event = new KeyboardEvent("keydown", { + key: "k", + ctrlKey: true, + bubbles: true, + }); + window.dispatchEvent(event); + expect(handler).toHaveBeenCalled(); + }); + + it("应该将 metaKey(Cmd)映射为 Ctrl", () => { + manager.register("Ctrl+N", handler); + manager.startListening(); + + const event = new KeyboardEvent("keydown", { + key: "n", + metaKey: true, + bubbles: true, + }); + window.dispatchEvent(event); + expect(handler).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/undefined-chat/src/platform/KeybindingManager.ts b/apps/undefined-chat/src/platform/KeybindingManager.ts new file mode 100644 index 00000000..aa1579b1 --- /dev/null +++ b/apps/undefined-chat/src/platform/KeybindingManager.ts @@ -0,0 +1,124 @@ +/** + * KeybindingManager - 桌面端快捷键管理器 + * + * 统一管理应用内快捷键绑定,支持: + * - Ctrl/Cmd、Alt、Shift 组合键 + * - 单键绑定(如 Escape) + * - 运行时注册/注销 + * - 自动防止默认浏览器行为 + */ + +export type KeybindingHandler = () => void; + +export class KeybindingManager { + private bindings = new Map(); + private boundListener: ((event: KeyboardEvent) => void) | null = null; + + /** + * 注册快捷键 + * @param key 标准化的键名(如 "Ctrl+N"、"Escape") + * @param handler 触发时的回调函数 + */ + register(key: string, handler: KeybindingHandler): void { + this.bindings.set(key, handler); + } + + /** + * 注销快捷键 + * @param key 标准化的键名 + */ + unregister(key: string): void { + this.bindings.delete(key); + } + + /** + * 清空所有绑定 + */ + clear(): void { + this.bindings.clear(); + } + + /** + * 开始监听键盘事件(通常在组件挂载时调用) + */ + startListening(): void { + if (this.boundListener) { + return; // 已在监听 + } + this.boundListener = (event: KeyboardEvent) => { + this.handleKeyDown(event); + }; + window.addEventListener("keydown", this.boundListener); + } + + /** + * 停止监听键盘事件(通常在组件卸载时调用) + */ + stopListening(): void { + if (this.boundListener) { + window.removeEventListener("keydown", this.boundListener); + this.boundListener = null; + } + } + + /** + * 处理键盘按下事件 + */ + private handleKeyDown(event: KeyboardEvent): void { + const key = this.normalizeKey(event); + const handler = this.bindings.get(key); + const isTextInput = + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement; + if (isTextInput && key !== "Escape" && !handler) { + return; + } + + if (handler) { + event.preventDefault(); + event.stopPropagation(); + handler(); + } + } + + /** + * 标准化键盘事件为统一的键名字符串 + * 格式:[Ctrl+][Alt+][Shift+]Key + * + * 注意: + * - macOS 上的 Cmd 键会统一映射为 Ctrl(metaKey) + * - 修饰键按 Ctrl → Alt → Shift 的顺序排列 + */ + private normalizeKey(event: KeyboardEvent): string { + const parts: string[] = []; + + // Ctrl 或 Cmd(macOS) + if (event.ctrlKey || event.metaKey) { + parts.push("Ctrl"); + } + + // Alt/Option + if (event.altKey) { + parts.push("Alt"); + } + + // Shift + if (event.shiftKey) { + parts.push("Shift"); + } + + // 主键(统一为大写) + const mainKey = + event.key.length === 1 ? event.key.toUpperCase() : event.key; + parts.push(mainKey); + + return parts.join("+"); + } + + /** + * 获取当前所有绑定的键名列表(用于调试) + */ + getRegisteredKeys(): string[] { + return Array.from(this.bindings.keys()); + } +} diff --git a/apps/undefined-chat/src/platform/PlatformContext.test.tsx b/apps/undefined-chat/src/platform/PlatformContext.test.tsx new file mode 100644 index 00000000..89c20542 --- /dev/null +++ b/apps/undefined-chat/src/platform/PlatformContext.test.tsx @@ -0,0 +1,291 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + PlatformProvider, + isAndroidPlatform, + isDesktopPlatform, + isMobilePlatform, + usePlatform, +} from "./PlatformContext"; +import type { PlatformInfo } from "./types"; + +// Mock Tauri invoke +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +const { invoke } = await import("@tauri-apps/api/core"); + +describe("PlatformContext", () => { + it("提供默认平台信息", () => { + function TestComponent() { + const platform = usePlatform(); + return
{platform.os}
; + } + + render( + + + , + ); + + // 初始状态应该是 unknown + expect(screen.getByTestId("platform-os")).toHaveTextContent("unknown"); + }); + + it("从 Tauri 命令获取平台信息", async () => { + const mockPlatform: PlatformInfo = { + os: "linux", + family: "unix", + arch: "x86_64", + debug: false, + supportsSystemKeyring: true, + supportsSecureApiKeyStorage: true, + supportsSse: true, + supportsHtmlPreview: true, + }; + + vi.mocked(invoke).mockResolvedValueOnce(mockPlatform); + + function TestComponent() { + const platform = usePlatform(); + return ( +
+
{platform.os}
+
{platform.arch}
+
+ {String(platform.supportsSystemKeyring)} +
+
+ ); + } + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("platform-os")).toHaveTextContent("linux"); + }); + + expect(screen.getByTestId("platform-arch")).toHaveTextContent("x86_64"); + expect(screen.getByTestId("supports-keyring")).toHaveTextContent("true"); + }); + + it("Tauri 失败时使用浏览器回退检测", async () => { + vi.mocked(invoke).mockRejectedValueOnce(new Error("Tauri not available")); + + // 模拟 Android userAgent + Object.defineProperty(navigator, "userAgent", { + value: "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36", + configurable: true, + }); + + function TestComponent() { + const platform = usePlatform(); + return
{platform.os}
; + } + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("platform-os")).toHaveTextContent("android"); + }); + }); + + it("区分系统密钥链和安全 API Key 存储能力", async () => { + const mockPlatform: PlatformInfo = { + os: "android", + family: "unix", + arch: "aarch64", + debug: false, + supportsSystemKeyring: false, + supportsSecureApiKeyStorage: true, + supportsSse: true, + supportsHtmlPreview: true, + }; + vi.mocked(invoke).mockResolvedValueOnce(mockPlatform); + + function TestComponent() { + const platform = usePlatform(); + return ( +
+
+ {String(platform.supportsSystemKeyring)} +
+
+ {String(platform.supportsSecureApiKeyStorage)} +
+
+ ); + } + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("supports-secure-api-key")).toHaveTextContent( + "true", + ); + }); + expect(screen.getByTestId("supports-keyring")).toHaveTextContent("false"); + }); + + it("处理 Tauri 调用错误", async () => { + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + vi.mocked(invoke).mockRejectedValueOnce(new Error("Network error")); + + function TestComponent() { + const platform = usePlatform(); + return
{platform.os}
; + } + + render( + + + , + ); + + await waitFor(() => { + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + + consoleWarnSpy.mockRestore(); + }); +}); + +describe("平台判断工具函数", () => { + it("isAndroidPlatform 正确识别 Android", () => { + expect( + isAndroidPlatform({ + os: "android", + family: "unix", + arch: "aarch64", + debug: false, + supportsSystemKeyring: false, + supportsSecureApiKeyStorage: false, + supportsSse: true, + supportsHtmlPreview: false, + }), + ).toBe(true); + + expect( + isAndroidPlatform({ + os: "linux", + family: "unix", + arch: "x86_64", + debug: false, + supportsSystemKeyring: true, + supportsSecureApiKeyStorage: true, + supportsSse: true, + supportsHtmlPreview: true, + }), + ).toBe(false); + }); + + it("isDesktopPlatform 正确识别桌面平台", () => { + expect( + isDesktopPlatform({ + os: "windows", + family: "windows", + arch: "x86_64", + debug: false, + supportsSystemKeyring: true, + supportsSecureApiKeyStorage: true, + supportsSse: true, + supportsHtmlPreview: true, + }), + ).toBe(true); + + expect( + isDesktopPlatform({ + os: "macos", + family: "unix", + arch: "aarch64", + debug: false, + supportsSystemKeyring: true, + supportsSecureApiKeyStorage: true, + supportsSse: true, + supportsHtmlPreview: true, + }), + ).toBe(true); + + expect( + isDesktopPlatform({ + os: "linux", + family: "unix", + arch: "x86_64", + debug: false, + supportsSystemKeyring: true, + supportsSecureApiKeyStorage: true, + supportsSse: true, + supportsHtmlPreview: true, + }), + ).toBe(true); + + expect( + isDesktopPlatform({ + os: "android", + family: "unix", + arch: "aarch64", + debug: false, + supportsSystemKeyring: false, + supportsSecureApiKeyStorage: false, + supportsSse: true, + supportsHtmlPreview: false, + }), + ).toBe(false); + }); + + it("isMobilePlatform 正确识别移动平台", () => { + expect( + isMobilePlatform({ + os: "android", + family: "unix", + arch: "aarch64", + debug: false, + supportsSystemKeyring: false, + supportsSecureApiKeyStorage: false, + supportsSse: true, + supportsHtmlPreview: false, + }), + ).toBe(true); + + expect( + isMobilePlatform({ + os: "ios", + family: "unix", + arch: "aarch64", + debug: false, + supportsSystemKeyring: true, + supportsSecureApiKeyStorage: true, + supportsSse: true, + supportsHtmlPreview: false, + }), + ).toBe(true); + + expect( + isMobilePlatform({ + os: "linux", + family: "unix", + arch: "x86_64", + debug: false, + supportsSystemKeyring: true, + supportsSecureApiKeyStorage: true, + supportsSse: true, + supportsHtmlPreview: true, + }), + ).toBe(false); + }); +}); diff --git a/apps/undefined-chat/src/platform/PlatformContext.tsx b/apps/undefined-chat/src/platform/PlatformContext.tsx new file mode 100644 index 00000000..f492f8e1 --- /dev/null +++ b/apps/undefined-chat/src/platform/PlatformContext.tsx @@ -0,0 +1,135 @@ +import { invoke } from "@tauri-apps/api/core"; +import { + type ReactNode, + createContext, + useContext, + useEffect, + useState, +} from "react"; +import { DEFAULT_PLATFORM_INFO, type PlatformInfo } from "./types"; + +/** + * 平台上下文 - 提供全局平台信息 + */ +const PlatformContext = createContext(DEFAULT_PLATFORM_INFO); + +/** + * 平台信息提供者组件 + */ +export function PlatformProvider({ children }: { children: ReactNode }) { + const [platform, setPlatform] = useState(DEFAULT_PLATFORM_INFO); + + useEffect(() => { + detectPlatform() + .then(setPlatform) + .catch((error) => { + console.error("Failed to detect platform:", error); + }); + }, []); + + return ( + + {children} + + ); +} + +/** + * 使用平台信息的 Hook + * + * @returns 当前平台信息 + * @example + * ```tsx + * const platform = usePlatform(); + * if (platform.os === "android") { + * return ; + * } + * ``` + */ +export function usePlatform(): PlatformInfo { + return useContext(PlatformContext); +} + +/** + * 检测平台信息 + * + * 优先通过 Tauri 命令获取,失败时回退到浏览器信息 + */ +async function detectPlatform(): Promise { + try { + const info = await invoke("get_platform_info"); + // 校验返回值:非对象 / 缺少 os(如非 Tauri 环境 invoke 解析为 undefined)时回退, + // 避免下游 isDesktopPlatform(platform.os) 读取 undefined 崩溃 + if (info && typeof info.os === "string") { + return info; + } + return getFallbackPlatformInfo(); + } catch (error) { + console.warn("Failed to invoke get_platform_info, using fallback:", error); + return getFallbackPlatformInfo(); + } +} + +/** + * 回退平台检测(基于浏览器 API) + */ +function getFallbackPlatformInfo(): PlatformInfo { + const ua = navigator.userAgent.toLowerCase(); + const isAndroid = ua.includes("android"); + const isIOS = /iphone|ipad|ipod/.test(ua); + const isMac = ua.includes("mac"); + const isWindows = ua.includes("win"); + const isLinux = ua.includes("linux") && !isAndroid; + + let os = "unknown"; + let family = "unknown"; + + if (isAndroid) { + os = "android"; + family = "unix"; + } else if (isIOS) { + os = "ios"; + family = "unix"; + } else if (isMac) { + os = "macos"; + family = "unix"; + } else if (isWindows) { + os = "windows"; + family = "windows"; + } else if (isLinux) { + os = "linux"; + family = "unix"; + } + + return { + os, + family, + arch: "unknown", + debug: false, + supportsSystemKeyring: false, + supportsSecureApiKeyStorage: false, + supportsSse: true, + supportsHtmlPreview: false, + }; +} + +/** + * 判断是否为 Android 平台 + */ +export function isAndroidPlatform(platform: PlatformInfo): boolean { + return platform.os === "android"; +} + +/** + * 判断是否为桌面平台 + */ +export function isDesktopPlatform(platform: PlatformInfo): boolean { + return ["windows", "macos", "linux"].includes(platform.os); +} + +/** + * 判断是否为移动平台 + */ +export function isMobilePlatform(platform: PlatformInfo): boolean { + return ["android", "ios"].includes(platform.os); +} diff --git a/apps/undefined-chat/src/platform/PlatformContext.usage.md b/apps/undefined-chat/src/platform/PlatformContext.usage.md new file mode 100644 index 00000000..efd3a3a8 --- /dev/null +++ b/apps/undefined-chat/src/platform/PlatformContext.usage.md @@ -0,0 +1,294 @@ +# PlatformContext 使用指南 + +## 概述 + +`PlatformContext` 提供了跨平台的平台信息检测和上下文管理,支持桌面端(Windows/macOS/Linux)和移动端(Android)。 + +## 基础用法 + +### 1. 在应用根组件包装 PlatformProvider + +```tsx +import { PlatformProvider } from "./platform/PlatformContext"; + +function App() { + return ( + + + + ); +} +``` + +### 2. 在任意子组件中使用 usePlatform Hook + +```tsx +import { usePlatform } from "./platform/PlatformContext"; + +function MyComponent() { + const platform = usePlatform(); + + return ( +
+

当前系统: {platform.os}

+

架构: {platform.arch}

+
+ ); +} +``` + +## 平台信息字段 + +```typescript +interface PlatformInfo { + os: string; // 操作系统: "windows" | "macos" | "linux" | "android" | "ios" | "unknown" + family: string; // 系统家族: "windows" | "unix" | "unknown" + arch: string; // 架构: "x86_64" | "aarch64" | "unknown" + debug: boolean; // 是否为调试构建 + supportsSystemKeyring: boolean; // 是否支持系统密钥链 + supportsSecureApiKeyStorage: boolean; // 是否支持安全的 API Key 存储 + supportsSse: boolean; // 是否支持 SSE 流式传输 + supportsHtmlPreview: boolean; // 是否支持 HTML 预览 +} +``` + +> 类型定义与默认值见 `src/platform/types.ts`(`PlatformInfo` / `DEFAULT_PLATFORM_INFO`),与 Rust 后端 `PlatformInfo` 对应。 + +## 条件渲染示例 + +### 根据平台类型渲染不同 UI + +```tsx +import { usePlatform, isAndroidPlatform, isDesktopPlatform } from "./platform/PlatformContext"; + +function AdaptiveUI() { + const platform = usePlatform(); + + if (isAndroidPlatform(platform)) { + return ; + } + + if (isDesktopPlatform(platform)) { + return ; + } + + return ; +} +``` + +### 根据操作系统显示快捷键 + +```tsx +function ShortcutHint() { + const platform = usePlatform(); + const modifier = platform.os === "macos" ? "Cmd" : "Ctrl"; + + return ( +
+ 按 {modifier}+K 打开命令面板 +
+ ); +} +``` + +### 根据平台能力启用功能 + +```tsx +function FeatureComponent() { + const platform = usePlatform(); + + return ( +
+ {platform.supportsSystemKeyring && ( + + )} + + {platform.supportsHtmlPreview && ( + + )} + + {platform.supportsSse ? ( + + ) : ( + + )} +
+ ); +} +``` + +### Android 特定布局 + +```tsx +function ChatLayout() { + const platform = usePlatform(); + + return ( +
+ {isAndroidPlatform(platform) ? ( + <> + {/* Android: 底部导航 */} + + + + ) : ( + <> + {/* 桌面: 侧边栏 */} + + + + )} +
+ ); +} +``` + +> 上例为模式示意。实际 `App.tsx` 的移动端判定取「真实移动平台 ∨ 窄视口断点」(`isNarrowViewport || isMobilePlatform(platform)`),以避免平板/移动设备横屏(>768px)被误判为桌面,详见上文「在 App.tsx 中的实际应用」。 + +## 工具函数 + +### isAndroidPlatform + +判断是否为 Android 平台: + +```tsx +import { isAndroidPlatform, usePlatform } from "./platform/PlatformContext"; + +const platform = usePlatform(); +if (isAndroidPlatform(platform)) { + // Android 特定逻辑 +} +``` + +### isDesktopPlatform + +判断是否为桌面平台(Windows/macOS/Linux): + +```tsx +import { isDesktopPlatform, usePlatform } from "./platform/PlatformContext"; + +const platform = usePlatform(); +if (isDesktopPlatform(platform)) { + // 桌面端特定逻辑 +} +``` + +### isMobilePlatform + +判断是否为移动平台(Android/iOS): + +```tsx +import { isMobilePlatform, usePlatform } from "./platform/PlatformContext"; + +const platform = usePlatform(); +if (isMobilePlatform(platform)) { + // 移动端特定逻辑 +} +``` + +## 平台检测机制 + +1. **优先级**:优先通过 Tauri `get_platform_info` 命令获取准确的平台信息 +2. **回退**:如果 Tauri 不可用(如 Web 环境),回退到基于 `navigator.userAgent` 的检测 +3. **默认值**:初始状态使用 `DEFAULT_PLATFORM_INFO`,异步检测完成后更新 + +## 在 App.tsx 中的实际应用 + +`src/App.tsx` 已实际接入平台抽象层:`usePlatform()` 驱动真实平台判定,移动端布局取「真实移动平台 ∨ 窄视口断点」,桌面端用 `DesktopLayout` 包装工作区,Android 生命周期按真实平台启用。 + +```tsx +import { useMediaQuery } from "./hooks/useMediaQuery"; +import { DesktopLayout } from "./platform/DesktopLayout"; +import { + isAndroidPlatform, + isDesktopPlatform, + isMobilePlatform, + usePlatform, +} from "./platform/PlatformContext"; + +function App() { + const platform = usePlatform(); + + // 窄视口或真实移动平台均视为移动端: + // 解决平板/移动设备横屏(>768px)被误判为桌面 + const isNarrowViewport = useMediaQuery("(max-width: 768px)"); + const isMobile = isNarrowViewport || isMobilePlatform(platform); + + // 以真实平台为准启用 Android 生命周期(替代旧的 UA 判定) + useEffect(() => { + if (!isAndroidPlatform(platform)) { + return undefined; + } + return setupAndroidLifecycle(store); + }, [store, platform]); + + return ( +
+ + +
+ {/* 桌面平台用 DesktopLayout 透明包裹,预留平台增强位 */} + +
{/* ... */}
+ + +
+
+
+ ); +} + +/** + * 桌面平台用 DesktopLayout(display:contents 透明包装,预留自定义标题栏/原生菜单), + * 其它平台直接渲染子节点。 + */ +function WorkspaceLayout({ isDesktop, children }: { + isDesktop: boolean; + children: ReactNode; +}) { + return isDesktop ? {children} : <>{children}; +} +``` + +### DesktopLayout + +桌面平台下包裹工作区的语义组件(`src/platform/DesktopLayout.tsx`)。当前以 `display: contents` 作透明包装——自身盒子从布局中消失,子元素直接参与 `.chat-workspace` 的 flex 列布局,不破坏现有布局,同时为自定义标题栏(`data-tauri-drag-region`)、原生菜单栏等桌面增强预留挂载点(`enableCustomTitleBar` 开关)。 + +### ConnectionSetup + +统一的连接 / 配置组件(`src/platform/ConnectionSetup.tsx`),替代了早期内联的 setup 面板。支持两种模式: + +- `mode="setup"`:首次连接,需填写 Runtime URL + API Key,无关闭按钮; +- `mode="settings"`:运行期修改配置,API Key 留空表示沿用原值,可关闭,并可通过 `children` 附加额外设置项(如自动滚动开关)。 + +内置最近使用的 Runtime 历史(localStorage,最多 5 条)、URL 必填与格式校验、全文案 i18n;持久化逻辑(保存配置→保存密钥→bootstrap)由调用方在 `onConnect` 中完成。`App.tsx` 据 `needsSetup` 在 `setup` / `settings` 间切换。 + +## 测试 + +```tsx +import { render, screen } from "@testing-library/react"; +import { PlatformProvider, usePlatform } from "./platform/PlatformContext"; + +it("组件根据平台信息渲染", () => { + function TestComponent() { + const platform = usePlatform(); + return
{platform.os}
; + } + + render( + + + + ); + + // 默认值 + expect(screen.getByTestId("os")).toHaveTextContent("unknown"); +}); +``` + +## 注意事项 + +1. **必须包装 PlatformProvider**:使用 `usePlatform` 的组件必须在 `PlatformProvider` 内部 +2. **异步检测**:平台信息是异步获取的,初始渲染时使用默认值 +3. **响应式设计**:`App.tsx` 已结合视口媒体查询(`useMediaQuery`)与平台检测(`isMobilePlatform`)判定移动端,两者取并集 +4. **性能考虑**:平台信息在应用启动时检测一次,后续读取无性能开销 diff --git a/apps/undefined-chat/src/platform/index.ts b/apps/undefined-chat/src/platform/index.ts new file mode 100644 index 00000000..85cc2be9 --- /dev/null +++ b/apps/undefined-chat/src/platform/index.ts @@ -0,0 +1,31 @@ +/** + * Platform 模块 - 跨平台特定功能 + * + * 导出: + * - PlatformProvider/usePlatform: 平台信息上下文 + * - KeybindingManager: 快捷键管理 + * - DesktopLayout: 桌面端布局包装器 + * - ConnectionSetup: Android 连接配置组件 + * - AndroidLifecycle: Android 生命周期管理 + * - PlatformInfo: 平台信息类型 + */ + +export { + PlatformProvider, + usePlatform, + isAndroidPlatform, + isDesktopPlatform, + isMobilePlatform, +} from "./PlatformContext"; +export { KeybindingManager } from "./KeybindingManager"; +export type { KeybindingHandler } from "./KeybindingManager"; +export { DesktopLayout } from "./DesktopLayout"; +export type { DesktopLayoutProps } from "./DesktopLayout"; +export { ConnectionSetup } from "./ConnectionSetup"; +export type { ConnectionSetupProps, RuntimeConfig } from "./ConnectionSetup"; +export { + setupAndroidLifecycle, + isAndroid, +} from "./AndroidLifecycle"; +export type { PlatformInfo } from "./types"; +export { DEFAULT_PLATFORM_INFO } from "./types"; diff --git a/apps/undefined-chat/src/platform/integration.test.tsx b/apps/undefined-chat/src/platform/integration.test.tsx new file mode 100644 index 00000000..18731c19 --- /dev/null +++ b/apps/undefined-chat/src/platform/integration.test.tsx @@ -0,0 +1,159 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DesktopLayout } from "./DesktopLayout"; +import { PlatformProvider, usePlatform } from "./PlatformContext"; +import { DEFAULT_PLATFORM_INFO } from "./types"; + +describe("Platform Integration", () => { + it("DesktopLayout 应该正常渲染子组件", () => { + const { getByText } = render( + +
测试内容
+
, + ); + expect(getByText("测试内容")).toBeDefined(); + }); + + it("DesktopLayout 应该应用 desktop-layout 类名", () => { + const { container } = render( + +
内容
+
, + ); + const layout = container.querySelector(".desktop-layout"); + expect(layout).toBeDefined(); + }); + + it("DesktopLayout 应该支持自定义标题栏选项", () => { + const { container } = render( + +
内容
+
, + ); + const layout = container.querySelector('[data-custom-titlebar="true"]'); + expect(layout).toBeDefined(); + }); + + it("在组件中使用 usePlatform 进行条件渲染", () => { + function PlatformAwareComponent() { + const platform = usePlatform(); + + return ( +
+
{platform.os}
+ {platform.os === "android" && ( +
移动端 UI
+ )} + {["windows", "macos", "linux"].includes(platform.os) && ( +
桌面端 UI
+ )} + {platform.supportsSystemKeyring && ( +
支持系统密钥链
+ )} +
+ ); + } + + render( + + + , + ); + + // 默认状态下应该显示 unknown + expect(screen.getByTestId("os")).toHaveTextContent("unknown"); + }); + + it("根据平台信息显示不同快捷键提示", () => { + function KeybindingHint() { + const platform = usePlatform(); + const modifier = platform.os === "macos" ? "Cmd" : "Ctrl"; + + return ( +
+ {modifier}+K +
+ ); + } + + // 测试默认平台 + render( + + + , + ); + + // 默认应该显示 Ctrl(因为默认 os 是 unknown) + expect(screen.getByTestId("shortcut")).toHaveTextContent("Ctrl+K"); + }); + + it("根据平台能力启用/禁用功能", () => { + function FeatureToggle() { + const platform = usePlatform(); + + return ( +
+ {platform.supportsSse ? ( +
使用 SSE 流式传输
+ ) : ( +
使用轮询模式
+ )} + {platform.supportsHtmlPreview && ( + + )} +
+ ); + } + + render( + + + , + ); + + // 默认平台支持 SSE + expect(screen.getByTestId("sse-enabled")).toBeInTheDocument(); + // 默认平台不支持 HTML 预览 + expect(screen.queryByTestId("preview-btn")).not.toBeInTheDocument(); + }); + + it("嵌套组件可以访问平台上下文", () => { + function ParentComponent() { + return ( +
+ +
+ ); + } + + function ChildComponent() { + const platform = usePlatform(); + return ( +
+ +
{platform.os}
+
+ ); + } + + function GrandchildComponent() { + const platform = usePlatform(); + return
{platform.arch}
; + } + + render( + + + , + ); + + expect(screen.getByTestId("child-os")).toHaveTextContent( + DEFAULT_PLATFORM_INFO.os, + ); + expect(screen.getByTestId("grandchild-arch")).toHaveTextContent( + DEFAULT_PLATFORM_INFO.arch, + ); + }); +}); diff --git a/apps/undefined-chat/src/platform/types.ts b/apps/undefined-chat/src/platform/types.ts new file mode 100644 index 00000000..000be5a4 --- /dev/null +++ b/apps/undefined-chat/src/platform/types.ts @@ -0,0 +1,27 @@ +/** + * 平台信息类型定义(与 Rust 后端 PlatformInfo 对应) + */ +export interface PlatformInfo { + os: string; + family: string; + arch: string; + debug: boolean; + supportsSystemKeyring: boolean; + supportsSecureApiKeyStorage: boolean; + supportsSse: boolean; + supportsHtmlPreview: boolean; +} + +/** + * 默认平台信息(用于初始状态或 Web 环境) + */ +export const DEFAULT_PLATFORM_INFO: PlatformInfo = { + os: "unknown", + family: "unknown", + arch: "unknown", + debug: false, + supportsSystemKeyring: false, + supportsSecureApiKeyStorage: false, + supportsSse: true, + supportsHtmlPreview: false, +}; diff --git a/apps/undefined-chat/src/rendering/AttachmentImage.test.tsx b/apps/undefined-chat/src/rendering/AttachmentImage.test.tsx new file mode 100644 index 00000000..88fb4c7a --- /dev/null +++ b/apps/undefined-chat/src/rendering/AttachmentImage.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LOCALE_STORAGE_KEY, LanguageProvider } from "../i18n"; +import type { AttachmentPreviewResult } from "../runtime-client/types"; +import { AttachmentImage } from "./AttachmentImage"; +import { AttachmentImageProvider } from "./AttachmentImageContext"; + +function imageResult(): AttachmentPreviewResult { + return { + status: 200, + ok: true, + mediaType: "image/png", + bytes: [137, 80, 78, 71], + body: null, + }; +} + +function renderWithProvider( + ui: ReactNode, + previewImpl?: () => Promise, +) { + const previewAttachment = vi.fn(previewImpl ?? (async () => imageResult())); + return { + previewAttachment, + ...render( + + + {ui} + + , + ), + }; +} + +describe("AttachmentImage", () => { + // 固定为简体中文,使断言不受测试环境 navigator.language 影响 + beforeEach(() => { + window.localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); + }); + + it("加载成功显示 img(blob src)", async () => { + renderWithProvider(); + + const img = await screen.findByRole("img", { name: "chart.png" }); + expect(img.getAttribute("src")).toMatch(/^blob:/); + }); + + it("加载失败显示文件图标降级,不渲染 img", async () => { + renderWithProvider( + , + async () => ({ + status: 415, + ok: false, + mediaType: null, + bytes: [], + body: "x", + }), + ); + + expect(await screen.findByText("IMG")).toBeInTheDocument(); + expect(screen.queryByRole("img")).toBeNull(); + }); + + it("点击已加载图片回传 blob URL 给 onOpenImage", async () => { + const onOpenImage = vi.fn(); + renderWithProvider( + , + ); + + const img = await screen.findByAltText("chart.png"); + fireEvent.click(img); + + expect(onOpenImage).toHaveBeenCalledWith( + expect.stringMatching(/^blob:/), + "chart.png", + ); + }); +}); diff --git a/apps/undefined-chat/src/rendering/AttachmentImage.tsx b/apps/undefined-chat/src/rendering/AttachmentImage.tsx new file mode 100644 index 00000000..ee516499 --- /dev/null +++ b/apps/undefined-chat/src/rendering/AttachmentImage.tsx @@ -0,0 +1,107 @@ +import { type CSSProperties, useEffect, useState } from "react"; +import { useTranslation } from "../i18n"; +import { getFileIcon } from "../utils/file-icon"; +import { useAttachmentImage } from "./AttachmentImageContext"; + +export type AttachmentImageProps = { + uid: string; + alt: string; + /** 加载失败时按此 MIME 选择降级图标文本,默认按图片处理 */ + mediaType?: string; + className?: string; + style?: CSSProperties; + /** 提供则图片可点击/回车打开大图,回传已加载的 blob URL */ + onOpenImage?: (src: string, alt: string) => void; +}; + +type LoadState = + | { status: "loading" } + | { status: "loaded"; url: string } + | { status: "error" }; + +/** + * 按 UID 经 Tauri(带 auth)拉取附件并以 blob URL 渲染图片。 + * + * 三态:加载中(占位)/ 加载完成(``,可点击放大)/ 失败(文件图标降级)。 + * blob URL 由 {@link AttachmentImageProvider} 缓存与释放,本组件不自行 revoke。 + */ +export function AttachmentImage({ + uid, + alt, + mediaType, + className, + style, + onOpenImage, +}: AttachmentImageProps) { + const { loadAttachmentBlob } = useAttachmentImage(); + const { t } = useTranslation(); + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + loadAttachmentBlob(uid) + .then((loaded) => { + if (cancelled) return; + setState( + loaded ? { status: "loaded", url: loaded.url } : { status: "error" }, + ); + }) + .catch(() => { + if (!cancelled) setState({ status: "error" }); + }); + return () => { + cancelled = true; + }; + }, [uid, loadAttachmentBlob]); + + if (state.status === "loading") { + return ( +
+ ); + } + + if (state.status === "error") { + return ( +
+ {getFileIcon(mediaType ?? "image/")} +
+ ); + } + + const handleOpen = () => onOpenImage?.(state.url, alt); + + return ( + {alt} { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleOpen(); + } + } + : undefined + } + role={onOpenImage ? "button" : undefined} + tabIndex={onOpenImage ? 0 : undefined} + /> + ); +} diff --git a/apps/undefined-chat/src/rendering/AttachmentImageContext.test.tsx b/apps/undefined-chat/src/rendering/AttachmentImageContext.test.tsx new file mode 100644 index 00000000..adf67af6 --- /dev/null +++ b/apps/undefined-chat/src/rendering/AttachmentImageContext.test.tsx @@ -0,0 +1,133 @@ +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import type { + AttachmentPreviewResult, + RuntimeClient, +} from "../runtime-client/types"; +import { + AttachmentImageProvider, + useAttachmentImage, +} from "./AttachmentImageContext"; + +type PreviewClient = Pick; + +function imageResult(): AttachmentPreviewResult { + return { + status: 200, + ok: true, + mediaType: "image/png", + bytes: [137, 80, 78, 71], + body: null, + }; +} + +function makeClient(impl?: () => Promise) { + const previewAttachment = vi.fn(impl ?? (async () => imageResult())); + const client: PreviewClient = { previewAttachment }; + return { client, previewAttachment }; +} + +function makeWrapper(client: PreviewClient, maxCacheSize?: number) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe("AttachmentImageContext", () => { + it("缓存命中:同一 UID 只拉取一次,返回同一 blob URL", async () => { + const { client, previewAttachment } = makeClient(); + const { result } = renderHook(() => useAttachmentImage(), { + wrapper: makeWrapper(client), + }); + + const a = await result.current.loadAttachmentBlob("pic_1"); + const b = await result.current.loadAttachmentBlob("pic_1"); + + expect(a?.url).toBeTruthy(); + expect(a?.url).toBe(b?.url); + expect(previewAttachment).toHaveBeenCalledTimes(1); + }); + + it("并发去重:同时请求同一 UID 只发一次", async () => { + const { client, previewAttachment } = makeClient(); + const { result } = renderHook(() => useAttachmentImage(), { + wrapper: makeWrapper(client), + }); + + const [a, b] = await Promise.all([ + result.current.loadAttachmentBlob("pic_1"), + result.current.loadAttachmentBlob("pic_1"), + ]); + + expect(a?.url).toBe(b?.url); + expect(previewAttachment).toHaveBeenCalledTimes(1); + }); + + it("ok:false 返回 null", async () => { + const { client } = makeClient(async () => ({ + status: 415, + ok: false, + mediaType: null, + bytes: [], + body: "nope", + })); + const { result } = renderHook(() => useAttachmentImage(), { + wrapper: makeWrapper(client), + }); + + expect(await result.current.loadAttachmentBlob("pic_1")).toBeNull(); + }); + + it("previewAttachment 抛错返回 null", async () => { + const { client } = makeClient(async () => { + throw new Error("boom"); + }); + const { result } = renderHook(() => useAttachmentImage(), { + wrapper: makeWrapper(client), + }); + + expect(await result.current.loadAttachmentBlob("pic_1")).toBeNull(); + }); + + it("LRU 超上限淘汰最旧并 revoke", async () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL"); + const { client, previewAttachment } = makeClient(); + const { result } = renderHook(() => useAttachmentImage(), { + wrapper: makeWrapper(client, 2), + }); + + await result.current.loadAttachmentBlob("pic_1"); + await result.current.loadAttachmentBlob("pic_2"); + await result.current.loadAttachmentBlob("pic_3"); // 淘汰最旧 pic_1 + + expect(revokeSpy).toHaveBeenCalledTimes(1); + + // pic_1 已被淘汰,再请求会重新拉取(总计 4 次) + await result.current.loadAttachmentBlob("pic_1"); + expect(previewAttachment).toHaveBeenCalledTimes(4); + + revokeSpy.mockRestore(); + }); + + it("Provider 卸载时 revoke 全部 blob URL", async () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL"); + const { client } = makeClient(); + const { result, unmount } = renderHook(() => useAttachmentImage(), { + wrapper: makeWrapper(client), + }); + + await result.current.loadAttachmentBlob("pic_1"); + await result.current.loadAttachmentBlob("pic_2"); + revokeSpy.mockClear(); + + unmount(); + + expect(revokeSpy).toHaveBeenCalledTimes(2); + revokeSpy.mockRestore(); + }); +}); diff --git a/apps/undefined-chat/src/rendering/AttachmentImageContext.tsx b/apps/undefined-chat/src/rendering/AttachmentImageContext.tsx new file mode 100644 index 00000000..d58ec934 --- /dev/null +++ b/apps/undefined-chat/src/rendering/AttachmentImageContext.tsx @@ -0,0 +1,144 @@ +import { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from "react"; +import type { RuntimeClient } from "../runtime-client/types"; + +export type LoadedAttachmentBlob = { + url: string; + mediaType: string; +}; + +type AttachmentImageContextValue = { + loadAttachmentBlob: (uid: string) => Promise; +}; + +const AttachmentImageContext = + createContext(null); + +const DEFAULT_MAX_CACHE_SIZE = 60; + +export type AttachmentImageProviderProps = { + /** 仅依赖 previewAttachment,便于测试以最小桩注入 */ + client: Pick; + /** blob 缓存上限,超出按 LRU 淘汰并 revoke。默认 60。 */ + maxCacheSize?: number; + children: ReactNode; +}; + +/** + * 通过 Tauri 命令(带 auth)按 UID 拉取附件字节并转为 blob URL 供 `` 渲染。 + * + * Runtime API 要求 `X-Undefined-API-Key` header,浏览器 `` 不带该 + * header 会被拦截(401);故图片统一经 `client.previewAttachment`(Rust 端带 auth) + * 拉字节,在前端转 blob URL 渲染。 + * + * - LRU 缓存 uid→blobUrl:缩略图/正文图/全屏大图复用同一 blob,避免重复拉取。 + * - inflight 去重:同一 UID 并发请求只发一次。 + * - blob URL 归本 Provider 所有:消费组件**不**自行 revoke(缩略图与大图共享同一 + * URL,组件卸载即 revoke 会让仍打开的大图破图),仅在 LRU 淘汰或 Provider 卸载 + * 时 revoke。 + */ +export function AttachmentImageProvider({ + client, + maxCacheSize = DEFAULT_MAX_CACHE_SIZE, + children, +}: AttachmentImageProviderProps) { + const cacheRef = useRef>(new Map()); + const inflightRef = useRef>>( + new Map(), + ); + + const loadAttachmentBlob = useCallback( + (uid: string): Promise => { + const cache = cacheRef.current; + const inflight = inflightRef.current; + + const cached = cache.get(uid); + if (cached) { + // LRU touch:移到队尾 + cache.delete(uid); + cache.set(uid, cached); + return Promise.resolve(cached); + } + + const pending = inflight.get(uid); + if (pending) return pending; + + const promise = (async (): Promise => { + try { + const result = await client.previewAttachment({ attachmentId: uid }); + if (!result.ok || result.bytes.length === 0) { + return null; + } + const mediaType = result.mediaType || "application/octet-stream"; + const blob = new Blob([new Uint8Array(result.bytes)], { + type: mediaType, + }); + const loaded: LoadedAttachmentBlob = { + url: URL.createObjectURL(blob), + mediaType, + }; + cache.set(uid, loaded); + // 超上限淘汰最旧(队首)并 revoke + while (cache.size > maxCacheSize) { + const oldestKey = cache.keys().next().value; + if (oldestKey === undefined) break; + const victim = cache.get(oldestKey); + cache.delete(oldestKey); + if (victim) URL.revokeObjectURL(victim.url); + } + return loaded; + } catch { + return null; + } finally { + inflight.delete(uid); + } + })(); + + inflight.set(uid, promise); + return promise; + }, + [client, maxCacheSize], + ); + + // Provider 卸载时释放全部 blob URL + useEffect(() => { + const cache = cacheRef.current; + return () => { + for (const { url } of cache.values()) { + URL.revokeObjectURL(url); + } + cache.clear(); + }; + }, []); + + const value = useMemo( + () => ({ loadAttachmentBlob }), + [loadAttachmentBlob], + ); + + return ( + + {children} + + ); +} + +/** + * 获取附件图片加载能力。必须在 {@link AttachmentImageProvider} 内使用。 + */ +export function useAttachmentImage(): AttachmentImageContextValue { + const value = useContext(AttachmentImageContext); + if (value === null) { + throw new Error( + "useAttachmentImage must be used within an AttachmentImageProvider", + ); + } + return value; +} diff --git a/apps/undefined-chat/src/rendering/AttachmentProcessor.test.tsx b/apps/undefined-chat/src/rendering/AttachmentProcessor.test.tsx new file mode 100644 index 00000000..b1246dc3 --- /dev/null +++ b/apps/undefined-chat/src/rendering/AttachmentProcessor.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { Attachment } from "../runtime-client/types"; +import { + extractAttachmentTags, + findAttachmentByUid, +} from "./AttachmentProcessor"; + +describe("extractAttachmentTags", () => { + it("提取 为占位符并收集 UID(占位符以空行包裹)", () => { + const { cleanContent, attachmentUids } = extractAttachmentTags( + '看图结束', + ); + expect(attachmentUids).toEqual(["pic_1"]); + expect(cleanContent).toContain("\n\nATTACHMENT_PLACEHOLDER_0\n\n"); + }); + + it("兼容 旧标签与多附件,序号与 UID 顺序对应", () => { + const { attachmentUids } = extractAttachmentTags( + '', + ); + expect(attachmentUids).toEqual(["pic_1", "pic_2"]); + }); + + it("无附件标签时原样返回", () => { + const { cleanContent, attachmentUids } = extractAttachmentTags("纯文本"); + expect(cleanContent).toBe("纯文本"); + expect(attachmentUids).toEqual([]); + }); +}); + +describe("findAttachmentByUid", () => { + const att: Attachment = { + id: "pic_1", + name: "x.png", + size: 1, + mediaType: "image/png", + kind: "image", + downloadUrl: null, + previewUrl: null, + discarded: false, + }; + + it("按 UID 命中返回附件", () => { + expect(findAttachmentByUid([att], "pic_1")).toBe(att); + }); + + it("未命中返回 null", () => { + expect(findAttachmentByUid([att], "nope")).toBeNull(); + }); +}); diff --git a/apps/undefined-chat/src/rendering/AttachmentProcessor.tsx b/apps/undefined-chat/src/rendering/AttachmentProcessor.tsx new file mode 100644 index 00000000..3001f615 --- /dev/null +++ b/apps/undefined-chat/src/rendering/AttachmentProcessor.tsx @@ -0,0 +1,36 @@ +import type { Attachment } from "../runtime-client/types"; + +type ExtractResult = { + cleanContent: string; + attachmentUids: string[]; +}; + +/** + * 解析消息内容中的附件标签 `` / ``, + * 替换为占位符并收集 UID。 + * + * 占位符以空行包裹,确保后续 Markdown 渲染时作为独立块级元素,不破坏文字块结构。 + * 图片在 Runtime API 输出环节已统一注册为附件并改写为 `` + * (后端 `segment_text` 重建文本,不会残留 `[CQ:image]`/`[CQ:file]` 等 CQ 码), + * 故客户端只需处理 UID 附件标签;实际图片字节经 Tauri(带 auth)按 UID 拉取。 + */ +export function extractAttachmentTags(content: string): ExtractResult { + const uids: string[] = []; + const attachmentPattern = + /<(?:attachment|pic)\s+uid=["']([^"']+)["']\s*\/?\s*>/gi; + const cleanContent = content.replace(attachmentPattern, (_match, uid) => { + uids.push(uid); + return `\n\nATTACHMENT_PLACEHOLDER_${uids.length - 1}\n\n`; + }); + return { cleanContent, attachmentUids: uids }; +} + +/** + * 从附件列表中根据 UID 查找对应附件 + */ +export function findAttachmentByUid( + attachments: Attachment[], + uid: string, +): Attachment | null { + return attachments.find((a) => a.id === uid) || null; +} diff --git a/apps/undefined-chat/src/rendering/CodeBlock.css b/apps/undefined-chat/src/rendering/CodeBlock.css new file mode 100644 index 00000000..7c0f7e5b --- /dev/null +++ b/apps/undefined-chat/src/rendering/CodeBlock.css @@ -0,0 +1,308 @@ +/* 代码块样式 - 参考 webui components.css 1065-1233 行 */ + +.runtime-code-block { + min-width: 0; + max-width: 100%; + margin: 0.5em 0; + border-radius: 6px; + border: 1px solid var(--border-color, #d1d5db); + background: color-mix( + in srgb, + var(--bg-app, #f9fafb) 88%, + var(--bg-deep, #111827) + ); + overflow: hidden; +} + +.runtime-code-toolbar { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 34px; + padding: 5px 8px 5px 12px; + border-bottom: 1px solid + color-mix(in srgb, var(--border-color, #d1d5db) 72%, transparent); + background: color-mix(in srgb, var(--bg-card, #f3f4f6) 58%, transparent); +} + +.runtime-code-language { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary, #9ca3af); + font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, + "Courier New", monospace; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.runtime-code-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +.runtime-code-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-secondary, #6b7280); + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: + background 0.16s ease, + border-color 0.16s ease, + color 0.16s ease, + transform 0.16s ease; +} + +.runtime-code-action:hover, +.runtime-code-action:focus-visible { + border-color: color-mix( + in srgb, + var(--accent, #2563eb) 28%, + var(--border-color, #d1d5db) + ); + background: color-mix(in srgb, var(--accent, #2563eb) 10%, transparent); + color: var(--text-primary, #1a1a1a); +} + +.runtime-code-action.primary { + background: color-mix(in srgb, var(--accent, #2563eb) 12%, transparent); + color: var(--accent, #2563eb); +} + +.runtime-code-action.primary:hover, +.runtime-code-action.primary:focus-visible { + background: var(--accent, #2563eb); + color: #fff; +} + +.runtime-code-body { + position: relative; +} + +/* 折叠状态 */ +.runtime-code-block.is-collapsed .runtime-code-body { + max-height: 9.2em; + overflow-y: auto; + overflow-x: hidden; + position: relative; +} + +.runtime-code-block.is-collapsed .runtime-code-body::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3em; + background: linear-gradient( + to bottom, + transparent, + var(--bg-code, #ffffff) 90% + ); + pointer-events: none; +} + +.runtime-code-body pre { + max-width: 100%; + margin: 0; + padding: 10px 14px; + border: 0; + border-radius: 0; + background: transparent; + overflow-x: hidden; + overflow-y: auto; + white-space: pre-wrap; + word-wrap: break-word; + font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, + "Courier New", monospace; + font-size: 12.5px; + line-height: 1.5; +} + +.runtime-code-body pre code { + display: block; + min-width: 0; + max-width: 100%; + padding: 0; + border: none; + background: none; + border-radius: 0; + white-space: pre-wrap; + word-wrap: break-word; + color: var(--text-primary, #1a1a1a); +} + +.runtime-code-body pre code.hljs { + padding: 0; + background: transparent; + color: var(--text-primary, #1a1a1a); +} + +/* ============ 语法高亮颜色(浅色主题)============ */ +.runtime-code-block .hljs-keyword, +.runtime-code-block .hljs-doctag, +.runtime-code-block .hljs-template-tag, +.runtime-code-block .hljs-type { + color: #9b3fb5; + font-weight: 600; +} + +.runtime-code-block .hljs-string, +.runtime-code-block .hljs-regexp, +.runtime-code-block .hljs-meta .hljs-string { + color: #246f4f; +} + +.runtime-code-block .hljs-comment, +.runtime-code-block .hljs-quote { + color: var(--text-tertiary, #9ca3af); + font-style: italic; +} + +.runtime-code-block .hljs-number, +.runtime-code-block .hljs-literal, +.runtime-code-block .hljs-variable, +.runtime-code-block .hljs-attribute, +.runtime-code-block .hljs-symbol { + color: #b45f2b; +} + +.runtime-code-block .hljs-title, +.runtime-code-block .hljs-title.function_, +.runtime-code-block .hljs-title.class_, +.runtime-code-block .hljs-section { + color: #1f6fb2; + font-weight: 600; +} + +.runtime-code-block .hljs-operator, +.runtime-code-block .hljs-punctuation, +.runtime-code-block .hljs-meta { + color: #8a6350; +} + +.runtime-code-block .hljs-property, +.runtime-code-block .hljs-attr, +.runtime-code-block .hljs-selector-attr, +.runtime-code-block .hljs-selector-class, +.runtime-code-block .hljs-selector-id { + color: #b25577; +} + +.runtime-code-block .hljs-name, +.runtime-code-block .hljs-selector-tag, +.runtime-code-block .hljs-built_in { + color: #22735a; +} + +.runtime-code-block .hljs-addition { + color: #1d7f45; + background: color-mix(in srgb, #1d7f45 12%, transparent); +} + +.runtime-code-block .hljs-deletion { + color: #b31d28; + background: color-mix(in srgb, #b31d28 10%, transparent); +} + +/* ============ 语法高亮颜色(深色主题)============ */ +@media (prefers-color-scheme: dark) { + .runtime-code-block { + background: color-mix(in srgb, var(--bg-app, #0d1117) 88%, #000); + } + + .runtime-code-body pre code.hljs, + .runtime-code-body pre code { + color: var(--text-primary, #e5e7eb); + } + + .runtime-code-block.is-collapsed .runtime-code-body::after { + background: linear-gradient( + to bottom, + transparent, + var(--bg-code, #0d1117) 90% + ); + } + + .runtime-code-block .hljs-keyword, + .runtime-code-block .hljs-doctag, + .runtime-code-block .hljs-template-tag, + .runtime-code-block .hljs-type { + color: #da7dd8; + font-weight: 600; + } + + .runtime-code-block .hljs-string, + .runtime-code-block .hljs-regexp, + .runtime-code-block .hljs-meta .hljs-string { + color: #56c288; + } + + .runtime-code-block .hljs-comment, + .runtime-code-block .hljs-quote { + color: var(--text-tertiary, #6b7280); + font-style: italic; + } + + .runtime-code-block .hljs-number, + .runtime-code-block .hljs-literal, + .runtime-code-block .hljs-variable, + .runtime-code-block .hljs-attribute, + .runtime-code-block .hljs-symbol { + color: #e9a774; + } + + .runtime-code-block .hljs-title, + .runtime-code-block .hljs-title.function_, + .runtime-code-block .hljs-title.class_, + .runtime-code-block .hljs-section { + color: #69b7ee; + font-weight: 600; + } + + .runtime-code-block .hljs-operator, + .runtime-code-block .hljs-punctuation, + .runtime-code-block .hljs-meta { + color: #d0a694; + } + + .runtime-code-block .hljs-property, + .runtime-code-block .hljs-attr, + .runtime-code-block .hljs-selector-attr, + .runtime-code-block .hljs-selector-class, + .runtime-code-block .hljs-selector-id { + color: #e695b8; + } + + .runtime-code-block .hljs-name, + .runtime-code-block .hljs-selector-tag, + .runtime-code-block .hljs-built_in { + color: #50bf96; + } + + .runtime-code-block .hljs-addition { + color: #5ec972; + background: color-mix(in srgb, #5ec972 12%, transparent); + } + + .runtime-code-block .hljs-deletion { + color: #e57373; + background: color-mix(in srgb, #e57373 10%, transparent); + } +} diff --git a/apps/undefined-chat/src/rendering/CodeBlock.test.tsx b/apps/undefined-chat/src/rendering/CodeBlock.test.tsx new file mode 100644 index 00000000..48ed389a --- /dev/null +++ b/apps/undefined-chat/src/rendering/CodeBlock.test.tsx @@ -0,0 +1,209 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LOCALE_STORAGE_KEY, LanguageProvider } from "../i18n"; +import { CodeBlock } from "./CodeBlock"; + +// CodeBlock 内部使用 useTranslation,需置于 LanguageProvider 下 +function renderCodeBlock(node: ReactElement) { + return render({node}); +} + +describe("CodeBlock", () => { + // Mock clipboard API + const mockWriteText = vi.fn(); + + beforeEach(() => { + // 固定为简体中文,使断言不受测试环境 navigator.language 影响 + window.localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); + // Mock clipboard API + Object.defineProperty(navigator, "clipboard", { + value: { + writeText: mockWriteText, + }, + writable: true, + configurable: true, + }); + mockWriteText.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("应该渲染代码块", () => { + const code = 'console.log("Hello, World!");'; + renderCodeBlock(); + + // 检查语言标签 + expect(screen.getByText(/javascript/i)).toBeInTheDocument(); + // 检查代码内容(使用 textContent 因为可能被拆分成多个 span) + const codeElement = document.querySelector("code"); + expect(codeElement?.textContent).toContain("console"); + expect(codeElement?.textContent).toContain("log"); + }); + + it("应该自动检测语言", () => { + const code = 'print("Hello, World!")'; + renderCodeBlock(); + + // highlight.js 应该自动检测语言 + const languageLabel = screen.getByText(/python|stylus|plaintext/i); + expect(languageLabel).toBeInTheDocument(); + }); + + it("应该在超过 maxLines 行时显示折叠按钮", () => { + const code = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + renderCodeBlock(); + + expect(screen.getByText("展开")).toBeInTheDocument(); + }); + + it("应该在不超过 maxLines 行时不显示折叠按钮", () => { + const code = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + renderCodeBlock(); + + expect(screen.queryByText("展开")).not.toBeInTheDocument(); + expect(screen.queryByText("折叠")).not.toBeInTheDocument(); + }); + + it("应该在 collapsible=false 时不显示折叠按钮", () => { + const code = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + renderCodeBlock(); + + expect(screen.queryByText("展开")).not.toBeInTheDocument(); + expect(screen.queryByText("折叠")).not.toBeInTheDocument(); + }); + + it("应该切换折叠状态", async () => { + const user = userEvent.setup(); + const code = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join( + "\n", + ); + const { container } = renderCodeBlock( + , + ); + + const codeBlock = container.querySelector(".runtime-code-block"); + + // 初始状态应该是折叠的 + expect(codeBlock).toHaveClass("is-collapsed"); + expect(screen.getByText("展开")).toBeInTheDocument(); + + // 点击展开 + await user.click(screen.getByText("展开")); + expect(screen.getByText("折叠")).toBeInTheDocument(); + expect(codeBlock).not.toHaveClass("is-collapsed"); + + // 点击折叠 + await user.click(screen.getByText("折叠")); + expect(screen.getByText("展开")).toBeInTheDocument(); + expect(codeBlock).toHaveClass("is-collapsed"); + }); + + it("应该复制代码到剪贴板", async () => { + const code = 'console.log("test");'; + + renderCodeBlock(); + + const copyButton = screen.getByText("复制"); + + // 验证 navigator.clipboard 是否存在 + expect(navigator.clipboard).toBeDefined(); + expect(navigator.clipboard.writeText).toBeDefined(); + + // 使用 fireEvent 直接触发点击 + fireEvent.click(copyButton); + + // 等待一下确保异步操作完成 + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockWriteText).toHaveBeenCalledWith(code); + await waitFor(() => { + expect(screen.getByText("已复制")).toBeInTheDocument(); + }); + + // 2秒后应该恢复为"复制" + await waitFor( + () => { + expect(screen.getByText("复制")).toBeInTheDocument(); + }, + { timeout: 2500 }, + ); + }); + + it("应该为 HTML 代码显示预览按钮", () => { + const code = "
Hello
"; + const onPreviewHtml = vi.fn(); + renderCodeBlock( + , + ); + + expect(screen.getByText("预览 HTML")).toBeInTheDocument(); + }); + + it("应该调用 onPreviewHtml", async () => { + const user = userEvent.setup(); + const code = "
Hello
"; + const onPreviewHtml = vi.fn(); + renderCodeBlock( + , + ); + + const previewButton = screen.getByText("预览 HTML"); + await user.click(previewButton); + + expect(onPreviewHtml).toHaveBeenCalledWith({ + title: "HTML 预览", + html: code, + }); + }); + + it("应该为非 HTML 代码不显示预览按钮", () => { + const code = 'console.log("test");'; + const onPreviewHtml = vi.fn(); + renderCodeBlock( + , + ); + + expect(screen.queryByText("预览 HTML")).not.toBeInTheDocument(); + }); + + it("应该处理语法高亮错误", () => { + const code = "some invalid code that might break highlighting"; + // 不应该抛出错误 + expect(() => { + renderCodeBlock( + , + ); + }).not.toThrow(); + }); + + it("应该正确渲染空代码", () => { + const code = ""; + renderCodeBlock(); + + const languageLabel = screen.getByText(/javascript/i); + expect(languageLabel).toBeInTheDocument(); + }); + + it("应该处理包含特殊字符的代码", () => { + const code = "const str = \"\";"; + renderCodeBlock(); + + const codeElement = document.querySelector("code"); + expect(codeElement?.textContent).toContain("const str"); + expect(codeElement?.textContent).toContain("script"); + }); +}); diff --git a/apps/undefined-chat/src/rendering/CodeBlock.tsx b/apps/undefined-chat/src/rendering/CodeBlock.tsx new file mode 100644 index 00000000..79968a9c --- /dev/null +++ b/apps/undefined-chat/src/rendering/CodeBlock.tsx @@ -0,0 +1,151 @@ +import hljs from "highlight.js"; +import { useMemo, useState } from "react"; +import { useTranslation } from "../i18n"; +import "./CodeBlock.css"; + +export type CodeBlockProps = { + code: string; + language?: string; + showLineNumbers?: boolean; + collapsible?: boolean; + maxLines?: number; + onPreviewHtml?: (input: { title: string; html: string }) => void; +}; + +/** + * 代码高亮和折叠组件 + * - 集成 highlight.js 进行语法高亮 + * - 支持代码折叠(超过 maxLines 行自动折叠) + * - 支持复制代码 + * - 支持 HTML 预览 + */ +export function CodeBlock({ + code, + language = "", + showLineNumbers = false, + collapsible = true, + maxLines = 8, + onPreviewHtml, +}: CodeBlockProps) { + const { t } = useTranslation(); + const [isCollapsed, setIsCollapsed] = useState(false); + const [copied, setCopied] = useState(false); + + // 语法高亮 + const { highlightedCode, detectedLanguage } = useMemo(() => { + const trimmedCode = code.trim(); + try { + if (language) { + // htm 别名映射到 html + const normalizedLanguage = + language.toLowerCase() === "htm" ? "html" : language; + // 指定了语言,尝试使用指定语言高亮 + const result = hljs.highlight(trimmedCode, { + language: normalizedLanguage, + ignoreIllegals: true, + }); + return { + highlightedCode: result.value, + detectedLanguage: normalizedLanguage, + }; + } + // 未指定语言,自动检测 + const result = hljs.highlightAuto(trimmedCode); + return { + highlightedCode: result.value, + detectedLanguage: result.language || "plaintext", + }; + } catch (err) { + console.error("Highlight error:", err); + return { + highlightedCode: trimmedCode, + detectedLanguage: "plaintext", + }; + } + }, [code, language]); + + // 计算行数 + const lineCount = useMemo(() => { + return code.trim().split("\n").length; + }, [code]); + + // 是否需要折叠 + const shouldCollapse = collapsible && lineCount > maxLines; + + // 初始化折叠状态(只在组件挂载时设置一次) + const [initialized, setInitialized] = useState(false); + if (!initialized && shouldCollapse) { + setIsCollapsed(true); + setInitialized(true); + } + + // 复制代码 + async function handleCopy() { + try { + await navigator.clipboard.writeText(code.trim()); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy code:", err); + } + } + + // 切换折叠 + function toggleCollapse() { + setIsCollapsed(!isCollapsed); + } + + // 是否为 HTML + const isHtml = ["html", "htm"].includes(detectedLanguage.toLowerCase()); + + return ( +
+
+ + {detectedLanguage || "code"} + +
+ {shouldCollapse && ( + + )} + {isHtml && onPreviewHtml && ( + + )} + +
+
+
+
+					
+				
+
+
+ ); +} diff --git a/apps/undefined-chat/src/rendering/HtmlPreview.test.tsx b/apps/undefined-chat/src/rendering/HtmlPreview.test.tsx new file mode 100644 index 00000000..cc31c625 --- /dev/null +++ b/apps/undefined-chat/src/rendering/HtmlPreview.test.tsx @@ -0,0 +1,91 @@ +import { render as baseRender, screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LOCALE_STORAGE_KEY, LanguageProvider } from "../i18n"; +import type { MarkdownContentProps } from "./MarkdownContent"; +import { MarkdownContent } from "./MarkdownContent"; + +// MarkdownContent 内部经 CodeBlock 使用 useTranslation,需置于 LanguageProvider 下 +function render(node: ReactNode) { + return baseRender({node}); +} + +describe("HTML Preview", () => { + let onPreviewHtml: MarkdownContentProps["onPreviewHtml"]; + + beforeEach(() => { + // 固定为简体中文,使断言不受测试环境 navigator.language 影响 + window.localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); + onPreviewHtml = vi.fn(); + }); + + it("should show preview button for HTML code blocks", () => { + const content = "```html\n

Hello

\n```"; + render(); + + expect(screen.getByText("预览 HTML")).toBeInTheDocument(); + }); + + it("should show preview button for HTM code blocks", () => { + const content = "```htm\n

Test

\n```"; + render(); + + expect(screen.getByText("预览 HTML")).toBeInTheDocument(); + }); + + it("should not show preview button for non-HTML code blocks", () => { + const content = "```javascript\nconsole.log('hi');\n```"; + render(); + + expect(screen.queryByText("预览 HTML")).not.toBeInTheDocument(); + }); + + it("should call onPreviewHtml with correct data when clicked", async () => { + const user = userEvent.setup(); + const htmlCode = "

Test Page

"; + const content = `\`\`\`html\n${htmlCode}\n\`\`\``; + + render(); + + const previewButton = screen.getByText("预览 HTML"); + await user.click(previewButton); + + expect(onPreviewHtml).toHaveBeenCalledOnce(); + expect(onPreviewHtml).toHaveBeenCalledWith({ + title: "HTML 预览", + html: htmlCode, + }); + }); + + it("should handle multiple code blocks", async () => { + const user = userEvent.setup(); + const content = ` +\`\`\`html +
First
+\`\`\` + +Some text + +\`\`\`html +
Second
+\`\`\` +`; + render(); + + const buttons = screen.getAllByText("预览 HTML"); + expect(buttons).toHaveLength(2); + + await user.click(buttons[0]); + expect(onPreviewHtml).toHaveBeenLastCalledWith({ + title: "HTML 预览", + html: "
First
", + }); + + await user.click(buttons[1]); + expect(onPreviewHtml).toHaveBeenLastCalledWith({ + title: "HTML 预览", + html: "
Second
", + }); + }); +}); diff --git a/apps/undefined-chat/src/rendering/MarkdownContent.css b/apps/undefined-chat/src/rendering/MarkdownContent.css new file mode 100644 index 00000000..69bc9e05 --- /dev/null +++ b/apps/undefined-chat/src/rendering/MarkdownContent.css @@ -0,0 +1,352 @@ +/* Markdown 渲染样式 - 参考 webui components.css */ + +.message-markdown { + width: 100%; + min-width: 0; +} + +.markdown-body { + white-space: normal; + word-wrap: break-word; + line-height: 1.6; + color: var(--text-primary, #1a1a1a); +} + +/* 通用元素 */ +.markdown-body > * { + min-width: 0; + max-width: 100%; +} + +.markdown-body > *:first-child { + margin-top: 0; +} + +.markdown-body > *:last-child { + margin-bottom: 0; +} + +/* 段落 */ +.markdown-body p { + margin: 0.4em 0; +} + +/* 标题 */ +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin: 0.6em 0 0.3em; + line-height: 1.3; + font-weight: 600; +} + +.markdown-body h1 { + font-size: 1.25em; +} + +.markdown-body h2 { + font-size: 1.15em; +} + +.markdown-body h3 { + font-size: 1.05em; +} + +.markdown-body h4 { + font-size: 1em; +} + +.markdown-body h5 { + font-size: 0.95em; +} + +.markdown-body h6 { + font-size: 0.9em; +} + +/* 列表 */ +.markdown-body ul, +.markdown-body ol { + margin: 0.4em 0; + padding-left: 1.5em; +} + +.markdown-body li { + margin: 0.15em 0; +} + +.markdown-body li > p { + margin: 0.2em 0; +} + +/* 任务列表 */ +.markdown-body input[type="checkbox"] { + margin-right: 0.5em; + vertical-align: middle; +} + +/* 引用块 */ +.markdown-body blockquote { + margin: 0.4em 0; + padding: 0.3em 0.8em; + border-left: 3px solid var(--border-color, #d1d5db); + color: var(--text-secondary, #6b7280); + background: var(--bg-subtle, #f9fafb); + overflow: hidden; /* 防止内容溢出 */ +} + +.markdown-body blockquote > *:first-child { + margin-top: 0; +} + +.markdown-body blockquote > *:last-child { + margin-bottom: 0; +} + +/* 引用块内的代码块自动换行,不折叠/限高 */ +.markdown-body blockquote pre { + overflow-x: hidden; + white-space: pre-wrap; + word-wrap: break-word; +} + +.markdown-body blockquote code { + white-space: pre-wrap; + word-wrap: break-word; +} + +/* 表格 */ +.markdown-body table { + width: 100%; + max-width: 100%; + table-layout: auto; + border-collapse: collapse; + margin: 0.5em 0; + font-size: 13px; + border: 1px solid var(--border-color, #d1d5db); +} + +.markdown-body th, +.markdown-body td { + border: 1px solid var(--border-color, #d1d5db); + padding: 6px 10px; + overflow-wrap: anywhere; + word-break: break-word; + text-align: left; +} + +.markdown-body th { + background: var(--bg-app, #f3f4f6); + font-weight: 600; +} + +.markdown-body tr:nth-child(even) { + background: var(--bg-subtle, #f9fafb); +} + +/* 水平分割线 */ +.markdown-body hr { + margin: 0.8em 0; + border: none; + border-top: 1px solid var(--border-color, #d1d5db); +} + +/* 链接 */ +.markdown-body a { + color: var(--accent, #2563eb); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + +.markdown-body a:hover { + text-decoration-thickness: 2px; +} + +/* 行内代码 */ +.markdown-body code { + padding: 0.15em 0.4em; + margin: 0 0.1em; + font-size: 0.9em; + font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, + "Courier New", monospace; + background: var(--bg-code-inline, #f3f4f6); + border: 1px solid var(--border-color-subtle, #e5e7eb); + border-radius: 3px; +} + +.markdown-body pre code { + padding: 0; + margin: 0; + font-size: inherit; + background: none; + border: none; +} + +/* 删除线 */ +.markdown-body del { + text-decoration: line-through; + opacity: 0.7; +} + +/* 强调 */ +.markdown-body strong { + font-weight: 600; +} + +.markdown-body em { + font-style: italic; +} + +/* 图片 */ +.markdown-body img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 0.5em 0; +} + +/* 代码块容器 */ +.code-block { + min-width: 0; + max-width: 100%; + margin: 0.5em 0; + border-radius: 6px; + border: 1px solid var(--border-color, #d1d5db); + background: var(--bg-code-block, #f9fafb); + overflow: hidden; +} + +.code-block figcaption { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 36px; + padding: 6px 10px; + border-bottom: 1px solid var(--border-color-subtle, #e5e7eb); + background: var(--bg-code-header, #f3f4f6); + font-size: 12px; + font-weight: 500; + color: var(--text-secondary, #6b7280); +} + +.code-block figcaption span { + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.code-block figcaption button { + padding: 4px 10px; + font-size: 12px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 4px; + background: white; + color: var(--text-primary, #1a1a1a); + cursor: pointer; + transition: all 0.15s ease; +} + +.code-block figcaption button:hover { + background: var(--bg-hover, #f3f4f6); + border-color: var(--accent, #2563eb); + color: var(--accent, #2563eb); +} + +.code-block pre { + margin: 0; + padding: 12px; + overflow-x: auto; + font-size: 13px; + line-height: 1.5; + font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, + "Courier New", monospace; + background: var(--bg-code, #ffffff); +} + +.code-block code { + display: block; + padding: 0; + margin: 0; + background: none; + border: none; + color: var(--text-primary, #1a1a1a); +} + +/* 深色模式适配 */ +@media (prefers-color-scheme: dark) { + .markdown-body { + color: var(--text-primary, #e5e7eb); + } + + .markdown-body blockquote { + border-left-color: var(--border-color, #374151); + color: var(--text-secondary, #9ca3af); + background: var(--bg-subtle, #1f2937); + } + + .markdown-body table { + border-color: var(--border-color, #374151); + } + + .markdown-body th, + .markdown-body td { + border-color: var(--border-color, #374151); + } + + .markdown-body th { + background: var(--bg-app, #1f2937); + } + + .markdown-body tr:nth-child(even) { + background: var(--bg-subtle, #111827); + } + + .markdown-body hr { + border-top-color: var(--border-color, #374151); + } + + .markdown-body a { + color: var(--accent, #3b82f6); + } + + .markdown-body code { + background: var(--bg-code-inline, #1f2937); + border-color: var(--border-color-subtle, #374151); + color: var(--text-primary, #e5e7eb); + } + + .code-block { + border-color: var(--border-color, #374151); + background: var(--bg-code-block, #1f2937); + } + + .code-block figcaption { + border-bottom-color: var(--border-color-subtle, #374151); + background: var(--bg-code-header, #111827); + color: var(--text-secondary, #9ca3af); + } + + .code-block figcaption button { + background: var(--bg-button, #1f2937); + border-color: var(--border-color, #374151); + color: var(--text-primary, #e5e7eb); + } + + .code-block figcaption button:hover { + background: var(--bg-hover, #374151); + border-color: var(--accent, #3b82f6); + color: var(--accent, #3b82f6); + } + + .code-block pre { + background: var(--bg-code, #0d1117); + } + + .code-block code { + color: var(--text-primary, #e5e7eb); + } +} diff --git a/apps/undefined-chat/src/rendering/MarkdownContent.test.tsx b/apps/undefined-chat/src/rendering/MarkdownContent.test.tsx new file mode 100644 index 00000000..a3eb9b40 --- /dev/null +++ b/apps/undefined-chat/src/rendering/MarkdownContent.test.tsx @@ -0,0 +1,403 @@ +import { render as baseRender, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom"; +import { LOCALE_STORAGE_KEY, LanguageProvider } from "../i18n"; +import type { Attachment } from "../runtime-client/types"; +import { AttachmentImageProvider } from "./AttachmentImageContext"; +import { MarkdownContent } from "./MarkdownContent"; + +// MarkdownContent 内部经 CodeBlock / AttachmentImage 使用 useTranslation,需置于 Provider 下 +function render(node: ReactNode) { + return baseRender({node}); +} + +describe("MarkdownContent", () => { + const mockOnPreviewHtml = vi.fn(); + + // 固定为简体中文,使断言不受测试环境 navigator.language 影响 + beforeEach(() => { + window.localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); + }); + + it("渲染基本文本", () => { + render( + , + ); + expect(screen.getByText("Hello world")).toBeInTheDocument(); + }); + + it("渲染标题", () => { + const content = `# H1 + +## H2 + +### H3`; + + const { container } = render( + , + ); + + const h1 = container.querySelector("h1"); + const h2 = container.querySelector("h2"); + const h3 = container.querySelector("h3"); + + expect(h1).not.toBeNull(); + expect(h2).not.toBeNull(); + expect(h3).not.toBeNull(); + }); + + it("渲染表格", () => { + const tableMarkdown = `| 列1 | 列2 | 列3 | +|-----|-----|-----| +| A | B | C | +| D | E | F |`; + + render( + , + ); + + // 检查表头 + expect(screen.getByText("列1")).toBeInTheDocument(); + expect(screen.getByText("列2")).toBeInTheDocument(); + expect(screen.getByText("列3")).toBeInTheDocument(); + + // 检查表格内容 + expect(screen.getByText("A")).toBeInTheDocument(); + expect(screen.getByText("E")).toBeInTheDocument(); + expect(screen.getByText("F")).toBeInTheDocument(); + }); + + it("渲染引用块", () => { + const { container } = render( + , + ); + + const blockquote = container.querySelector("blockquote"); + expect(blockquote).not.toBeNull(); + expect(blockquote?.textContent).toContain("这是一个引用块"); + }); + + it("渲染任务列表", () => { + const taskList = `- [x] 已完成任务 +- [ ] 未完成任务 +- [x] 另一个已完成`; + + render( + , + ); + + expect(screen.getByText("已完成任务")).toBeInTheDocument(); + expect(screen.getByText("未完成任务")).toBeInTheDocument(); + expect(screen.getByText("另一个已完成")).toBeInTheDocument(); + + // 检查复选框 + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(3); + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).not.toBeChecked(); + expect(checkboxes[2]).toBeChecked(); + }); + + it("渲染删除线", () => { + render( + , + ); + const deleted = screen.getByText("删除的文本").closest("del"); + expect(deleted).toBeInTheDocument(); + }); + + it("渲染有序列表", () => { + const list = `1. 第一项 +2. 第二项 +3. 第三项`; + + render( + , + ); + + expect(screen.getByText("第一项")).toBeInTheDocument(); + expect(screen.getByText("第二项")).toBeInTheDocument(); + expect(screen.getByText("第三项")).toBeInTheDocument(); + }); + + it("渲染无序列表", () => { + const list = `- Item A +- Item B +- Item C`; + + render( + , + ); + + expect(screen.getByText("Item A")).toBeInTheDocument(); + expect(screen.getByText("Item B")).toBeInTheDocument(); + expect(screen.getByText("Item C")).toBeInTheDocument(); + }); + + it("渲染链接(带安全属性)", () => { + render( + , + ); + + const link = screen.getByText("点击这里").closest("a"); + expect(link).toHaveAttribute("href", "https://example.com"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("渲染行内代码", () => { + render( + , + ); + + const code = screen.getByText("inline code"); + expect(code.tagName).toBe("CODE"); + }); + + it("渲染代码块", () => { + const codeBlock = "```javascript\nconst x = 1;\nconsole.log(x);\n```"; + + const { container } = render( + , + ); + + expect(screen.getByText("javascript")).toBeInTheDocument(); + const code = container.querySelector("code"); + expect(code?.textContent).toContain("const x = 1;"); + expect(screen.getByText("复制")).toBeInTheDocument(); + }); + + it("渲染 HTML 代码块时显示预览按钮", () => { + const htmlBlock = "```html\n
Hello
\n```"; + + render( + , + ); + + expect(screen.getByText("html")).toBeInTheDocument(); + expect(screen.getByText("预览 HTML")).toBeInTheDocument(); + expect(screen.getByText("复制")).toBeInTheDocument(); + }); + + it("渲染混合内容", () => { + const mixedContent = `# 标题 + +普通段落文本。 + +## 表格示例 + +| 名称 | 年龄 | +|------|------| +| 张三 | 25 | + +## 列表示例 + +- [x] 完成项 +- [ ] 待办项 + +> 引用内容 + +\`\`\`python +print("Hello") +\`\`\``; + + const { container } = render( + , + ); + + // 检查各部分是否存在 + expect(screen.getByText("标题")).toBeInTheDocument(); + expect(screen.getByText("普通段落文本。")).toBeInTheDocument(); + expect(screen.getByText("表格示例")).toBeInTheDocument(); + expect(screen.getByText("张三")).toBeInTheDocument(); + expect(screen.getByText("完成项")).toBeInTheDocument(); + expect(screen.getByText("引用内容")).toBeInTheDocument(); + + // 检查代码块内容(使用 container 查询,因为被高亮拆分) + const codeElement = container.querySelector("code"); + expect(codeElement?.textContent).toContain('print("Hello")'); + }); + + it("渲染强调和粗体", () => { + render( + , + ); + + expect(screen.getByText("粗体")).toBeInTheDocument(); + expect(screen.getByText("斜体")).toBeInTheDocument(); + expect(screen.getByText("粗斜体")).toBeInTheDocument(); + }); + + it("处理换行(remark-breaks)", () => { + const content = `第一行 +第二行 +第三行`; + + const { container } = render( + , + ); + + // remark-breaks 会将单个换行符转换为
+ expect(container.querySelectorAll("br").length).toBeGreaterThan(0); + }); + + it("渲染水平分割线", () => { + const content = `段落1 + +--- + +段落2`; + + const { container } = render( + , + ); + + const hr = container.querySelector("hr"); + expect(hr).toBeInTheDocument(); + }); + + it("渲染图片", () => { + render( + , + ); + + const img = screen.getByAltText("alt text"); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("src", "https://example.com/image.png"); + expect(img).toHaveAttribute("loading", "lazy"); + }); + + describe("正文附件图片()", () => { + const imageAttachment: Attachment = { + id: "pic_1", + name: "chart.png", + size: 2048, + mediaType: "image/png", + kind: "image", + downloadUrl: null, + previewUrl: null, + discarded: false, + }; + + function renderWithImageProvider(ui: ReactNode) { + const previewAttachment = vi.fn(async () => ({ + status: 200, + ok: true, + mediaType: "image/png", + bytes: [137, 80, 78, 71], + body: null, + })); + return render( + + {ui} + , + ); + } + + it(" 渲染为 blob 图片,与文字共存", async () => { + renderWithImageProvider( + , + ); + + const img = await screen.findByAltText("chart.png"); + expect(img.getAttribute("src")).toMatch(/^blob:/); + expect(screen.getByText("看图")).toBeInTheDocument(); + }); + + it("非图片附件占位符被移除", () => { + const fileAttachment: Attachment = { + ...imageAttachment, + id: "file_1", + name: "doc.pdf", + mediaType: "application/pdf", + kind: "file", + }; + renderWithImageProvider( + , + ); + + expect(screen.queryByRole("img")).toBeNull(); + expect(screen.getByText("文件")).toBeInTheDocument(); + expect(screen.getByText("结束")).toBeInTheDocument(); + expect(screen.queryByText(/ATTACHMENT_PLACEHOLDER/)).toBeNull(); + }); + + it("未知 pic_ 附件标签 fallback 为图片预览", async () => { + const { container } = renderWithImageProvider( + , + ); + + const img = await screen.findByRole("img", { name: "图片预览" }); + expect(img.getAttribute("src")).toMatch(/^blob:/); + expect(container.textContent).toContain("看图"); + }); + + it("未知非 pic_ 附件标签被移除", () => { + const { container } = renderWithImageProvider( + , + ); + + expect(container.querySelector(".runtime-chat-image")).toBeNull(); + expect(screen.getByText("文件")).toBeInTheDocument(); + expect(screen.getByText("结束")).toBeInTheDocument(); + }); + + it("文字与图片混排保持顺序", async () => { + renderWithImageProvider( + , + ); + + await screen.findByAltText("chart.png"); + expect(screen.getByText("前文")).toBeInTheDocument(); + expect(screen.getByText("后文")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/undefined-chat/src/rendering/MarkdownContent.tsx b/apps/undefined-chat/src/rendering/MarkdownContent.tsx new file mode 100644 index 00000000..2b8074d1 --- /dev/null +++ b/apps/undefined-chat/src/rendering/MarkdownContent.tsx @@ -0,0 +1,250 @@ +import { Fragment, type ReactNode, useMemo } from "react"; +import ReactMarkdown from "react-markdown"; +import type { Components } from "react-markdown"; +import remarkBreaks from "remark-breaks"; +import remarkGfm from "remark-gfm"; +import { useTranslation } from "../i18n"; +import type { Attachment } from "../runtime-client/types"; +import { isImageAttachment } from "../utils/attachment"; +import { AttachmentImage } from "./AttachmentImage"; +import { + extractAttachmentTags, + findAttachmentByUid, +} from "./AttachmentProcessor"; +import { CodeBlock } from "./CodeBlock"; +import { markdownRehypePlugins } from "./sanitize"; +import "./MarkdownContent.css"; + +export type HtmlPreviewRequest = { + title: string; + html: string; +}; + +export type MarkdownContentProps = { + content: string; + onPreviewHtml: (input: HtmlPreviewRequest) => void; + attachments?: Attachment[]; + onImageClick?: (src: string, alt: string) => void; +}; + +type Segment = + | { type: "text"; value: string } + | { type: "code"; language: string; value: string }; + +function splitSegments(content: string): Segment[] { + const segments: Segment[] = []; + const pattern = /```([A-Za-z0-9_-]*)\n?([\s\S]*?)```/g; + let cursor = 0; + for (const match of content.matchAll(pattern)) { + const index = match.index ?? 0; + if (index > cursor) { + segments.push({ type: "text", value: content.slice(cursor, index) }); + } + segments.push({ + type: "code", + language: match[1]?.trim().toLowerCase() ?? "", + value: match[2] ?? "", + }); + cursor = index + match[0].length; + } + if (cursor < content.length) { + segments.push({ type: "text", value: content.slice(cursor) }); + } + return segments.length > 0 ? segments : [{ type: "text", value: content }]; +} + +type TextBlockProps = { + value: string; + onPreviewHtml: (input: HtmlPreviewRequest) => void; + onImageClick?: (src: string, alt: string) => void; +}; + +function TextBlock({ value, onPreviewHtml, onImageClick }: TextBlockProps) { + const components = useMemo( + () => ({ + // 自定义链接渲染:添加 target="_blank" 和安全属性 + a: ({ href, children, ...props }) => ( + + {children} + + ), + // 自定义图片渲染:Markdown ![](url) 外链图,支持点击预览 + // biome-ignore lint/a11y/useAltText: alt is passed from markdown content + img: ({ src, alt, ...props }) => ( + {alt { + if (onImageClick && src) { + onImageClick(src, alt || ""); + } + }} + onKeyDown={(e) => { + if (onImageClick && src && (e.key === "Enter" || e.key === " ")) { + onImageClick(src, alt || ""); + } + }} + role={onImageClick ? "button" : undefined} + tabIndex={onImageClick ? 0 : undefined} + {...props} + /> + ), + // 自定义表格渲染 + table: ({ children, ...props }) => ( +
+ {children}
+
+ ), + // 自定义代码块渲染:应用折叠逻辑 + code: ({ className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || ""); + const codeString = String(children).replace(/\n$/, ""); + + // 检查是否为代码块(有 language- 前缀且有换行符) + if (match && codeString.includes("\n")) { + return ( + + ); + } + + return ( + + {children} + + ); + }, + }), + [onPreviewHtml, onImageClick], + ); + + return ( +
+ + {value} + +
+ ); +} + +const PLACEHOLDER_SPLIT = /(ATTACHMENT_PLACEHOLDER_\d+)/; +const PLACEHOLDER_MATCH = /^ATTACHMENT_PLACEHOLDER_(\d+)$/; + +/** + * 将含附件占位符的文字段渲染为「文字块 + 内联附件图片」交错序列。 + * 图片附件渲染为 {@link AttachmentImage}(经 Tauri 带 auth 拉取转 blob); + * 非图片附件移除(由附件区展示);纯文字仍走 {@link TextBlock}(含 Markdown)。 + */ +function renderTextWithAttachments( + value: string, + keyPrefix: string, + attachmentUids: string[], + attachments: Attachment[], + onPreviewHtml: (input: HtmlPreviewRequest) => void, + onImageClick?: (src: string, alt: string) => void, + fallbackImageAlt = "image", +): ReactNode[] { + const nodes: ReactNode[] = []; + value.split(PLACEHOLDER_SPLIT).forEach((part, idx) => { + const match = PLACEHOLDER_MATCH.exec(part); + if (match) { + const uid = attachmentUids[Number(match[1])]; + const attachment = uid ? findAttachmentByUid(attachments, uid) : null; + if (attachment && !isImageAttachment(attachment)) { + return; + } + if (attachment || uid?.startsWith("pic_")) { + nodes.push( + , + ); + } + return; + } + if (!part.trim()) return; + nodes.push( + , + ); + }); + return nodes; +} + +export function MarkdownContent({ + content, + onPreviewHtml, + attachments = [], + onImageClick, +}: MarkdownContentProps) { + const { t } = useTranslation(); + // 提取附件标签并替换为占位符(占位符以空行包裹,作为独立块级元素) + const { cleanContent, attachmentUids } = useMemo( + () => extractAttachmentTags(content), + [content], + ); + + const segments = useMemo(() => splitSegments(cleanContent), [cleanContent]); + + return ( +
+ {segments.map((segment, index) => { + if (segment.type === "code") { + return ( + + ); + } + return ( + + {renderTextWithAttachments( + segment.value, + String(index), + attachmentUids, + attachments, + onPreviewHtml, + onImageClick, + t("image.preview"), + )} + + ); + })} +
+ ); +} diff --git a/apps/undefined-chat/src/rendering/sanitize.test.tsx b/apps/undefined-chat/src/rendering/sanitize.test.tsx new file mode 100644 index 00000000..7676bf6a --- /dev/null +++ b/apps/undefined-chat/src/rendering/sanitize.test.tsx @@ -0,0 +1,399 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom"; +import { renderWithProviders } from "../test-utils"; +import { MarkdownContent } from "./MarkdownContent"; +import { markdownRehypePlugins, markdownSanitizeSchema } from "./sanitize"; + +/** + * XSS sanitize 回归测试。 + * + * 守护 P-render 启用的「正文内联 HTML 渲染 + sanitize」安全行为: + * MarkdownContent 经 rehype-raw 解析正文原始 HTML,再经 rehype-sanitize 按 + * {@link markdownSanitizeSchema} 白名单清洗。任何放宽白名单、误删 sanitize 插件、 + * 调换插件顺序的回退都会让本组测试变红。 + * + * 实现细节(按 sanitize.ts / hast-util-sanitize defaultSchema 写断言,非臆测): + * - script 在 defaultSchema.strip 中,标签连同内容被剥离。 + * - on*(onerror/onclick…)不在任何 attributes 白名单中,被剥离。 + * - href 协议白名单 = http/https/irc/ircs/mailto/xmpp;src = http/https; + * javascript:/data: 等危险协议的属性被整条移除。 + * - iframe/object/embed/form 不在 tagNames 中,标签被剥离(子文本保留)。 + * - 安全内容(b/strong/a[https]/img[https]/span[class]/table/列表)正常渲染。 + */ + +const noop = () => {}; + +function renderMarkdown(content: string) { + return renderWithProviders( + , + ); +} + +describe("MarkdownContent XSS sanitize", () => { + it("剥离 后文", + ); + + // script 标签不出现在 DOM + expect(container.querySelector("script")).toBeNull(); + // 标签内容不作为可见文本注入 + expect(container.textContent).not.toContain("window.__xss_script__"); + // 周边安全文本仍正常渲染 + expect(screen.getByText(/前文/)).toBeInTheDocument(); + expect(screen.getByText(/后文/)).toBeInTheDocument(); + // 脚本未执行(rehype-raw 经 sanitize 不会运行脚本,双保险断言) + expect(spy).not.toHaveBeenCalled(); + + (window as unknown as { __xss_script__?: () => void }).__xss_script__ = + undefined; + }); + + it("剥离 等 on* 事件处理属性", () => { + const { container } = renderMarkdown( + '', + ); + + const img = container.querySelector("img"); + // 图片标签本身在白名单内,被保留 + expect(img).not.toBeNull(); + // 但 on* 事件处理属性被剥离 + expect(img?.getAttribute("onerror")).toBeNull(); + expect(img?.getAttribute("onclick")).toBeNull(); + expect(img?.hasAttribute("onerror")).toBe(false); + expect(img?.hasAttribute("onclick")).toBe(false); + }); + + it('中和 危险协议', () => { + const { container } = renderMarkdown( + '点我', + ); + + const anchor = container.querySelector("a"); + expect(anchor).not.toBeNull(); + // javascript: 不在 href 协议白名单(http/https/irc/ircs/mailto/xmpp), + // 危险属性被整条移除,绝不保留 javascript: 协议。 + const href = anchor?.getAttribute("href") ?? ""; + expect(href.toLowerCase()).not.toContain("javascript:"); + // 链接文本保留 + expect(screen.getByText("点我")).toBeInTheDocument(); + }); + + it('中和 危险协议(src 白名单仅 http/https)', () => { + const { container } = renderMarkdown( + 'bad', + ); + + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + const src = img?.getAttribute("src") ?? ""; + expect(src.toLowerCase()).not.toContain("javascript:"); + }); + + it("剥离 ', + '', + '', + '
', + ].join("\n"), + ); + + // 这些标签均不在 markdownSanitizeSchema.tagNames 白名单中,被剥离 + expect(container.querySelector("iframe")).toBeNull(); + expect(container.querySelector("object")).toBeNull(); + expect(container.querySelector("embed")).toBeNull(); + expect(container.querySelector("form")).toBeNull(); + }); + + it("保留安全内联 HTML:粗体/链接/图片/带 class 的 span", () => { + const { container } = renderMarkdown( + [ + "加粗 强调", + '安全链接', + '安全图片', + '高亮文字', + ].join("\n"), + ); + + // 粗体 / 强调标签保留 + expect(screen.getByText("加粗").tagName).toBe("B"); + expect(screen.getByText("强调").tagName).toBe("STRONG"); + + // https 链接保留 href,并由组件附加安全属性 + const anchor = screen.getByText("安全链接").closest("a"); + expect(anchor).toHaveAttribute("href", "https://example.com"); + expect(anchor).toHaveAttribute("target", "_blank"); + expect(anchor).toHaveAttribute("rel", "noopener noreferrer"); + + // https 图片保留 src + const img = screen.getByAltText("安全图片"); + expect(img).toHaveAttribute("src", "https://example.com/ok.png"); + + // 带 class 的 span 保留(className 是 schema 唯一放宽的展示属性) + const span = container.querySelector("span.highlight"); + expect(span).not.toBeNull(); + expect(span?.textContent).toBe("高亮文字"); + }); + + it("保留 HTML 表格与列表结构", () => { + const { container } = renderMarkdown( + [ + "", + "
", + "
  • 项一
  • 项二
", + ].join("\n"), + ); + + expect(container.querySelector("table")).not.toBeNull(); + expect(container.querySelector("th")?.textContent).toBe("列"); + expect(container.querySelector("td")?.textContent).toBe("值"); + const items = container.querySelectorAll("li"); + expect(items.length).toBe(2); + expect(items[0]?.textContent).toBe("项一"); + expect(items[1]?.textContent).toBe("项二"); + }); + + it("混合(恶意 + 安全)内容:剥离恶意、保留安全", () => { + const spy = vi.fn(); + (window as unknown as { __xss_mixed__?: () => void }).__xss_mixed__ = spy; + const { container } = renderMarkdown( + [ + "保留这个", + "", + '留', + '坏链', + ].join("\n"), + ); + + // 安全标签保留 + expect(screen.getByText("保留这个").tagName).toBe("B"); + const img = screen.getByAltText("留"); + expect(img).toHaveAttribute("src", "https://example.com/ok.png"); + + // 恶意向量被中和 + expect(container.querySelector("script")).toBeNull(); + expect(img.hasAttribute("onerror")).toBe(false); + const badAnchor = screen.getByText("坏链").closest("a"); + expect((badAnchor?.getAttribute("href") ?? "").toLowerCase()).not.toContain( + "javascript:", + ); + expect(spy).not.toHaveBeenCalled(); + + (window as unknown as { __xss_mixed__?: () => void }).__xss_mixed__ = + undefined; + }); +}); + +describe("markdownSanitizeSchema 白名单契约", () => { + it("script 在 strip 列表中,且不在 tagNames 白名单", () => { + expect(markdownSanitizeSchema.strip).toContain("script"); + expect(markdownSanitizeSchema.tagNames).not.toContain("script"); + }); + + it("危险标签不在 tagNames 白名单", () => { + for (const tag of ["iframe", "object", "embed", "form", "style"]) { + expect(markdownSanitizeSchema.tagNames).not.toContain(tag); + } + }); + + it("on* 事件处理属性不在任何 attributes 白名单", () => { + const allAttrs = Object.values( + markdownSanitizeSchema.attributes ?? {}, + ).flat(); + // attributes 项可能是字符串或 [name, ...allowedValues] 元组,取名字部分 + const attrNames = allAttrs.map((entry) => + Array.isArray(entry) ? entry[0] : entry, + ); + for (const name of attrNames) { + if (typeof name === "string") { + expect(name.toLowerCase().startsWith("on")).toBe(false); + } + } + }); + + it("href/src 协议白名单不含危险协议", () => { + const protocols = markdownSanitizeSchema.protocols ?? {}; + expect(protocols.href).toEqual( + expect.arrayContaining(["http", "https", "mailto"]), + ); + expect(protocols.href).not.toContain("javascript"); + expect(protocols.src).toEqual(["http", "https"]); + expect(protocols.src).not.toContain("javascript"); + }); + + it("仅最小化扩展:className 加入通配属性,其余可执行向量未放开", () => { + const wildcard = markdownSanitizeSchema.attributes?.["*"] ?? []; + expect(wildcard).toContain("className"); + // style 属性不应被放开(可承载 expression / url(javascript:) 等) + expect(wildcard).not.toContain("style"); + }); + + it("rehype 插件顺序固定:先 rehype-raw 再 rehype-sanitize", () => { + // [rehypeRaw, [rehypeSanitize, schema]] + expect(markdownRehypePlugins).toHaveLength(2); + // 第一项是函数(rehype-raw 默认导出),第二项是 [plugin, schema] 元组 + expect(typeof markdownRehypePlugins[0]).toBe("function"); + expect(Array.isArray(markdownRehypePlugins[1])).toBe(true); + const sanitizeEntry = markdownRehypePlugins[1] as [unknown, unknown]; + expect(typeof sanitizeEntry[0]).toBe("function"); + expect(sanitizeEntry[1]).toBe(markdownSanitizeSchema); + }); +}); + +/** + * SVG / MathML / data: 隐式 XSS 向量回归守护。 + * + * 这些向量当前都被 {@link markdownSanitizeSchema}(= hast-util-sanitize + * defaultSchema + className 扩展)天然挡住,但此前无显式测试。本组锁死该行为, + * 防止未来升级 hast-util-sanitize 或误改 schema 导致回归: + * + * - svg/math 系标签(svg/use/animate/math/mtext…)均不在 defaultSchema.tagNames + * 白名单中 → 标签被整体剥离(实测渲染为空

),其上的 on* 与 href 自然失效。 + * - script 在 strip 列表中 → 标签连同内容删除,绝不执行(全局 spy 断言未调用)。 + * - data: 不在 protocols.href(http/https/irc/ircs/mailto/xmpp)或 protocols.src + * (http/https)白名单中 → href/src 属性被整条移除(getAttribute 返回 null)。 + * - 协议规避变体(vbscript:、含 tab 的 java script:、javascript: 实体) + * 同样不在协议白名单 → href 被移除。 + */ +describe("MarkdownContent 隐式 XSS 向量(svg/math/data:)", () => { + it("剥离整个 子树并删除内嵌 的 data: URI(src 白名单仅 http/https)', () => { + const { container } = renderMarkdown( + 'bad', + ); + + // 在白名单内被保留,但 data: 不在 src 协议白名单 → src 被整条移除。 + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toBeNull(); + expect((img?.getAttribute("src") ?? "").toLowerCase()).not.toContain( + "data:", + ); + }); + + it("移除协议规避变体的 href:vbscript:/含 tab 的 javascript/实体编码", () => { + for (const href of [ + "vbscript:msgbox(1)", + "java script:alert(1)", // 含 tab 字符实体的 javascript: + "javascript:alert(1)", // : 实体形式 + ]) { + const { container } = renderMarkdown(`x`); + const anchor = container.querySelector("a"); + expect(anchor).not.toBeNull(); + // 三种变体均不在 href 协议白名单 → href 被移除,绝不残留可执行协议。 + const got = (anchor?.getAttribute("href") ?? "").toLowerCase(); + expect(got).not.toContain("vbscript:"); + expect(got).not.toContain("javascript:"); + } + }); +}); + +describe("markdownSanitizeSchema 隐式 XSS 契约(svg/math/data:)", () => { + it("svg 系标签不在 tagNames 白名单", () => { + for (const tag of ["svg", "use", "animate", "foreignObject", "set"]) { + expect(markdownSanitizeSchema.tagNames).not.toContain(tag); + } + }); + + it("math 系标签不在 tagNames 白名单", () => { + for (const tag of ["math", "mtext", "annotation", "maction"]) { + expect(markdownSanitizeSchema.tagNames).not.toContain(tag); + } + }); + + it("data 不在 href/src 协议白名单", () => { + const protocols = markdownSanitizeSchema.protocols ?? {}; + expect(protocols.href).not.toContain("data"); + expect(protocols.src).not.toContain("data"); + }); + + it("vbscript 不在 href/src 协议白名单", () => { + const protocols = markdownSanitizeSchema.protocols ?? {}; + expect(protocols.href).not.toContain("vbscript"); + expect(protocols.src).not.toContain("vbscript"); + }); +}); diff --git a/apps/undefined-chat/src/rendering/sanitize.ts b/apps/undefined-chat/src/rendering/sanitize.ts new file mode 100644 index 00000000..f284f0b2 --- /dev/null +++ b/apps/undefined-chat/src/rendering/sanitize.ts @@ -0,0 +1,43 @@ +import type { Options as ReactMarkdownOptions } from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import type { Options as SanitizeSchema } from "rehype-sanitize"; + +/** react-markdown `rehypePlugins` 的列表类型(来源于 unified 的 PluggableList)。 */ +type RehypePlugins = NonNullable; + +/** + * 正文内联 HTML 渲染的 sanitize 白名单 schema。 + * + * 以 `hast-util-sanitize` 的 {@link defaultSchema} 为安全基线(已天然剥离 + * `script`、`on*` 事件处理属性、`style` 属性,并将协议限制为 `http/https/mailto` + * 等安全协议,未收录 `iframe`/`object`/`embed`/`form` 等危险标签),在其上做 + * **最小化扩展**,仅放开纯展示需要的安全属性,绝不放开任何可执行向量。 + * + * 设计原则:宁严勿宽。新增项仅限不可能承载脚本的展示属性(如 `className`)。 + * + * 注意:正文内联 HTML 渲染与「HTML 预览窗口」是两回事——预览窗口可运行脚本, + * 正文内联 HTML 经此 schema 后绝不执行任何脚本。 + */ +export const markdownSanitizeSchema: SanitizeSchema = { + ...defaultSchema, + // 允许 className 用于纯展示样式(class 名无法承载脚本)。 + // defaultSchema 仅在 code / li 上允许 className,这里补充常见展示标签, + // 以保留来源 HTML 的排版样式(如

)。 + attributes: { + ...defaultSchema.attributes, + "*": [...(defaultSchema.attributes?.["*"] ?? []), "className"], + }, +}; + +/** + * react-markdown `rehypePlugins` 列表:先 `rehype-raw` 把正文里的原始 HTML + * 解析进 hast 树,再用 `rehype-sanitize` 按 {@link markdownSanitizeSchema} + * 白名单清洗。 + * + * **顺序不可调换**:必须先解析再清洗,否则原始 HTML 不会被白名单过滤。 + */ +export const markdownRehypePlugins: RehypePlugins = [ + rehypeRaw, + [rehypeSanitize, markdownSanitizeSchema], +]; diff --git a/apps/undefined-chat/src/runtime-client/tauri.test.ts b/apps/undefined-chat/src/runtime-client/tauri.test.ts new file mode 100644 index 00000000..b74a56af --- /dev/null +++ b/apps/undefined-chat/src/runtime-client/tauri.test.ts @@ -0,0 +1,508 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { createTauriRuntimeClient } from "./tauri"; +import type { RuntimeSseEvent, RuntimeSseStatus } from "./types"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +describe("createTauriRuntimeClient", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("normalizes conversation and active job responses from Tauri commands", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + conversations: [ + { + id: "default", + title: "默认会话", + title_source: "temporary", + title_status: "temporary", + created_at: "2026-06-08T10:00:00", + updated_at: "2026-06-08T10:01:00", + virtual_user_id: "webchat", + message_count: 2, + is_running: true, + }, + ], + active_job: { + job_id: "job-1", + conversation_id: "default", + status: "running", + mode: "chat", + created_at: 1770000000, + updated_at: 1770000001, + finished_at: null, + elapsed_ms: 1000, + duration_ms: null, + current_stage: "thinking", + current_stage_detail: "正在处理", + current_stage_started_at: 1770000000, + current_stage_elapsed_ms: 1000, + last_seq: 3, + error: null, + reply: "", + messages: [], + current_agent_stages: [], + current_tool_calls: [], + history_finalized: false, + waiting_input: null, + }, + default_conversation_id: "default", + virtual_user_id: "webchat", + }, + }); + + const client = createTauriRuntimeClient(); + const response = await client.listConversations(); + + expect(invoke).toHaveBeenCalledWith("list_conversations"); + expect(response.conversations[0]).toMatchObject({ + id: "default", + isRunning: true, + messageCount: 2, + titleSource: "temporary", + }); + expect(response.activeJob?.jobId).toBe("job-1"); + expect(response.activeJob?.conversationId).toBe("default"); + expect(response.defaultConversationId).toBe("default"); + }); + + test("creates conversations through the generic Runtime request bridge", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 201, + ok: true, + body: { + conversation: { + id: "conv-new", + title: "排障", + title_source: "manual", + title_status: "ready", + created_at: "2026-06-08T10:00:00", + updated_at: "2026-06-08T10:00:00", + virtual_user_id: "webchat", + message_count: 0, + is_running: false, + }, + }, + }); + + const client = createTauriRuntimeClient(); + const response = await client.createConversation("排障"); + + expect(invoke).toHaveBeenCalledWith("runtime_request", { + input: { + method: "POST", + path: "/api/v1/chat/conversations", + body: { title: "排障" }, + headers: [], + }, + }); + expect(response).toMatchObject({ + id: "conv-new", + title: "排障", + messageCount: 0, + }); + }); + + test("defaults a missing conversation title to an empty string for UI fallback", async () => { + // 标题缺省不再硬编码中文:归一化为空串,由 UI 兜底显示本地化默认标题 + vi.mocked(invoke).mockResolvedValueOnce({ + status: 201, + ok: true, + body: { + conversation: { + id: "conv-untitled", + message_count: 0, + is_running: false, + }, + }, + }); + + const client = createTauriRuntimeClient(); + const response = await client.createConversation(); + + expect(response.title).toBe(""); + }); + + test("maps structured messages to the Runtime API payload contract", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 202, + ok: true, + body: { + job_id: "job-2", + conversation_id: "conv-2", + status: "queued", + mode: "chat", + created_at: 1770000002, + updated_at: 1770000002, + finished_at: null, + elapsed_ms: 0, + duration_ms: null, + current_stage: "queued", + current_stage_detail: null, + current_stage_started_at: null, + current_stage_elapsed_ms: null, + last_seq: 0, + error: null, + reply: "", + messages: [], + current_agent_stages: [], + current_tool_calls: [], + history_finalized: false, + waiting_input: null, + }, + }); + + const client = createTauriRuntimeClient(); + const response = await client.sendMessage({ + conversationId: "conv-2", + message: { + text: "解释这段日志", + attachmentIds: ["att-1"], + references: [{ messageId: "msg-1", quote: "报错片段" }], + }, + }); + + expect(invoke).toHaveBeenCalledWith("send_message", { + input: { + conversationId: "conv-2", + message: { + text: "解释这段日志", + attachment_ids: ["att-1"], + references: [ + { + source_message_id: "msg-1", + selected_text: "报错片段", + }, + ], + }, + }, + }); + expect(response.jobId).toBe("job-2"); + }); + + test("normalizes Runtime history references into UI references", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + conversation_id: "default", + virtual_user_id: "webchat", + permission: "superadmin", + count: 1, + items: [ + { + message_id: "msg-2", + role: "user", + content: "继续解释", + timestamp: "2026-06-08T10:00:00+08:00", + attachments: [], + references: [ + { + source_message_id: "msg-1", + selected_text: "报错片段", + }, + ], + }, + ], + limit: 50, + before: null, + has_more: false, + next_before: null, + total: 1, + }, + }); + + const client = createTauriRuntimeClient(); + const response = await client.getHistory({ + conversationId: "default", + limit: 50, + }); + + expect(response.items[0]?.references).toEqual([ + { + messageId: "msg-1", + quote: "报错片段", + }, + ]); + }); + + test("listens for typed runtime SSE events and statuses", async () => { + const unlistenEvent = vi.fn(); + const unlistenStatus = vi.fn(); + let eventCallback = (_event: { payload: RuntimeSseEvent }): void => { + throw new Error("event listener was not registered"); + }; + let statusCallback = (_event: { payload: RuntimeSseStatus }): void => { + throw new Error("status listener was not registered"); + }; + vi.mocked(listen).mockImplementation(async (eventName, handler) => { + if (eventName === "runtime-sse-event") { + eventCallback = handler as (event: { + payload: RuntimeSseEvent; + }) => void; + return unlistenEvent; + } + statusCallback = handler as (event: { + payload: RuntimeSseStatus; + }) => void; + return unlistenStatus; + }); + const onEvent = vi.fn(); + const onStatus = vi.fn(); + + const client = createTauriRuntimeClient(); + const stop = await client.listenRuntimeSse(onEvent, onStatus); + eventCallback({ + payload: { + jobId: "job-1", + seq: 1, + eventType: "stage", + payload: { stage: "thinking" }, + subscriptionId: "sub-1", + }, + }); + statusCallback({ + payload: { + jobId: "job-1", + status: "connected", + detail: null, + subscriptionId: "sub-1", + }, + }); + stop(); + + expect(listen).toHaveBeenCalledWith( + "runtime-sse-event", + expect.any(Function), + ); + expect(listen).toHaveBeenCalledWith( + "runtime-sse-status", + expect.any(Function), + ); + expect(onEvent).toHaveBeenCalledWith({ + jobId: "job-1", + seq: 1, + eventType: "stage", + payload: { stage: "thinking" }, + subscriptionId: "sub-1", + }); + expect(onStatus).toHaveBeenCalledWith({ + jobId: "job-1", + status: "connected", + detail: null, + subscriptionId: "sub-1", + }); + expect(unlistenEvent).toHaveBeenCalledOnce(); + expect(unlistenStatus).toHaveBeenCalledOnce(); + }); + + test("deletes a conversation through the runtime request bridge", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 204, + ok: true, + body: {}, + }); + + const client = createTauriRuntimeClient(); + await client.deleteConversation("conv-delete"); + + expect(invoke).toHaveBeenCalledWith("runtime_request", { + input: { + method: "DELETE", + path: "/api/v1/chat/conversations/conv-delete", + body: null, + headers: [], + }, + }); + }); + + test("deleteConversation rejects failed runtime request responses", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 409, + ok: false, + body: { error: "conversation has an active job" }, + }); + + const client = createTauriRuntimeClient(); + + await expect(client.deleteConversation("conv-busy")).rejects.toThrow( + "conversation has an active job", + ); + }); + + test("fetches history page with cursor support", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + conversation_id: "default", + virtual_user_id: "webchat", + permission: "superadmin", + count: 2, + items: [ + { + message_id: "msg-2", + role: "bot", + content: "响应内容", + timestamp: "2026-06-08T10:02:00+08:00", + attachments: [], + references: [], + }, + { + message_id: "msg-1", + role: "user", + content: "用户请求", + timestamp: "2026-06-08T10:01:00+08:00", + attachments: [], + references: [], + }, + ], + limit: 50, + before: 1770000060, + has_more: true, + next_before: 1770000030, + total: 120, + }, + }); + + const client = createTauriRuntimeClient(); + const response = await client.getHistoryPage("default", 1770000060, 50); + + expect(invoke).toHaveBeenCalledWith("get_history", { + input: { + conversationId: "default", + limit: 50, + before: 1770000060, + }, + }); + expect(response).toMatchObject({ + conversationId: "default", + count: 2, + hasMore: true, + nextBefore: 1770000030, + cursor: 1770000030, + total: 120, + }); + expect(response.items).toHaveLength(2); + expect(response.items[0]?.messageId).toBe("msg-2"); + }); + + test("fetches history page without cursor (first page)", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + conversation_id: "default", + virtual_user_id: "webchat", + permission: "superadmin", + count: 1, + items: [ + { + message_id: "msg-latest", + role: "bot", + content: "最新消息", + timestamp: "2026-06-08T10:05:00+08:00", + attachments: [], + references: [], + }, + ], + limit: 50, + before: null, + has_more: false, + next_before: null, + total: 1, + }, + }); + + const client = createTauriRuntimeClient(); + const response = await client.getHistoryPage("default"); + + expect(invoke).toHaveBeenCalledWith("get_history", { + input: { + conversationId: "default", + limit: 50, + }, + }); + expect(response).toMatchObject({ + conversationId: "default", + count: 1, + hasMore: false, + cursor: null, + }); + }); + + test("normalizes commands with subcommands and snake_case alias triggers", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + commands: [ + { + name: "conv", + trigger: "/conv", + description: "管理会话", + usage: "/conv <子命令>", + example: "/conv new 调试", + aliases: ["c"], + alias_triggers: ["/c"], + available: true, + subcommands: [ + { + name: "new", + trigger: "/conv new", + description: "新建会话", + args: "[标题]", + usage: "/conv new [标题]", + available: true, + }, + ], + }, + { + // 缺省字段应回退:无 trigger → /name、无 available → true + name: "ping", + description: "测试连通", + }, + ], + }, + }); + + const client = createTauriRuntimeClient(); + const response = await client.listCommands(); + + expect(invoke).toHaveBeenCalledWith("list_commands"); + + const conv = response.commands[0]; + expect(conv).toMatchObject({ + name: "conv", + trigger: "/conv", + aliases: ["c"], + aliasTriggers: ["/c"], + available: true, + }); + expect(conv?.subcommands[0]).toMatchObject({ + name: "new", + trigger: "/conv new", + args: "[标题]", + usage: "/conv new [标题]", + available: true, + }); + + const ping = response.commands[1]; + expect(ping?.trigger).toBe("/ping"); + expect(ping?.available).toBe(true); + expect(ping?.subcommands).toEqual([]); + expect(ping?.aliasTriggers).toEqual([]); + }); +}); diff --git a/apps/undefined-chat/src/runtime-client/tauri.ts b/apps/undefined-chat/src/runtime-client/tauri.ts new file mode 100644 index 00000000..ea757524 --- /dev/null +++ b/apps/undefined-chat/src/runtime-client/tauri.ts @@ -0,0 +1,728 @@ +import { invoke as originalInvoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { t } from "../i18n"; + +function invoke(cmd: string, args?: unknown): Promise { + if ( + typeof window === "undefined" || + !("__TAURI_INTERNALS__" in (window as unknown as Record)) + ) { + const isTest = + typeof globalThis !== "undefined" && + (globalThis as unknown as Record) + ?.process?.env?.NODE_ENV === "test"; + if (!isTest) { + // 环境级错误(运行在普通浏览器中):用模块级 t(固定 defaultLocale)渲染 + throw new Error(t("runtime.tauriRequired")); + } + } + return args === undefined + ? originalInvoke(cmd) + : // biome-ignore lint/suspicious/noExplicitAny: match original invoke args signature + originalInvoke(cmd, args as any); +} +import type { + ActiveJobsResponse, + AgentStageSnapshot, + ApiKeyStatus, + Attachment, + AttachmentDownloadInput, + AttachmentDownloadResult, + AttachmentPreviewInput, + AttachmentPreviewResult, + ChatEvent, + ChatJob, + CommandInfo, + CommandsResponse, + Conversation, + ConversationsResponse, + EventStreamSubscription, + HistoryItem, + HistoryPageResponse, + HistoryResponse, + HistoryWebchat, + HtmlPreviewInput, + JobEventsJsonResponse, + MessageReference, + RuntimeClient, + RuntimeConfig, + RuntimeHealth, + RuntimeSseEvent, + RuntimeSseStatus, + SendMessageInput, + SubcommandInfo, + ToolCallSnapshot, + UploadAttachmentInput, +} from "./types"; + +type RawRecord = Record; + +function record(value: unknown): RawRecord { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as RawRecord) + : {}; +} + +function text(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function numberValue(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function nullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function bool(value: unknown, fallback = false): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function arrayRecords(value: unknown): RawRecord[] { + return Array.isArray(value) + ? value.filter( + (item): item is RawRecord => + Boolean(item) && typeof item === "object" && !Array.isArray(item), + ) + : []; +} + +function arrayStrings(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function field(raw: RawRecord, camel: string, snake: string): unknown { + return raw[camel] ?? raw[snake]; +} + +function parseJsonRecord(textValue: string): RawRecord { + try { + return record(JSON.parse(textValue)); + } catch { + return {}; + } +} + +function runtimeErrorMessage(body: unknown, status: number): string { + const raw = record(body); + return ( + text(raw.error) || + text(raw.message) || + text(raw.text) || + `Runtime request failed with status ${status}` + ); +} + +function unwrapRuntimeBody(value: unknown): unknown { + const raw = record(value); + if ("status" in raw && "ok" in raw && "body" in raw) { + const status = numberValue(raw.status); + if (!bool(raw.ok)) { + throw new Error(runtimeErrorMessage(raw.body, status)); + } + return raw.body; + } + return value; +} + +function normalizeRuntimeConfig( + value: unknown, + apiKeyStatus: ApiKeyStatus, +): RuntimeConfig | null { + if (value === null || value === undefined) { + return null; + } + const raw = record(value); + const runtimeUrl = text(field(raw, "runtimeUrl", "runtime_url")); + if (!runtimeUrl) { + return null; + } + return { + runtimeUrl, + hasApiKey: apiKeyStatus.available, + }; +} + +function normalizeApiKeyStatus(value: unknown): ApiKeyStatus { + const raw = record(value); + return { + available: bool(raw.available), + storage: text(raw.storage, "system-keyring"), + degraded: bool(raw.degraded), + keyPreview: + text(field(raw, "keyPreview", "key_preview")) || + (text(raw.key_preview) ? text(raw.key_preview) : null), + detail: text(raw.detail), + }; +} + +function normalizeAttachment(value: unknown): Attachment { + const raw = record(value); + const id = + text(raw.id) || + text(raw.uid) || + text(raw.attachment_id) || + text(raw.attachmentId); + return { + id, + name: + text(raw.name) || + text(raw.display_name) || + text(raw.displayName) || + id || + "attachment", + size: numberValue(raw.size), + mediaType: text( + field(raw, "mediaType", "media_type"), + "application/octet-stream", + ), + kind: text(raw.kind, "file"), + downloadUrl: text(field(raw, "downloadUrl", "download_url")) || null, + previewUrl: text(field(raw, "previewUrl", "preview_url")) || null, + discarded: bool(raw.discarded), + }; +} + +function normalizeReference(value: unknown): MessageReference { + const raw = record(value); + return { + messageId: + text(field(raw, "messageId", "message_id")) || + text(field(raw, "sourceMessageId", "source_message_id")), + quote: text(raw.quote) || text(field(raw, "selectedText", "selected_text")), + }; +} + +function runtimeMessagePayload(input: SendMessageInput): RawRecord { + return { + text: input.message.text, + attachment_ids: input.message.attachmentIds, + references: input.message.references.map((reference) => ({ + source_message_id: reference.messageId, + selected_text: reference.quote, + })), + }; +} + +function normalizeEvent(value: unknown): ChatEvent { + const raw = record(value); + return { + seq: numberValue(raw.seq), + event: text(raw.event, "message"), + payload: record(raw.payload), + }; +} + +function normalizeToolCall(value: unknown): ToolCallSnapshot { + const raw = record(value); + return { + id: + text(raw.id) || + text(raw.webchat_call_id) || + text(raw.webchatCallId) || + text(raw.name, "tool"), + name: text(raw.name) || text(raw.api_name) || text(raw.apiName, "tool"), + status: text(raw.status, "running"), + elapsedMs: nullableNumber(field(raw, "elapsedMs", "elapsed_ms")), + durationMs: nullableNumber(field(raw, "durationMs", "duration_ms")), + detail: text(raw.detail) || text(raw.result_preview) || undefined, + argumentsPreview: + text(raw.argumentsPreview) || + text(raw.arguments_preview) || + text(raw.input) || + undefined, + resultPreview: + text(raw.resultPreview) || + text(raw.result_preview) || + text(raw.output) || + undefined, + uiHint: text(raw.uiHint) || text(raw.ui_hint) || undefined, + currentStage: + text(raw.currentStage) || text(raw.current_stage) || undefined, + isAgent: bool(raw.isAgent) || bool(raw.is_agent) || undefined, + children: arrayRecords(raw.children).map(normalizeToolCall), + timeline: Array.isArray(raw.timeline) ? raw.timeline : undefined, + }; +} + +function normalizeAgentStage(value: unknown): AgentStageSnapshot { + const raw = record(value); + return { + id: + text(raw.id) || + text(raw.webchat_call_id) || + text(raw.webchatCallId) || + text(raw.agent_name) || + text(raw.agentName, "agent"), + name: + text(raw.name) || text(raw.agent_name) || text(raw.agentName, "agent"), + stage: text(raw.stage, "running"), + status: text(raw.status, "running"), + elapsedMs: nullableNumber(field(raw, "elapsedMs", "elapsed_ms")), + detail: text(raw.detail) || undefined, + }; +} + +function normalizeJob(value: unknown): ChatJob | null { + const raw = record(value); + const jobId = text(field(raw, "jobId", "job_id")); + if (!jobId) { + return null; + } + return { + jobId, + conversationId: text(field(raw, "conversationId", "conversation_id")), + status: text(raw.status, "queued"), + mode: text(raw.mode, "chat"), + createdAt: numberValue(field(raw, "createdAt", "created_at")), + updatedAt: numberValue(field(raw, "updatedAt", "updated_at")), + finishedAt: nullableNumber(field(raw, "finishedAt", "finished_at")), + elapsedMs: numberValue(field(raw, "elapsedMs", "elapsed_ms")), + durationMs: nullableNumber(field(raw, "durationMs", "duration_ms")), + currentStage: text(field(raw, "currentStage", "current_stage"), "queued"), + currentStageDetail: + text(field(raw, "currentStageDetail", "current_stage_detail")) || null, + currentStageStartedAt: nullableNumber( + field(raw, "currentStageStartedAt", "current_stage_started_at"), + ), + currentStageElapsedMs: nullableNumber( + field(raw, "currentStageElapsedMs", "current_stage_elapsed_ms"), + ), + lastSeq: numberValue(field(raw, "lastSeq", "last_seq")), + error: text(raw.error) || null, + reply: text(raw.reply), + messages: arrayStrings(raw.messages), + currentAgentStages: arrayRecords( + field(raw, "currentAgentStages", "current_agent_stages"), + ).map(normalizeAgentStage), + currentToolCalls: arrayRecords( + field(raw, "currentToolCalls", "current_tool_calls"), + ).map(normalizeToolCall), + historyFinalized: bool(field(raw, "historyFinalized", "history_finalized")), + currentTimeline: [], + waitingInput: field(raw, "waitingInput", "waiting_input") ?? null, + }; +} + +function normalizeConversation(value: unknown): Conversation { + const raw = record(value); + return { + id: text(raw.id), + // 标题缺省交由 UI 兜底(空串时显示 app.topbar.defaultConversation) + title: text(raw.title, ""), + titleSource: text(field(raw, "titleSource", "title_source")), + titleStatus: text(field(raw, "titleStatus", "title_status")), + createdAt: text(field(raw, "createdAt", "created_at")), + updatedAt: text(field(raw, "updatedAt", "updated_at")), + virtualUserId: text(field(raw, "virtualUserId", "virtual_user_id")), + messageCount: numberValue(field(raw, "messageCount", "message_count")), + isRunning: bool(field(raw, "isRunning", "is_running")), + }; +} + +function normalizeWebchat(value: unknown): HistoryWebchat | undefined { + const raw = record(value); + if (Object.keys(raw).length === 0) { + return undefined; + } + return { + displayOnly: bool(field(raw, "displayOnly", "display_only")), + jobId: text(field(raw, "jobId", "job_id")), + mode: text(raw.mode), + status: text(raw.status), + createdAt: + (field(raw, "createdAt", "created_at") as number | string | null) ?? null, + finishedAt: + (field(raw, "finishedAt", "finished_at") as number | string | null) ?? + null, + durationMs: nullableNumber(field(raw, "durationMs", "duration_ms")), + events: arrayRecords(raw.events).map(normalizeEvent), + calls: Array.isArray(raw.calls) ? raw.calls : [], + timeline: Array.isArray(raw.timeline) ? raw.timeline : [], + }; +} + +function normalizeHistoryItem(value: unknown): HistoryItem { + const raw = record(value); + const role = text(raw.role, "user"); + const normalizedRole: HistoryItem["role"] = + role === "bot" || role === "system" ? role : "user"; + const webchat = normalizeWebchat(raw.webchat); + return { + messageId: + text(field(raw, "messageId", "message_id")) || + `${normalizedRole}-${text(raw.timestamp)}-${text(raw.content).length}`, + role: normalizedRole, + content: text(raw.content), + timestamp: text(raw.timestamp), + attachments: arrayRecords(raw.attachments).map(normalizeAttachment), + references: arrayRecords(raw.references).map(normalizeReference), + ...(webchat ? { webchat } : {}), + }; +} + +function normalizeSubcommand(value: unknown): SubcommandInfo { + const raw = record(value); + return { + name: text(raw.name), + trigger: text(raw.trigger), + description: text(raw.description), + args: text(raw.args), + usage: text(raw.usage), + available: bool(raw.available, true), + }; +} + +function normalizeCommand(value: unknown): CommandInfo { + const raw = record(value); + const name = text(raw.name); + return { + name, + trigger: text(raw.trigger) || `/${name}`, + description: text(raw.description), + usage: text(raw.usage), + example: text(raw.example), + aliases: arrayStrings(raw.aliases), + aliasTriggers: arrayStrings(field(raw, "aliasTriggers", "alias_triggers")), + subcommands: arrayRecords(raw.subcommands).map(normalizeSubcommand), + available: bool(raw.available, true), + }; +} + +function requireJob(value: unknown): ChatJob { + const normalized = normalizeJob(value); + if (!normalized) { + throw new Error("Runtime response did not include a job"); + } + return normalized; +} + +function requireAttachmentFromUpload(value: unknown): Attachment { + const raw = record(value); + const status = numberValue(raw.status, 200); + const body = + typeof raw.body === "string" ? parseJsonRecord(raw.body) : record(raw.body); + if (status < 200 || status >= 300) { + throw new Error(runtimeErrorMessage(body, status)); + } + const attachment = normalizeAttachment(body.attachment ?? body); + if (!attachment.id) { + throw new Error("Runtime upload response did not include an attachment"); + } + return attachment; +} + +function normalizeDownloadResult(value: unknown): AttachmentDownloadResult { + const raw = record(value); + return { + status: numberValue(raw.status), + ok: bool(raw.ok), + savedFileName: text(field(raw, "savedFileName", "saved_file_name")) || null, + bytesWritten: numberValue(field(raw, "bytesWritten", "bytes_written")), + mediaType: text(field(raw, "mediaType", "media_type")) || null, + body: text(raw.body) || null, + }; +} + +function normalizePreviewResult(value: unknown): AttachmentPreviewResult { + const raw = record(value); + return { + status: numberValue(raw.status), + ok: bool(raw.ok), + mediaType: text(field(raw, "mediaType", "media_type")) || null, + bytes: Array.isArray(raw.bytes) + ? raw.bytes.filter( + (item): item is number => + typeof item === "number" && Number.isInteger(item), + ) + : [], + body: text(raw.body) || null, + }; +} + +function normalizeSubscription(value: unknown): EventStreamSubscription { + const raw = record(value); + return { + subscriptionId: text(field(raw, "subscriptionId", "subscription_id")), + jobId: text(field(raw, "jobId", "job_id")), + afterSeq: numberValue(field(raw, "afterSeq", "after_seq")), + }; +} + +export function createTauriRuntimeClient(): RuntimeClient { + return { + async getRuntimeConfig(): Promise { + const [config, apiKeyStatus] = await Promise.all([ + invoke("get_runtime_config"), + this.loadApiKeyStatus(), + ]); + return normalizeRuntimeConfig(config, apiKeyStatus); + }, + + async saveRuntimeConfig(runtimeUrl: string): Promise { + const [config, apiKeyStatus] = await Promise.all([ + invoke("save_runtime_config", { + input: { runtimeUrl }, + }), + this.loadApiKeyStatus(), + ]); + const normalized = normalizeRuntimeConfig(config, apiKeyStatus); + if (!normalized) { + throw new Error("Runtime config response did not include runtimeUrl"); + } + return normalized; + }, + + async saveApiKey(apiKey: string): Promise { + return normalizeApiKeyStatus(await invoke("save_api_key", { apiKey })); + }, + + async confirmInsecureStorageFallback(): Promise { + return normalizeApiKeyStatus( + await invoke("confirm_insecure_storage_fallback"), + ); + }, + + async loadApiKeyStatus(): Promise { + return normalizeApiKeyStatus(await invoke("load_api_key_status")); + }, + + async probeRuntime(): Promise { + const raw = record(await invoke("probe_runtime")); + return { + ok: bool(raw.ok), + status: numberValue(raw.status), + body: text(raw.body), + }; + }, + + async listConversations(): Promise { + const raw = record(unwrapRuntimeBody(await invoke("list_conversations"))); + return { + conversations: arrayRecords(raw.conversations).map( + normalizeConversation, + ), + activeJob: normalizeJob(field(raw, "activeJob", "active_job")), + defaultConversationId: text( + field(raw, "defaultConversationId", "default_conversation_id"), + ), + virtualUserId: text(field(raw, "virtualUserId", "virtual_user_id")), + }; + }, + + async getHistory(input): Promise { + const raw = record( + unwrapRuntimeBody(await invoke("get_history", { input })), + ); + return { + conversationId: text(field(raw, "conversationId", "conversation_id")), + virtualUserId: text(field(raw, "virtualUserId", "virtual_user_id")), + permission: text(raw.permission), + count: numberValue(raw.count), + items: arrayRecords(raw.items).map(normalizeHistoryItem), + limit: numberValue(raw.limit, input.limit), + before: nullableNumber(raw.before), + hasMore: bool(field(raw, "hasMore", "has_more")), + nextBefore: nullableNumber(field(raw, "nextBefore", "next_before")), + total: numberValue(raw.total), + }; + }, + + async getHistoryPage( + conversationId: string, + before?: number | null, + limit = 50, + ): Promise { + const input = { + conversationId, + limit, + ...(before !== undefined && before !== null ? { before } : {}), + }; + const raw = record( + unwrapRuntimeBody(await invoke("get_history", { input })), + ); + return { + conversationId: text(field(raw, "conversationId", "conversation_id")), + virtualUserId: text(field(raw, "virtualUserId", "virtual_user_id")), + permission: text(raw.permission), + count: numberValue(raw.count), + items: arrayRecords(raw.items).map(normalizeHistoryItem), + limit: numberValue(raw.limit, limit), + before: nullableNumber(raw.before), + hasMore: bool(field(raw, "hasMore", "has_more")), + nextBefore: nullableNumber(field(raw, "nextBefore", "next_before")), + cursor: nullableNumber(field(raw, "nextBefore", "next_before")), + total: numberValue(raw.total), + }; + }, + + async getActiveJobs(input = {}): Promise { + const raw = record( + unwrapRuntimeBody(await invoke("get_active_jobs", { input })), + ); + const legacyJob = normalizeJob(raw.job); + const jobs = arrayRecords(raw.jobs) + .map(normalizeJob) + .filter((item): item is ChatJob => item !== null); + return { + job: legacyJob, + jobs: jobs.length > 0 ? jobs : legacyJob ? [legacyJob] : [], + }; + }, + + async sendMessage(input: SendMessageInput): Promise { + return requireJob( + unwrapRuntimeBody( + await invoke("send_message", { + input: { + conversationId: input.conversationId, + message: runtimeMessagePayload(input), + }, + }), + ), + ); + }, + + async createConversation(title?: string): Promise { + const body = title?.trim() ? { title: title.trim() } : {}; + const raw = record( + unwrapRuntimeBody( + await invoke("runtime_request", { + input: { + method: "POST", + path: "/api/v1/chat/conversations", + body, + headers: [], + }, + }), + ), + ); + const conversation = normalizeConversation(raw.conversation ?? raw); + if (!conversation.id) { + throw new Error("Runtime response did not include a conversation"); + } + return conversation; + }, + + async deleteConversation(conversationId: string): Promise { + // DELETE 不带请求体:Tauri runtime 桥接层仅允许 POST/PATCH 携带 body + unwrapRuntimeBody( + await invoke("runtime_request", { + input: { + method: "DELETE", + path: `/api/v1/chat/conversations/${conversationId}`, + body: null, + headers: [], + }, + }), + ); + }, + + async renameConversation( + conversationId: string, + title: string, + ): Promise<{ ok: boolean }> { + const raw = record( + unwrapRuntimeBody( + await invoke("runtime_request", { + input: { + method: "PATCH", + path: `/api/v1/chat/conversations/${conversationId}`, + body: { title }, + headers: [], + }, + }), + ), + ); + return { ok: bool(raw.ok, true) }; + }, + + async cancelJob(jobId: string): Promise { + return requireJob( + unwrapRuntimeBody(await invoke("cancel_job", { input: { jobId } })), + ); + }, + + async listCommands(): Promise { + const raw = record(unwrapRuntimeBody(await invoke("list_commands"))); + return { + commands: arrayRecords(raw.commands).map(normalizeCommand), + }; + }, + + async fetchJobEventsJson(input): Promise { + const raw = record( + unwrapRuntimeBody(await invoke("fetch_job_events_json", { input })), + ); + return { + job: requireJob(raw.job), + after: numberValue(raw.after, input.afterSeq), + lastSeq: numberValue(field(raw, "lastSeq", "last_seq")), + events: arrayRecords(raw.events).map(normalizeEvent), + }; + }, + + async startJobEventStream(input): Promise { + return normalizeSubscription( + await invoke("start_job_event_stream", { + input: { + jobId: input.jobId, + afterSeq: input.afterSeq, + }, + }), + ); + }, + + async stopJobEventStream(subscriptionId: string): Promise { + await invoke("stop_job_event_stream", { subscriptionId }); + }, + + async uploadAttachment(input: UploadAttachmentInput): Promise { + return requireAttachmentFromUpload( + await invoke("upload_attachment_streaming", { input }), + ); + }, + + async saveAttachment( + input: AttachmentDownloadInput, + ): Promise { + return normalizeDownloadResult( + await invoke("save_attachment", { input }), + ); + }, + + async previewAttachment( + input: AttachmentPreviewInput, + ): Promise { + return normalizePreviewResult( + await invoke("preview_attachment_bytes", { input }), + ); + }, + + async openHtmlPreview(input: HtmlPreviewInput): Promise { + await invoke("open_html_preview", { input }); + }, + + async listenRuntimeSse(onEvent, onStatus): Promise<() => void> { + const unlistenEvent = await listen( + "runtime-sse-event", + (event) => { + onEvent(event.payload); + }, + ); + const unlistenStatus = await listen( + "runtime-sse-status", + (event) => { + onStatus(event.payload); + }, + ); + return () => { + unlistenEvent(); + unlistenStatus(); + }; + }, + }; +} diff --git a/apps/undefined-chat/src/runtime-client/types.ts b/apps/undefined-chat/src/runtime-client/types.ts new file mode 100644 index 00000000..949c19ac --- /dev/null +++ b/apps/undefined-chat/src/runtime-client/types.ts @@ -0,0 +1,346 @@ +export type ConnectionState = + | "idle" + | "connecting" + | "connected" + | "streaming" + | "resuming" + | "json_fallback" + | "disconnected"; + +export type RuntimeConfig = { + runtimeUrl: string; + hasApiKey: boolean; +}; + +export type ApiKeyStatus = { + available: boolean; + storage: string; + degraded: boolean; + keyPreview: string | null; + detail: string; +}; + +export type RuntimeHealth = { + ok: boolean; + status: number; + body: string; +}; + +export type Conversation = { + id: string; + title: string; + titleSource: string; + titleStatus: string; + createdAt: string; + updatedAt: string; + virtualUserId: string; + messageCount: number; + isRunning: boolean; +}; + +export type Attachment = { + id: string; + name: string; + size: number; + mediaType: string; + kind: string; + downloadUrl: string | null; + previewUrl: string | null; + discarded: boolean; +}; + +export type MessageReference = { + messageId: string; + quote: string; + /** + * 引用来源: + * - 缺省 / "message":引用整条历史消息(messageId 指向真实消息) + * - "selection":划词引用,messageId 为本地生成的合成 id,quote 为选中文本 + */ + kind?: "message" | "selection"; +}; + +export type HistoryWebchat = { + displayOnly: boolean; + jobId: string; + mode: string; + status: string; + createdAt: number | string | null; + finishedAt: number | string | null; + durationMs: number | null; + events: ChatEvent[]; + calls: unknown[]; + timeline: unknown[]; +}; + +export type HistoryItem = { + messageId: string; + role: "user" | "bot" | "system"; + content: string; + timestamp: string; + attachments: Attachment[]; + references: MessageReference[]; + webchat?: HistoryWebchat; +}; + +export type ToolCallSnapshot = { + id: string; + name: string; + status: string; + elapsedMs: number | null; + durationMs?: number | null; + detail?: string; + argumentsPreview?: string; + resultPreview?: string; + uiHint?: string; + currentStage?: string; + isAgent?: boolean; + children?: ToolCallSnapshot[]; + timeline?: unknown[]; +}; + +export type AgentStageSnapshot = { + id: string; + name: string; + stage: string; + status: string; + elapsedMs: number | null; + detail?: string; +}; + +/** + * 流式时间线条目(对齐 WebUI appendTimelineMessage / upsertTimelineToolBlock 的 append 顺序)。 + * 按事件到达顺序记录 message 文本段与顶层 tool call,保证流式过程中文字与工具块按真实时间交错显示。 + */ +export type StreamingTimelineItem = + | { type: "message"; seq: number; content: string } + | { type: "call"; seq: number; callId: string }; + +export type ChatJob = { + jobId: string; + conversationId: string; + status: string; + mode: string; + createdAt: number; + updatedAt: number; + finishedAt: number | null; + elapsedMs: number; + durationMs: number | null; + currentStage: string; + currentStageDetail: string | null; + currentStageStartedAt: number | null; + currentStageElapsedMs: number | null; + lastSeq: number; + error: string | null; + reply: string; + messages: string[]; + currentAgentStages: AgentStageSnapshot[]; + currentToolCalls: ToolCallSnapshot[]; + /** 流式时间线:按事件到达顺序的 message 段 + 顶层 call(对齐 WebUI append 顺序) */ + currentTimeline: StreamingTimelineItem[]; + historyFinalized: boolean; + waitingInput: unknown | null; +}; + +export type ChatEvent = { + seq: number; + event: string; + payload: Record; +}; + +export type SubcommandInfo = { + name: string; + trigger: string; + description: string; + args: string; + usage: string; + available: boolean; +}; + +export type CommandInfo = { + /** 裸命令名,不含前导 "/"(渲染时按需补 "/") */ + name: string; + trigger: string; + description: string; + usage: string; + example: string; + aliases: string[]; + aliasTriggers: string[]; + subcommands: SubcommandInfo[]; + available: boolean; +}; + +export type SendMessageInput = { + conversationId: string; + message: { + text: string; + attachmentIds: string[]; + references: MessageReference[]; + }; +}; + +export type RuntimeSseEvent = { + subscriptionId: string; + jobId: string; + seq: number; + eventType: string | null; + payload: Record; +}; + +export type RuntimeSseStatus = { + subscriptionId: string; + jobId: string; + status: "connected" | "closed" | "error" | string; + detail: string | null; +}; + +export type HtmlPreviewInput = { + title: string; + html: string; +}; + +export type ConversationsResponse = { + conversations: Conversation[]; + activeJob: ChatJob | null; + defaultConversationId: string; + virtualUserId: string; +}; + +export type HistoryResponse = { + conversationId: string; + virtualUserId: string; + permission: string; + count: number; + items: HistoryItem[]; + limit: number; + before: number | null; + hasMore: boolean; + nextBefore: number | null; + total: number; +}; + +export type ActiveJobsResponse = { + job: ChatJob | null; + jobs: ChatJob[]; +}; + +export type CommandsResponse = { + commands: CommandInfo[]; +}; + +export type DeleteConversationResponse = { + success: boolean; + conversationId: string; +}; + +export type HistoryPageResponse = { + conversationId: string; + virtualUserId: string; + permission: string; + count: number; + items: HistoryItem[]; + limit: number; + before: number | null; + hasMore: boolean; + nextBefore: number | null; + cursor: number | null; + total: number; +}; + +export type JobEventsJsonResponse = { + job: ChatJob; + after: number; + lastSeq: number; + events: ChatEvent[]; +}; + +export type UploadAttachmentInput = { + filePath: string; +}; + +export type AttachmentDownloadInput = { + attachmentId: string; + fileName?: string | null; +}; + +export type AttachmentDownloadResult = { + status: number; + ok: boolean; + savedFileName: string | null; + bytesWritten: number; + mediaType: string | null; + body: string | null; +}; + +export type AttachmentPreviewInput = { + attachmentId: string; +}; + +export type AttachmentPreviewResult = { + status: number; + ok: boolean; + mediaType: string | null; + bytes: number[]; + body: string | null; +}; + +export type EventStreamSubscription = { + subscriptionId: string; + jobId: string; + afterSeq: number; +}; + +export type RuntimeClient = { + getRuntimeConfig: () => Promise; + saveRuntimeConfig: (runtimeUrl: string) => Promise; + saveApiKey: (apiKey: string) => Promise; + confirmInsecureStorageFallback: () => Promise; + loadApiKeyStatus: () => Promise; + probeRuntime: () => Promise; + listConversations: () => Promise; + createConversation: (title?: string) => Promise; + deleteConversation: (conversationId: string) => Promise; + renameConversation: ( + conversationId: string, + title: string, + ) => Promise<{ ok: boolean }>; + getHistory: (input: { + conversationId: string; + limit: number; + before?: number | null; + }) => Promise; + getHistoryPage: ( + conversationId: string, + before?: number | null, + limit?: number, + ) => Promise; + getActiveJobs: (input?: { + conversationId?: string | null; + }) => Promise; + sendMessage: (input: SendMessageInput) => Promise; + cancelJob: (jobId: string) => Promise; + listCommands: () => Promise; + fetchJobEventsJson: (input: { + jobId: string; + afterSeq: number; + conversationId?: string | null; + }) => Promise; + startJobEventStream: (input: { + jobId: string; + afterSeq: number; + conversationId?: string | null; + }) => Promise; + stopJobEventStream: (subscriptionId: string) => Promise; + uploadAttachment: (input: UploadAttachmentInput) => Promise; + saveAttachment: ( + input: AttachmentDownloadInput, + ) => Promise; + previewAttachment: ( + input: AttachmentPreviewInput, + ) => Promise; + openHtmlPreview: (input: HtmlPreviewInput) => Promise; + listenRuntimeSse: ( + onEvent: (event: RuntimeSseEvent) => void, + onStatus: (status: RuntimeSseStatus) => void, + ) => Promise<() => void>; +}; diff --git a/apps/undefined-chat/src/styles.css b/apps/undefined-chat/src/styles.css new file mode 100644 index 00000000..f3682f05 --- /dev/null +++ b/apps/undefined-chat/src/styles.css @@ -0,0 +1,2969 @@ +/* ======================================== + CSS Variables - Morandi Orange Theme (Default) + ======================================== */ +:root { + /* 主色调 - 莫兰迪橙色(从 webui 迁移) */ + --primary: #d97757; + --primary-hover: #c56545; + --primary-subtle: #fbeee9; + + /* 背景色(webui 配色) */ + --bg-app: #f9f5f1; + --bg-sidebar: #f0ebe4; + --bg-workspace: #f9f5f1; + --bg-surface: #ffffff; + --bg-surface-hover: #efe6d8; + --bg-card: #ffffff; + --bg-input: #ffffff; + --bg-deep: #efe6d8; + --bg-glow: #fff7ec; + + /* 消息气泡 */ + --bg-message-bot: #ffffff; + --bg-message-user: #fbeee9; + + /* 文字色(webui 配色) */ + --text-primary: #3d3935; + --text-secondary: #6e675f; + --text-tertiary: #9e968c; + + /* 边框色(webui 配色) */ + --border-primary: #e6e0d8; + --border-subtle: #e6e0d8; + --border-message: #e6e0d8; + --border-color: #e6e0d8; + --border-focus: #d97757; + + /* 状态色(webui 配色) */ + --status-success: #4a7c59; + --status-success-bg: rgba(74, 124, 89, 0.12); + --status-error: #c94040; + --status-error-text: #c94040; + --status-warning: #cc8925; + + /* 代码块 */ + --bg-code: #1e1e1a; + --text-code: #e0dbd1; + --border-code: #3d3935; + + /* 输入焦点 */ + --focus-border: #d97757; + --focus-ring: rgba(217, 119, 87, 0.2); + --keyboard-inset: 0px; + + /* 阴影效果(webui 配色) */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -1px rgba(0, 0, 0, 0.03); + --shadow-lg: 0 24px 60px rgba(31, 27, 22, 0.12); + + /* webui 兼容变量 */ + --accent-color: #d97757; + --accent-hover: #c56545; + --accent-subtle: #fbeee9; + --accent: #d97757; + --success: #4a7c59; + --warning: #cc8925; + --error: #c94040; + + /* 字体设置(webui 配色) */ + --font-serif: "IBM Plex Serif", "Times New Roman", serif; + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: "IBM Plex Mono", "Menlo", monospace; + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 24px; + + color: var(--text-primary); + background: var(--bg-app); + font-family: var(--font-sans), "Microsoft YaHei", sans-serif; + font-size: 15px; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +/* ======================================== + CSS Variables - Morandi Dark Theme + ======================================== */ +[data-theme="dark"] { + /* 主色调 */ + --primary: #d97757; + --primary-hover: #c56545; + --primary-subtle: #2d1f1c; + + /* 背景色(webui dark 配色) */ + --bg-app: #0f1112; + --bg-sidebar: #161b1f; + --bg-workspace: #0f1112; + --bg-surface: #171c1f; + --bg-surface-hover: #14181a; + --bg-card: #171c1f; + --bg-input: #12171a; + --bg-deep: #14181a; + --bg-glow: #1b2125; + + /* 消息气泡 */ + --bg-message-bot: #171c1f; + --bg-message-user: #2d1f1c; + + /* 文字色(webui dark 配色) */ + --text-primary: #f4efe7; + --text-secondary: #b2a79b; + --text-tertiary: #6d6256; + + /* 边框色(webui dark 配色) */ + --border-primary: #2b3439; + --border-subtle: #2b3439; + --border-message: #2b3439; + --border-color: #2b3439; + + /* 状态色 */ + --status-success: #4a7c59; + --status-success-bg: rgba(74, 124, 89, 0.12); + --status-error: #c94040; + --status-error-text: #c94040; + --status-warning: #cc8925; + + /* 代码块 */ + --bg-code: #0e1418; + --text-code: #cfd8dc; + --border-code: #2b3439; + + /* 输入焦点 */ + --focus-border: #d97757; + --focus-ring: rgba(217, 119, 87, 0.25); + + /* 阴影效果 */ + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 20px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.45); + + /* webui 兼容变量 */ + --accent-color: #d97757; + --accent-hover: #c56545; + --accent-subtle: #2d1f1c; + --accent: #d97757; + --success: #4a7c59; + --warning: #cc8925; + --error: #c94040; + + color: var(--text-primary); + background: var(--bg-app); +} + +/* ======================================== + Animations + ======================================== */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 流式光标闪烁(对齐 WebUI runtime-chat-cursor) */ +@keyframes runtime-chat-cursor { + 0%, + 50% { + opacity: 1; + } + 51%, + 100% { + opacity: 0; + } +} + +@keyframes pulse-shimmer { + 0% { + opacity: 0.65; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.65; + } +} + +@keyframes rotate-spinner { + 100% { + transform: rotate(360deg); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + overflow: hidden; +} + +button, +textarea, +input { + font: inherit; +} + +button { + border: 0; + cursor: pointer; + outline: none; + background: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +button:focus-visible, +a:focus-visible, +textarea:focus-visible, +input:focus-visible, +[tabindex]:focus-visible { + outline: 2px solid var(--focus-border); + outline-offset: 2px; + box-shadow: 0 0 0 3px var(--focus-ring); +} + +a { + color: var(--primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* ======================================== + Main Layout Structure + ======================================== */ +.chat-app { + display: flex; + width: 100vw; + height: 100vh; + height: 100svh; + background: var(--bg-app); + color: var(--text-primary); + transition: background-color 0.3s, color 0.3s; + position: relative; + overflow: hidden; +} + +/* 侧边栏 */ +.conversation-list { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg-sidebar); + border-right: 1px solid var(--border-primary); + /* 左侧贴屏,补 safe-area-inset-left 覆盖横屏刘海/圆角 */ + padding: max(14px, env(safe-area-inset-top)) 14px max(14px, env(safe-area-inset-bottom)) + max(14px, env(safe-area-inset-left)); + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.2s; + position: relative; + z-index: 10; +} + +/* 折叠后的侧边栏 */ +.conversation-list.collapsed { + width: 0; + padding-left: 0; + padding-right: 0; + border-right: 0px solid transparent; + opacity: 0; + pointer-events: none; + transform: translateX(-30px); +} + +.rail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 16px; + height: 40px; +} + +.rail-header h1 { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + color: var(--primary); + letter-spacing: -0.5px; +} + +.ghost-button, +.icon-button { + border: 1px solid var(--border-primary); + background: var(--bg-surface); + color: var(--text-primary); + border-radius: 8px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--shadow-sm); +} + +.ghost-button:hover:not(:disabled), +.icon-button:hover:not(:disabled) { + background: var(--bg-surface-hover); + border-color: var(--primary); + color: var(--primary); +} + +.ghost-button { + padding: 8px 14px; + font-weight: 550; + gap: 6px; +} + +/* 左侧新建会话按钮设计 */ +.new-chat-btn { + width: 100%; + justify-content: center; + margin-bottom: 16px; + background: var(--primary); + color: #ffffff; + border-color: var(--primary); + box-shadow: var(--shadow-sm); + font-weight: 600; +} + +.new-chat-btn:hover:not(:disabled) { + background: var(--primary-hover); + color: #ffffff; + border-color: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(95, 141, 158, 0.2); +} + +.conversation-scroll { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + padding-right: 2px; + margin-right: -6px; +} + +.conversation-scroll::-webkit-scrollbar { + width: 5px; +} +.conversation-scroll::-webkit-scrollbar-thumb { + background: var(--border-primary); + border-radius: 99px; +} + +.conversation-item { + display: flex; + flex-direction: column; + width: 100%; + gap: 4px; + padding: 12px 14px; + border-radius: 10px; + background: transparent; + color: var(--text-primary); + text-align: left; + transition: all 0.2s; + border: 1px solid transparent; + position: relative; +} + +.conversation-item:hover { + background: var(--bg-surface-hover); +} + +.conversation-item[aria-current="page"] { + background: var(--bg-surface); + border-color: var(--border-primary); + box-shadow: var(--shadow-sm); +} + +.conversation-item[aria-current="page"]::before { + content: ""; + position: absolute; + left: 0; + top: 12px; + bottom: 12px; + width: 3px; + background: var(--primary); + border-radius: 0 4px 4px 0; +} + +/* 会话项容器:悬停显示删除按钮 */ +.conversation-item-wrap { + position: relative; +} + +.conversation-delete { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + min-width: 28px; + min-height: 28px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-tertiary); + opacity: 0; + transition: opacity 0.16s ease, background 0.16s ease, color 0.16s ease; +} + +.conversation-rename { + position: absolute; + top: 50%; + right: 40px; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + min-width: 28px; + min-height: 28px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-tertiary); + opacity: 0; + transition: opacity 0.16s ease, background 0.16s ease, color 0.16s ease; +} + +.conversation-item-wrap:hover .conversation-delete, +.conversation-delete:focus-visible { + opacity: 1; +} + +.conversation-item-wrap:hover .conversation-rename, +.conversation-rename:focus-visible { + opacity: 1; +} + +.conversation-delete:hover { + background: color-mix(in srgb, var(--error) 14%, transparent); + color: var(--error); +} + +.conversation-rename:hover { + background: color-mix(in srgb, var(--primary) 14%, transparent); + color: var(--primary); +} + +/* 按钮内联加载指示 */ +.btn-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid color-mix(in srgb, currentColor 35%, transparent); + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* 历史加载态 */ +.timeline-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + min-height: 280px; + color: var(--text-tertiary); +} + +.timeline-spinner { + width: 28px; + height: 28px; + border: 3px solid var(--border-primary); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.timeline-loading p { + margin: 0; + font-size: 0.875rem; +} + +.timeline-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + max-width: 560px; + margin: 40px auto; + padding: 28px 24px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); +} + +.timeline-error-text { + margin: 0; + text-align: center; + color: var(--status-error-text); + font-size: 0.875rem; + line-height: 1.5; +} + +.timeline-error .ghost-button { + margin-top: 4px; +} + +.timeline-load-more { + display: flex; + justify-content: center; + padding: 8px 0 14px; +} + +/* 二次确认弹窗 */ +.confirm-dialog-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: color-mix(in srgb, #020617 62%, transparent); + backdrop-filter: blur(4px); + animation: fadeIn 0.16s ease; +} + +.confirm-dialog { + width: min(400px, 100%); + display: flex; + flex-direction: column; + gap: 12px; + padding: 22px; + border-radius: 14px; + background: var(--bg-surface); + border: 1px solid var(--border-primary); + box-shadow: var(--shadow-lg); + animation: slideUpFadeIn 0.18s ease-out; +} + +.confirm-dialog-title { + margin: 0; + font-size: 1.05rem; + font-weight: 650; + color: var(--text-primary); +} + +.confirm-dialog-message { + margin: 0; + font-size: 0.9rem; + line-height: 1.6; + color: var(--text-secondary); +} + +.confirm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; +} + +.primary-button, +.danger-button { + padding: 8px 16px; + border-radius: 8px; + font-weight: 600; + border: 1px solid transparent; + transition: filter 0.16s ease, background 0.16s ease; +} + +.primary-button { + background: var(--primary); + color: #ffffff; + border-color: var(--primary); +} + +.primary-button:hover { + background: var(--primary-hover); +} + +.danger-button { + background: var(--error); + color: #ffffff; + border-color: var(--error); +} + +.danger-button:hover { + filter: brightness(0.94); +} + +/* 触摸端无悬停:删除按钮常驻可见 */ +@media (max-width: 768px) { + .conversation-delete { + opacity: 0.55; + width: 36px; + height: 36px; + } + .conversation-rename { + opacity: 0.55; + width: 36px; + height: 36px; + right: 48px; + } +} + +.conversation-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.conversation-title { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.conversation-meta { + color: var(--text-tertiary); + font-size: 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.running-dot { + flex-shrink: 0; + border-radius: 99px; + background: var(--status-success-bg); + color: var(--status-success); + padding: 1px 6px; + font-size: 0.7rem; + font-weight: 600; + animation: pulse-shimmer 2s infinite ease-in-out; +} + +/* 侧边栏底部面板 */ +.sidebar-footer { + margin-top: auto; + padding-top: 14px; + border-top: 1px solid var(--border-primary); + display: flex; + flex-direction: column; + gap: 10px; +} + +.sidebar-footer-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.user-badge { + display: flex; + align-items: center; + gap: 8px; +} + +.user-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--primary-subtle); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; +} + +/* ======================================== + Workspace Area + ======================================== */ +.chat-workspace { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + height: 100%; + background: var(--bg-workspace); + transition: background-color 0.3s; + position: relative; +} + +/* 顶栏 */ +.chat-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 56px; + border-bottom: 1px solid var(--border-primary); + /* 左右补 safe-area 覆盖横屏刘海/圆角,避免标题与按钮被遮挡 */ + padding: max(8px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) 8px + max(20px, env(safe-area-inset-left)); + background: color-mix(in srgb, var(--bg-workspace) 85%, transparent); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + z-index: 5; + transition: background-color 0.3s, border-color 0.3s; +} + +.topbar-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.chat-title-block { + display: flex; + flex-direction: column; + min-width: 0; +} + +.chat-title-block strong { + font-size: 0.95rem; + font-weight: 650; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.connection-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 99px; + font-size: 0.72rem; + font-weight: 600; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); +} + +.connection-pill::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; +} + +.connection-pill.connected::before { background: var(--status-success); } +.connection-pill.connecting::before, +.connection-pill.resuming::before { + background: var(--status-warning); + animation: pulse-shimmer 1.5s infinite; +} +.connection-pill.disconnected::before { background: var(--status-error); } +.connection-pill.streaming::before, +.connection-pill.json_fallback::before { + background: var(--primary); + animation: pulse-shimmer 1s infinite; +} + +.runtime-indicator { + font-size: 0.75rem; + color: var(--text-tertiary); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 4px 8px; + background: var(--bg-surface); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 消息流框架 */ +.timeline-shell { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.timeline { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; + /* 软键盘弹起时,滚动锚点预留键盘高度 + 输入区净空, + 保证 scrollIntoView 能把最后一条消息带到键盘上方而非被遮挡 */ + scroll-padding-bottom: calc(var(--keyboard-inset) + 16px); +} + +/* 隐藏滚动条 */ +.timeline::-webkit-scrollbar { + display: none; +} +.timeline { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ +} + +/* ======================================== + Empty State (ChatGPT Welcoming Page) + ======================================== */ +.welcome-container { + max-width: 680px; + margin: auto; + text-align: center; + padding: 40px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 28px; + animation: fadeInUp 0.5s ease; +} + +.welcome-logo { + width: 72px; + height: 72px; + border-radius: 22px; + background: linear-gradient(135deg, var(--primary) 0%, #a4c3d2 100%); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-lg); +} + +.welcome-header h2 { + margin: 0 0 8px; + font-size: 1.8rem; + font-weight: 700; + letter-spacing: -0.5px; +} + +.welcome-header p { + margin: 0; + color: var(--text-secondary); + font-size: 1rem; +} + +.shortcut-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + width: 100%; +} + +.shortcut-card { + background: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: 12px; + padding: 16px; + text-align: left; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--shadow-sm); + display: flex; + flex-direction: column; + gap: 4px; +} + +.shortcut-card:hover { + transform: translateY(-2px); + border-color: var(--primary); + box-shadow: var(--shadow-md); +} + +.shortcut-card .icon { + display: inline-flex; + align-items: center; + margin-bottom: 2px; + color: var(--primary); +} + +.shortcut-card .title { + font-weight: 600; + color: var(--text-primary); + font-size: 0.9rem; +} + +.shortcut-card .desc { + font-size: 0.8rem; + color: var(--text-secondary); +} + +/* ======================================== + Message Bubble Rendering + ======================================== */ +/* 历史消息已迁移至 Runtime Chat Components(runtime-chat-item),对齐 WebUI webchat。 + 原 ChatGPT 风格的 message-row / avatar-wrapper / message-bubble / message-meta 已移除。 */ + +/* ======================================== + 消息引用按钮(从 WebUI runtime-chat 迁移) + 悬浮显示:默认 opacity:0,消息行 hover 或聚焦时显现 + ======================================== */ +.runtime-chat-quote-btn { + display: inline-flex; + align-items: center; + min-height: 22px; + min-width: 44px; + padding: 1px 7px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-tertiary); + font-size: 11px; + line-height: 1; + letter-spacing: 0; + text-transform: none; + cursor: pointer; + opacity: 0; + transform: translateY(-1px); + transition: + opacity 0.16s ease, + color 0.16s ease, + background 0.16s ease, + border-color 0.16s ease; +} +.runtime-chat-item:hover .runtime-chat-quote-btn, +.runtime-chat-quote-btn:focus-visible { + opacity: 1; +} +.runtime-chat-quote-btn:hover, +.runtime-chat-quote-btn:focus-visible { + border-color: color-mix(in srgb, var(--accent) 28%, var(--border-color)); + background: color-mix(in srgb, var(--accent) 9%, transparent); + color: var(--accent-color); +} +.runtime-chat-quote-btn.is-visible { + opacity: 1; +} + +.message-jump-highlight { + animation: messageJumpHighlight 1.6s ease; +} + +@keyframes messageJumpHighlight { + 0%, + 100% { + box-shadow: var(--shadow-sm); + } + 20%, + 70% { + box-shadow: 0 0 0 3px var(--focus-ring), var(--shadow-md); + border-color: var(--primary); + } +} + +/* ======================================== + Markdown & Typographical Styling + ======================================== */ +.message-markdown { + word-break: break-word; +} + +.message-markdown h1, +.message-markdown h2, +.message-markdown h3, +.message-markdown h4 { + margin: 16px 0 8px; + font-weight: 700; + color: var(--text-primary); +} + +.message-markdown h1 { font-size: 1.4rem; border-bottom: 1px solid var(--border-subtle); padding-bottom: 4px; } +.message-markdown h2 { font-size: 1.2rem; } +.message-markdown h3 { font-size: 1.05rem; } + +.message-markdown p { + margin: 0 0 10px; +} + +.message-markdown p:last-child { + margin-bottom: 0; +} + +.message-markdown code { + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace; + font-size: 0.88em; + padding: 2px 6px; + border-radius: 5px; + background: var(--bg-surface-hover); + border: 1px solid var(--border-subtle); + color: var(--primary-hover); +} + +.message-markdown blockquote { + margin: 10px 0; + border-left: 3.5px solid var(--primary); + padding-left: 12px; + color: var(--text-secondary); + background: var(--bg-app); + border-radius: 0 6px 6px 0; + padding-top: 4px; + padding-bottom: 4px; +} + +.message-markdown ul, +.message-markdown ol { + margin: 0 0 10px; + padding-left: 20px; +} + +.message-markdown li { + margin-bottom: 4px; +} + +.message-markdown table { + border-collapse: collapse; + width: 100%; + margin: 12px 0; + font-size: 0.9rem; +} + +.message-markdown th, +.message-markdown td { + border: 1px solid var(--border-primary); + padding: 8px 12px; + text-align: left; +} + +.message-markdown th { + background: var(--bg-surface-hover); + font-weight: 600; +} + +/* ======================================== + Code Block (ChatGPT style) + ======================================== */ +.code-block { + margin: 14px 0; + overflow: hidden; + border-radius: 10px; + border: 1px solid var(--border-code); + background: var(--bg-code); + color: var(--text-code); + box-shadow: var(--shadow-sm); +} + +.code-block figcaption { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 14px; + background: color-mix(in srgb, var(--bg-code) 85%, #ffffff 15%); + border-bottom: 1px solid var(--border-code); + color: var(--text-tertiary); + font-size: 0.78rem; + font-weight: 600; +} + +.code-block figcaption button { + border-radius: 5px; + background: transparent; + border: 1px solid var(--border-code); + color: var(--text-code); + padding: 4px 8px; + font-size: 0.75rem; + transition: all 0.2s; +} + +.code-block figcaption button:hover { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; +} + +.code-block pre { + overflow-x: auto; + margin: 0; + padding: 14px; +} + +.code-block code { + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace; + font-size: 0.88rem; + border: 0; + background: transparent; + color: inherit; + padding: 0; + word-break: normal; +} + +/* ======================================== + Thinking Box & Event Drawer + ======================================== */ +.thinking-box { + background: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: 10px; + margin: 12px auto; + max-width: 800px; + width: 100%; + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.thinking-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + cursor: pointer; + background: var(--bg-app); + user-select: none; + transition: background-color 0.2s; +} + +.thinking-header:hover { + background: var(--bg-surface-hover); +} + +.thinking-title-block { + display: flex; + align-items: center; + gap: 10px; +} + +.thinking-dot-spinner { + width: 14px; + height: 14px; + border: 2px solid var(--primary-subtle); + border-top-color: var(--primary); + border-radius: 50%; + animation: rotate-spinner 0.8s linear infinite; +} + +.thinking-header span { + font-weight: 600; + font-size: 0.82rem; + color: var(--primary); +} + +.thinking-chevron { + transition: transform 0.25s ease; + color: var(--text-tertiary); +} + +.thinking-box.expanded .thinking-chevron { + transform: rotate(180deg); +} + +.thinking-content { + border-top: 1px solid var(--border-primary); + max-height: 250px; + overflow-y: auto; + padding: 12px 14px; + background: var(--bg-surface); + display: none; +} + +.thinking-box.expanded .thinking-content { + display: block; +} + +/* Event List */ +.event-list { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0; + margin: 0; + list-style: none; +} + +.event-item { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 0.8rem; + line-height: 1.4; + color: var(--text-secondary); + padding: 4px 6px; + border-left: 2.5px solid var(--border-primary); +} + +.event-item.event-error { + border-left-color: var(--status-error); + color: var(--status-error-text); +} + +.event-type-badge { + font-weight: 600; + color: var(--primary); + font-size: 0.72rem; + padding: 1px 4px; + border-radius: 4px; + background: var(--primary-subtle); +} + +/* ======================================== + Floating Input Composer + ======================================== */ +.composer-wrapper { + /* 底部叠加软键盘高度;左右补 safe-area 覆盖横屏刘海/圆角 */ + padding: 0 max(20px, env(safe-area-inset-right)) + calc(max(24px, env(safe-area-inset-bottom)) + var(--keyboard-inset)) + max(20px, env(safe-area-inset-left)); + background: transparent; + position: relative; + z-index: 4; +} + +.composer { + max-width: 800px; + width: 100%; + margin: 0 auto; + background: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: 20px; + box-shadow: var(--shadow-md); + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + overflow: visible; + position: relative; +} + +.composer:focus-within { + border-color: var(--focus-border); + box-shadow: var(--shadow-lg), 0 0 0 3px var(--focus-ring); +} + +/* 输入框内部的附件区 */ +.composer-attachments { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 14px 4px; + margin: 0; + list-style: none; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-app); +} + +.composer-attachments li { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: 10px; + padding: 6px 10px; + font-size: 0.78rem; + box-shadow: var(--shadow-sm); + animation: fadeInUp 0.2s ease; +} + +.composer-attachments li button { + color: var(--text-tertiary); + font-size: 1rem; + line-height: 1; + padding: 0 2px; +} + +.composer-attachments li button:hover { + color: var(--status-error); +} + +/* 引用区 */ +.composer-references { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 14px 4px; + background: var(--bg-app); + border-bottom: 1px solid var(--border-subtle); +} + +.composer-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: 10px; + padding: 4px 10px; + font-size: 0.78rem; + max-width: 100%; + animation: fadeInUp 0.2s ease; +} + +.composer-chip .chip-icon { + color: var(--primary); + font-size: 0.9rem; + flex-shrink: 0; + line-height: 1; +} + +.composer-chip .chip-preview { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); +} + +.composer-chip button { + color: var(--text-tertiary); + font-size: 1.1rem; + line-height: 1; + padding: 0 2px; + flex-shrink: 0; + transition: color 0.2s; +} + +.composer-chip .chip-preview { + display: block; + min-width: 0; + max-width: min(420px, 62vw); + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + color: var(--text-secondary); + font-size: inherit; + line-height: inherit; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.composer-chip .chip-preview:disabled { + cursor: default; + opacity: 1; +} + +.composer-chip .chip-clear { + min-width: 28px; + min-height: 28px; + align-items: center; + justify-content: center; +} + +.composer-chip button:hover { + color: var(--status-error); +} + +/* 输入核心行 */ +.composer-input-row { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 10px 12px; + position: relative; +} + +.composer .icon-button { + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + border-radius: 50%; + flex-shrink: 0; + border: 1px solid transparent; + box-shadow: none; + color: var(--text-secondary); + background: transparent; +} + +.composer .icon-button:hover { + background: var(--bg-surface-hover); + color: var(--primary); +} + +.composer textarea { + flex: 1; + border: none; + background: transparent; + color: var(--text-primary); + padding: 8px 6px; + resize: none; + max-height: 160px; + min-height: 38px; + line-height: 1.5; + outline: none; +} + +.composer button[type="submit"] { + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + border-radius: 50%; + background: var(--primary); + color: #ffffff; + flex-shrink: 0; + transition: background-color 0.2s, transform 0.15s; +} + +.composer button[type="submit"]:hover:not(:disabled) { + background: var(--primary-hover); + transform: scale(1.05); +} + +.composer button[type="submit"]:active:not(:disabled) { + transform: scale(0.95); +} + +/* 命令自动联想区 */ +.command-suggestions { + background: var(--bg-surface); + border-top: 1px solid var(--border-subtle); + margin: 0; + padding: 6px; + list-style: none; + max-height: 200px; + overflow-y: auto; +} + +.command-suggestions li { + display: flex; + justify-content: space-between; + padding: 8px 12px; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.2s; +} + +.command-suggestions li:hover { + background: var(--bg-surface-hover); +} + +.command-suggestions li span:first-child { + font-weight: 600; + color: var(--primary); +} + +.command-suggestions li span:last-child { + color: var(--text-secondary); + font-size: 0.8rem; +} + +/* 提示信息 */ +.composer-note { + text-align: center; + font-size: 0.72rem; + color: var(--text-tertiary); + margin-top: 6px; +} + +/* ======================================== + Setup Modal & App Level Error + ======================================== */ +.setup-panel-container { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: fadeInUp 0.3s ease; +} + +.setup-panel { + background: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: 16px; + padding: 24px; + max-width: 420px; + width: 90%; + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + gap: 16px; +} + +.setup-panel h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + color: var(--primary); +} + +.setup-panel label { + display: flex; + flex-direction: column; + gap: 6px; +} + +.setup-panel label span { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); +} + +.setup-panel input[type="url"], +.setup-panel input[type="password"] { + border: 1px solid var(--border-primary); + background: var(--bg-workspace); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 8px; + outline: none; + transition: all 0.2s; +} + +.setup-panel input:focus { + border-color: var(--focus-border); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.setup-checkbox { + flex-direction: row !important; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.setup-checkbox input { + width: 16px; + height: 16px; +} + +.setup-panel button[type="submit"] { + background: var(--primary); + color: #ffffff; + border-radius: 8px; + padding: 10px; + font-weight: 600; + transition: background-color 0.2s; +} + +.setup-panel button[type="submit"]:hover { + background: var(--primary-hover); +} + +.setup-panel p { + margin: 0; + font-size: 0.72rem; + color: var(--text-tertiary); + line-height: 1.4; +} + +.app-error { + background: var(--status-error-text); + color: #ffffff; + padding: 8px 12px; + border-radius: 8px; + font-size: 0.8rem; + margin: 8px auto 0; + max-width: 800px; + width: calc(100% - 40px); +} + +/* ======================================== + Mobile Styles / Responsive Settings + ======================================== */ +.mobile-header-btn { + display: none; +} + +/* 移动式抽屉布局触发条件(OR 逻辑,逗号分隔): + 1. max-width:768px —— 竖屏手机 / 窄窗口 + 2. 横屏且高度受限 —— 横屏手机(宽度常 >768px,但纵向空间不足以容纳常驻侧栏) + 命中任一即走抽屉布局,消除“横屏 >768px 退桌面常驻侧栏”问题。 */ +@media (max-width: 768px), (orientation: landscape) and (max-height: 480px) { + .chat-app { + flex-direction: column; + } + + .conversation-list { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: min(86vw, 320px); + transform: translateX(-100%); + opacity: 0; + pointer-events: none; + box-shadow: var(--shadow-lg); + } + + .conversation-list.active { + transform: translateX(0); + opacity: 1; + pointer-events: auto; + } + + /* 移动端菜单背景遮罩 */ + .sidebar-overlay { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + z-index: 8; + display: none; + } + + .sidebar-overlay.active { + display: block; + } + + .mobile-header-btn { + display: inline-flex; + width: 44px; + height: 44px; + border-radius: 8px; + border: 1px solid var(--border-primary); + background: var(--bg-surface); + } + + .timeline { + padding: 16px 14px; + } + + .shortcut-grid { + grid-template-columns: 1fr; + } + + .composer-wrapper { + padding: 0 max(10px, env(safe-area-inset-right)) + calc(max(16px, env(safe-area-inset-bottom)) + var(--keyboard-inset)) + max(10px, env(safe-area-inset-left)); + } + + .conversation-delete { + right: 6px; + } + + .conversation-rename { + right: 52px; + } +} + +/* 横屏手机专属微调:纵向空间稀缺,抽屉收窄、垂直 padding 压缩; + 抽屉左侧仍受基础 .conversation-list 的 safe-area-inset-left 保护 */ +@media (orientation: landscape) and (max-height: 480px) and (pointer: coarse) { + .conversation-list { + width: min(60vw, 300px); + } + + .chat-topbar { + min-height: 48px; + } + + .timeline { + padding: 10px 14px; + /* 横屏时键盘占据大量高度,时间线滚动锚点额外预留净空 */ + scroll-padding-bottom: calc(var(--keyboard-inset) + 12px); + } + + .welcome-container { + padding: 20px; + gap: 16px; + } +} + +/* 平板竖屏(约 768–1024):宽度足以保留常驻侧栏,但收窄以让出聊天区。 + 仅在触摸设备且竖屏时收窄,避免影响桌面同尺寸窗口的手感。 */ +@media (min-width: 769px) and (max-width: 1024px) and (orientation: portrait) and (pointer: coarse) { + .conversation-list { + width: 248px; + } +} + +/* ======================================== + 触控目标尺寸(WCAG 2.5.5 / 平台触控规范 ≥44px) + 以 pointer:coarse 为触发条件,覆盖所有触摸设备: + 竖屏手机、横屏手机(>768px)、平板,与视口断点解耦, + 不再受“仅 max-width:768px 内生效”的限制。 + 仅补齐基础态不足 44px 的可点元素;本身达标者 + (.mobile-header-btn / .runtime-chat-command-item / + .thinking-header / .conversation-item / .config-item / + 图片查看器控件)不重复声明。 + ======================================== */ +@media (pointer: coarse) { + /* 方形图标按钮:宽高同时拉满,保持圆形/方形比例 */ + .conversation-delete, + .conversation-rename, + .composer .icon-button, + .composer button[type="submit"], + .composer-chip .chip-clear, + .runtime-chat-quote-btn { + min-width: 44px; + min-height: 44px; + } + + /* 带文字的横向按钮 / 列表项:仅保证可点高度,不强制方形 */ + .runtime-code-action, + .code-block figcaption button { + min-height: 44px; + } + + /* 命令联想项是 flex 行,补 align-items 让单行文字垂直居中 */ + .command-suggestions li { + min-height: 44px; + align-items: center; + } +} + +/* ======================================== + Runtime Chat Components (from webui) + ======================================== */ +.runtime-chat-log { + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-app); + min-width: 0; + min-height: 220px; + max-height: 420px; + overflow-x: hidden; + overflow-y: auto; + padding: 12px; + margin-bottom: 12px; + display: grid; + align-content: start; + gap: 10px; +} + +.runtime-chat-item { + min-width: 0; + max-width: 100%; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + padding: 12px 14px; + background: var(--bg-card); + animation: fadeInUp 0.18s ease-out; +} + +.runtime-chat-item.user { + border-color: rgba(217, 119, 87, 0.35); +} + +.runtime-chat-item.bot { + border-color: var(--border-color); +} + +/* 流式光标(对齐 WebUI runtime.js:streaming 时内容末尾的闪烁竖线,替代"思考中..."占位符) */ +.runtime-chat-item.streaming .runtime-chat-content::after { + content: ""; + display: inline-block; + width: 7px; + height: 1.1em; + margin-left: 4px; + vertical-align: -0.15em; + background: var(--accent); + animation: runtime-chat-cursor 0.9s steps(2, start) infinite; +} + +.runtime-chat-role { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-tertiary); + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 8px; + min-height: 18px; + flex-wrap: wrap; +} + +.runtime-chat-role-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-tertiary); +} + +.runtime-chat-content { + min-width: 0; + max-width: 100%; + font-size: 15px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; +} + +.runtime-chat-content.markdown { + white-space: normal; +} + +.runtime-chat-content.markdown > * { + min-width: 0; + max-width: 100%; +} + +.runtime-chat-content.markdown > *:first-child { + margin-top: 0; +} + +.runtime-chat-content.markdown > *:last-child { + margin-bottom: 0; +} + +.runtime-chat-content.markdown p { + margin: 0.4em 0; +} + +.runtime-chat-content.markdown h1, +.runtime-chat-content.markdown h2, +.runtime-chat-content.markdown h3, +.runtime-chat-content.markdown h4 { + margin: 0.6em 0 0.3em; + line-height: 1.3; + font-weight: 700; + color: var(--text-primary); +} + +.runtime-chat-content.markdown h1 { + font-size: 1.25em; +} + +.runtime-chat-content.markdown h2 { + font-size: 1.15em; +} + +.runtime-chat-content.markdown h3 { + font-size: 1.05em; +} + +.runtime-chat-content.markdown ul, +.runtime-chat-content.markdown ol { + margin: 0.4em 0; + padding-left: 1.5em; +} + +.runtime-chat-content.markdown li { + margin: 0.15em 0; +} + +.runtime-chat-content.markdown blockquote { + margin: 0.4em 0; + padding: 0.3em 0.8em; + border-left: 3px solid var(--border-color); + color: var(--text-secondary); +} + +.runtime-chat-content.markdown table { + width: 100%; + max-width: 100%; + table-layout: fixed; + border-collapse: collapse; + margin: 0.5em 0; + font-size: 13px; +} + +.runtime-chat-content.markdown th, +.runtime-chat-content.markdown td { + border: 1px solid var(--border-color); + padding: 4px 8px; + overflow-wrap: anywhere; +} + +.runtime-chat-content code { + display: inline-block; + max-width: 100%; + padding: 2px 6px; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-app); + font-family: var(--font-mono); + font-size: 12px; + white-space: normal; + overflow-wrap: anywhere; +} + +/* Runtime Tool Components */ +.runtime-chat-tools { + display: grid; + gap: 8px; +} + +.runtime-tool-block { + --tool-accent: var(--accent-color); + min-width: 0; + max-width: 100%; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-app) 92%, var(--bg-card)); + overflow: hidden; + position: relative; + transition: + border-color 0.18s ease, + background 0.18s ease, + transform 0.18s ease, + box-shadow 0.18s ease; +} + +.runtime-tool-block::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--tool-accent); + opacity: 0.65; +} + +.runtime-tool-block.is-agent { + --tool-accent: var(--accent-color); + background: color-mix(in srgb, var(--bg-card) 60%, var(--bg-app)); +} + +.runtime-tool-block.is-tool { + --tool-accent: color-mix(in srgb, var(--success) 76%, var(--accent-color)); +} + +.runtime-tool-block.running { + --tool-accent: color-mix(in srgb, var(--warning) 82%, var(--accent-color)); +} + +.runtime-tool-block.done { + --tool-accent: var(--success); +} + +.runtime-tool-block.error { + --tool-accent: var(--error); +} + +.runtime-tool-block.cancelled { + --tool-accent: var(--warning); +} + +.runtime-tool-block:hover { + border-color: color-mix(in srgb, var(--tool-accent) 38%, var(--border-color)); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); +} + +.runtime-tool-block summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + cursor: pointer; + min-height: 32px; + padding: 3px 10px 3px 13px; + font-size: 12px; + line-height: 1.2; + color: var(--text-secondary); + list-style: none; +} + +.runtime-tool-block summary::-webkit-details-marker { + display: none; +} + +.runtime-tool-block summary::before { + content: ""; + width: 6px; + height: 6px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.18s ease; + opacity: 0.7; +} + +.runtime-tool-block[open] summary::before { + transform: rotate(45deg); +} + +.runtime-tool-block summary .runtime-tool-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + font-size: 12px; + font-weight: 650; +} + +.runtime-tool-block summary .runtime-tool-status { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-style: normal; + white-space: nowrap; + color: var(--text-tertiary); +} + +.runtime-tool-block.running summary .runtime-tool-status { + color: var(--warning); +} + +.runtime-tool-block.done summary .runtime-tool-status { + color: var(--success); +} + +.runtime-tool-block.error summary .runtime-tool-status { + color: var(--error); +} + +.runtime-tool-preview { + min-width: 0; + max-width: 100%; + border-top: 1px solid var(--border-color); + padding: 8px 10px 10px; + animation: fadeInUp 0.18s ease-out; +} + +.runtime-tool-preview-label { + margin-bottom: 6px; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; +} + +.runtime-tool-preview-body { + min-width: 0; + max-width: 100%; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: min(34vh, 260px); + overflow: auto; +} + +.runtime-tool-preview-body.is-structured { + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-app)); + white-space: normal; +} + +/* Code blocks in runtime */ +.runtime-code-block { + min-width: 0; + max-width: 100%; + margin: 0.5em 0; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-app) 88%, var(--bg-deep)); + overflow: hidden; +} + +.runtime-code-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 34px; + padding: 5px 8px 5px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + background: color-mix(in srgb, var(--bg-card) 58%, transparent); +} + +.runtime-code-language { + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; +} + +.runtime-code-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +.runtime-code-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: + background 0.16s ease, + border-color 0.16s ease, + color 0.16s ease, + transform 0.16s ease; +} + +.runtime-code-action:hover, +.runtime-code-action:focus-visible { + border-color: color-mix(in srgb, var(--accent) 28%, var(--border-color)); + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--text-primary); +} + +.runtime-code-action.primary { + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); +} + +.runtime-code-action.primary:hover, +.runtime-code-action.primary:focus-visible { + background: var(--accent); + color: #fff; +} + +.runtime-code-body { + position: relative; +} + +/* 默认折叠:限制高度约 3-4 行 */ +.runtime-code-block.is-collapsed .runtime-code-body { + max-height: 9.2em; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; +} + +.runtime-code-body pre { + max-width: 100%; + margin: 0; + padding: 10px 14px; + border: 0; + border-radius: 0; + background: transparent; + overflow-x: hidden; + white-space: pre-wrap; + word-wrap: break-word; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.5; +} + +.runtime-code-body pre code { + display: block; + min-width: 0; + max-width: 100%; + padding: 0; + border: none; + background: none; + border-radius: 0; + white-space: pre-wrap; + word-wrap: break-word; + color: var(--text-primary); +} + +.runtime-code-body pre code.hljs { + padding: 0; + background: transparent; + color: var(--text-primary); +} + +/* ============ 语法高亮颜色(浅色主题)============ */ +.runtime-code-block .hljs-keyword, +.runtime-code-block .hljs-doctag, +.runtime-code-block .hljs-template-tag, +.runtime-code-block .hljs-type { + color: #9b3fb5; + font-weight: 600; +} + +.runtime-code-block .hljs-string, +.runtime-code-block .hljs-regexp, +.runtime-code-block .hljs-meta .hljs-string { + color: #246f4f; +} + +.runtime-code-block .hljs-comment, +.runtime-code-block .hljs-quote { + color: var(--text-tertiary); + font-style: italic; +} + +.runtime-code-block .hljs-number, +.runtime-code-block .hljs-literal, +.runtime-code-block .hljs-variable, +.runtime-code-block .hljs-attribute, +.runtime-code-block .hljs-symbol { + color: #b45f2b; +} + +.runtime-code-block .hljs-title, +.runtime-code-block .hljs-title.function_, +.runtime-code-block .hljs-title.class_, +.runtime-code-block .hljs-section { + color: #1f6fb2; + font-weight: 600; +} + +.runtime-code-block .hljs-operator, +.runtime-code-block .hljs-punctuation, +.runtime-code-block .hljs-meta { + color: #8a6350; +} + +.runtime-code-block .hljs-property, +.runtime-code-block .hljs-attr, +.runtime-code-block .hljs-selector-attr, +.runtime-code-block .hljs-selector-class, +.runtime-code-block .hljs-selector-id { + color: #b25577; +} + +.runtime-code-block .hljs-name, +.runtime-code-block .hljs-selector-tag, +.runtime-code-block .hljs-built_in { + color: #22735a; +} + +.runtime-code-block .hljs-addition { + color: #1d7f45; + background: color-mix(in srgb, #1d7f45 12%, transparent); +} + +.runtime-code-block .hljs-deletion { + color: #b31d28; + background: color-mix(in srgb, #b31d28 10%, transparent); +} + +/* ============ 语法高亮颜色(深色主题)============ */ +[data-theme="dark"] .runtime-code-block .hljs-keyword, +[data-theme="dark"] .runtime-code-block .hljs-doctag, +[data-theme="dark"] .runtime-code-block .hljs-template-tag, +[data-theme="dark"] .runtime-code-block .hljs-type { + color: #c792ea; +} + +[data-theme="dark"] .runtime-code-block .hljs-string, +[data-theme="dark"] .runtime-code-block .hljs-regexp, +[data-theme="dark"] .runtime-code-block .hljs-meta .hljs-string { + color: #89d39a; +} + +[data-theme="dark"] .runtime-code-block .hljs-comment, +[data-theme="dark"] .runtime-code-block .hljs-quote { + color: #7f8a91; +} + +[data-theme="dark"] .runtime-code-block .hljs-number, +[data-theme="dark"] .runtime-code-block .hljs-literal, +[data-theme="dark"] .runtime-code-block .hljs-variable, +[data-theme="dark"] .runtime-code-block .hljs-attribute, +[data-theme="dark"] .runtime-code-block .hljs-symbol { + color: #f2b86d; +} + +[data-theme="dark"] .runtime-code-block .hljs-title, +[data-theme="dark"] .runtime-code-block .hljs-title.function_, +[data-theme="dark"] .runtime-code-block .hljs-title.class_, +[data-theme="dark"] .runtime-code-block .hljs-section { + color: #82b8ff; +} + +[data-theme="dark"] .runtime-code-block .hljs-operator, +[data-theme="dark"] .runtime-code-block .hljs-punctuation, +[data-theme="dark"] .runtime-code-block .hljs-meta { + color: #c6a58d; +} + +[data-theme="dark"] .runtime-code-block .hljs-property, +[data-theme="dark"] .runtime-code-block .hljs-attr, +[data-theme="dark"] .runtime-code-block .hljs-selector-attr, +[data-theme="dark"] .runtime-code-block .hljs-selector-class, +[data-theme="dark"] .runtime-code-block .hljs-selector-id { + color: #f08bad; +} + +[data-theme="dark"] .runtime-code-block .hljs-name, +[data-theme="dark"] .runtime-code-block .hljs-selector-tag, +[data-theme="dark"] .runtime-code-block .hljs-built_in { + color: #7fd6b4; +} + +[data-theme="dark"] .runtime-code-block .hljs-addition { + color: #89d39a; + background: color-mix(in srgb, #89d39a 13%, transparent); +} + +[data-theme="dark"] .runtime-code-block .hljs-deletion { + color: #ff8d8d; + background: color-mix(in srgb, #ff8d8d 12%, transparent); +} + +/* Runtime Chat Input */ +.runtime-chat-input { + min-height: 88px; + resize: vertical; + line-height: 1.5; + width: 100%; + padding: 10px 14px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + background: var(--bg-input); + color: var(--text-primary); + font-size: 14px; + outline: none; +} + +.runtime-chat-input:focus { + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.1); +} + +/* ======================================== + Runtime Chat Command Palette + ======================================== */ +.runtime-chat-command-palette { + position: absolute; + bottom: calc(100% + 10px); + left: 0; + right: 0; + z-index: 50; + display: grid; + gap: 4px; + max-height: min(360px, 46vh); + padding: 8px; + background: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + overflow-y: auto; + animation: slideUpFadeIn 0.18s ease-out; +} + +.runtime-chat-command-head { + padding: 4px 8px 2px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-tertiary); +} + +@keyframes slideUpFadeIn { + from { + opacity: 0; + transform: translateY(8px) scale(0.99); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.runtime-chat-command-palette::-webkit-scrollbar { + width: 6px; +} + +.runtime-chat-command-palette::-webkit-scrollbar-thumb { + background: var(--border-primary); + border-radius: 3px; +} + +.runtime-chat-command-item { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(140px, 38%); + align-items: center; + gap: 12px; + width: 100%; + min-height: 56px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.14s ease, background 0.14s ease, transform 0.14s ease; +} + +.runtime-chat-command-item:hover { + background: var(--bg-surface-hover); +} + +.runtime-chat-command-item.active { + border-color: color-mix(in srgb, var(--accent) 26%, var(--border-primary)); + background: color-mix(in srgb, var(--accent) 8%, transparent); + transform: translateX(2px); +} + +.runtime-chat-command-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.runtime-chat-command-name { + font-size: 14px; + font-weight: 600; + color: var(--primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-chat-command-desc { + font-size: 12px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-chat-command-side { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + text-align: right; +} + +.runtime-chat-command-side code { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-family: var(--font-mono); + padding: 2px 6px; + border-radius: 4px; + background: var(--bg-app); + border: 1px solid var(--border-subtle); + color: var(--text-tertiary); +} + +.runtime-chat-command-meta { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + color: var(--text-tertiary); +} + +/* 无子命令时的帮助卡片 */ +.runtime-chat-command-help { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; +} + +.runtime-chat-command-help-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.runtime-chat-command-help-name { + font-size: 14px; + font-weight: 600; + font-family: var(--font-mono); + color: var(--primary); +} + +.runtime-chat-command-help-kicker { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-tertiary); +} + +.runtime-chat-command-help-desc { + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} + +.runtime-chat-command-help-grid { + display: flex; + flex-direction: column; + gap: 6px; +} + +.runtime-chat-command-help-row { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + align-items: center; + gap: 10px; +} + +.runtime-chat-command-help-key { + font-size: 11px; + color: var(--text-tertiary); +} + +.runtime-chat-command-help-val { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-family: var(--font-mono); + padding: 2px 6px; + border-radius: 4px; + background: var(--bg-app); + border: 1px solid var(--border-subtle); + color: var(--text-secondary); +} + +.runtime-chat-command-help-note { + font-size: 11px; + color: var(--text-tertiary); +} + +/* Runtime Images */ +.runtime-chat-image { + display: block; + max-width: min(720px, 100%); + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + margin: 6px 0; + cursor: zoom-in; + transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease; +} + +.runtime-chat-image:hover { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border-color)); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16); + transform: translateY(-1px); +} + +/* Runtime File Cards */ +.runtime-chat-file-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + margin: 6px 0; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-app); + max-width: 360px; +} + +.runtime-chat-file-name { + font-size: 13px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-chat-file-size { + font-size: 11px; + color: var(--text-tertiary); + margin-top: 2px; +} + +/* ======================================== + Connection Setup (Android) + ======================================== */ +.connection-setup-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; + background: var(--bg-app); +} + +.connection-setup { + width: 100%; + max-width: 480px; + background: var(--bg-surface); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + padding: 32px; + box-shadow: var(--shadow-lg); + animation: fadeInUp 0.3s ease-out; +} + +.setup-header { + text-align: center; + margin-bottom: 32px; +} + +.setup-logo { + color: var(--primary); + margin: 0 auto 16px; +} + +.setup-header h2 { + margin: 0 0 8px; + font-size: 24px; + font-weight: 600; + color: var(--text-primary); +} + +.setup-header p { + margin: 0; + font-size: 14px; + color: var(--text-secondary); +} + +.recent-configs { + margin-bottom: 24px; +} + +.recent-configs h3 { + margin: 0 0 12px; + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.config-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.config-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 16px; + background: var(--bg-surface-hover); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + font-size: 14px; + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition: all 0.2s; +} + +.config-item:hover { + background: var(--bg-deep); + border-color: var(--primary); + transform: translateX(4px); +} + +.config-item svg { + flex-shrink: 0; + color: var(--text-tertiary); +} + +.config-item span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.connection-setup form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.connection-setup label { + display: flex; + flex-direction: column; + gap: 8px; +} + +.label-text { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.connection-setup input[type="url"], +.connection-setup input[type="password"] { + width: 100%; + padding: 12px 16px; + background: var(--bg-input); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + font-size: 15px; + color: var(--text-primary); + transition: all 0.2s; +} + +.connection-setup input:focus { + outline: none; + border-color: var(--focus-border); + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.setup-error { + margin: 0; + padding: 12px 16px; + background: var(--status-error-bg); + border: 1px solid var(--status-error); + border-radius: var(--radius-sm); + font-size: 14px; + color: var(--status-error-text); +} + +.connect-button { + width: 100%; + padding: 14px 24px; + background: var(--primary); + border: none; + border-radius: var(--radius-sm); + font-size: 16px; + font-weight: 600; + color: white; + cursor: pointer; + transition: all 0.2s; +} + +.connect-button:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.connect-button:active { + transform: translateY(0); +} + +.setup-hint { + display: flex; + gap: 12px; + padding: 16px; + background: var(--bg-glow); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + margin-top: 8px; +} + +.setup-hint svg { + flex-shrink: 0; + color: var(--primary); + margin-top: 2px; +} + +.setup-hint p { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} + +/* ======================================== + Image Viewer Modal + ======================================== */ +.runtime-image-viewer { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 28px; + background: color-mix(in srgb, #020617 78%, transparent); + backdrop-filter: blur(7px); + cursor: zoom-out; + animation: fadeIn 0.18s ease; +} + +.runtime-image-viewer-stage { + max-width: calc(100vw - 56px); + max-height: calc(100dvh - 128px); + overflow: auto; + padding: 8px; + cursor: default; + touch-action: pan-x pan-y pinch-zoom; +} + +.runtime-image-viewer-figure { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin: 0; + max-width: min(1120px, calc(100vw - 56px)); + max-height: calc(100dvh - 56px); + cursor: default; + transform-origin: center center; +} + +.runtime-image-viewer img, +.runtime-image-viewer-image { + display: block; + max-width: min(1120px, calc(100vw - 56px)); + max-height: calc(100dvh - 104px); + object-fit: contain; + animation: zoomIn 0.3s ease; + border-radius: var(--radius-sm); + background: #020617; + box-shadow: 0 24px 70px rgba(2, 6, 23, 0.46); +} + +.runtime-image-viewer-caption { + max-width: min(720px, calc(100vw - 56px)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: rgba(255, 255, 255, 0.78); + font-size: 12px; + text-align: center; +} + +.runtime-image-viewer-toolbar { + position: fixed; + left: 50%; + bottom: max(16px, env(safe-area-inset-bottom)); + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.58); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.18); + z-index: 10000; +} + +.runtime-image-viewer-toolbar button { + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.14); + color: #ffffff; + font-size: 14px; + font-weight: 700; +} + +.runtime-image-viewer-toolbar button:hover, +.runtime-image-viewer-toolbar button:focus-visible { + background: rgba(255, 255, 255, 0.24); +} + +.image-viewer-close-button, +.runtime-image-viewer-close { + position: fixed; + top: max(16px, env(safe-area-inset-top)); + right: max(16px, env(safe-area-inset-right)); + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 20px; + line-height: 1; + transition: all 0.2s; + z-index: 10000; +} + +.image-viewer-close-button:hover, +.runtime-image-viewer-close:hover { + background: rgba(255, 255, 255, 0.25); + transform: scale(1.1); +} + +.image-viewer-close-button:active, +.runtime-image-viewer-close:active { + transform: scale(0.95); +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes zoomIn { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* ======================================== + Runtime Chat Stage (from webui components.css) + ======================================== */ +.runtime-chat-stage { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: min(260px, 100%); + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font-size: 11px; + line-height: 1.25; + letter-spacing: 0; + text-transform: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.runtime-chat-stage[hidden] { + display: none; +} + +.runtime-chat-stage.is-final { + background: color-mix(in srgb, var(--success) 12%, transparent); + color: var(--success); +} + +.runtime-chat-stage::before { + content: ""; + width: 5px; + height: 5px; + flex: 0 0 auto; + border-radius: 999px; + background: currentColor; + opacity: 0.85; +} + +/* 消息时间戳(用户要求保留;WebUI 无此元素,置于 role 行末尾,小号灰色) */ +.runtime-chat-time { + margin-left: auto; + font-size: 11px; + color: var(--text-tertiary); + letter-spacing: 0; + text-transform: none; + white-space: nowrap; +} diff --git a/apps/undefined-chat/src/test-fixtures.ts b/apps/undefined-chat/src/test-fixtures.ts new file mode 100644 index 00000000..e10b778e --- /dev/null +++ b/apps/undefined-chat/src/test-fixtures.ts @@ -0,0 +1,282 @@ +import { vi } from "vitest"; +import type { + ApiKeyStatus, + AttachmentDownloadResult, + AttachmentPreviewResult, + ChatEvent, + ChatJob, + CommandInfo, + Conversation, + EventStreamSubscription, + HistoryItem, + RuntimeClient, + SubcommandInfo, +} from "./runtime-client/types"; + +export function subcommandInfo( + overrides: Partial = {}, +): SubcommandInfo { + return { + name: "new", + trigger: "/conv new", + description: "新建会话", + args: "[标题]", + usage: "/conv new [标题]", + available: true, + ...overrides, + }; +} + +export function commandInfo(overrides: Partial = {}): CommandInfo { + // trigger / usage / example 默认按 name 派生,避免遗漏导致搜索文本被默认值污染 + const name = overrides.name ?? "help"; + return { + name, + trigger: `/${name}`, + description: "显示帮助", + usage: `/${name}`, + example: `/${name}`, + aliases: [], + aliasTriggers: [], + subcommands: [], + available: true, + ...overrides, + }; +} + +export function conversation( + overrides: Partial = {}, +): Conversation { + return { + id: "default", + title: "默认会话", + titleSource: "temporary", + titleStatus: "temporary", + createdAt: "2026-06-08T10:00:00", + updatedAt: "2026-06-08T10:00:00", + virtualUserId: "webchat", + messageCount: 2, + isRunning: false, + ...overrides, + }; +} + +export function historyItem(overrides: Partial = {}): HistoryItem { + return { + messageId: `msg-${overrides.role ?? "user"}`, + role: "user", + content: "你好", + timestamp: "2026-06-08T10:00:00", + attachments: [], + references: [], + ...overrides, + }; +} + +export function job(overrides: Partial = {}): ChatJob { + return { + jobId: "job-1", + conversationId: "default", + status: "running", + mode: "chat", + createdAt: 1770000000, + updatedAt: 1770000001, + finishedAt: null, + elapsedMs: 1000, + durationMs: null, + currentStage: "thinking", + currentStageDetail: "正在处理", + currentStageStartedAt: 1770000000, + currentStageElapsedMs: 1000, + lastSeq: 0, + error: null, + reply: "", + messages: [], + currentAgentStages: [], + currentToolCalls: [], + historyFinalized: false, + currentTimeline: [], + waitingInput: null, + ...overrides, + }; +} + +export function event(overrides: Partial = {}): ChatEvent { + return { + seq: 1, + event: "stage", + payload: { + job_id: "job-1", + conversation_id: "default", + stage: "thinking", + }, + ...overrides, + }; +} + +export function runtimeClientStub( + overrides: Partial = {}, +): RuntimeClient { + const apiKeyStatus: ApiKeyStatus = { + available: true, + storage: "system-keyring", + degraded: false, + keyPreview: "sk-...test", + detail: "", + }; + const streamSubscription: EventStreamSubscription = { + subscriptionId: "sub-1", + jobId: "job-1", + afterSeq: 0, + }; + const downloadResult: AttachmentDownloadResult = { + status: 200, + ok: true, + savedFileName: "note.txt", + bytesWritten: 12, + mediaType: "text/plain", + body: null, + }; + const previewResult: AttachmentPreviewResult = { + status: 200, + ok: true, + mediaType: "image/png", + bytes: [137, 80, 78, 71], + body: null, + }; + return { + getRuntimeConfig: vi.fn(async () => ({ + runtimeUrl: "http://127.0.0.1:8788", + hasApiKey: true, + })), + saveRuntimeConfig: vi.fn(async (runtimeUrl: string) => ({ + runtimeUrl, + hasApiKey: true, + })), + saveApiKey: vi.fn(async () => apiKeyStatus), + confirmInsecureStorageFallback: vi.fn(async () => ({ + ...apiKeyStatus, + storage: "insecure-file", + degraded: true, + detail: "Insecure storage fallback confirmed for this app session", + })), + loadApiKeyStatus: vi.fn(async () => apiKeyStatus), + probeRuntime: vi.fn(async () => ({ + ok: true, + status: 200, + body: "ok", + })), + listConversations: vi.fn(async () => ({ + conversations: [conversation()], + activeJob: null, + defaultConversationId: "default", + virtualUserId: "webchat", + })), + createConversation: vi.fn(async (title?: string) => + conversation({ + id: title ? "custom" : "new", + title: title || "新会话", + messageCount: 0, + }), + ), + deleteConversation: vi.fn(async () => undefined), + renameConversation: vi.fn(async () => ({ ok: true })), + getHistory: vi.fn(async () => ({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 0, + items: [], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 0, + })), + getHistoryPage: vi.fn(async () => ({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 0, + items: [], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + cursor: null, + total: 0, + })), + getActiveJobs: vi.fn(async () => ({ + job: null, + jobs: [], + })), + sendMessage: vi.fn(async () => job()), + cancelJob: vi.fn(async () => job({ status: "cancelled" })), + listCommands: vi.fn(async () => ({ + commands: [ + { + name: "help", + trigger: "/help", + description: "显示帮助", + usage: "/help", + example: "/help", + aliases: ["h"], + aliasTriggers: ["/h"], + subcommands: [], + available: true, + }, + { + name: "conv", + trigger: "/conv", + description: "管理会话", + usage: "/conv <子命令>", + example: "/conv new 调试", + aliases: [], + aliasTriggers: [], + available: true, + subcommands: [ + { + name: "new", + trigger: "/conv new", + description: "新建会话", + args: "[标题]", + usage: "/conv new [标题]", + available: true, + }, + { + name: "list", + trigger: "/conv list", + description: "列出会话", + args: "", + usage: "/conv list", + available: true, + }, + ], + }, + ], + })), + fetchJobEventsJson: vi.fn(async () => ({ + job: job(), + after: 0, + lastSeq: 0, + events: [], + })), + startJobEventStream: vi.fn(async () => streamSubscription), + stopJobEventStream: vi.fn(async () => undefined), + uploadAttachment: vi.fn(async () => ({ + id: "att-1", + name: "note.txt", + size: 12, + mediaType: "text/plain", + kind: "file", + downloadUrl: "/api/v1/chat/attachments/att-1", + previewUrl: null, + discarded: false, + })), + saveAttachment: vi.fn(async () => downloadResult), + previewAttachment: vi.fn(async () => previewResult), + openHtmlPreview: vi.fn(async () => undefined), + listenRuntimeSse: vi.fn(async () => () => undefined), + ...overrides, + }; +} diff --git a/apps/undefined-chat/src/test-setup.ts b/apps/undefined-chat/src/test-setup.ts new file mode 100644 index 00000000..796ec620 --- /dev/null +++ b/apps/undefined-chat/src/test-setup.ts @@ -0,0 +1,121 @@ +import "@testing-library/jest-dom/vitest"; +import { beforeEach, vi } from "vitest"; +import { LOCALE_STORAGE_KEY } from "./i18n"; + +// 全局锁定 zh-CN:jsdom 默认 navigator.language=en-US 会让 LanguageProvider 回退到 en, +// 破坏现有中文文案断言。每个测试前显式写入 localStorage,确保默认中文; +// 需要测试英文的用例可在测试内自行覆盖(setLocale 或写入 localStorage)。 +beforeEach(() => { + try { + window.localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); + } catch { + // localStorage 不可用时忽略 + } +}); + +// Mock Tauri API +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue({ + os: "linux", + arch: "x86_64", + version: "test", + }), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); + +// jsdom 未实现 scrollIntoView:命令面板键盘导航会调用它,提供 no-op 避免报错。 +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + +// jsdom 未实现 matchMedia:提供基于 window.innerWidth 的轻量实现, +// 让 useMediaQuery 等在测试中可用(支持设置 innerWidth 模拟移动端视口)。 +// 仅解析 max-width,其余查询(如 prefers-color-scheme)返回 false,保持默认浅色主题。 +if (!window.matchMedia) { + type MediaQueryListener = (event: MediaQueryListEvent) => void; + type MutableMediaQueryList = MediaQueryList & { + _setMatches: (matches: boolean) => void; + _syncMatches: (matches: boolean) => void; + }; + const queryLists = new Map(); + const computeMatches = (query: string): boolean => { + const maxWidth = /max-width:\s*(\d+)px/.exec(query); + return maxWidth ? window.innerWidth <= Number(maxWidth[1]) : false; + }; + const makeQueryList = (query: string): MutableMediaQueryList => { + let matches = computeMatches(query); + const listeners = new Set(); + const mql = { + get matches() { + return matches; + }, + media: query, + onchange: null, + addEventListener: ( + _type: string, + listener: EventListenerOrEventListenerObject, + ) => { + listeners.add(listener as MediaQueryListener); + }, + removeEventListener: ( + _type: string, + listener: EventListenerOrEventListenerObject, + ) => { + listeners.delete(listener as MediaQueryListener); + }, + addListener: (listener: MediaQueryListener) => { + listeners.add(listener); + }, + removeListener: (listener: MediaQueryListener) => { + listeners.delete(listener); + }, + dispatchEvent: () => false, + _syncMatches(nextMatches: boolean) { + matches = nextMatches; + }, + _setMatches(nextMatches: boolean) { + if (matches === nextMatches) { + return; + } + matches = nextMatches; + const event = { matches, media: query } as MediaQueryListEvent; + for (const listener of listeners) { + listener(event); + } + mql.onchange?.(event); + }, + } as MutableMediaQueryList; + return mql; + }; + + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string): MediaQueryList => { + const existing = queryLists.get(query); + if (existing) { + existing._syncMatches(computeMatches(query)); + return existing; + } + const created = makeQueryList(query); + queryLists.set(query, created); + return created; + }, + }); + window.addEventListener("resize", () => { + for (const [query, mql] of queryLists) { + mql._setMatches(computeMatches(query)); + } + }); +} + +// jsdom 未实现 URL.createObjectURL/revokeObjectURL:图片附件经 blob URL 渲染需要它们, +// 提供轻量 mock(返回唯一 blob: URL,revoke 为 no-op)。 +if (!URL.createObjectURL) { + let blobUrlCounter = 0; + URL.createObjectURL = vi.fn(() => `blob:mock-${++blobUrlCounter}`); + URL.revokeObjectURL = vi.fn(); +} diff --git a/apps/undefined-chat/src/test-utils.tsx b/apps/undefined-chat/src/test-utils.tsx new file mode 100644 index 00000000..f13e41e4 --- /dev/null +++ b/apps/undefined-chat/src/test-utils.tsx @@ -0,0 +1,32 @@ +import { + type RenderOptions, + type RenderResult, + render, +} from "@testing-library/react"; +import type { ReactElement, ReactNode } from "react"; +import { LOCALE_STORAGE_KEY, LanguageProvider, type Locale } from "./i18n"; + +/** + * 测试包裹器:提供 LanguageProvider,使组件内 useTranslation 可用。 + */ +function LanguageWrapper({ children }: { children: ReactNode }) { + return {children}; +} + +/** + * 渲染并包裹 {@link LanguageProvider},默认锁定 `zh-CN` locale。 + * + * jsdom 默认 `navigator.language=en-US`,LanguageProvider 会回退到 `en`, + * 导致现有中文文案断言失效;故渲染前显式写入 localStorage 锁定语言。 + * 需要测试英文时传入 `locale="en"`。 + * + * 返回值与 `@testing-library/react` 的 `render` 一致(含 rerender,会保留同一 wrapper)。 + */ +export function renderWithProviders( + ui: ReactElement, + options?: Omit, + locale: Locale = "zh-CN", +): RenderResult { + window.localStorage.setItem(LOCALE_STORAGE_KEY, locale); + return render(ui, { wrapper: LanguageWrapper, ...options }); +} diff --git a/apps/undefined-chat/src/theme/ThemeToggle.test.tsx b/apps/undefined-chat/src/theme/ThemeToggle.test.tsx new file mode 100644 index 00000000..461eef19 --- /dev/null +++ b/apps/undefined-chat/src/theme/ThemeToggle.test.tsx @@ -0,0 +1,102 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { renderWithProviders } from "../test-utils"; +import { ThemeToggle } from "./ThemeToggle"; +import * as useThemeModule from "./use-theme"; + +// Mock useTheme hook +vi.mock("./use-theme", () => ({ + useTheme: vi.fn(), +})); + +describe("ThemeToggle", () => { + const mockToggleTheme = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("renders moon icon in light mode", () => { + vi.mocked(useThemeModule.useTheme).mockReturnValue({ + theme: "light", + effectiveTheme: "light", + setTheme: vi.fn(), + toggleTheme: mockToggleTheme, + }); + + renderWithProviders(); + + expect(screen.getByLabelText("切换到暗色模式")).toBeInTheDocument(); + expect(screen.getByLabelText("月亮图标")).toBeInTheDocument(); + }); + + test("renders sun icon in dark mode", () => { + vi.mocked(useThemeModule.useTheme).mockReturnValue({ + theme: "dark", + effectiveTheme: "dark", + setTheme: vi.fn(), + toggleTheme: mockToggleTheme, + }); + + renderWithProviders(); + + expect(screen.getByLabelText("切换到亮色模式")).toBeInTheDocument(); + expect(screen.getByLabelText("太阳图标")).toBeInTheDocument(); + }); + + test("calls toggleTheme on click", async () => { + vi.mocked(useThemeModule.useTheme).mockReturnValue({ + theme: "light", + effectiveTheme: "light", + setTheme: vi.fn(), + toggleTheme: mockToggleTheme, + }); + + renderWithProviders(); + + const button = screen.getByRole("button"); + await userEvent.click(button); + + expect(mockToggleTheme).toHaveBeenCalledOnce(); + }); + + test("button has correct accessibility attributes", () => { + vi.mocked(useThemeModule.useTheme).mockReturnValue({ + theme: "light", + effectiveTheme: "light", + setTheme: vi.fn(), + toggleTheme: mockToggleTheme, + }); + + renderWithProviders(); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("type", "button"); + expect(button).toHaveAttribute("aria-label", "切换到暗色模式"); + expect(button).toHaveAttribute("title", "切换到暗色模式"); + }); + + test("updates icon when theme changes", () => { + vi.mocked(useThemeModule.useTheme).mockReturnValue({ + theme: "light", + effectiveTheme: "light", + setTheme: vi.fn(), + toggleTheme: mockToggleTheme, + }); + + const { rerender } = renderWithProviders(); + expect(screen.getByLabelText("月亮图标")).toBeInTheDocument(); + + // 模拟主题切换 + vi.mocked(useThemeModule.useTheme).mockReturnValue({ + theme: "dark", + effectiveTheme: "dark", + setTheme: vi.fn(), + toggleTheme: mockToggleTheme, + }); + + rerender(); + expect(screen.getByLabelText("太阳图标")).toBeInTheDocument(); + }); +}); diff --git a/apps/undefined-chat/src/theme/ThemeToggle.tsx b/apps/undefined-chat/src/theme/ThemeToggle.tsx new file mode 100644 index 00000000..1b6dd8c3 --- /dev/null +++ b/apps/undefined-chat/src/theme/ThemeToggle.tsx @@ -0,0 +1,68 @@ +/** + * 主题切换按钮组件 + * 显示月亮(亮色模式)或太阳(暗色模式)图标 + */ + +import { useTranslation } from "../i18n"; +import { useTheme } from "./use-theme"; + +export function ThemeToggle() { + const { effectiveTheme, toggleTheme } = useTheme(); + const { t } = useTranslation(); + const toggleLabel = + effectiveTheme === "light" ? t("theme.toDark") : t("theme.toLight"); + + return ( + + ); +} diff --git a/apps/undefined-chat/src/theme/theme-manager.test.ts b/apps/undefined-chat/src/theme/theme-manager.test.ts new file mode 100644 index 00000000..152ce077 --- /dev/null +++ b/apps/undefined-chat/src/theme/theme-manager.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { themeManager } from "./theme-manager"; + +describe("ThemeManager", () => { + let mockMatchMedia: (query: string) => MediaQueryList; + let darkModeListeners: ((event: MediaQueryListEvent) => void)[] = []; + let isDarkMode = false; + + beforeEach(async () => { + // Mock localStorage + const storage: Record = {}; + vi.spyOn(Storage.prototype, "getItem").mockImplementation( + (key: string) => storage[key] || null, + ); + vi.spyOn(Storage.prototype, "setItem").mockImplementation( + (key: string, value: string) => { + storage[key] = value; + }, + ); + + // Mock matchMedia + darkModeListeners = []; + isDarkMode = false; + mockMatchMedia = (query: string) => { + if (query === "(prefers-color-scheme: dark)") { + return { + matches: isDarkMode, + addEventListener: vi.fn((event: string, handler: () => void) => { + if (event === "change") { + darkModeListeners.push(handler); + } + }), + removeEventListener: vi.fn((event: string, handler: () => void) => { + if (event === "change") { + const index = darkModeListeners.indexOf(handler); + if (index > -1) { + darkModeListeners.splice(index, 1); + } + } + }), + } as unknown as MediaQueryList; + } + return {} as MediaQueryList; + }; + vi.stubGlobal("matchMedia", mockMatchMedia); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + test("initializes with system theme by default", () => { + expect(themeManager.getTheme()).toBeDefined(); + expect(themeManager.getEffectiveTheme()).toMatch(/^(light|dark)$/); + }); + + test("setTheme updates theme and persists to localStorage", () => { + themeManager.setTheme("dark"); + + expect(themeManager.getTheme()).toBe("dark"); + expect(themeManager.getEffectiveTheme()).toBe("dark"); + expect(localStorage.getItem("undefined-chat-theme")).toBe("dark"); + }); + + test("setTheme notifies listeners", () => { + const listener = vi.fn(); + const unsubscribe = themeManager.subscribe(listener); + + themeManager.setTheme("dark"); + expect(listener).toHaveBeenCalled(); + + unsubscribe(); + }); + + test("toggleTheme switches between light and dark", () => { + themeManager.setTheme("light"); + + themeManager.toggleTheme(); + expect(themeManager.getTheme()).toBe("dark"); + expect(themeManager.getEffectiveTheme()).toBe("dark"); + + themeManager.toggleTheme(); + expect(themeManager.getTheme()).toBe("light"); + expect(themeManager.getEffectiveTheme()).toBe("light"); + }); + + test("subscribe returns unsubscribe function", () => { + const listener = vi.fn(); + const unsubscribe = themeManager.subscribe(listener); + + themeManager.setTheme("dark"); + expect(listener).toHaveBeenCalled(); + + listener.mockClear(); + unsubscribe(); + themeManager.setTheme("light"); + expect(listener).not.toHaveBeenCalled(); + }); + + test("handles different theme values", () => { + themeManager.setTheme("light"); + expect(themeManager.getEffectiveTheme()).toBe("light"); + + themeManager.setTheme("dark"); + expect(themeManager.getEffectiveTheme()).toBe("dark"); + + themeManager.setTheme("system"); + expect(themeManager.getEffectiveTheme()).toMatch(/^(light|dark)$/); + }); +}); diff --git a/apps/undefined-chat/src/theme/theme-manager.ts b/apps/undefined-chat/src/theme/theme-manager.ts new file mode 100644 index 00000000..c624245b --- /dev/null +++ b/apps/undefined-chat/src/theme/theme-manager.ts @@ -0,0 +1,129 @@ +/** + * 主题管理器 + * 负责主题偏好的持久化、读取和系统偏好监听 + */ + +export type Theme = "light" | "dark" | "system"; +export type EffectiveTheme = "light" | "dark"; + +const STORAGE_KEY = "undefined-chat-theme"; +const DEFAULT_THEME: Theme = "system"; + +class ThemeManager { + private theme: Theme = DEFAULT_THEME; + private effectiveTheme: EffectiveTheme = "light"; + private listeners: Set<() => void> = new Set(); + private mediaQuery: MediaQueryList | null = null; + + constructor() { + this.initialize(); + } + + private initialize(): void { + // 读取持久化的主题偏好 + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; + if (stored === "light" || stored === "dark" || stored === "system") { + this.theme = stored; + } + + // 监听系统主题偏好变化 + if (typeof window !== "undefined" && window.matchMedia) { + this.mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + this.mediaQuery.addEventListener("change", this.handleSystemThemeChange); + } + + // 计算当前有效主题 + this.effectiveTheme = this.computeEffectiveTheme(); + } + + private handleSystemThemeChange = (): void => { + if (this.theme === "system") { + const newEffectiveTheme = this.computeEffectiveTheme(); + if (newEffectiveTheme !== this.effectiveTheme) { + this.effectiveTheme = newEffectiveTheme; + this.notifyListeners(); + } + } + }; + + private computeEffectiveTheme(): EffectiveTheme { + if (this.theme === "light") return "light"; + if (this.theme === "dark") return "dark"; + + // system 模式:跟随系统偏好 + if (this.mediaQuery?.matches) { + return "dark"; + } + return "light"; + } + + private notifyListeners(): void { + for (const listener of this.listeners) { + listener(); + } + } + + /** + * 获取当前主题偏好 + */ + getTheme(): Theme { + return this.theme; + } + + /** + * 获取当前有效主题(light 或 dark) + */ + getEffectiveTheme(): EffectiveTheme { + return this.effectiveTheme; + } + + /** + * 设置主题偏好 + */ + setTheme(theme: Theme): void { + this.theme = theme; + localStorage.setItem(STORAGE_KEY, theme); + + const newEffectiveTheme = this.computeEffectiveTheme(); + if (newEffectiveTheme !== this.effectiveTheme) { + this.effectiveTheme = newEffectiveTheme; + } + + this.notifyListeners(); + } + + /** + * 切换主题(light <-> dark) + * 注意:仅在 light 和 dark 之间切换,不涉及 system + */ + toggleTheme(): void { + const newTheme = this.effectiveTheme === "light" ? "dark" : "light"; + this.setTheme(newTheme); + } + + /** + * 订阅主题变化 + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * 清理资源 + */ + destroy(): void { + if (this.mediaQuery) { + this.mediaQuery.removeEventListener( + "change", + this.handleSystemThemeChange, + ); + } + this.listeners.clear(); + } +} + +// 单例实例 +export const themeManager = new ThemeManager(); diff --git a/apps/undefined-chat/src/theme/use-theme.test.ts b/apps/undefined-chat/src/theme/use-theme.test.ts new file mode 100644 index 00000000..cad1943d --- /dev/null +++ b/apps/undefined-chat/src/theme/use-theme.test.ts @@ -0,0 +1,96 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { useTheme } from "./use-theme"; + +// Mock themeManager +vi.mock("./theme-manager", () => { + let theme: "light" | "dark" | "system" = "system"; + let effectiveTheme: "light" | "dark" = "light"; + const listeners = new Set<() => void>(); + + return { + themeManager: { + getTheme: () => theme, + getEffectiveTheme: () => effectiveTheme, + setTheme: vi.fn((newTheme: "light" | "dark" | "system") => { + theme = newTheme; + if (newTheme === "light") effectiveTheme = "light"; + if (newTheme === "dark") effectiveTheme = "dark"; + for (const listener of listeners) { + listener(); + } + }), + toggleTheme: vi.fn(() => { + theme = effectiveTheme === "light" ? "dark" : "light"; + effectiveTheme = effectiveTheme === "light" ? "dark" : "light"; + for (const listener of listeners) { + listener(); + } + }), + subscribe: vi.fn((callback: () => void) => { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; + }), + }, + }; +}); + +describe("useTheme", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns current theme state", () => { + const { result } = renderHook(() => useTheme()); + + expect(result.current.theme).toBeDefined(); + expect(result.current.effectiveTheme).toBeDefined(); + expect(typeof result.current.setTheme).toBe("function"); + expect(typeof result.current.toggleTheme).toBe("function"); + }); + + test("setTheme updates theme", async () => { + const { result } = renderHook(() => useTheme()); + + result.current.setTheme("dark"); + + await waitFor(() => { + expect(result.current.effectiveTheme).toBe("dark"); + }); + }); + + test("toggleTheme switches between themes", async () => { + const { result } = renderHook(() => useTheme()); + + const initialTheme = result.current.effectiveTheme; + result.current.toggleTheme(); + + await waitFor(() => { + expect(result.current.effectiveTheme).not.toBe(initialTheme); + }); + }); + + test("subscribes to theme changes", async () => { + renderHook(() => useTheme()); + const { themeManager } = await import("./theme-manager"); + + expect(themeManager.subscribe).toHaveBeenCalled(); + }); + + test("returns consistent structure across re-renders", () => { + const { result, rerender } = renderHook(() => useTheme()); + + const initialTheme = result.current.theme; + const initialEffectiveTheme = result.current.effectiveTheme; + + rerender(); + + // 结构保持一致 + expect(result.current.theme).toBe(initialTheme); + expect(result.current.effectiveTheme).toBe(initialEffectiveTheme); + expect(typeof result.current.setTheme).toBe("function"); + expect(typeof result.current.toggleTheme).toBe("function"); + }); +}); diff --git a/apps/undefined-chat/src/theme/use-theme.ts b/apps/undefined-chat/src/theme/use-theme.ts new file mode 100644 index 00000000..77314e2a --- /dev/null +++ b/apps/undefined-chat/src/theme/use-theme.ts @@ -0,0 +1,39 @@ +/** + * React hook for theme management + * 使用 useSyncExternalStore 订阅主题变化,与现有 chat-store 保持一致 + */ + +import { useSyncExternalStore } from "react"; +import { type EffectiveTheme, type Theme, themeManager } from "./theme-manager"; + +interface UseThemeReturn { + /** 用户设置的主题偏好(light | dark | system) */ + theme: Theme; + /** 当前有效主题(light | dark) */ + effectiveTheme: EffectiveTheme; + /** 设置主题偏好 */ + setTheme: (theme: Theme) => void; + /** 切换主题(light <-> dark) */ + toggleTheme: () => void; +} + +export function useTheme(): UseThemeReturn { + const theme = useSyncExternalStore( + (callback) => themeManager.subscribe(callback), + () => themeManager.getTheme(), + () => themeManager.getTheme(), + ); + + const effectiveTheme = useSyncExternalStore( + (callback) => themeManager.subscribe(callback), + () => themeManager.getEffectiveTheme(), + () => themeManager.getEffectiveTheme(), + ); + + return { + theme, + effectiveTheme, + setTheme: (newTheme: Theme) => themeManager.setTheme(newTheme), + toggleTheme: () => themeManager.toggleTheme(), + }; +} diff --git a/apps/undefined-chat/src/utils/attachment.test.ts b/apps/undefined-chat/src/utils/attachment.test.ts new file mode 100644 index 00000000..ae21d0d6 --- /dev/null +++ b/apps/undefined-chat/src/utils/attachment.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import type { Attachment } from "../runtime-client/types"; +import { isImageAttachment } from "./attachment"; + +function attachment(overrides: Partial = {}): Attachment { + return { + id: "att-1", + name: "file", + size: 0, + mediaType: "application/octet-stream", + kind: "file", + downloadUrl: null, + previewUrl: null, + discarded: false, + ...overrides, + }; +} + +describe("isImageAttachment", () => { + it("returns true when kind is image regardless of mediaType", () => { + expect( + isImageAttachment( + attachment({ kind: "image", mediaType: "application/octet-stream" }), + ), + ).toBe(true); + }); + + it("returns true for image/* media types", () => { + expect( + isImageAttachment(attachment({ kind: "file", mediaType: "image/png" })), + ).toBe(true); + expect( + isImageAttachment(attachment({ kind: "file", mediaType: "image/jpeg" })), + ).toBe(true); + expect( + isImageAttachment(attachment({ kind: "file", mediaType: "image/gif" })), + ).toBe(true); + expect( + isImageAttachment(attachment({ kind: "file", mediaType: "image/webp" })), + ).toBe(true); + expect( + isImageAttachment( + attachment({ kind: "file", mediaType: "image/svg+xml" }), + ), + ).toBe(true); + }); + + it("returns true when both kind and mediaType indicate an image", () => { + expect( + isImageAttachment(attachment({ kind: "image", mediaType: "image/png" })), + ).toBe(true); + }); + + it("returns false for non-image media types", () => { + expect( + isImageAttachment( + attachment({ kind: "file", mediaType: "application/pdf" }), + ), + ).toBe(false); + expect( + isImageAttachment(attachment({ kind: "file", mediaType: "text/plain" })), + ).toBe(false); + expect( + isImageAttachment(attachment({ kind: "file", mediaType: "video/mp4" })), + ).toBe(false); + expect( + isImageAttachment(attachment({ kind: "file", mediaType: "audio/mpeg" })), + ).toBe(false); + }); + + it("returns false for empty or unknown mediaType when kind is not image", () => { + expect(isImageAttachment(attachment({ kind: "file", mediaType: "" }))).toBe( + false, + ); + expect( + isImageAttachment(attachment({ kind: "unknown", mediaType: "unknown" })), + ).toBe(false); + }); + + it("does not match media types that merely contain 'image' but do not start with 'image/'", () => { + expect( + isImageAttachment( + attachment({ kind: "file", mediaType: "application/x-image" }), + ), + ).toBe(false); + }); +}); diff --git a/apps/undefined-chat/src/utils/attachment.ts b/apps/undefined-chat/src/utils/attachment.ts new file mode 100644 index 00000000..e6f8c7cf --- /dev/null +++ b/apps/undefined-chat/src/utils/attachment.ts @@ -0,0 +1,10 @@ +import type { Attachment } from "../runtime-client/types"; + +/** + * 判断附件是否为图片(按粗分类 kind 或 MIME media_type)。 + */ +export function isImageAttachment(attachment: Attachment): boolean { + return ( + attachment.kind === "image" || attachment.mediaType.startsWith("image/") + ); +} diff --git a/apps/undefined-chat/src/utils/file-icon.test.ts b/apps/undefined-chat/src/utils/file-icon.test.ts new file mode 100644 index 00000000..e7f0a50a --- /dev/null +++ b/apps/undefined-chat/src/utils/file-icon.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { getFileIcon } from "./file-icon"; + +describe("getFileIcon", () => { + it.each([ + ["image/png", "IMG"], + ["video/mp4", "VID"], + ["audio/mp3", "AUD"], + ["text/plain", "TXT"], + ["application/pdf", "PDF"], + ["application/zip", "ZIP"], + ["application/x-tar", "ZIP"], + ["application/json", "DAT"], + ["application/xml", "DAT"], + ["application/yaml", "DAT"], + ["application/octet-stream", "FILE"], + ["", "FILE"], + ])("%s → %s", (mediaType, expected) => { + expect(getFileIcon(mediaType)).toBe(expected); + }); +}); diff --git a/apps/undefined-chat/src/utils/file-icon.ts b/apps/undefined-chat/src/utils/file-icon.ts new file mode 100644 index 00000000..9fd55b7d --- /dev/null +++ b/apps/undefined-chat/src/utils/file-icon.ts @@ -0,0 +1,18 @@ +/** + * 根据 MIME 类型返回文件图标文本(附件卡片与图片加载失败降级共用)。 + */ +export function getFileIcon(mediaType: string): string { + if (mediaType.startsWith("image/")) return "IMG"; + if (mediaType.startsWith("video/")) return "VID"; + if (mediaType.startsWith("audio/")) return "AUD"; + if (mediaType.startsWith("text/")) return "TXT"; + if (mediaType.includes("pdf")) return "PDF"; + if (mediaType.includes("zip") || mediaType.includes("tar")) return "ZIP"; + if ( + mediaType.includes("json") || + mediaType.includes("xml") || + mediaType.includes("yaml") + ) + return "DAT"; + return "FILE"; +} diff --git a/apps/undefined-chat/src/utils/file-size.test.ts b/apps/undefined-chat/src/utils/file-size.test.ts new file mode 100644 index 00000000..d8fe5fcf --- /dev/null +++ b/apps/undefined-chat/src/utils/file-size.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { formatFileSize } from "./file-size"; + +describe("formatFileSize", () => { + it("formats zero bytes", () => { + expect(formatFileSize(0)).toBe("0 B"); + }); + + it("formats negative bytes as 0 B", () => { + expect(formatFileSize(-100)).toBe("0 B"); + }); + + it("formats bytes less than 1KB", () => { + expect(formatFileSize(1)).toBe("1 B"); + expect(formatFileSize(100)).toBe("100 B"); + expect(formatFileSize(1023)).toBe("1023 B"); + }); + + it("formats kilobytes", () => { + expect(formatFileSize(1024)).toBe("1 KB"); + expect(formatFileSize(1536)).toBe("1.5 KB"); + expect(formatFileSize(10240)).toBe("10 KB"); + expect(formatFileSize(1024 * 999)).toBe("999 KB"); + }); + + it("formats megabytes", () => { + expect(formatFileSize(1024 * 1024)).toBe("1 MB"); + expect(formatFileSize(1024 * 1024 * 1.5)).toBe("1.5 MB"); + expect(formatFileSize(1024 * 1024 * 10)).toBe("10 MB"); + expect(formatFileSize(1024 * 1024 * 999)).toBe("999 MB"); + }); + + it("formats gigabytes", () => { + expect(formatFileSize(1024 * 1024 * 1024)).toBe("1 GB"); + expect(formatFileSize(1024 * 1024 * 1024 * 2.5)).toBe("2.5 GB"); + expect(formatFileSize(1024 * 1024 * 1024 * 10)).toBe("10 GB"); + }); + + it("rounds to one decimal place", () => { + expect(formatFileSize(1536)).toBe("1.5 KB"); + expect(formatFileSize(1638)).toBe("1.6 KB"); // 1638 / 1024 = 1.6 + expect(formatFileSize(1024 * 1024 * 1.55)).toBe("1.6 MB"); + }); +}); diff --git a/apps/undefined-chat/src/utils/file-size.ts b/apps/undefined-chat/src/utils/file-size.ts new file mode 100644 index 00000000..53b8d3eb --- /dev/null +++ b/apps/undefined-chat/src/utils/file-size.ts @@ -0,0 +1,20 @@ +/** + * 格式化文件大小为人类可读的字符串 + * @param bytes 字节数 + * @returns 格式化的文件大小字符串(如 "1.5 MB") + */ +export function formatFileSize(bytes: number): string { + if (bytes <= 0) { + return "0 B"; + } + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${Math.round((bytes / 1024) * 10) / 10} KB`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${Math.round((bytes / 1024 / 1024) * 10) / 10} MB`; + } + return `${Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10} GB`; +} diff --git a/apps/undefined-chat/tests/e2e/README.md b/apps/undefined-chat/tests/e2e/README.md new file mode 100644 index 00000000..99fe6c75 --- /dev/null +++ b/apps/undefined-chat/tests/e2e/README.md @@ -0,0 +1,66 @@ +# jsdom 集成测试 + +`tests/e2e/` 目录使用 Vitest + Testing Library + jsdom 运行 App 级流程测试。它们验证 React 状态、Runtime client mock、Tauri API mock 和用户交互,不是真实 Tauri WebView、浏览器或 Android 端到端测试。 + +## 测试文件 + +当前 6 个文件、共 50 个用例: + +```text +tests/e2e/ +├── connection-setup.test.tsx # Runtime 配置与 API Key 表单 +├── conversation-management.test.tsx # 会话创建、切换、删除 +├── message-sending.test.tsx # 消息发送、草稿、运行中限制 +├── attachment-upload.test.tsx # 附件队列、上传状态、错误处理 +├── command-execution.test.tsx # 斜杠命令和键盘补全 +└── history-loading.test.tsx # 历史加载和分页 +``` + +所有文件统一通过 `src/test-utils.tsx` 的 `renderWithProviders()` 挂载,由全局 `src/test-setup.ts` 锁定 `zh-CN` 区域(jsdom 默认 `navigator.language=en-US` 会让 `LanguageProvider` 回退到 en)。 + +## 运行 + +```bash +npm run test:e2e +npm run test:e2e -- history-loading.test.tsx +npm run test:unit +npm run test:all +npm run check +``` + +`npm run check` 还会运行 Biome、TypeScript、cargo fmt/check/test。 + +## 覆盖范围 + +已覆盖: + +- 初始连接配置、保存配置、显式不安全存储降级。 +- 会话创建、切换、删除确认和移动端侧栏基本行为。 +- 消息发送、运行中禁发、空消息拦截、草稿隔离。 +- 附件选择、上传中/成功/失败状态、发送时附件 ID。 +- 命令面板打开、过滤、方向键、Tab/Enter 补全、Escape 关闭。 +- 历史初始加载、时间顺序、加载更早消息、缓存、错误态。 +- App 级单测覆盖移动端动态视口抽屉、新建后关闭抽屉、图片附件预览进入应用内查看器,以及模态层打开时阻断全局快捷键穿透。 + +## 不覆盖的真实平台风险 + +jsdom 不能覆盖: + +- Tauri WebView 真实窗口行为和 HTML preview window close/destroy 清理。 +- Android `content://` provider 实读、权限生命周期和大文件上传。 +- 软键盘、安全区、刘海屏和真实触控目标。 +- 后台/前台生命周期恢复和系统暂停 SSE 的行为。 +- 原生 keyring/Stronghold 在不同桌面发行版或移动端的可用性。 +- 浏览器/设备截图回归、Tab 焦点顺序和性能预算。 + +这些需要 Playwright/真机 smoke 或发布前手动 checklist 补充。 + +## 编写建议 + +- 统一用 `src/test-utils.tsx` 的 `renderWithProviders()` 挂载,不要自行拼装 Provider 树或单独设置区域。 +- 区域默认锁定 `zh-CN`(由全局 `src/test-setup.ts` 设置),断言中文文案;需要验证英文时在用例内显式切换。 +- 优先使用 `getByRole`、`getByLabelText` 等语义查询。 +- 使用 `src/test-fixtures.ts` 的 `runtimeClientStub` mock Runtime 行为,避免测试依赖网络。 +- 使用 `await screen.findBy...` 或 `waitFor` 等待异步状态。 +- 测试文件中 `beforeEach` 调用 `vi.resetAllMocks()`。 +- 对滚动、blob URL、matchMedia、Tauri dialog 等 jsdom 缺失能力,优先在 `src/test-setup.ts` 提供集中 mock。 diff --git a/apps/undefined-chat/tests/e2e/attachment-upload.test.tsx b/apps/undefined-chat/tests/e2e/attachment-upload.test.tsx new file mode 100644 index 00000000..83832589 --- /dev/null +++ b/apps/undefined-chat/tests/e2e/attachment-upload.test.tsx @@ -0,0 +1,339 @@ +import { open } from "@tauri-apps/plugin-dialog"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { App } from "../../src/App"; +import { createTauriRuntimeClient } from "../../src/runtime-client/tauri"; +import { runtimeClientStub } from "../../src/test-fixtures"; +import { renderWithProviders } from "../../src/test-utils"; + +vi.mock("../../src/runtime-client/tauri", () => ({ + createTauriRuntimeClient: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); + +describe("E2E: Attachment Upload", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("通过原生文件选择器添加附件", async () => { + vi.mocked(open).mockResolvedValue("/home/user/document.pdf"); + const client = runtimeClientStub({ + uploadAttachment: vi.fn(async () => ({ + id: "att-123", + name: "document.pdf", + size: 102400, + mediaType: "application/pdf", + kind: "file", + downloadUrl: "/api/v1/chat/attachments/att-123", + previewUrl: null, + discarded: false, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 点击添加附件按钮 + const attachBtn = screen.getByRole("button", { name: "添加附件" }); + await userEvent.click(attachBtn); + + // 验证原生对话框被调用 + expect(open).toHaveBeenCalledWith({ + multiple: false, + directory: false, + title: "选择附件", + pickerMode: "document", + fileAccessMode: "copy", + }); + + // 验证上传 API 被调用 + await waitFor(() => { + expect(client.uploadAttachment).toHaveBeenCalledWith({ + filePath: "/home/user/document.pdf", + }); + }); + + // 附件应该出现在附件队列中 + expect(await screen.findByText("document.pdf")).toBeInTheDocument(); + }); + + test("显示上传中状态", async () => { + vi.mocked(open).mockResolvedValue("/home/user/large-file.zip"); + const client = runtimeClientStub({ + uploadAttachment: vi.fn( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + id: "att-456", + name: "large-file.zip", + size: 10485760, + mediaType: "application/zip", + kind: "file", + downloadUrl: "/api/v1/chat/attachments/att-456", + previewUrl: null, + discarded: false, + }), + 1000, + ); + }), + ), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 添加附件 + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + + // 应该立即显示上传中状态 + expect(await screen.findByText("large-file.zip")).toBeInTheDocument(); + + // 等待上传完成 + await waitFor( + () => { + expect(client.uploadAttachment).toHaveBeenCalled(); + }, + { timeout: 2000 }, + ); + }); + + test("显示上传错误", async () => { + vi.mocked(open).mockResolvedValue("/home/user/invalid.bin"); + const client = runtimeClientStub({ + uploadAttachment: vi.fn(async () => { + throw new Error("文件类型不支持"); + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + + // 等待上传尝试 + await waitFor( + () => { + expect(client.uploadAttachment).toHaveBeenCalled(); + }, + { timeout: 2000 }, + ); + + // 应该显示文件名(即使上传失败,附件队列也会显示) + expect(await screen.findByText("invalid.bin")).toBeInTheDocument(); + }); + + test("移除附件", async () => { + vi.mocked(open).mockResolvedValue("/home/user/temp.txt"); + const client = runtimeClientStub({ + uploadAttachment: vi.fn(async () => ({ + id: "att-temp", + name: "temp.txt", + size: 100, + mediaType: "text/plain", + kind: "file", + downloadUrl: "/api/v1/chat/attachments/att-temp", + previewUrl: null, + discarded: false, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 添加附件 + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + + // 等待附件出现 + expect(await screen.findByText("temp.txt")).toBeInTheDocument(); + + // 移除附件(使用 aria-label 查找) + const removeBtn = screen.getByLabelText("移除 temp.txt"); + await userEvent.click(removeBtn); + + // 附件应该被移除 + await waitFor(() => { + expect(screen.queryByText("temp.txt")).not.toBeInTheDocument(); + }); + }); + + test("阻止在附件上传中时发送消息", async () => { + vi.mocked(open).mockResolvedValue("/home/user/uploading.png"); + const client = runtimeClientStub({ + uploadAttachment: vi.fn( + () => + new Promise(() => { + // 永不 resolve,模拟长时间上传 + }), + ), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 添加附件(开始上传) + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + await screen.findByText("uploading.png"); + + // 输入消息 + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "测试消息"); + + // 尝试发送 + const sendBtn = screen.getByRole("button", { name: "发送" }); + await userEvent.click(sendBtn); + + // 应该显示错误 + expect(await screen.findByText("附件仍在上传")).toBeInTheDocument(); + + // sendMessage 不应该被调用 + expect(client.sendMessage).not.toHaveBeenCalled(); + }); + + test("阻止发送包含上传失败附件的消息", async () => { + vi.mocked(open).mockResolvedValue("/home/user/error.bin"); + const client = runtimeClientStub({ + uploadAttachment: vi.fn(async () => { + throw new Error("上传失败"); + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 添加附件(上传失败) + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + + // 等待上传尝试 + await waitFor( + () => { + expect(client.uploadAttachment).toHaveBeenCalled(); + }, + { timeout: 2000 }, + ); + + // 附件应该出现 + expect(await screen.findByText("error.bin")).toBeInTheDocument(); + + // 输入消息 + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "测试消息"); + + // 尝试发送(发送按钮应该被禁用或发送被阻止) + const sendBtn = screen.getByRole("button", { name: "发送" }); + + // 由于附件状态为 error,发送可能被阻止 + // sendMessage 不应该被调用(测试逻辑) + await userEvent.click(sendBtn); + + // 等待一小段时间确认没有发送 + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(client.sendMessage).not.toHaveBeenCalled(); + }); + + test("发送消息时包含附件 ID", async () => { + vi.mocked(open).mockResolvedValue("/home/user/report.pdf"); + const client = runtimeClientStub({ + uploadAttachment: vi.fn(async () => ({ + id: "att-report-123", + name: "report.pdf", + size: 51200, + mediaType: "application/pdf", + kind: "file", + downloadUrl: "/api/v1/chat/attachments/att-report-123", + previewUrl: null, + discarded: false, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 添加附件 + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + await screen.findByText("report.pdf"); + + // 等待上传完成 + await waitFor(() => { + expect(client.uploadAttachment).toHaveBeenCalled(); + }); + + // 输入消息并发送 + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "这是报告"); + await userEvent.click(screen.getByRole("button", { name: "发送" })); + + // 验证消息包含附件 ID + await waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith({ + conversationId: "default", + message: { + text: "这是报告", + attachmentIds: ["att-report-123"], + references: [], + }, + }); + }); + }); + + test("用户取消文件选择时不触发上传", async () => { + vi.mocked(open).mockResolvedValue(null); + const client = runtimeClientStub(); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + + // 应该不调用上传 API + await waitFor(() => { + expect(client.uploadAttachment).not.toHaveBeenCalled(); + }); + }); + + test("附件在发送后被清空", async () => { + vi.mocked(open).mockResolvedValue("/home/user/note.txt"); + const client = runtimeClientStub(); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 添加附件 + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + await screen.findByText("note.txt"); + + // 发送消息 + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "附件测试"); + await userEvent.click(screen.getByRole("button", { name: "发送" })); + + // 附件应该被清空 + await waitFor(() => { + expect(screen.queryByText("note.txt")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/undefined-chat/tests/e2e/command-execution.test.tsx b/apps/undefined-chat/tests/e2e/command-execution.test.tsx new file mode 100644 index 00000000..ab679a58 --- /dev/null +++ b/apps/undefined-chat/tests/e2e/command-execution.test.tsx @@ -0,0 +1,333 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { App } from "../../src/App"; +import { createTauriRuntimeClient } from "../../src/runtime-client/tauri"; +import { commandInfo, runtimeClientStub } from "../../src/test-fixtures"; +import { renderWithProviders } from "../../src/test-utils"; + +vi.mock("../../src/runtime-client/tauri", () => ({ + createTauriRuntimeClient: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); + +// 命令名(如 "/help")在面板里既出现在命令名标题、也出现在右侧用法 code 中, +// 与 WebUI 一致。测试中统一用命令名 span(.runtime-chat-command-name)精确定位。 +const NAME_SELECTOR = ".runtime-chat-command-name"; +const findCmd = (label: string) => + screen.findByText(label, { selector: NAME_SELECTOR }); +const getCmd = (label: string) => + screen.getByText(label, { selector: NAME_SELECTOR }); +const queryCmd = (label: string) => + screen.queryByText(label, { selector: NAME_SELECTOR }); + +describe("E2E: Command Execution", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("通过斜杠快捷键打开命令面板", async () => { + const client = runtimeClientStub({ + listCommands: vi.fn(async () => ({ + commands: [ + commandInfo({ name: "help", description: "显示帮助信息" }), + commandInfo({ name: "clear", description: "清空会话历史" }), + commandInfo({ name: "model", description: "切换模型" }), + ], + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 在输入框中输入 / + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "/"); + + // 命令面板应该打开 + expect(await findCmd("/help")).toBeInTheDocument(); + expect(screen.getByText("显示帮助信息")).toBeInTheDocument(); + }); + + test("过滤命令列表", async () => { + const client = runtimeClientStub({ + listCommands: vi.fn(async () => ({ + commands: [ + commandInfo({ name: "help", description: "显示帮助信息" }), + commandInfo({ name: "history", description: "查看历史记录" }), + commandInfo({ name: "model", description: "切换模型" }), + commandInfo({ name: "clear", description: "清空会话" }), + ], + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "/h"); + + // 应该只显示名称以 h 开头的命令 + expect(await findCmd("/help")).toBeInTheDocument(); + expect(getCmd("/history")).toBeInTheDocument(); + expect(queryCmd("/model")).not.toBeInTheDocument(); + expect(queryCmd("/clear")).not.toBeInTheDocument(); + }); + + test("使用键盘导航命令列表", async () => { + const client = runtimeClientStub({ + listCommands: vi.fn(async () => ({ + commands: [ + commandInfo({ name: "help", description: "显示帮助" }), + commandInfo({ name: "history", description: "历史记录" }), + commandInfo({ name: "model", description: "切换模型" }), + ], + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "/"); + + await findCmd("/help"); + + // 按下方向键导航 + await userEvent.keyboard("{ArrowDown}"); + await userEvent.keyboard("{ArrowDown}"); + + // 验证导航状态(通过 aria-selected) + const modelItem = (await findCmd("/model")).closest( + '[role="option"]', + ) as HTMLElement; + expect(modelItem?.getAttribute("aria-selected")).toBe("true"); + }); + + test("选择命令后填充到输入框", async () => { + const client = runtimeClientStub({ + listCommands: vi.fn(async () => ({ + commands: [commandInfo({ name: "help", description: "显示帮助" })], + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入") as HTMLTextAreaElement; + await userEvent.type(input, "/"); + + const helpCmd = await findCmd("/help"); + await userEvent.click(helpCmd); + + // 无参数命令选择后不追加空格(与 WebUI 一致) + expect(input.value).toBe("/help"); + + // 命令面板应该关闭 + await waitFor(() => { + expect(screen.queryByText("显示帮助")).not.toBeInTheDocument(); + }); + }); + + test("Enter 键选择当前高亮的命令", async () => { + const client = runtimeClientStub({ + listCommands: vi.fn(async () => ({ + commands: [ + commandInfo({ name: "help", description: "显示帮助" }), + commandInfo({ name: "clear", description: "清空会话" }), + ], + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入") as HTMLTextAreaElement; + await userEvent.type(input, "/"); + + await findCmd("/help"); + + // 按下向下键选择第二个命令 + await userEvent.keyboard("{ArrowDown}"); + + // 按 Enter 选择 + await userEvent.keyboard("{Enter}"); + + // 应该填充第二个命令(无参数命令不追加空格) + expect(input.value).toBe("/clear"); + }); + + test("Escape 键关闭命令面板", async () => { + const client = runtimeClientStub({ + listCommands: vi.fn(async () => ({ + commands: [commandInfo({ name: "help", description: "显示帮助" })], + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "/"); + + await findCmd("/help"); + + // 按 Escape 关闭 + await userEvent.keyboard("{Escape}"); + + // 命令面板应该关闭(只检查描述文本) + await waitFor(() => { + expect(screen.queryByText("显示帮助")).not.toBeInTheDocument(); + }); + }); + + test("删除斜杠后关闭命令面板", async () => { + const client = runtimeClientStub({ + listCommands: vi.fn(async () => ({ + commands: [commandInfo({ name: "help", description: "显示帮助" })], + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "/h"); + + await findCmd("/help"); + + // 删除所有字符 + await userEvent.keyboard("{Backspace}{Backspace}"); + + // 命令面板应该关闭 + await waitFor(() => { + expect(queryCmd("/help")).not.toBeInTheDocument(); + }); + }); + + test("没有匹配的命令时关闭命令面板", async () => { + const client = runtimeClientStub({ + listCommands: vi.fn(async () => ({ + commands: [ + commandInfo({ name: "help", description: "显示帮助" }), + commandInfo({ name: "clear", description: "清空会话" }), + ], + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "/xyz"); + + // 无匹配项时命令面板不展示任何候选 + await waitFor(() => { + expect(queryCmd("/help")).not.toBeInTheDocument(); + expect(queryCmd("/clear")).not.toBeInTheDocument(); + }); + }); + + test("发送命令消息", async () => { + const client = runtimeClientStub({ + listCommands: vi.fn(async () => ({ + commands: [commandInfo({ name: "help", description: "显示帮助" })], + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "/"); + + const helpCmd = await findCmd("/help"); + await userEvent.click(helpCmd); + + // 选择后输入框是 "/help",发送时会 trim + const sendBtn = screen.getByRole("button", { name: "发送" }); + await userEvent.click(sendBtn); + + // 验证发送命令消息 + await waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith({ + conversationId: "default", + message: { + text: "/help", + attachmentIds: [], + references: [], + }, + }); + }); + }); + + test("命令面板在不同会话间保持独立", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [ + { id: "conv-1", title: "会话一" }, + { id: "conv-2", title: "会话二" }, + ], + activeJob: null, + defaultConversationId: "conv-1", + virtualUserId: "webchat", + })), + listCommands: vi.fn(async () => ({ + commands: [commandInfo({ name: "help", description: "显示帮助" })], + })), + getHistory: vi.fn(async ({ conversationId }) => ({ + conversationId, + virtualUserId: "webchat", + permission: "superadmin", + count: 0, + items: [], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 0, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入") as HTMLTextAreaElement; + + // 在会话一打开命令面板 + await userEvent.type(input, "/h"); + await findCmd("/help"); + + // 切换到会话二 + await userEvent.click(screen.getByRole("button", { name: /会话二/ })); + + // 命令面板应该关闭,输入框清空 + await waitFor(() => { + expect(queryCmd("/help")).not.toBeInTheDocument(); + expect(input.value).toBe(""); + }); + }); +}); diff --git a/apps/undefined-chat/tests/e2e/connection-setup.test.tsx b/apps/undefined-chat/tests/e2e/connection-setup.test.tsx new file mode 100644 index 00000000..28cfefaa --- /dev/null +++ b/apps/undefined-chat/tests/e2e/connection-setup.test.tsx @@ -0,0 +1,240 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { App } from "../../src/App"; +import { createTauriRuntimeClient } from "../../src/runtime-client/tauri"; +import { runtimeClientStub } from "../../src/test-fixtures"; +import { renderWithProviders } from "../../src/test-utils"; + +vi.mock("../../src/runtime-client/tauri", () => ({ + createTauriRuntimeClient: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); + +describe("E2E: Connection Setup Flow", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("显示连接配置界面当没有配置时", async () => { + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => null), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + expect(await screen.findByText("连接到 Runtime")).toBeInTheDocument(); + expect(screen.getByLabelText("Runtime URL")).toBeInTheDocument(); + expect(screen.getByLabelText("API Key")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "保存并连接" })).toBeInTheDocument(); + }); + + test("使用默认 URL 预填充输入框", async () => { + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => null), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + const urlInput = (await screen.findByLabelText( + "Runtime URL", + )) as HTMLInputElement; + expect(urlInput.value).toBe("http://127.0.0.1:8788"); + }); + + test("保存配置并在连接后引导应用", async () => { + const client = runtimeClientStub({ + getRuntimeConfig: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + runtimeUrl: "http://localhost:8788", + hasApiKey: true, + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + const urlInput = await screen.findByLabelText("Runtime URL"); + const keyInput = screen.getByLabelText("API Key"); + const connectBtn = screen.getByRole("button", { name: "保存并连接" }); + + await userEvent.clear(urlInput); + await userEvent.type(urlInput, "http://localhost:8788"); + await userEvent.type(keyInput, "sk-test-key-12345"); + await userEvent.click(connectBtn); + + await waitFor(() => { + expect(client.saveRuntimeConfig).toHaveBeenCalledWith( + "http://localhost:8788", + ); + expect(client.saveApiKey).toHaveBeenCalledWith("sk-test-key-12345"); + }); + + // 验证引导流程 + expect(client.probeRuntime).toHaveBeenCalled(); + expect(client.listConversations).toHaveBeenCalled(); + expect(client.getActiveJobs).toHaveBeenCalled(); + expect(client.listCommands).toHaveBeenCalled(); + + // 验证进入主界面 + expect(await screen.findByRole("log", { name: "消息" })).toBeInTheDocument(); + }); + + test("在 URL 和 API Key 都必填时阻止提交", async () => { + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => null), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + const keyInput = await screen.findByLabelText("API Key"); + const connectBtn = screen.getByRole("button", { name: "保存并连接" }); + + // 清空 URL,尝试提交 + const urlInput = screen.getByLabelText("Runtime URL"); + await userEvent.clear(urlInput); + + await userEvent.click(connectBtn); + + // 表单应该被验证阻止,不会调用 API + expect(client.saveRuntimeConfig).not.toHaveBeenCalled(); + + // 输入 URL 但不输入 Key + await userEvent.type(urlInput, "http://localhost:8788"); + await userEvent.click(connectBtn); + + // API Key 也是必填的 + expect(client.saveApiKey).not.toHaveBeenCalled(); + }); + + test("支持不安全存储降级选项", async () => { + const client = runtimeClientStub({ + getRuntimeConfig: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + runtimeUrl: "http://localhost:8788", + hasApiKey: true, + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByText("连接到 Runtime"); + + const urlInput = screen.getByLabelText("Runtime URL"); + const keyInput = screen.getByLabelText("API Key"); + const fallbackCheckbox = screen.getByLabelText("允许不安全存储降级"); + const connectBtn = screen.getByRole("button", { name: "保存并连接" }); + + await userEvent.clear(urlInput); + await userEvent.type(urlInput, "http://localhost:8788"); + await userEvent.type(keyInput, "sk-test"); + await userEvent.click(fallbackCheckbox); + await userEvent.click(connectBtn); + + await waitFor(() => { + expect(client.confirmInsecureStorageFallback).toHaveBeenCalledOnce(); + expect(client.saveApiKey).toHaveBeenCalledWith("sk-test"); + }); + }); + + test("显示配置保存错误", async () => { + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => null), + saveRuntimeConfig: vi.fn(async () => { + throw new Error("网络连接失败"); + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + const urlInput = await screen.findByLabelText("Runtime URL"); + const keyInput = screen.getByLabelText("API Key"); + const connectBtn = screen.getByRole("button", { name: "保存并连接" }); + + await userEvent.clear(urlInput); + await userEvent.type(urlInput, "http://invalid-url:9999"); + await userEvent.type(keyInput, "sk-test"); + await userEvent.click(connectBtn); + + expect(await screen.findByText("网络连接失败")).toBeInTheDocument(); + }); + + test("已有配置时可以通过设置按钮重新打开配置面板", async () => { + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => ({ + runtimeUrl: "http://127.0.0.1:8788", + hasApiKey: true, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + // 等待应用加载完成 + await screen.findByRole("navigation", { name: "会话" }); + + // 点击设置按钮(使用更具体的选择器) + const settingsBtn = screen + .getAllByTitle("配置 Runtime") + .find((el) => el.tagName === "BUTTON") as HTMLElement; + expect(settingsBtn).toBeDefined(); + await userEvent.click(settingsBtn); + + // 配置面板应该打开,标题不同 + expect(await screen.findByText("Runtime 配置")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "保存并连接" })).toBeInTheDocument(); + + // 应该有关闭按钮(按 title 查找) + const closeBtn = screen + .getAllByTitle("关闭") + .find((el) => el.tagName === "BUTTON") as HTMLElement; + expect(closeBtn).toBeDefined(); + await userEvent.click(closeBtn); + + // 面板关闭 + await waitFor(() => { + expect(screen.queryByText("Runtime 配置")).not.toBeInTheDocument(); + }); + }); + + test("配置面板中 API Key 输入框对已有配置显示占位符", async () => { + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => ({ + runtimeUrl: "http://127.0.0.1:8788", + hasApiKey: true, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 打开设置(使用更具体的选择器) + const settingsBtn = screen + .getAllByTitle("配置 Runtime") + .find((el) => el.tagName === "BUTTON") as HTMLElement; + expect(settingsBtn).toBeDefined(); + await userEvent.click(settingsBtn); + + const keyInput = (await screen.findByLabelText( + "API Key", + )) as HTMLInputElement; + + // 已有配置时显示占位符而不是要求必填 + expect(keyInput.placeholder).toContain("••••"); + expect(keyInput.required).toBe(false); + }); +}); diff --git a/apps/undefined-chat/tests/e2e/conversation-management.test.tsx b/apps/undefined-chat/tests/e2e/conversation-management.test.tsx new file mode 100644 index 00000000..d3af1f24 --- /dev/null +++ b/apps/undefined-chat/tests/e2e/conversation-management.test.tsx @@ -0,0 +1,318 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { App } from "../../src/App"; +import { createTauriRuntimeClient } from "../../src/runtime-client/tauri"; +import { + conversation, + historyItem, + runtimeClientStub, +} from "../../src/test-fixtures"; +import { renderWithProviders } from "../../src/test-utils"; + +vi.mock("../../src/runtime-client/tauri", () => ({ + createTauriRuntimeClient: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); + +describe("E2E: Conversation Management", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("创建新会话", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [conversation({ id: "default", title: "默认会话" })], + activeJob: null, + defaultConversationId: "default", + virtualUserId: "webchat", + })), + createConversation: vi.fn(async () => + conversation({ id: "new", title: "新会话", messageCount: 0 }), + ), + getHistory: vi + .fn() + .mockResolvedValueOnce({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 0, + items: [], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 0, + }) + .mockResolvedValueOnce({ + conversationId: "new", + virtualUserId: "webchat", + permission: "superadmin", + count: 0, + items: [], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 0, + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 点击新建按钮 + const createBtn = screen.getByRole("button", { name: "新建" }); + await userEvent.click(createBtn); + + // 验证 API 调用 + await waitFor(() => { + expect(client.createConversation).toHaveBeenCalledOnce(); + }); + + // 新会话应该出现并被选中 + expect(await screen.findByRole("button", { name: /新会话/ })).toBeInTheDocument(); + expect(client.getHistory).toHaveBeenCalledWith({ + conversationId: "new", + limit: 50, + }); + }); + + test("在不同会话间切换", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [ + conversation({ id: "conv-1", title: "会话一" }), + conversation({ id: "conv-2", title: "会话二" }), + conversation({ id: "conv-3", title: "会话三" }), + ], + activeJob: null, + defaultConversationId: "conv-1", + virtualUserId: "webchat", + })), + getHistory: vi + .fn() + .mockResolvedValueOnce({ + conversationId: "conv-1", + virtualUserId: "webchat", + permission: "superadmin", + count: 1, + items: [historyItem({ messageId: "msg-1", content: "消息1" })], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 1, + }) + .mockResolvedValueOnce({ + conversationId: "conv-2", + virtualUserId: "webchat", + permission: "superadmin", + count: 1, + items: [historyItem({ messageId: "msg-2", content: "消息2" })], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 1, + }) + .mockResolvedValueOnce({ + conversationId: "conv-3", + virtualUserId: "webchat", + permission: "superadmin", + count: 1, + items: [historyItem({ messageId: "msg-3", content: "消息3" })], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 1, + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + // 等待初始会话加载 + expect(await screen.findByText("消息1")).toBeInTheDocument(); + + // 切换到会话二 + await userEvent.click(screen.getByRole("button", { name: /会话二/ })); + expect(await screen.findByText("消息2")).toBeInTheDocument(); + expect(screen.queryByText("消息1")).not.toBeInTheDocument(); + + // 切换到会话三 + await userEvent.click(screen.getByRole("button", { name: /会话三/ })); + expect(await screen.findByText("消息3")).toBeInTheDocument(); + expect(screen.queryByText("消息2")).not.toBeInTheDocument(); + + // 切换回会话一 + await userEvent.click(screen.getByRole("button", { name: /会话一/ })); + expect(await screen.findByText("消息1")).toBeInTheDocument(); + + // 验证历史只加载一次(缓存生效) + expect(client.getHistory).toHaveBeenCalledTimes(3); + }); + + test("显示会话列表中的消息计数", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [ + conversation({ id: "conv-1", title: "空会话", messageCount: 0 }), + conversation({ id: "conv-2", title: "有消息", messageCount: 42 }), + ], + activeJob: null, + defaultConversationId: "conv-1", + virtualUserId: "webchat", + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 验证会话标题显示(使用 getAllByText 因为可能在列表和主区域都显示) + expect(screen.getAllByText("空会话").length).toBeGreaterThan(0); + expect(screen.getByText("有消息")).toBeInTheDocument(); + }); + + test("高亮当前选中的会话", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [ + conversation({ id: "conv-1", title: "会话一" }), + conversation({ id: "conv-2", title: "会话二" }), + ], + activeJob: null, + defaultConversationId: "conv-1", + virtualUserId: "webchat", + })), + getHistory: vi.fn(async ({ conversationId }) => ({ + conversationId, + virtualUserId: "webchat", + permission: "superadmin", + count: 0, + items: [], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 0, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const conv1Btn = screen.getByRole("button", { name: /会话一/ }); + const conv2Btn = screen.getByRole("button", { name: /会话二/ }); + + // 初始选中会话一(通过 aria-current="page" 标识) + expect(conv1Btn.getAttribute("aria-current")).toBe("page"); + expect(conv2Btn.getAttribute("aria-current")).toBe(null); + + // 切换到会话二 + await userEvent.click(conv2Btn); + + await waitFor(() => { + expect(conv1Btn.getAttribute("aria-current")).toBe(null); + expect(conv2Btn.getAttribute("aria-current")).toBe("page"); + }); + }); + + test("在移动端视口关闭侧边栏选择会话后", async () => { + // 模拟移动端宽度 + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 375, + }); + + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [ + conversation({ id: "conv-1", title: "会话一" }), + conversation({ id: "conv-2", title: "会话二" }), + ], + activeJob: null, + defaultConversationId: "conv-1", + virtualUserId: "webchat", + })), + getHistory: vi.fn(async ({ conversationId }) => ({ + conversationId, + virtualUserId: "webchat", + permission: "superadmin", + count: 0, + items: [], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 0, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + const nav = await screen.findByRole("navigation", { name: "会话" }); + + // 打开移动端菜单 + const menuBtn = screen.getByRole("button", { name: "打开会话列表" }); + await userEvent.click(menuBtn); + expect(nav.classList.contains("active")).toBe(true); + + // 选择会话二 + await userEvent.click(screen.getByRole("button", { name: /会话二/ })); + + // 侧边栏应该自动关闭(overlay 和侧栏本体都不再 active) + const overlay = document.querySelector(".sidebar-overlay"); + await waitFor(() => { + expect(overlay?.classList.contains("active")).toBe(false); + expect(nav.classList.contains("active")).toBe(false); + }); + + // 恢复窗口宽度 + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1024, + }); + }); + + test("默认选中第一个会话如果没有指定默认会话", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [ + conversation({ id: "first", title: "第一个" }), + conversation({ id: "second", title: "第二个" }), + ], + activeJob: null, + defaultConversationId: "", // 空默认 + virtualUserId: "webchat", + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 应该加载第一个会话的历史 + await waitFor(() => { + expect(client.getHistory).toHaveBeenCalledWith({ + conversationId: "first", + limit: 50, + }); + }); + }); +}); diff --git a/apps/undefined-chat/tests/e2e/history-loading.test.tsx b/apps/undefined-chat/tests/e2e/history-loading.test.tsx new file mode 100644 index 00000000..279d7fd9 --- /dev/null +++ b/apps/undefined-chat/tests/e2e/history-loading.test.tsx @@ -0,0 +1,344 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { App } from "../../src/App"; +import { createTauriRuntimeClient } from "../../src/runtime-client/tauri"; +import { + conversation, + historyItem, + runtimeClientStub, +} from "../../src/test-fixtures"; +import { renderWithProviders } from "../../src/test-utils"; + +vi.mock("../../src/runtime-client/tauri", () => ({ + createTauriRuntimeClient: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); + +describe("E2E: History Loading", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("初始加载会话历史", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [conversation({ id: "default", title: "测试会话" })], + activeJob: null, + defaultConversationId: "default", + virtualUserId: "webchat", + })), + getHistory: vi.fn(async () => ({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 3, + items: [ + historyItem({ messageId: "msg-1", content: "第一条消息", role: "user" }), + historyItem({ messageId: "msg-2", content: "第二条消息", role: "bot" }), + historyItem({ messageId: "msg-3", content: "第三条消息", role: "user" }), + ], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 3, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + // 验证历史消息加载 + expect(await screen.findByText("第一条消息")).toBeInTheDocument(); + expect(screen.getByText("第二条消息")).toBeInTheDocument(); + expect(screen.getByText("第三条消息")).toBeInTheDocument(); + + // 验证 API 调用 + expect(client.getHistory).toHaveBeenCalledWith({ + conversationId: "default", + limit: 50, + }); + }); + + test("按时间顺序显示消息", async () => { + const client = runtimeClientStub({ + getHistory: vi.fn(async () => ({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 3, + items: [ + historyItem({ + messageId: "msg-1", + content: "早上的消息", + timestamp: "2026-06-13T08:00:00", + }), + historyItem({ + messageId: "msg-2", + content: "中午的消息", + timestamp: "2026-06-13T12:00:00", + }), + historyItem({ + messageId: "msg-3", + content: "晚上的消息", + timestamp: "2026-06-13T20:00:00", + }), + ], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 3, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByText("早上的消息"); + + // 验证消息按顺序出现 + const messages = screen.getAllByRole("article"); + expect(messages.length).toBeGreaterThanOrEqual(3); + + // 验证文本内容顺序 + const timeline = screen.getByRole("log", { name: "消息" }); + const text = timeline.textContent || ""; + const morningIndex = text.indexOf("早上的消息"); + const noonIndex = text.indexOf("中午的消息"); + const eveningIndex = text.indexOf("晚上的消息"); + + expect(morningIndex).toBeLessThan(noonIndex); + expect(noonIndex).toBeLessThan(eveningIndex); + }); + + test("支持分页加载更多历史", async () => { + const client = runtimeClientStub({ + getHistory: vi.fn(async () => ({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 2, + items: [ + historyItem({ messageId: "msg-3", content: "消息3" }), + historyItem({ messageId: "msg-4", content: "消息4" }), + ], + limit: 2, + before: null, + hasMore: true, + nextBefore: 1000, + total: 4, + })), + getHistoryPage: vi.fn(async () => ({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 2, + items: [ + historyItem({ messageId: "msg-1", content: "消息1" }), + historyItem({ messageId: "msg-2", content: "消息2" }), + ], + limit: 2, + before: 1000, + hasMore: false, + nextBefore: null, + cursor: null, + total: 4, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + // 等待初始加载 + await screen.findByText("消息3"); + expect(screen.getByText("消息4")).toBeInTheDocument(); + + expect(client.getHistory).toHaveBeenCalledTimes(1); + + await userEvent.click( + screen.getByRole("button", { name: "加载更早消息" }), + ); + + expect(await screen.findByText("消息1")).toBeInTheDocument(); + expect(screen.getByText("消息2")).toBeInTheDocument(); + expect(client.getHistoryPage).toHaveBeenCalledWith("default", 1000, 50); + }); + + test("没有更多历史时隐藏加载按钮", async () => { + const client = runtimeClientStub({ + getHistory: vi.fn(async () => ({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 2, + items: [ + historyItem({ messageId: "msg-1", content: "消息1" }), + historyItem({ messageId: "msg-2", content: "消息2" }), + ], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 2, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByText("消息1"); + + // 不应该有加载更多按钮 + expect( + screen.queryByRole("button", { name: /加载更多|更早/ }), + ).not.toBeInTheDocument(); + }); + + test("空会话显示空状态", async () => { + const client = runtimeClientStub({ + getHistory: vi.fn(async () => ({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 0, + items: [], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 0, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 应该显示空状态或输入框 + const timeline = screen.getByRole("log", { name: "消息" }); + expect(timeline).toBeInTheDocument(); + + // 不应该有消息 + const messages = screen.queryAllByRole("article"); + expect(messages.length).toBe(0); + }); + + test("切换会话时缓存历史", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [ + conversation({ id: "conv-1", title: "会话一" }), + conversation({ id: "conv-2", title: "会话二" }), + ], + activeJob: null, + defaultConversationId: "conv-1", + virtualUserId: "webchat", + })), + getHistory: vi + .fn() + .mockResolvedValueOnce({ + conversationId: "conv-1", + virtualUserId: "webchat", + permission: "superadmin", + count: 1, + items: [historyItem({ messageId: "msg-1", content: "会话一消息" })], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 1, + }) + .mockResolvedValueOnce({ + conversationId: "conv-2", + virtualUserId: "webchat", + permission: "superadmin", + count: 1, + items: [historyItem({ messageId: "msg-2", content: "会话二消息" })], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 1, + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + // 加载会话一 + await screen.findByText("会话一消息"); + + // 切换到会话二 + await userEvent.click(screen.getByRole("button", { name: /会话二/ })); + await screen.findByText("会话二消息"); + + // 切换回会话一 + await userEvent.click(screen.getByRole("button", { name: /会话一/ })); + await screen.findByText("会话一消息"); + + // getHistory 应该只被调用两次(缓存生效) + expect(client.getHistory).toHaveBeenCalledTimes(2); + }); + + test("显示历史加载错误", async () => { + const client = runtimeClientStub({ + getHistory: vi.fn(async () => { + throw new Error("网络连接超时"); + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + // 应该显示错误信息 + expect(await screen.findByText(/网络连接超时/)).toBeInTheDocument(); + }); + + test("显示消息角色(用户/机器人)", async () => { + const client = runtimeClientStub({ + getHistory: vi.fn(async () => ({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 2, + items: [ + historyItem({ messageId: "msg-1", content: "用户消息", role: "user" }), + historyItem({ messageId: "msg-2", content: "机器人回复", role: "bot" }), + ], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 2, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByText("用户消息"); + await screen.findByText("机器人回复"); + + // 验证消息元素包含角色信息(runtime-chat-item ${role},对齐 WebUI 结构) + const messages = screen.getAllByRole("article"); + expect(messages.length).toBeGreaterThanOrEqual(2); + + const userMsg = messages.find((el) => el.textContent?.includes("用户消息")); + const botMsg = messages.find((el) => el.textContent?.includes("机器人回复")); + + expect( + userMsg?.classList.contains("runtime-chat-item") && + userMsg?.classList.contains("user"), + ).toBe(true); + expect( + botMsg?.classList.contains("runtime-chat-item") && + botMsg?.classList.contains("bot"), + ).toBe(true); + }); +}); diff --git a/apps/undefined-chat/tests/e2e/message-sending.test.tsx b/apps/undefined-chat/tests/e2e/message-sending.test.tsx new file mode 100644 index 00000000..3bfe9ad2 --- /dev/null +++ b/apps/undefined-chat/tests/e2e/message-sending.test.tsx @@ -0,0 +1,327 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { App } from "../../src/App"; +import { createTauriRuntimeClient } from "../../src/runtime-client/tauri"; +import { + conversation, + historyItem, + job, + runtimeClientStub, +} from "../../src/test-fixtures"; +import { renderWithProviders } from "../../src/test-utils"; + +vi.mock("../../src/runtime-client/tauri", () => ({ + createTauriRuntimeClient: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), +})); + +describe("E2E: Message Sending", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("发送文本消息并接收回复", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [conversation({ id: "default", title: "测试会话" })], + activeJob: null, + defaultConversationId: "default", + virtualUserId: "webchat", + })), + getHistory: vi + .fn() + .mockResolvedValueOnce({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 0, + items: [], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 0, + }) + .mockResolvedValueOnce({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 2, + items: [ + historyItem({ messageId: "user-1", content: "你好", role: "user" }), + historyItem({ + messageId: "bot-1", + content: "你好!有什么可以帮助你的吗?", + role: "bot", + }), + ], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 2, + }), + sendMessage: vi.fn(async () => + job({ + jobId: "job-1", + conversationId: "default", + status: "running", + }), + ), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 输入消息 + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "你好"); + + // 发送消息 + const sendBtn = screen.getByRole("button", { name: "发送" }); + await userEvent.click(sendBtn); + + // 验证 API 调用 + await waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith({ + conversationId: "default", + message: { + text: "你好", + attachmentIds: [], + references: [], + }, + }); + }); + + // 输入框应该被清空 + expect((input as HTMLTextAreaElement).value).toBe(""); + + // 验证发送后禁用输入(因为 job 在运行) + expect(sendBtn).toBeDisabled(); + }); + + test("阻止在 job 运行时发送消息", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [conversation({ id: "default", title: "测试会话" })], + activeJob: null, + defaultConversationId: "default", + virtualUserId: "webchat", + })), + getActiveJobs: vi.fn(async () => ({ + job: job({ jobId: "job-1", conversationId: "default", status: "running" }), + jobs: [ + job({ jobId: "job-1", conversationId: "default", status: "running" }), + ], + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 输入框和发送按钮应该被禁用 + const input = screen.getByLabelText("消息输入"); + const sendBtn = screen.getByRole("button", { name: "发送" }); + + expect(input).toBeDisabled(); + expect(sendBtn).toBeDisabled(); + }); + + test("阻止发送空消息", async () => { + const client = runtimeClientStub(); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 不输入任何内容,直接点击发送 + const sendBtn = screen.getByRole("button", { name: "发送" }); + await userEvent.click(sendBtn); + + // 不应该调用 sendMessage + expect(client.sendMessage).not.toHaveBeenCalled(); + }); + + test("支持换行输入(Shift+Enter)", async () => { + const client = runtimeClientStub(); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入") as HTMLTextAreaElement; + + // 输入多行文本(Shift+Enter 由组件内部处理,测试直接模拟结果) + await userEvent.type(input, "第一行"); + // 模拟 Shift+Enter(实际按键) + await userEvent.keyboard("{Shift>}{Enter}{/Shift}"); + await userEvent.type(input, "第二行"); + + expect(input.value).toContain("第一行\n第二行"); + + // 不应该触发发送 + expect(client.sendMessage).not.toHaveBeenCalled(); + }); + + test("Enter 键发送消息", async () => { + const client = runtimeClientStub(); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "测试消息{Enter}"); + + await waitFor(() => { + expect(client.sendMessage).toHaveBeenCalledWith({ + conversationId: "default", + message: { + text: "测试消息", + attachmentIds: [], + references: [], + }, + }); + }); + }); + + test("显示发送错误", async () => { + const client = runtimeClientStub({ + sendMessage: vi.fn(async () => { + throw new Error("网络请求失败"); + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "测试"); + + const sendBtn = screen.getByRole("button", { name: "发送" }); + await userEvent.click(sendBtn); + + // 应该显示错误消息 + expect(await screen.findByText("网络请求失败")).toBeInTheDocument(); + }); + + test("草稿在会话间独立保存", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [ + conversation({ id: "conv-1", title: "会话一" }), + conversation({ id: "conv-2", title: "会话二" }), + ], + activeJob: null, + defaultConversationId: "conv-1", + virtualUserId: "webchat", + })), + getHistory: vi.fn(async ({ conversationId }) => ({ + conversationId, + virtualUserId: "webchat", + permission: "superadmin", + count: 0, + items: [], + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 0, + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 在会话一中输入草稿 + const input = screen.getByLabelText("消息输入") as HTMLTextAreaElement; + await userEvent.type(input, "会话一的草稿"); + expect(input.value).toBe("会话一的草稿"); + + // 切换到会话二 + await userEvent.click(screen.getByRole("button", { name: /会话二/ })); + + // 输入框应该清空 + await waitFor(() => { + expect(input.value).toBe(""); + }); + + // 在会话二中输入 + await userEvent.type(input, "会话二的草稿"); + expect(input.value).toBe("会话二的草稿"); + + // 切换回会话一 + await userEvent.click(screen.getByRole("button", { name: /会话一/ })); + + // 应该恢复会话一的草稿 + await waitFor(() => { + expect(input.value).toBe("会话一的草稿"); + }); + }); + + test("发送后清空草稿和附件", async () => { + const client = runtimeClientStub({ + sendMessage: vi.fn(async () => + job({ jobId: "job-1", conversationId: "default", status: "running" }), + ), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 输入消息 + const input = screen.getByLabelText("消息输入") as HTMLTextAreaElement; + await userEvent.type(input, "测试消息"); + + // 发送 + await userEvent.click(screen.getByRole("button", { name: "发送" })); + + // 验证草稿被清空 + await waitFor(() => { + expect(input.value).toBe(""); + }); + }); + + test("没有可用会话时显示错误", async () => { + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [], + activeJob: null, + defaultConversationId: "", + virtualUserId: "webchat", + })), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + + renderWithProviders(); + + await screen.findByRole("navigation", { name: "会话" }); + + // 尝试发送消息 + const input = screen.getByLabelText("消息输入"); + await userEvent.type(input, "测试"); + + const sendBtn = screen.getByRole("button", { name: "发送" }); + await userEvent.click(sendBtn); + + // 应该显示错误 + expect(await screen.findByText("没有可用会话")).toBeInTheDocument(); + }); +}); diff --git a/apps/undefined-chat/tsconfig.json b/apps/undefined-chat/tsconfig.json new file mode 100644 index 00000000..547002f5 --- /dev/null +++ b/apps/undefined-chat/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src"] +} diff --git a/apps/undefined-chat/vite.config.ts b/apps/undefined-chat/vite.config.ts new file mode 100644 index 00000000..b24829ad --- /dev/null +++ b/apps/undefined-chat/vite.config.ts @@ -0,0 +1,22 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 1430, + strictPort: true, + }, + preview: { + port: 4183, + strictPort: true, + }, + build: { + target: ["es2022", "chrome110", "safari16"], + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: "./src/test-setup.ts", + }, +}); diff --git a/apps/undefined-chat/vitest.e2e.config.ts b/apps/undefined-chat/vitest.e2e.config.ts new file mode 100644 index 00000000..2ed6f802 --- /dev/null +++ b/apps/undefined-chat/vitest.e2e.config.ts @@ -0,0 +1,13 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: "./src/test-setup.ts", + include: ["tests/e2e/**/*.test.ts", "tests/e2e/**/*.test.tsx"], + testTimeout: 10000, + }, +}); diff --git a/apps/undefined-console/biome.json b/apps/undefined-console/biome.json index 3ae703df..4efcaa7a 100644 --- a/apps/undefined-console/biome.json +++ b/apps/undefined-console/biome.json @@ -1,20 +1,20 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "files": { - "include": [ - "src/**/*.ts", - "package.json", - "tsconfig.json", - "vite.config.ts", - "src-tauri/tauri.conf.json", - "src-tauri/capabilities/**/*.json" - ] - }, - "linter": { - "rules": { - "correctness": { - "noUnusedVariables": "warn" - } + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "include": [ + "src/**/*.ts", + "package.json", + "tsconfig.json", + "vite.config.ts", + "src-tauri/tauri.conf.json", + "src-tauri/capabilities/**/*.json" + ] + }, + "linter": { + "rules": { + "correctness": { + "noUnusedVariables": "warn" + } + } } - } } diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json index e489f352..8809814a 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.5.1", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.5.1", + "version": "3.6.0", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-http": "^2.3.0" diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json index 1dc9706b..ca28f7bc 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.5.1", + "version": "3.6.0", "type": "module", "scripts": { "tauri": "tauri", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index 92597cb6..5a6975dc 100644 --- a/apps/undefined-console/src-tauri/Cargo.lock +++ b/apps/undefined-console/src-tauri/Cargo.lock @@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "undefined_console" -version = "3.5.1" +version = "3.6.0" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index f0642840..e0bfa215 100644 --- a/apps/undefined-console/src-tauri/Cargo.toml +++ b/apps/undefined-console/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_console" -version = "3.5.1" +version = "3.6.0" description = "Undefined cross-platform management console" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json index 50a82be6..2b255807 100644 --- a/apps/undefined-console/src-tauri/tauri.conf.json +++ b/apps/undefined-console/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Console", - "version": "3.5.1", + "version": "3.6.0", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/biome.json b/biome.json index c48ae9cf..4ff58ea0 100644 --- a/biome.json +++ b/biome.json @@ -1,33 +1,33 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "formatter": { - "indentStyle": "space", - "indentWidth": 4 - }, - "files": { - "include": ["src/Undefined/webui/static/js/**/*.js"], - "ignore": ["src/Undefined/webui/static/js/vendor/**"] - }, - "linter": { - "rules": { - "complexity": { - "noForEach": "off", - "useOptionalChain": "off", - "useArrowFunction": "off" - }, - "correctness": { - "noUnusedVariables": "off" - }, - "style": { - "noUnusedTemplateLiteral": "off", - "noParameterAssign": "off", - "useNumberNamespace": "off", - "useTemplate": "off" - }, - "suspicious": { - "noControlCharactersInRegex": "off", - "noRedundantUseStrict": "off" - } + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "formatter": { + "indentStyle": "space", + "indentWidth": 4 + }, + "files": { + "include": ["src/Undefined/webui/static/js/**/*.js"], + "ignore": ["src/Undefined/webui/static/js/vendor/**"] + }, + "linter": { + "rules": { + "complexity": { + "noForEach": "off", + "useOptionalChain": "off", + "useArrowFunction": "off" + }, + "correctness": { + "noUnusedVariables": "off" + }, + "style": { + "noUnusedTemplateLiteral": "off", + "noParameterAssign": "off", + "useNumberNamespace": "off", + "useTemplate": "off" + }, + "suspicious": { + "noControlCharactersInRegex": "off", + "noRedundantUseStrict": "off" + } + } } - } } diff --git a/code/NagaAgent b/code/NagaAgent index 5b1ca050..eb71318f 160000 --- a/code/NagaAgent +++ b/code/NagaAgent @@ -1 +1 @@ -Subproject commit 5b1ca050c877e4aed9bdaf0777418a377f631176 +Subproject commit eb71318f76f2195bbca3a583458c058cc80e27f8 diff --git a/config.toml.example b/config.toml.example index 423bb847..65fc169a 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1160,6 +1160,9 @@ auto_extract_enabled = false # zh: GitHub API 请求超时(秒)。<=0 回退 10,>60 截断到 60。 # en: GitHub API request timeout (seconds). <=0 falls back to 10, >60 is clamped to 60. request_timeout_seconds = 10.0 +# zh: GitHub API 请求重试次数。仅重试网络/超时异常和 429/5xx 状态码;<0 回退 0,>5 截断到 5。 +# en: GitHub API retry count. Retries only network/timeout errors and 429/5xx statuses; <0 falls back to 0, >5 is clamped to 5. +request_retries = 2 # zh: 自动提取功能的群聊白名单(空=跟随全局 access.allowed_group_ids)。 # en: Group allowlist for auto-extraction (empty = follow global access.allowed_group_ids). auto_extract_group_ids = [] @@ -1234,6 +1237,9 @@ port = 8787 # zh: WebUI 密码(首次启动必须修改默认值;默认密码无法登录)。 # en: WebUI password (must be changed on first run; default password cannot be used to log in). password = "changeme" +# zh: WebUI 启动时是否自动启动机器人进程。 +# en: Auto-start bot when WebUI starts. +autostart_bot = false # zh: 主进程 Runtime API(供 WebUI/外部系统读取探针、记忆检索、AI Chat 使用)。 # en: Runtime API in the main process (for WebUI/external integrations: probes, memory queries, AI chat). @@ -1286,6 +1292,9 @@ bot_name = "Undefined" # zh: ChromaDB 向量数据库存储路径。 # en: ChromaDB vector store path. path = "data/cognitive/chromadb" +# zh: ChromaDB 前台连续处理上限;达到后若有后台/维护任务,会让出一次执行机会。 +# en: Max consecutive foreground Chroma operations before one maintenance/background slot is allowed. +scheduler_foreground_burst = 8 [cognitive.query] # zh: 自动注入上下文时的召回条数。 diff --git a/docs/app.md b/docs/app.md index 94a38945..6267e5f2 100644 --- a/docs/app.md +++ b/docs/app.md @@ -1,6 +1,13 @@ # 跨平台 App 与远程管理 -Undefined 当前的跨平台 App(`apps/undefined-console/`)定位为: +Undefined 当前包含两个 Tauri App: + +- `apps/undefined-console/`:跨平台管理 Console,定位为远程 WebUI 连接器 / 启动器。 +- `apps/undefined-chat/`:原生优先 WebChat 客户端,直接连接 Runtime API。完整说明见 [Undefined Chat](undefined-chat.md)。 + +> 本文第 2–10 节描述的是 **Console** 的连接器 / 启动器定位(不再维护平行后台、轻量打开远程 WebUI)。**Undefined Chat 不同**:它是直连 Runtime API 的原生聊天客户端,自带会话/历史/附件/工具调用渲染、中英双语、HTML 内联渲染与独立预览窗口、移动端适配等完整能力,详见 [Undefined Chat](undefined-chat.md),不要把下文 Console 的“尽量轻”原则套用到 Chat。 + +Undefined Console 定位为: - 保存多个远程连接档案 - 对 Management API / Runtime API 做基础连通性探测 @@ -121,16 +128,17 @@ Android 端仍然走同一套连接模型,但 UI 目标是: 每次 `v*` tag 发布时,Release workflow 计划同步上传: - Python:`wheel` + `sdist` -- Windows:`.exe` + `.msi` -- Linux:`.AppImage` + `.deb` -- macOS:`.dmg`(Intel / Apple Silicon) -- Android:`.apk` +- Console:`Undefined-Console-*` 桌面端和 Android 产物 +- Chat:`Undefined-Chat-*` 桌面端和 Android 产物 + +两个 App 的版本都必须与 `pyproject.toml` 主版本一致。使用 `uv run python scripts/bump_version.py ` 统一更新版本;pre-tag 和 Release workflow 会校验 Console / Chat 的 `package.json`、`package-lock.json`、`Cargo.toml`、`tauri.conf.json` 和 `Cargo.lock`。 ## 9. 本地开发 App 位于: - `apps/undefined-console/` +- `apps/undefined-chat/` 常用命令: @@ -145,6 +153,17 @@ npm run tauri:android:init npm run tauri:android -- --apk ``` +Undefined Chat 使用同一套命令: + +```bash +cd apps/undefined-chat +npm install +npm run check +npm run tauri:dev +npm run tauri:android:init +npm run tauri:android:prepare:check +``` + ### 典型本地调试流程 1. 终端 1: diff --git a/docs/build.md b/docs/build.md index 2a0d903e..6eebb9c9 100644 --- a/docs/build.md +++ b/docs/build.md @@ -5,12 +5,15 @@ - Python 包构建(`wheel` / `sdist`) - `Undefined-webui` 管理控制台的本地开发与验证 - 跨平台连接器 `apps/undefined-console/` 的桌面端 / Android 构建 +- 原生优先聊天客户端 `apps/undefined-chat/` 的桌面端 / Android 构建 +- 手动 GitHub Actions artifact 构建 - GitHub Release 工作流的发布矩阵 > 约定: > > - 浏览器版管理入口仍然是 `uv run Undefined-webui` -> - 桌面端 / Android App 是额外的连接器 / 容器,不替代 `Undefined-webui` +> - 桌面端 / Android Console 是额外的连接器 / 容器,不替代 `Undefined-webui` +> - Undefined Chat 是 Runtime API 的原生聊天客户端,Runtime 仍是会话、历史、任务、附件和事件真源 > - Release 工作流默认覆盖 `Windows / macOS / Linux / Android`,不包含 `iOS` ## 1. 环境准备 @@ -40,7 +43,7 @@ uv run playwright install ### Node.js / Rust / Tauri -如果需要构建跨平台控制台,请额外准备: +如果需要构建跨平台 Console 或 Chat,请额外准备: - Node.js:建议 `22` - Rust stable @@ -107,7 +110,7 @@ uv run pytest tests/test_webui_management_api.py -q uv run ruff check src/Undefined/webui ``` -## 4. 跨平台控制台 App +## 4. 跨平台 App 当前 App 的职责不是维护一套长期独立的第二后台,而是: @@ -117,12 +120,18 @@ uv run ruff check src/Undefined/webui - 自动尝试登录后打开真正的远程 WebUI - 退出 WebUI 后回到主界面 -跨平台客户端位于: +跨平台 Console 位于: ```text apps/undefined-console/ ``` +Undefined Chat 位于: + +```text +apps/undefined-chat/ +``` + ### 安装依赖 ```bash @@ -130,6 +139,13 @@ cd apps/undefined-console npm install ``` +Chat 使用同样的安装方式: + +```bash +cd apps/undefined-chat +npm install +``` + ### Web 壳本地调试 ```bash @@ -177,12 +193,23 @@ npm run tauri:build:no-strip -- --bundles deb ### Android 初始化与构建 +推荐使用仓库脚本统一检查环境、初始化生成工程、构建并收集产物: + +```bash +uv run python scripts/build_native_apps.py check --targets android --android-abi arm64-v8a +uv run python scripts/build_native_apps.py build --product chat --targets android --android-abi arm64-v8a +``` + +脚本只构建当前机器本地可构建的目标,不会自动安装 Android SDK、NDK 或 Rust target。缺少依赖时,`check` 和 `build` 会报告需要补齐的命令。 + 首次或 CI 环境中,先初始化 Android 项目: ```bash npm run tauri:android:init ``` +Undefined Chat 的 `tauri:android:init` 会在 Tauri 生成 `src-tauri/gen/android` 后自动运行 `scripts/prepare_tauri_android.py`,向生成工程注入移动端 HTML 预览使用的 `HtmlPreviewActivity` 和 Android Keystore 安全存储使用的 `SecretPlugin`。`src-tauri/gen/` 仍是生成目录,不提交到仓库。 + 构建 Android: ```bash @@ -231,7 +258,7 @@ sudo apt-get install -y \ - Android NDK - Rust Android targets -CI 当前会优先保证每个 release 至少产出一个可安装 APK;如果后续接入正式签名,可再升级为签名 release APK / AAB。 +Release workflow 会分别为 Console 和 Chat 构建 `arm64-v8a`、`armeabi-v7a`、`x86`、`x86_64` 的签名 release APK。发布环境必须配置 `ANDROID_KEYSTORE_BASE64`、`ANDROID_KEYSTORE_PASSWORD`、`ANDROID_KEY_ALIAS` 和 `ANDROID_KEY_PASSWORD`;缺少任一 secret 时 Android 发布任务会失败。 ## 6. Git Hook 集成 @@ -251,7 +278,7 @@ bash scripts/install_git_hooks.sh 安装后: - `pre-commit` 会继续执行 Python 的 `ruff + mypy` -- 当提交里包含 `apps/undefined-console/`、`src/Undefined/webui/static/js/`、`biome.json`、CI workflow 相关改动时,还会额外执行: +- 当提交里包含 `apps/undefined-console/`、`apps/undefined-chat/`、`src/Undefined/webui/static/js/`、`biome.json`、CI workflow 相关改动时,还会额外执行对应 App 的: - `Biome` 检查 - `TypeScript` 类型检查 - `cargo fmt --check` @@ -262,6 +289,8 @@ bash scripts/install_git_hooks.sh ```bash cd apps/undefined-console npm install +cd ../undefined-chat +npm install ``` ## 7. Release 工作流 @@ -279,12 +308,48 @@ npm install 工作流主要阶段: 1. `verify-python`:校验 tag、构建版本和 `CHANGELOG.md` 最新版本一致,并执行 `ruff`、`mypy`、`pytest`、`uv build`。 -2. `build-tauri-desktop`:构建 Linux `.AppImage` / `.deb`、Windows `.exe` / `.msi`、macOS x64 `.dmg` 和 macOS arm64 `.dmg`。 -3. `build-tauri-android`:构建 Android 通用 `.apk`。 -4. `publish-release`:汇总所有产物并上传 GitHub Release;Release notes 从 `CHANGELOG.md` 最新版本条目生成,不读取 tag 注释。 -5. `publish-pypi`:发布 Python 包到 PyPI。 +2. `verify-native-app`:分别对 Console 和 Chat 执行 `npm run check`。 +3. `build-tauri-desktop`:分别构建 Console / Chat 的 Linux `.AppImage` / `.deb`、Windows `.exe` / `.msi`、macOS x64 `.dmg` 和 macOS arm64 `.dmg`。 +4. `build-tauri-android`:分别构建 Console / Chat 的 Android `.apk`。 +5. `publish-release`:汇总所有产物并上传 GitHub Release;Release notes 从 `CHANGELOG.md` 最新版本条目生成,不读取 tag 注释。 +6. `publish-pypi`:发布 Python 包到 PyPI。 + +## 8. 手动 Artifact 工作流 + +如果只想让 GitHub Actions 编译一次原生 App 并从 workflow run 页面手动下载产物,不创建 GitHub Release,也不发布 PyPI,可以使用: -## 8. Release 产物矩阵 +```text +.github/workflows/manual-native-artifacts.yml +``` + +触发方式: + +- `workflow_dispatch` + +默认输入会构建 `Undefined Chat`: + +- 桌面端:Linux `.AppImage` / `.deb`、Windows `.exe` / `.msi`、macOS x64 / arm64 `.dmg` +- Android:`arm64-v8a` debug APK + +可选输入: + +- `source_ref`:要构建的分支、tag 或 SHA;留空时使用 Actions 页面选择的 ref。 +- `product`:`chat`、`console` 或 `all`。 +- `build_desktop`:是否构建桌面端。 +- `desktop_platform`:`all`、`linux`、`windows` 或 `macos`。 +- `build_android_debug`:是否构建 Android debug APK。 +- `android_abi`:`arm64-v8a`、`armeabi-v7a`、`x86`、`x86_64` 或 `all`。 + +手动 artifact 工作流的边界: + +- 不调用 `gh release create`。 +- 不发布 Python 包到 PyPI。 +- Android 只构建 debug APK,不需要配置 release keystore secrets。 +- artifacts 通过 `actions/upload-artifact` 上传到 workflow run,默认保留 14 天。 + +注意:GitHub 的 `workflow_dispatch` 手动入口通常要求 workflow 文件已存在于默认分支。若该文件只存在于 feature 分支,Actions 页面可能不会显示这个手动 workflow;将该 workflow 文件合入默认分支后,可在运行时通过 `source_ref` 指向任意待构建分支,例如 `feature/chat-app`。 + +## 9. Release 产物矩阵 每次正式 Release 计划上传: @@ -292,20 +357,27 @@ npm install - `wheel` - `sdist` - Windows - - `.exe` - - `.msi` + - `Undefined-Console-*-windows-x64-setup.exe` + - `Undefined-Console-*-windows-x64.msi` + - `Undefined-Chat-*-windows-x64-setup.exe` + - `Undefined-Chat-*-windows-x64.msi` - Linux - - `.AppImage` - - `.deb` + - `Undefined-Console-*-linux-x64.AppImage` + - `Undefined-Console-*-linux-x64.deb` + - `Undefined-Chat-*-linux-x64.AppImage` + - `Undefined-Chat-*-linux-x64.deb` - macOS - - Intel `.dmg` - - Apple Silicon `.dmg` + - `Undefined-Console-*-macos-x64.dmg` + - `Undefined-Console-*-macos-arm64.dmg` + - `Undefined-Chat-*-macos-x64.dmg` + - `Undefined-Chat-*-macos-arm64.dmg` - Android - - `.apk` + - `Undefined-Console-*-android-*-release.apk` + - `Undefined-Chat-*-android-*-release.apk` `iOS` 当前不在发布矩阵内。 -## 9. 推荐的本地构建顺序 +## 10. 推荐的本地构建顺序 如果你准备发布一个版本,建议本地先按以下顺序自检: @@ -323,20 +395,32 @@ uv build ```bash cd apps/undefined-console npm install -npm run check # 代码检查(lint + typecheck + cargo check) +npm run check # 代码检查与测试(lint/typecheck/test/cargo fmt/check/test,具体以 package.json 为准) # 注意:npm run tauri:build 会自动执行 npm run build,无需手动构建前端 + +cd ../undefined-chat +npm install +npm run check # Biome、TypeScript、unit + e2e(jsdom)测试、cargo fmt/check/test ``` 如果本次改动涉及 Android 构建链: +```bash +uv run python scripts/build_native_apps.py check --targets android --android-abi arm64-v8a +uv run python scripts/build_native_apps.py build --product chat --targets android --android-abi arm64-v8a +``` + +如需排查底层 Tauri Android 命令,可继续直接运行: + ```bash npm run tauri:android:init +npm run tauri:android:prepare:check # Undefined Chat 检查生成工程已包含 HtmlPreviewActivity/SecretPlugin npm run tauri:android:debug -- --apk ``` -## 10. 常见建议 +## 11. 常见建议 - 日常开发和首次部署,优先验证 `uv run Undefined-webui` 全流程是否顺畅。 - 改动管理接口时,优先补 `tests/test_webui_management_api.py`。 -- 改动发布矩阵时,务必同步更新 `README.md` 与本文件。 -- 改动 App 构建脚本时,注意同时检查 `apps/undefined-console/package.json` 与 `.github/workflows/release.yml`。 +- 改动发布矩阵时,务必同步更新 `README.md`、[Undefined Chat](undefined-chat.md) 与本文件。 +- 改动 App 构建脚本时,注意同时检查 `apps/undefined-console/package.json`、`apps/undefined-chat/package.json` 与 `.github/workflows/release.yml`。 diff --git a/docs/callable.md b/docs/callable.md index a671570a..ec489922 100644 --- a/docs/callable.md +++ b/docs/callable.md @@ -64,7 +64,7 @@ skills/agents/web_agent/callable.json - **自调用保护**:Agent 不会将自己注册为可调用工具 - **上下文隔离**:每次调用有独立上下文,历史记录按 Agent 分组保存 -- **迭代限制**:受 `max_iterations`(默认 20)约束,防止无限递归 +- **迭代限制**:受 `max_iterations`(默认 1000)约束,防止无限递归 ## 主工具共享(tools/) diff --git a/docs/cognitive-memory.md b/docs/cognitive-memory.md index b48a568e..1b94430e 100644 --- a/docs/cognitive-memory.md +++ b/docs/cognitive-memory.md @@ -5,7 +5,7 @@ 认知记忆系统是 Undefined 的三层分层记忆架构,模拟人类记忆机制: - **短期记忆**(`end.memo`):每轮对话结束自动记录便签备忘,最近 N 条始终注入,保持短期连续性,零配置开箱即用。若本轮由 MessageBatcher 合并多条消息,memo 应概括整个当前输入批次的处理结果。 -- **认知记忆**(`end.observations` + `cognitive.*`):核心层,AI 在每轮对话中主动观察当前输入批次,提取用户/群聊事实及有价值的自身行为(`observations`),经后台史官异步改写为绝对化事件并存入 ChromaDB 向量库,支持语义检索;当对话中出现新信息(偏好、身份、习惯等)时,史官自动合并更新 Markdown 侧写文件,下次对话时注入 prompt。 +- **认知记忆**(`end.observations` + `cognitive.*`):核心层,AI 在每轮对话中只观察当前输入批次,提取有价值的新观察(用户/群聊/第三方事实及有价值的自身行为)。`observations` 不要求与 bot 相关,也不要求长期稳定;历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为新事实来源。后台史官会异步改写为绝对化事件并存入 ChromaDB 向量库,支持语义检索;当对话中出现可沉淀为稳定画像的新信息(偏好、身份、习惯等)时,史官自动合并更新 Markdown 侧写文件,下次对话时注入 prompt。 - **置顶备忘录**(`memory.*`):AI 自身的置顶提醒(自我约束、待办事项,如"用户要求以后用英文回复"),每轮固定注入,支持增删改查。注意:用户事实(偏好、身份、习惯等)不应写入此层,一律通过 `end.observations` 写入认知记忆。 与旧 `end_summaries` 的区别: @@ -64,7 +64,7 @@ AI 调用 `end` 工具结束对话时,只做一次文件落盘(p95 < 5ms) `end` 字段语义: - `memo`:本轮便签纸,留给短期记忆看的简短备注(纯流水账动作写这里),可空。当前输入批次包含多条连续消息时,memo 应概括整批处理结果。 -- `observations`:本轮值得长期留存的观察列表(0..N 条),包括用户/群聊事实和有价值的自身行为(帮谁解决了什么),严格一条一个要点;每条会独立改写与入库。当前输入批次包含多条连续消息时,必须覆盖整批消息中值得留存的信息,不能只记录最后一条。 +- `observations`:本轮从当前输入批次提取的有价值新观察列表(0..N 条),包括用户/群聊/第三方事实和有价值的自身行为(帮谁解决了什么),不要求与 bot 相关,也不要求长期稳定,严格一条一个要点;每条会独立改写与入库。当前输入批次包含多条连续消息时,必须覆盖整批消息中有价值的信息,不能只记录最后一条。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 - 两字段都为空时,仅结束会话,不写认知队列。 ### 后台史官流水线 @@ -175,6 +175,19 @@ MMR_score = λ × relevance(doc, query) − (1 − λ) × max_similarity(doc, se 史官合并侧写时,会在 merge LLM 调用前用当前 observations 作为 query 从 ChromaDB 检索该实体的 top-8 历史事件,注入 merge prompt。这让史官拥有更丰富的上下文来判断哪些特征应保留,避免因本轮未提及而误删长期稳定特征。 +### ChromaDB 前后台调度 + +`cognitive_events` / `cognitive_profiles` 的 ChromaDB `query/upsert` 由进程内单 worker 串行执行,避免多群聊、WebChat 与史官后台同时访问 Chroma collection 时互相踩踏。调度只覆盖真正的 Chroma 读写;embedding 与 rerank 仍走各自模型队列,避免后台向量化长尾占住 Chroma worker。 + +优先级: + +- `foreground_critical`:显式用户工具/API 检索(如 `cognitive.search_events` / `cognitive.search_profiles`)。 +- `foreground`:自动上下文注入、用户触发的侧写展示名同步。 +- `maintenance`:史官合并侧写前的历史查询。 +- `background`:史官事件/侧写向量写入。 + +前台请求优先;连续处理 `scheduler_foreground_burst` 个前台操作后,如果维护/后台队列中有等待任务,会让出一次执行机会,防止史官长期饥饿。日志中的 `chroma_wait` 表示在调度器里等待的时间,`chroma_exec` 表示真正执行 Chroma 调用的时间。 + ### 自动注入场景的 Query 构造 每轮对话自动注入认知记忆(`PromptBuilder -> cognitive.build_context`)时,检索 `query` 的构造规则如下: @@ -185,8 +198,8 @@ MMR_score = λ × relevance(doc, query) − (1 − λ) × max_similarity(doc, se 说明: -- 该规则影响自动注入路径下的语义召回与 rerank(两者使用同一 query)。 -- 同一轮自动检索会复用同一个 query embedding;短时间内的相同 query 还会命中本地短 TTL 缓存,避免 group/private 多作用域场景重复向量化。 +- 单条消息/纯文本仍按一个 query 检索;同 sender 短窗口批次包含多条消息时,会对每条消息分别召回候选,合并去重后再用整批消息合并文本做最终 rerank。 +- 多消息批次中,每条消息 query 会各自生成 query embedding;同一条消息 query 在 group/private 多作用域查询间复用该 embedding。短时间内的相同 query 仍会命中本地短 TTL 缓存。 - 手动工具 `cognitive.search_events` / `cognitive.search_profiles` 仍使用调用方显式传入的 `query`。 ### 自动注入场景的跨会话检索与加权 @@ -195,7 +208,7 @@ MMR_score = λ × relevance(doc, query) − (1 − λ) × max_similarity(doc, se - 群聊:检索所有群聊事件(`request_type=group`),并对当前群命中做额外加权。 - 私聊:检索所有群聊事件 + 当前私聊事件(`request_type=private` 且 `user_id/sender_id` 命中),并对当前私聊命中做额外加权。 -- 最终结果会做去重并按融合分数排序后截断到 `auto_top_k`。 +- 最终结果会做去重;多消息批次启用认知 rerank 且模型可用时,用整批 query 对合并候选重排后截断到 `auto_top_k`,否则按融合分数排序截断。 可调参数(`[cognitive.query]`): @@ -266,6 +279,7 @@ data/cognitive/ | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `path` | str | `data/cognitive/chromadb` | ChromaDB 存储路径 | +| `scheduler_foreground_burst` | int | `8` | Chroma 前台连续处理上限;达到后若有维护/后台任务,会让出一次执行机会(需重启) | ### [cognitive.query] @@ -340,7 +354,7 @@ data/cognitive/ ### 热更新说明 - **支持热更新**:`cognitive.query.*`、`cognitive.historian.poll_interval_seconds`、`cognitive.historian.rewrite_max_retry`、`cognitive.historian.recent_messages_inject_k`、`cognitive.historian.recent_message_line_max_len`、`cognitive.historian.source_message_max_len` -- **需重启**:`cognitive.enabled`、`models.embedding.*`、`models.rerank.*` +- **需重启**:`cognitive.enabled`、`cognitive.vector_store.*`、`models.embedding.*`、`models.rerank.*` 说明: - `knowledge.enable_rerank` 仅控制知识库检索重排。 @@ -352,6 +366,13 @@ data/cognitive/ 认知记忆系统向 AI 暴露 3 个主动工具(toolset 前缀 `cognitive.`): +主动查阅建议: + +- 当当前输入依赖“之前 / 上次 / 刚才 / 那个 / 你记得吗 / 继续 / 按我的习惯 / 我们约定过”等历史事实时,应优先查看已注入的记忆,并按需调用 `cognitive.search_events` 或 `cognitive.get_profile`,避免凭印象回答。 +- 涉及用户偏好、身份、习惯、长期计划、承诺待办、群规、群氛围、历史争议、之前排查过的问题、以前给出的方案或“是否已经做过某事”时,应先查证再回答或行动。 +- 检索词应围绕当前输入批次、目标用户 QQ 号、群号和关键对象组织;不要泛泛搜索,也不要把历史检索当作回收旧任务的许可。 +- 需要核对、修改或删除 `memory.*` 置顶备忘时,应先用 `memory.list` 找到现有条目和 UUID,再执行更新或删除。 + ### cognitive.search_events 搜索历史事件记忆,用于回忆之前发生过的事情(支持时间范围硬过滤 + 时间衰减加权排序)。 diff --git a/docs/configuration.md b/docs/configuration.md index cefbcc04..1eb85423 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -385,6 +385,7 @@ Prompt caching 补充: 用途: - 仅供 `web_agent` 内的 `grok_search` 子工具使用。 +- 工具调用该模型时会注入专用 system prompt:以服务端当前时间作为“今天 / 最新 / 最近”的基准,要求先搜索、使用多组搜索查询或多个搜索工具、禁止编造,并在结果中给出来源。 默认: - `max_tokens=8192` @@ -550,6 +551,8 @@ Prompt caching 补充: | `repeat_cooldown_minutes` | `60` | 复读冷却时间(分钟)。同一内容被复读后,在冷却期内不再重复复读。?和 ? 视为等价。0 = 无冷却 | 整数,≥ 0 | | `inverted_question_enabled` | `false` | 倒问号(复读触发时若消息为问号则发送 ¿) | 布尔 | +复读支持图片等已登记附件:当连续相同内容是 `` 图片引用时,系统会先渲染成真实图片消息再发送,不会把 UID 占位字符串直接发到群里。 + 兼容:历史字段 `[core].keyword_reply_enabled` 仍可读取,建议迁移到 `[easter_egg]`。 --- @@ -789,7 +792,8 @@ Prompt caching 补充: | 字段 | 默认值 | 说明 | 约束/回退 | |---|---:|---|---| | `auto_extract_enabled` | `false` | 是否自动提取 GitHub 仓库链接或 `owner/repo` 仓库 ID | | -| `request_timeout_seconds` | `10.0` | GitHub API 请求超时(秒) | `<=0` 回退 `10`,`>60` 截断到 `60` | +| `request_timeout_seconds` | `10.0` | GitHub API 请求超时(秒),作为显式超时传入,不被 `[network].request_timeout_seconds` 覆盖 | `<=0` 回退 `10`,`>60` 截断到 `60` | +| `request_retries` | `2` | GitHub API 请求重试次数,仅重试网络/超时异常和 `429`/`5xx` 状态码 | `<0` 回退 `0`,`>5` 截断到 `5` | | `auto_extract_group_ids` | `[]` | 功能级群白名单 | 空时跟随全局 access | | `auto_extract_private_ids` | `[]` | 功能级私聊白名单 | 空时跟随全局 access | | `auto_extract_max_items` | `3` | 单条消息最多自动处理几个仓库 | `<=0` 回退 `3`,`>10` 截断到 `10` | @@ -799,6 +803,7 @@ Prompt caching 补充: - 裸 `owner/repo` 会作为 GitHub 仓库 ID 尝试一次 public API 请求;失败时只记录日志,不向会话发送错误消息。 - 仅支持 public 仓库。卡片渲染为图片,包含仓库 ID、作者头像、简介、stars、forks、issues、contributors、watchers、语言、许可证、默认分支和更新时间等信息。 - GitHub API 请求默认复用全局 `[proxy]` 代理设置。 +- 自动提取失败日志会记录异常类型、`repr(exc)` 和堆栈,便于定位代理连接失败等 `str(exc)` 为空的异常。 自动提取调度说明: - 斜杠命令优先级高于自动处理管线;命中命令后直接分发并结束本轮后续处理,不会触发自动提取或 AI 自动回复。命令输入和命令输出会写入历史,供后续 AI 轮次读取。 @@ -839,11 +844,13 @@ Prompt caching 补充: | `url` | `127.0.0.1` | WebUI 监听地址 | | `port` | `8787` | WebUI 端口(1..65535) | | `password` | `changeme` | WebUI 登录密码 | +| `autostart_bot` | `false` | WebUI 启动时是否自动启动机器人进程 | 关键行为: - 默认密码 `changeme` 禁止登录,必须先修改。 -- 未配置或为空时,会回退默认密码并标记为“默认密码模式”。 -- `webui.url/port/password` 修改需重启 WebUI 进程(机器人主进程中也属于重启生效类)。 +- 未配置或为空时,会回退默认密码并标记为”默认密码模式”。 +- `webui.url/port/password/autostart_bot` 修改需重启 WebUI 进程(机器人主进程中也属于重启生效类)。 +- `autostart_bot=true` 时,运行 `uv run Undefined-webui` 会自动拉起 bot 进程,无需手动点击启动按钮;与 WebUI 更新重启后的自动恢复机制(`pending_bot_autostart` marker)互不冲突。 --- @@ -880,6 +887,7 @@ Prompt caching 补充: | 字段 | 默认值 | 说明 | |---|---:|---| | `path` | `data/cognitive/chromadb` | Chroma 存储目录 | +| `scheduler_foreground_burst` | `8` | Chroma 前台连续处理上限;达到后若有维护/后台任务,会让出一次执行机会。需重启 | ### 4.24.3 `[cognitive.query]` @@ -889,7 +897,7 @@ Prompt caching 补充: | `auto_scope_candidate_multiplier` | `2` | 自动注入时每个作用域候选扩展倍数(候选数≈`auto_top_k * multiplier`) | | `auto_current_group_boost` | `1.15` | 群聊自动检索时,当前群命中额外加权系数 | | `auto_current_private_boost` | `1.25` | 私聊自动检索时,当前私聊命中额外加权系数 | -| `enable_rerank` | `true` | 认知检索是否启用 rerank | +| `enable_rerank` | `true` | 认知检索是否启用 rerank;自动注入的多消息批次会先逐条召回,再用整批 query 做最终重排 | | `recent_end_summaries_inject_k` | `30` | 最近 end 摘要注入条数,`0` 禁用 | | `time_decay_enabled` | `true` | 是否启用时间衰减加权 | | `time_decay_half_life_days_auto` | `14.0` | 自动注入场景半衰期 | @@ -1027,6 +1035,7 @@ Prompt caching 补充: - `webui.url` - `webui.port` - `webui.password` +- `webui.autostart_bot` - `api.*`(`enabled/host/port/auth_key/openapi_enabled`) - `memes.blob_dir` - `memes.preview_dir` diff --git a/docs/deployment.md b/docs/deployment.md index a042c25d..6382d7c1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -89,6 +89,17 @@ uv run Undefined-webui > > WebUI 功能详见 [WebUI 使用指南](webui-guide.md)。 +#### 自动启动选项 + +若希望 WebUI 启动后自动拉起机器人进程,可在 `config.toml` 中设置: + +```toml +[webui] +autostart_bot = true +``` + +这样运行 `uv run Undefined-webui` 时会自动启动 bot,无需手动操作。默认为 `false`。 + ### 6. 跨平台与资源路径(重要) - **资源读取**:运行时会优先从运行目录加载同名 `res/...` / `img/...`(便于覆盖),若不存在再使用安装包自带资源;并提供仓库结构兜底查找,因此从任意目录启动也能正常加载提示词与资源文案。 @@ -149,6 +160,7 @@ Undefined-webui > > - 选择 `Undefined`:直接在终端运行机器人,修改 `config.toml` 后重启生效(或依赖热重载能力)。 > - 选择 `Undefined-webui`:启动后访问 WebUI(默认 `http://127.0.0.1:8787`,密码默认 `changeme`;**首次启动必须修改默认密码,默认密码不可登录**;可在 `config.toml` 的 `[webui]` 中修改),在 WebUI 中在线编辑/校验配置,并通过 WebUI 启动/停止机器人进程。 +> - 若希望 `Undefined-webui` 启动后自动拉起机器人进程,可在 `config.toml` 的 `[webui]` 中设置 `autostart_bot = true`(默认 `false`)。 > `Undefined-webui` 会在检测到当前目录缺少 `config.toml` 时,自动从 `config.toml.example` 生成一份,便于直接在 WebUI 中修改。 > 提示:资源文件已随包发布,支持在非项目根目录启动;如需自定义内容,请参考上方源码部署的自定义指南。 diff --git a/docs/management-api.md b/docs/management-api.md index 346ae86b..0b2b8bbc 100644 --- a/docs/management-api.md +++ b/docs/management-api.md @@ -148,6 +148,8 @@ Management API 兼容两套鉴权: - `GET /api/v1/management/logs/files` - `GET /api/v1/management/logs/stream` +`logs` 与 `logs/stream` 支持 `lines` 查询参数,默认返回最近 1000 行,范围 `1..10000`。 + ### 系统信息 - `GET /api/v1/management/system` @@ -168,11 +170,203 @@ Management API 会把运行态相关能力统一代理到主进程 Runtime API - `GET /api/v1/management/runtime/probes/internal` - `GET /api/v1/management/runtime/probes/external` - `GET /api/v1/management/runtime/memory` +- `GET /api/v1/management/runtime/schedules` +- `POST /api/v1/management/runtime/schedules` +- `GET /api/v1/management/runtime/schedules/{task_id}` +- `PATCH /api/v1/management/runtime/schedules/{task_id}` +- `DELETE /api/v1/management/runtime/schedules/{task_id}` - `GET /api/v1/management/runtime/cognitive/events` - `GET /api/v1/management/runtime/cognitive/profiles` - `GET /api/v1/management/runtime/cognitive/profile/{entity_type}/{entity_id}` +- `GET /api/v1/management/runtime/commands` +- `GET /api/v1/management/runtime/commands/{command_name}` +- `GET /api/v1/management/runtime/chat/conversations` +- `POST /api/v1/management/runtime/chat/conversations` +- `PATCH /api/v1/management/runtime/chat/conversations/{conversation_id}` +- `DELETE /api/v1/management/runtime/chat/conversations/{conversation_id}` - `POST /api/v1/management/runtime/chat` - `GET /api/v1/management/runtime/chat/history` +- `DELETE /api/v1/management/runtime/chat/history` +- `GET /api/v1/management/runtime/chat/attachments/capabilities` +- `POST /api/v1/management/runtime/chat/attachments` +- `GET /api/v1/management/runtime/chat/attachments/{attachment_id}` +- `GET /api/v1/management/runtime/chat/attachments/{attachment_id}/preview` +- `POST /api/v1/management/runtime/chat/jobs` +- `POST /api/v1/management/runtime/chat/files` +- `GET /api/v1/management/runtime/chat/jobs/active` +- `GET /api/v1/management/runtime/chat/jobs/{job_id}` +- `GET /api/v1/management/runtime/chat/jobs/{job_id}/events` +- `POST /api/v1/management/runtime/chat/jobs/{job_id}/cancel` + +所有 Runtime 代理端点都会先校验 Management session / access token,再由 WebUI 后端注入 `X-Undefined-API-Key`;浏览器不会接触 Runtime `[api].auth_key`。 + +### `runtime/commands` + +代理 Runtime 斜杠命令 REST 资源,供 WebChat `/` 补全面板和管理端命令浏览使用。 + +- 参数: + - `scope`:默认 `webui`;也可传 `private` / `group`。 + - `q`、`include_hidden`、`include_unavailable`、`sender_id`、`user_id`、`group_id`:原样透传给 Runtime。 +- 校验: + - Management 登录态或 access token 必须有效。 + - 后端注入 `X-Undefined-API-Key`。 +- 响应: + - `200`:命令、别名、子命令、用法、权限和当前 scope 可用性。 + - Runtime 鉴权或配置错误会透传对应错误状态。 + +```http +GET /api/v1/management/runtime/commands?scope=webui&q=help +``` + +### `runtime/chat/conversations` + +管理 WebChat 多对话,支持查询、新建、重命名和删除。 + +- 参数 / Body: + - `GET` 无必填参数。 + - `POST` 可传 `{"title":"..."}`。 + - `PATCH` 传 `{"title":"..."}`。 + - `DELETE` 使用路径参数 `conversation_id`。 +- 校验: + - 删除会话时 Runtime 会检查是否存在运行中或收尾落盘中的 WebChat job。 +- 响应: + - `200` / `201`:会话列表或会话对象。 + - `404`:会话不存在。 + - `409`:仍有 WebChat job 阻塞会话删除。 + +```json +{ + "conversation": { + "id": "legacy-system-42", + "title": "新对话", + "virtual_user_id": 42 + } +} +``` + +### `runtime/chat`、`runtime/chat/history`、`runtime/chat/jobs`、`runtime/chat/jobs/active` + +代理 WebChat 发送、历史分页、后台 job 创建和 active job 查询;`conversation_id` 会在 body 或 query 中原样透传。 + +- 参数 / Body: + - `conversation_id`:可选;不传时使用 Runtime 默认兼容会话。 + - `POST runtime/chat`:`message` 必填,`stream` 可选。 + - `GET runtime/chat/history`:`limit`、`before`、`conversation_id`。 + - `DELETE runtime/chat/history`:`conversation_id`。 + - `POST runtime/chat/jobs`:`message` 必填,支持字符串或结构化 `{text, attachment_ids, references}`,`conversation_id` 可选。 + - `GET runtime/chat/jobs/active`:`conversation_id` 可选。 +- 校验: + - Runtime 会检查 `conversation_id` 是否存在。 + - `POST runtime/chat` 在 `stream=false` 时也会创建并等待 Runtime WebChat job;同一会话运行中或收尾落盘时再次发送会透传 `409`,不同会话可以并发运行。 + - 删除历史时,如果目标会话仍有运行中或收尾落盘中的 job,会透传 `409`。 +- 响应: + - `200` / `202`:聊天结果、历史页、job 快照或 active job。 + - `404`:会话不存在。 + - `409`:job 正在运行或历史尚未完成落盘。 +- 元数据语义: + - `webchat.duration_ms`、`webchat.events`、`webchat.timeline`、`current_tool_calls`、`stage` / `agent_stage` 是 **display-only**,用于刷新后恢复工具 / Agent 展示块、阶段和耗时。 + - 这些 WebChat 展示元数据不是 **AI-context**,不会作为后续 AI 对话上下文注入。 + - 工具 / Agent 输入输出预览由 Runtime 统一脱敏和截断。 + +```json +{ + "message": { + "text": "你好", + "attachment_ids": ["att_123"], + "references": [{"source_message_id": "msg_1", "selected_text": "引用"}] + }, + "conversation_id": "legacy-system-42" +} +``` + +### `runtime/chat/attachments` + +代理 Runtime 原生附件接口。Undefined Chat 原生客户端直连 Runtime 时使用同一组端点;WebUI 浏览器客户端通过 Management 代理访问,浏览器仍不会接触 Runtime API Key。 + +- 参数 / Body: + - `GET runtime/chat/attachments/capabilities`:返回上传上限和 multipart 字段名。 + - `POST runtime/chat/attachments`:`multipart/form-data` 字段 `file` 必填。 + - `GET runtime/chat/attachments/{attachment_id}`:下载附件。 + - `GET runtime/chat/attachments/{attachment_id}/preview`:预览可预览附件。 +- 校验: + - Management 登录态或 access token 必须有效。 + - Runtime 附件大小限制会返回 `413`。 +- 响应: + - `201`:`{ "attachment": {"id": "...", "name": "...", "size": 123, "media_type": "...", "kind": "..."} }`。 + - `400`:缺少 `file` 字段或 multipart body 无效。 + - `413`:文件超过限制。 + +```http +POST /api/v1/management/runtime/chat/attachments +Content-Type: multipart/form-data +``` + +```json +{ + "attachment": { + "id": "att_123", + "name": "report.pdf", + "size": 2048, + "media_type": "application/pdf", + "kind": "file" + } +} +``` + +发送消息时只把 Runtime 返回的 id 放入结构化消息: + +```json +{ + "message": { + "text": "分析附件", + "attachment_ids": ["att_123"], + "references": [] + } +} +``` + +`runtime/chat/files` 仍保留为旧 WebUI 浏览器文件缓存兼容路径;新客户端和新 WebChat 功能应使用 `runtime/chat/attachments`。 + +WebUI 内嵌聊天渲染 `` / `` 图片时会把 Runtime 返回的 `/api/v1/chat/attachments/{uid}/preview` 规整为 Management 代理路径 `/api/runtime/chat/attachments/{uid}/preview`,浏览器不直接请求 Runtime API。普通 Markdown 图片(如 `![Image #1](https://...)`)仍由前端安全 renderer 输出可点击预览的 ``,不走附件代理。 + +### `runtime/chat/jobs/{job_id}/events` + +按 `conversation_id + job_id + seq` 续接 WebChat job 事件,支持 JSON 增量查询和兼容 SSE。 + +- 参数: + - `conversation_id`:可选;传入时必须与 job 所属会话一致。 + - `after`:返回大于该 `seq` 的事件。 + - `format=json`:显式 JSON 查询。 + - `Accept: text/event-stream`:透传 Runtime WebChat SSE。 +- 校验: + - `conversation_id` 不一致时返回 `404`,避免跨会话误续接。 +- 响应: + - 默认 JSON:持久事件、当前顶层 `stage` 快照、当前 `agent_stage` 快照、`current_tool_calls` 和耗时字段。 + - SSE:事件帧和 keep-alive 由 Runtime 透传。 + +```json +{ + "job": { + "job_id": "9c1...", + "status": "running", + "current_stage": "waiting_tools", + "current_tool_calls": [] + }, + "after": 4, + "last_seq": 5, + "events": [ + { "seq": 5, "event": "stage", "payload": { "stage": "waiting_tools" } } + ] +} +``` + +```text +id: 5 +event: stage +data: {"stage":"waiting_tools"} +``` + +定时任务代理用于 WebUI“定时任务”页。Management API 会先校验 WebUI 登录态,再在服务端注入 Runtime API 的 `X-Undefined-API-Key` 请求头;浏览器前端不会直接接触 `[api].auth_key`。 除此之外,Management API 还额外代理了表情包库管理接口: diff --git a/docs/memes.md b/docs/memes.md index a4d8f671..15dcbd07 100644 --- a/docs/memes.md +++ b/docs/memes.md @@ -27,7 +27,7 @@ Undefined 平台自 3.3.0 版本起内置了强大的**全局表情包库**功 存储与索引完成后,AI Agent 会通过内置的 `memes.*` 系列工具使用表情包: - **`memes.search_memes`**:支持关键词检索(基于 SQLite)、语义检索(基于 ChromaDB 向量相似度)与混合检索(Hybrid)。AI 可借此根据当前对话的语境快速寻找最有梗的静态图或 GIF。 -- **发送机制**:使用统一的图片 `uid` 进行索引。系统不仅提供了 `memes.send_meme_by_uid` 让 AI 一键发送表情包,还支持 AI 输出 `` 统一资源标签指令进行图文混排。 +- **发送机制**:使用统一的图片 `uid` 进行索引。系统不仅提供了 `memes.send_meme_by_uid` 让 AI 一键发送表情包,还支持 AI 输出 `` 统一资源标签指令进行图文混排;用户发来的图片在 AI 上下文中也使用同一格式,旧 `` / `[图片 uid=...]` 仅作为兼容格式保留。 - **回复顺序**:只有当本轮明确是纯表情包 / 纯反应图回复时,AI 才应先搜索并发送表情包。凡是需要文字承接、解释、答疑、推进任务或确认操作的场景,都必须先发送必要文字;如果仍想补表情包,再把 `memes.search_memes` / `memes.send_meme_by_uid` 放到后续轮次,避免表情包检索拖慢首条回复体验。 ## 目录结构与配置 diff --git a/docs/message-batching.md b/docs/message-batching.md index fb913fc2..ad410ecb 100644 --- a/docs/message-batching.md +++ b/docs/message-batching.md @@ -30,7 +30,9 @@ `res/prompts/undefined.xml`、`res/prompts/undefined_nagaagent.xml` 与 `res/IMPORTANT/each.md` 均按"当前输入批次"适配:有【连续消息说明】时整批当前 `` 都属于本轮输入;没有连续说明时,当前输入批次退化为最后一条消息。防幽灵任务规则仍然生效,但它只隔离当前输入批次之外的历史消息;「催促/在吗」不等于新任务,历史同类或语义等价操作不得自动重跑(与 each.md 硬性熔断一致)。 -`end.memo` / `end.observations` 也按同一语义适配:当前输入批次包含多条连续消息时,短期 memo 要概括整批处理结果,认知 observations 要覆盖整批消息中值得留存的信息;后台史官收到的 `source_message` 会按时间顺序列出本批所有 ``,不会只取最后一条。 +Prompt 构建顺序按缓存命中友好设计:固定系统提示词、运行环境配置、Skills 元数据和强制规则尽量放在前面;会频繁变化的 memory / cognitive / end 摘要 / history / 当前时间 / 当前输入批次放在后面。`system_prompt_as_user=true` 时,系统块会合并进首条 user,但合并后的文本仍保留这个顺序,且当前输入批次仍在最后。 + +`end.memo` / `end.observations` 也按同一语义适配:当前输入批次包含多条连续消息时,短期 memo 要概括整批处理结果,认知 observations 要覆盖整批消息中有价值的新观察;这些观察不要求与 bot 相关,也不要求长期稳定,但只能来自当前输入批次。历史消息、认知记忆、侧写和最近消息参考只用于消歧,不能作为 observations 的新事实来源。后台史官收到的 `source_message` 会按时间顺序列出本批所有 ``,不会只取最后一条。 > **重要**:当前主提示词按 MessageBatcher 默认开启设计。`[message_batcher].enabled = true` 是推荐和默认配置;如果关闭 batcher,连续补充/修正会退化为逐条独立 AI 调用,提示词中的"当前输入批次"语义可能不再覆盖这些连续消息,需要单独调整提示词或接受旧版逐条触发行为。 diff --git a/docs/openapi.md b/docs/openapi.md index ea5973e0..ecd9771a 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -1,13 +1,13 @@ # Runtime API / OpenAPI 指南 -本文档说明 Undefined 主进程暴露的 Runtime API(含 OpenAPI 文档),以及 WebUI / App 如何通过 Management API 代理安全调用。 +本文档说明 Undefined 主进程暴露的 Runtime API(含 OpenAPI 文档),以及 WebUI / App 如何通过 Management API 代理或原生客户端安全调用。 > 职责边界: > > - **Management API**:配置、日志、Bot 启停、bootstrap probe、远程管理入口 > - **Runtime API**:主进程运行态能力(探针、记忆、认知、AI Chat、表情包库) > -> 如果你想看控制面接口,请同时参考 [Management API 文档](management-api.md)。 +> 如果你想看控制面接口,请同时参考 [Management API 文档](management-api.md)。如果你要接入原生聊天客户端,请参考 [Undefined Chat](undefined-chat.md)。 ## 1. 配置项 @@ -105,6 +105,7 @@ curl http://127.0.0.1:8788/openapi.json | `message_batcher` | `object` | 消息合并器快照(`config` 含 `enabled`/`window_seconds`/`pre_send_seconds`/`speculative_enabled`/`strategy`/`max_window_seconds`/`max_messages_per_batch`/`group_enabled`/`private_enabled`/`allow_cancel_after_send`/`shutdown`;`pending_buckets` 当前缓冲桶数;`buckets[]` 列出每个桶的 `scope`/`sender_id`/`count`/`elapsed_seconds`/`phase`(`typing`/`speculating`/`finalizing`)/`has_inflight`/`has_speculative_dispatch`) | | `memory` | `object` | 长期记忆(`count`:条数) | | `cognitive` | `object` | 认知服务(`enabled`、`queue`) | +| `scheduler` | `object` | 定时任务调度摘要(`available`、`count`、`running`) | | `api` | `object` | Runtime API 配置(`enabled`、`host`、`port`、`openapi_enabled`) | | `skills` | `object` | 技能统计,包含 `tools`、`toolsets`、`agents`、`pipelines`、`commands`、`anthropic_skills` 子对象 | | `models` | `object` | 模型配置;聊天类模型包含 `model_name`、脱敏 `api_url`、`api_mode`、`thinking_enabled`、`thinking_tool_call_compat`、`reasoning_content_replay`、`system_prompt_as_user`、`responses_tool_choice_compat`、`responses_force_stateless_replay`、`prompt_cache_enabled`、`reasoning_enabled`、`reasoning_effort` | @@ -209,6 +210,102 @@ curl http://127.0.0.1:8788/openapi.json - 入库文本和向量索引只使用纯文本 `description + tags + aliases`,不依赖 OCR。 - 后台重跑分析使用两阶段 LLM 管线:先判定,再描述。 +### 定时任务 + +- `GET /api/v1/schedules` +- `POST /api/v1/schedules` +- `GET /api/v1/schedules/{task_id}` +- `PATCH /api/v1/schedules/{task_id}` +- `DELETE /api/v1/schedules/{task_id}` + +`GET /api/v1/schedules` 返回: + +```json +{ + "count": 1, + "items": [ + { + "task_id": "task_daily_report", + "task_name": "每日摘要", + "mode": "self_instruction", + "cron": "0 9 * * *", + "target_type": "group", + "target_id": 123456, + "tool_name": "scheduler.call_self", + "tool_args": { "prompt": "总结昨天群里的待办。" }, + "self_instruction": "总结昨天群里的待办。", + "max_executions": null, + "current_executions": 0, + "next_run_time": "2026-06-07T09:00:00+08:00" + } + ] +} +``` + +创建和更新任务使用相同的 JSON 字段;`PATCH` 只提交需要修改的字段即可。`mode` 支持: + +| mode | 必填字段 | 说明 | +|---|---|---| +| `single` | `tool_name`、`tool_args` | 定时调用单个工具 | +| `multi` | `tools`、`execution_mode` | 定时串行或并行调用多个工具 | +| `self_instruction` | `self_instruction` | 在触发时唤醒 AI 自身执行自然语言指令 | + +通用字段: + +| 字段 | 说明 | +|---|---| +| `task_id` | 创建时可选;不传时自动生成。新建 ID 只允许字母、数字、`_`、`.`、`:`、`-`,最长 96 字符;已有历史任务即使 ID 含中文,也可继续通过详情、更新和删除接口管理 | +| `task_name` | 可选的可读名称 | +| `cron_expression` | 标准 5 段 crontab 表达式;也兼容字段名 `cron` | +| `target_type` | `group` 或 `private`,默认 `group` | +| `target_id` | 可选的发送目标 ID;`PATCH` 时传 `null` 可清空 | +| `max_executions` | 可选的最大执行次数;`PATCH` 时传 `null` 可清空 | + +创建“自我督办”任务: + +```json +{ + "task_id": "task_daily_review", + "task_name": "每日复盘", + "cron_expression": "0 9 * * *", + "mode": "self_instruction", + "self_instruction": "请总结昨天的待办,并提醒我今天优先处理前三项。", + "target_type": "private", + "target_id": 12345678 +} +``` + +创建单工具任务: + +```json +{ + "cron_expression": "*/30 * * * *", + "mode": "single", + "tool_name": "get_current_time", + "tool_args": { "format": "iso" } +} +``` + +创建多工具任务: + +```json +{ + "cron_expression": "0 8 * * 1", + "mode": "multi", + "execution_mode": "serial", + "tools": [ + { "tool_name": "get_current_time", "tool_args": {} }, + { "tool_name": "scheduler.call_self", "tool_args": { "prompt": "生成本周计划。" } } + ] +} +``` + +说明: +- `tool_name`、`tools`、`self_instruction` 互斥;显式传 `mode` 时也必须与对应字段一致。 +- 历史任务如果保存为单个 `scheduler.call_self` 工具调用,列表和详情会按 `self_instruction` 模式返回,并从 `prompt` 回填 `self_instruction`。 +- `tool_args` 必须是 JSON 对象;`tools` 必须是非空数组,最多 20 项。 +- 所有 `/api/v1/schedules*` 路由都遵循 Runtime API 的 `X-Undefined-API-Key` 鉴权。 + ### 认知记忆检索 / 侧写 - `GET /api/v1/cognitive/events?q=...` @@ -219,6 +316,32 @@ curl http://127.0.0.1:8788/openapi.json 说明:这些接口仅在 `cognitive.enabled = true` 时可用,否则返回错误。 +### 斜杠命令元数据 + +- `GET /api/v1/commands` +- `GET /api/v1/commands/{command_name}` + +查询参数: + +- `scope`:`webui` / `private` / `group`,默认 `webui`。`webui` 会按 WebChat 虚拟私聊的实际执行路径过滤:身份仍是 `system#42`,权限主体使用配置中的 `superadmin_qq`。 +- `q`:按命令名、别名、描述、用法和子命令过滤(可选)。 +- `include_hidden`:是否包含 `show_in_help=false` 的命令,默认 `false`。 +- `include_unavailable`:是否返回当前 scope / 权限下不可用的命令,并在 `unavailable_reason` 标明原因,默认 `false`。 +- `sender_id` / `user_id` / `group_id`:当 `scope=private` 或 `scope=group` 时可指定用于权限和可见性策略判断的身份。 + +响应包含 `commands[]`。命令项提供 `name`、`trigger`、`description`、`usage`、`example`、`permission`、`allow_in_private`、`aliases`、`alias_triggers`、`subcommands[]`、`inference`、`available` 和 `unavailable_reason`;子命令项提供 `name`、`trigger`、`description`、`args`、`usage`、`permission`、`allow_in_private`、`available` 和 `unavailable_reason`。WebUI 的 `/` 补全面板使用 `GET /api/v1/commands?scope=webui`,因此展示结果与 WebChat 实际命令分发保持一致。 + +### WebUI AI Chat 导览 + +- [WebUI AI Chat(特殊私聊)](#webui-ai-chat特殊私聊) +- [Event types](#event-types) +- [WebUI AI Chat Conversations](#webui-ai-chat-conversations) +- [WebUI AI Chat 历史记录](#webui-ai-chat-历史记录) +- [WebUI AI Chat Jobs](#webui-ai-chat-jobs) +- [Schemas / Appendix](#schemas--appendix) + +Undefined Chat 使用同一组 Runtime Chat 端点作为权威合同。客户端可以直接访问 Runtime API,并由 Tauri 负责 API Key 注入、安全存储、SSE 订阅、JSON fallback、上传下载和 HTML 预览隔离;不能把本地草稿或前端缓存视为会话/历史真源。 + ### WebUI AI Chat(特殊私聊) - `POST /api/v1/chat` @@ -227,27 +350,311 @@ curl http://127.0.0.1:8788/openapi.json ```json { "message": "你好", + "conversation_id": "legacy-system-42", "stream": false } ``` -- 当 `stream = true` 时,返回 `text/event-stream`(SSE): - - `event: meta`:会话元信息。 - - `event: message`:AI/命令输出片段。 - - `event: done`:最终汇总(与非流式 JSON 结构一致)。 - - 在长时间无内容时会发送 `: keep-alive` 注释帧,防止中间层空闲断连。 +- `stream = false` 返回同步 JSON,但后端同样会创建 WebChat job 并等待其完成;同一会话运行中或收尾落盘时再次发送会返回 `409`,不同会话可以并发运行。 +- 当 `stream = true` 时,Runtime 会创建 WebChat job 并通过旧接口返回 SSE;WebUI 默认使用 job 查询接口续接事件。 +- `conversation_id` 可选;不传时使用兼容默认会话 `legacy-system-42`,传入不存在的会话 ID 时返回 `404`。 +#### Event types + +- `meta`:会话元信息。 +- `stage`:顶层 AI 当前处理阶段,用于 WebUI 在 `AI` 标签后实时显示状态和总已用时;payload 形如 `{"job_id":"...","stage":"waiting_model","elapsed_ms":1234,"detail":"..."}`。 +- `agent_stage`:某个 Agent 内部当前阶段,payload 包含 `webchat_call_id`、`stage`、`stage_elapsed_ms`、`elapsed_ms`、`agent_name`。运行中查询可能返回 `transient=true` 的当前快照;这类快照不写入历史。 +- `tool_start` / `tool_end`:工具开始与结束。 +- `agent_start` / `agent_end`:Agent 调用开始与结束。 +- `requires_action`:预留给未来 Human-in-the-loop 授权、补充参数或确认动作;payload 会做敏感字段遮蔽,并保留在 job events 与 history `webchat.events` 中。 +- `message`:AI/命令最终输出片段。 +- `done`:最终汇总(与非流式 JSON 结构一致)。 +- `error`:任务失败或取消。 + +#### Lifecycle / Display Conventions + +- WebChat 不发布模型 token 级文本增量,也不发布工具参数增量;正文以 `message` 事件展示,工具只按生命周期事件展示。 +- `stage`、`agent_stage`、`webchat.calls`、`webchat.timeline`、`current_tool_calls` 和 `duration_ms` 是 display-only 展示元数据,不作为 AI-context 注入后续对话。 +- 工具结束事件 payload 会尽量带 `duration_ms`。运行中的 job 快照会在 `current_tool_calls` 返回仍在执行的工具 / Agent 及其后端计算的 `duration_ms`。 +- WebUI 每 0.5 秒查询一次;查询间隙只用本地时间临时递增显示,下一次查询后以 Runtime 返回值校准。 +- 并发工具按实际完成时间发布结束事件,LLM tool message 回填仍保持模型要求的原始顺序。 +- 工具 / Agent 事件 payload 由后端补齐调用链字段:`webchat_call_id`、`parent_webchat_call_id`、`depth`、`agent_path`。 +- 工具 / Agent 事件 payload 由后端补齐 `status`,取值通常为 `running`、`done`、`error`、`cancelled`。如果 job 失败或取消时仍有未闭合调用,历史 metadata 会在统一落盘阶段补齐失败 / 取消终态。 +- WebUI 展开工具 / Agent 调用块时,会按输入 / 输出分区展示由 Runtime 生成的 `arguments_preview` 和 `result_preview`。预览会递归遮蔽常见敏感字段并按长度截断;预览不是权限边界,工具实现仍应避免把完整凭证写入结果正文。 +- 工具事件 payload 可能带 `ui_hint`:`webchat_private_send` 表示同一 WebChat 私聊回复已通过 `message` 事件展示;`webchat_end` 表示 `end` 成功结束,工具块可隐藏重复的成功结果。 行为约定: -- 会话固定虚拟用户:`system`(`id = 42`)。 +- AI 视角固定虚拟私聊身份:`system`(`id = 42`)。多对话只隔离 WebChat 历史文件和前端列表,不改变 `RequestContext`、`sender_id`、`user_id`、权限或 AI 看到的用户身份。 - 权限视角:`superadmin`。 - 如果输入以 `/` 开头,按私聊命令分发执行(遵循命令 `allow_in_private` 开放策略)。 +- WebChat 会话持久化在 `data/webchat/conversations/.json`,一个会话一个 JSON 文件。删除会话会删除对应 JSON;不会写入单个全局 conversations JSON。 +- 首次加载会自动把旧版 `data/history/private_42.json` 或运行中的旧历史管理器记录迁移到 `legacy-system-42`,并写入 `data/webchat/legacy_private_42_migrated.json` 迁移标记。只要标记存在就不会重复迁移;即使删除迁移出的会话,也不会再次从旧文件恢复。 + +### WebUI AI Chat Conversations + +- `GET /api/v1/chat/conversations`:列出 WebChat 会话,响应包含 `conversations`、`active_job`、`default_conversation_id` 和 `virtual_user_id`。 +- `POST /api/v1/chat/conversations`:新建会话,Body 可选 `{"title":"..."}`。不传标题时先使用临时标题。 +- `PATCH /api/v1/chat/conversations/{conversation_id}`:重命名会话,Body 为 `{"title":"..."}`。手动标题会标记为 `manual`,后续不会被自动标题覆盖。 +- `DELETE /api/v1/chat/conversations/{conversation_id}`:删除会话 JSON。若目标会话存在运行中或收尾落盘的 WebChat job,返回 `409`。 + +会话标题由后端维护。第一条用户消息写入后会先用首问前若干字符作为临时标题;当该会话同时具备首问和首答时,后端会调标题生成模型用“首问 + 首答”生成正式标题。标题生成带状态和内容哈希校验,避免并发回复、手动重命名或历史变化时把旧标题写回新内容。 ### WebUI AI Chat 历史记录 -- `GET /api/v1/chat/history?limit=200` -- 用于读取虚拟私聊 `system#42` 的历史记录(只读)。 -- 返回中包含 `role/content/timestamp`,用于 WebUI 自动恢复会话视图。 +- `GET /api/v1/chat/history?conversation_id=&limit=50&before=` +- 用于分页读取指定 WebChat 会话的虚拟私聊 `system#42` 历史记录。默认返回最新一页,响应包含 `conversation_id/items/has_more/next_before/total`;客户端继续加载更早历史时把上次返回的 `next_before` 作为 `before` 传回。不传 `conversation_id` 时兼容读取默认会话。 +- 对于由 WebChat job 产生的回复,Bot 历史项可能包含 `webchat` 展示元数据。完整示例见下方折叠块,字段 schema 见 [Schemas / Appendix](#schemas--appendix)。 + +
+展开完整 history 示例 + +```json +{ + "role": "bot", + "content": "最终回复文本,可为空", + "timestamp": "2026-05-30 12:00:00", + "webchat": { + "display_only": true, + "job_id": "9c1...", + "mode": "chat", + "status": "done", + "created_at": 1780123200.0, + "finished_at": 1780123201.5, + "duration_ms": 1500, + "timeline": [ + { + "type": "call", + "seq": 2, + "call": { + "webchat_call_id": "call_agent", + "name": "web_agent", + "is_agent": true, + "status": "done", + "duration_ms": 900, + "children": [ + { + "webchat_call_id": "call_agent/call_search", + "parent_webchat_call_id": "call_agent", + "name": "search", + "is_agent": false, + "status": "done", + "result_preview": "摘要", + "duration_ms": 420, + "children": [] + } + ] + } + }, + { + "type": "message", + "seq": 4, + "content": "中间回复文本", + "elapsed_ms": 860 + } + ], + "calls": [ + { + "webchat_call_id": "call_agent", + "parent_webchat_call_id": "", + "tool_call_id": "call_agent", + "name": "web_agent", + "is_agent": true, + "status": "done", + "duration_ms": 900, + "children": [ + { + "webchat_call_id": "call_agent/call_search", + "parent_webchat_call_id": "call_agent", + "tool_call_id": "call_search", + "name": "search", + "is_agent": false, + "status": "done", + "result_preview": "摘要", + "duration_ms": 420, + "children": [] + } + ] + } + ], + "events": [ + { + "seq": 2, + "event": "tool_start", + "payload": { + "job_id": "9c1...", + "tool_call_id": "call_1", + "name": "search", + "arguments_preview": "{\"q\":\"test\"}", + "is_agent": false + } + }, + { + "seq": 4, + "event": "message", + "payload": { + "job_id": "9c1...", + "content": "中间回复文本" + } + }, + { + "seq": 5, + "event": "tool_end", + "payload": { + "job_id": "9c1...", + "tool_call_id": "call_1", + "name": "search", + "ok": true, + "duration_ms": 420, + "result_preview": "摘要", + "is_agent": false + } + } + ] + } +} +``` + +
+ +`webchat.timeline` 是后端生成的权威历史展示序列,按 `seq` 混排顶层工具 / Agent 调用节点与正文消息,前端刷新后优先按它忠实渲染同一 AI 气泡。`webchat.calls` 是后端由生命周期事件汇总出的调用树,包含每个工具 / Agent 的输入预览、输出预览、状态、耗时、`children` 和节点内 `timeline`;节点内 `timeline` 用于恢复 Agent 内部“子工具 / 子 Agent / 正文”的真实时序,Agent 阶段只恢复为摘要行状态。`webchat.events` 保留原始生命周期 / 正文事件,供兼容旧历史与诊断使用,不作为 AI 后续对话上下文注入。若一次 job 没有正文但有工具事件,历史 API 仍会返回该 Bot 项,`content` 为空字符串。 +- `DELETE /api/v1/chat/history?conversation_id=` +- 仅清空指定 WebChat 会话的 `system#42` 聊天历史,不删除长期记忆、认知记忆、profile 或其他 WebChat 会话。 +- 如果目标会话存在运行中或正在收尾落盘的 WebChat job,返回 `409`,避免旧任务继续写回已清空的历史。 + +### WebUI AI Chat Jobs + +- `POST /api/v1/chat/jobs`:创建后台 job。兼容旧 Body `{"message":"...","conversation_id":"..."}`,也支持结构化 Body `{"message":{"text":"...","attachment_ids":["..."],"references":[{"message_id":"...","quote":"..."}]},"conversation_id":"..."}`;`conversation_id` 可选。取消后重试最后一条纯文本用户消息时,可传 `reuse_previous_user_message: true`,Runtime 会要求最后一条可见用户历史与本次文本完全一致,并跳过重复写入用户历史。 +- WebChat job 在同一会话内 single-flight;如果目标会话已有 job 正在运行或收尾落盘,创建新 job 返回 `409`。不同会话可以并发运行。兼容的非流式 `POST /api/v1/chat` 也走同一套会话级 job 锁,只是等待完成后返回同步结果。 +- 附件先通过 `POST /api/v1/chat/attachments` 以 multipart 上传并由 Runtime 返回附件 metadata;发送消息时只传 Runtime 生成的 `attachment_ids`。客户端不应把本地文件内容、下载 URL 或临时缓存路径拼入 `message.text`。 +- 引用内容通过结构化 `references` 提交。Runtime 负责把引用写入历史 metadata,并按需要生成 AI 可见上下文;客户端不把引用块拼接进最终历史作为真源。 +- `GET /api/v1/chat/jobs/active?conversation_id=`:返回兼容字段 `job` 和 active `jobs[]`。不传时 `jobs[]` 包含所有运行中 WebChat job,`job` 为最新 active job;传入 `conversation_id` 时只返回目标会话的 active job。 +- `GET /api/v1/chat/jobs/{job_id}`:查询 job 状态、最后事件序号和已汇总输出。 +- `GET /api/v1/chat/jobs/{job_id}/events?after=&conversation_id=`:查询 `seq` 之后的增量事件,默认返回 JSON。`conversation_id` 可选;传入时必须与 job 所属会话一致,否则返回 `404`,用于刷新、断线或换客户端后避免跨会话误续接。 +- `GET /api/v1/chat/jobs/{job_id}/events?after=&format=json` 或请求头 `Accept: application/json`:显式查询 JSON。响应包含: + +```json +{ + "job": { + "job_id": "9c1...", + "status": "running", + "last_seq": 5, + "elapsed_ms": 2400, + "duration_ms": null, + "current_stage": "waiting_tools", + "current_stage_elapsed_ms": 1200, + "current_agent_stages": [ + { + "job_id": "9c1...", + "webchat_call_id": "call_agent", + "agent_name": "web_agent", + "stage": "waiting_model", + "stage_elapsed_ms": 900, + "elapsed_ms": 2400, + "transient": true + } + ], + "current_tool_calls": [ + { + "job_id": "9c1...", + "webchat_call_id": "call_agent", + "name": "web_agent", + "status": "running", + "is_agent": true, + "started_at": 1760000000.0, + "duration_ms": 2400, + "current_stage": "waiting_model", + "current_stage_elapsed_ms": 900 + } + ] + }, + "after": 5, + "last_seq": 5, + "events": [ + { + "seq": 5, + "event": "agent_stage", + "payload": { + "webchat_call_id": "call_agent", + "stage": "waiting_model", + "stage_elapsed_ms": 900, + "transient": true + } + } + ] +} +``` + +`events` 只包含 `after` 之后的持久事件以及当前运行阶段快照。快照使用当前 `last_seq`,便于刷新或断线后以 `job_id + seq` 轮询续接,但不会推进序号或重复写入历史。 + +Runtime 在同一个 job 条件锁内维护事件、顶层阶段、Agent 阶段和耗时快照,因此 JSON 查询和兼容 SSE 都应看到一致的 job 状态。 +- `GET /api/v1/chat/jobs/{job_id}/events?after=` 加请求头 `Accept: text/event-stream`:兼容 SSE 订阅;SSE 帧包含 `id: `,长时间无事件时会发送 keep-alive 注释帧。 +- `POST /api/v1/chat/jobs/{job_id}/cancel`:取消运行中的 job。 + +Runtime API 进程重启后不会恢复未完成 job;已落盘的聊天历史仍可通过 history 接口读取。 + +### Schemas / Appendix + +#### `webchat.events` + +```json +[ + { + "seq": 5, + "event": "tool_end", + "payload": { + "job_id": "9c1...", + "tool_call_id": "call_1", + "webchat_call_id": "call_1", + "parent_webchat_call_id": "", + "name": "search", + "status": "done", + "duration_ms": 420, + "arguments_preview": "{\"q\":\"test\"}", + "result_preview": "摘要" + } + } +] +``` + +#### `webchat.calls` + +```json +[ + { + "webchat_call_id": "call_agent", + "parent_webchat_call_id": "", + "name": "web_agent", + "is_agent": true, + "status": "done", + "duration_ms": 900, + "children": [] + } +] +``` + +#### History Response + +```json +{ + "conversation_id": "legacy-system-42", + "items": [ + { + "role": "bot", + "content": "最终回复文本", + "webchat": { + "display_only": true, + "duration_ms": 1500, + "timeline": [], + "calls": [], + "events": [] + } + } + ], + "has_more": false, + "next_before": null, + "total": 1 +} +``` ### 工具调用 API @@ -422,6 +829,17 @@ curl -N -H "X-Undefined-API-Key: $KEY" \ -d '{"message":"你好","stream":true}' \ "$API/api/v1/chat" +JOB_ID="$(curl -s -H "X-Undefined-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{"message":"你好"}' \ + "$API/api/v1/chat/jobs" | jq -r .job_id)" +curl -H "X-Undefined-API-Key: $KEY" \ + "$API/api/v1/chat/jobs/$JOB_ID/events?after=0&format=json" + +curl -N -H "X-Undefined-API-Key: $KEY" \ + -H "Accept: text/event-stream" \ + "$API/api/v1/chat/jobs/$JOB_ID/events?after=0" + # 列出可用工具(需 tool_invoke_enabled = true) curl -H "X-Undefined-API-Key: $KEY" "$API/api/v1/tools" @@ -445,17 +863,79 @@ WebUI 不直接在前端暴露 `auth_key`,而是通过后端代理访问主进 - `GET /api/runtime/probes/internal` - `GET /api/runtime/probes/external` - `GET /api/runtime/memory` +- `GET /api/runtime/schedules` +- `POST /api/runtime/schedules` +- `GET /api/runtime/schedules/{task_id}` +- `PATCH /api/runtime/schedules/{task_id}` +- `DELETE /api/runtime/schedules/{task_id}` - `GET /api/runtime/cognitive/events` - `GET /api/runtime/cognitive/profiles` - `GET /api/runtime/cognitive/profile/{entity_type}/{entity_id}` +- `GET /api/runtime/commands` +- `GET /api/runtime/commands/{command_name}` +- `GET /api/runtime/chat/conversations` +- `POST /api/runtime/chat/conversations` +- `PATCH /api/runtime/chat/conversations/{conversation_id}` +- `DELETE /api/runtime/chat/conversations/{conversation_id}` - `POST /api/runtime/chat` - `GET /api/runtime/chat/history` +- `DELETE /api/runtime/chat/history` +- `GET /api/runtime/chat/attachments/capabilities` +- `POST /api/runtime/chat/attachments` +- `GET /api/runtime/chat/attachments/{attachment_id}` +- `GET /api/runtime/chat/attachments/{attachment_id}/preview` +- `POST /api/runtime/chat/jobs` +- `GET /api/runtime/chat/jobs/active` +- `GET /api/runtime/chat/jobs/{job_id}` +- `GET /api/runtime/chat/jobs/{job_id}/events` +- `POST /api/runtime/chat/jobs/{job_id}/cancel` +- `POST /api/runtime/chat/files`(旧 WebUI 浏览器文件缓存兼容路径,新客户端使用 `chat/attachments`) - `GET /api/runtime/openapi` - `GET /api/runtime/tools` - `POST /api/runtime/tools/invoke` -WebUI 后端会自动从 `config.toml` 读取 `[api].auth_key` 并注入 Header。 -`/api/runtime/chat` 代理超时为 `480s`,并透传 SSE keep-alive。 +### Auth / Header 注入 + +WebUI 后端会先校验 WebUI 登录态,再自动从 `config.toml` 读取 `[api].auth_key` 并注入 `X-Undefined-API-Key`,前端只持有 WebUI 登录态,不直接暴露 Runtime API 密钥。 + +### Command Proxy + +`/api/runtime/commands` 代理斜杠命令 REST 资源。 + +- WebChat 输入框的 `/` 补全默认请求 `scope=webui`。 +- `q`、`include_hidden`、`include_unavailable` 等查询参数原样透传。 + +### Conversation Handling + +`/api/runtime/chat/conversations` 代理 WebChat 多对话管理。 + +- `GET/POST/PATCH/DELETE /api/runtime/chat/conversations...` 管理会话 JSON。 +- `/api/runtime/chat`、`/api/runtime/chat/history`、`/api/runtime/chat/jobs` 和 `/api/runtime/chat/jobs/active` 会透传 `conversation_id`。 +- 不传 `conversation_id` 时使用 Runtime 默认兼容会话。 +- 删除会话或清空历史时,目标会话运行中或收尾落盘中的 WebChat job 会导致 `409`。 + +### File Uploads + +Undefined Chat 原生客户端直接使用 Runtime 附件端点;WebUI 代理层应优先透传这些端点: + +- `GET /api/runtime/chat/attachments/capabilities` +- `POST /api/runtime/chat/attachments` +- `GET /api/runtime/chat/attachments/{attachment_id}` +- `GET /api/runtime/chat/attachments/{attachment_id}/preview` + +上传使用 `multipart/form-data`,字段名为 `file`。Runtime 返回 `attachment.id`、`name`、`size`、`media_type`、`kind`、`download_url` 和可选 `preview_url`;发送消息时把这些 id 放进结构化 `message.attachment_ids`。引用内容放进结构化 `message.references`,由 Runtime 写入历史 metadata。 + +WebUI 浏览器端渲染 UID 附件图片时应使用代理路径 `/api/runtime/chat/attachments/{attachment_id}/preview`,不要把 Runtime 返回的 `/api/v1/chat/attachments/...` 直接放进 ``。普通 Markdown 外链图片仍按安全 URL 白名单直接渲染为可点击预览图片。 + +### Event / Query Behavior + +`/api/runtime/chat/jobs/{job_id}/events` 代理 WebChat job 事件续接。 + +- 默认返回 JSON 增量查询。 +- `Accept: text/event-stream` 时透传 Runtime SSE 和 keep-alive。 +- `conversation_id` 传入时必须与 job 所属会话一致。 +- `after` 用于按 seq 增量续接。 +- 聊天代理超时按当前聊天模型队列预算计算。 ## 7. 故障排查 diff --git a/docs/pipelines.md b/docs/pipelines.md index fff9ab08..77eba0ed 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -6,12 +6,12 @@ ## 运行顺序 -1. `MessageHandler` 先并行执行消息预处理:附件收集、历史文本解析、昵称或群信息读取等。 +1. `MessageHandler` 先并行执行消息预处理:附件收集、历史文本解析、昵称或群信息读取等。图片、文件等媒体会登记为附件 UID,并在 AI 可见正文中统一写作 ``。 2. 用户消息先写入历史。 3. 若消息命中斜杠命令,立即分发命令并结束本轮后续流程;命令输入和命令输出会写入历史,供后续 AI 轮次读取。 4. 未命中命令时,`PipelineRegistry` 并行调用所有已注册管线的 `detect(context)`。 5. 对所有命中的管线,并行调用对应的 `process(detection, context)`。 -6. 管线发送出的信息、图片、文件或视频摘要通过统一发送器写入历史;本地图片、文件和视频会自动登记为当前会话可见的统一附件 UID。 +6. 管线发送出的信息、图片、文件或视频摘要通过统一发送器写入历史;本地图片、文件和视频会自动登记为当前会话可见的统一附件 UID,历史正文同样使用 `` 作为可复用引用。 7. 自动处理完成后,当前消息和管线输出一起进入 AI 自动回复/Agent 循环。 命中自动处理管线的消息会继续进入 AI 自动回复,让 AI 基于用户消息和刚写入的自动处理结果判断后续行为。 diff --git a/docs/superpowers/plans/2026-06-07-undefined-chat-poc.md b/docs/superpowers/plans/2026-06-07-undefined-chat-poc.md new file mode 100644 index 00000000..8a1e8d0a --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-undefined-chat-poc.md @@ -0,0 +1,2055 @@ +# Undefined Chat PoC Implementation Plan + +> 状态:已实现并归档(point-in-time 文档)。本计划记录 Phase 0 PoC 的历史执行步骤,后续完整产品计划见 [2026-06-08-undefined-chat-full.md](2026-06-08-undefined-chat-full.md),最新实现以 [docs/undefined-chat.md](../../undefined-chat.md) 与 `apps/undefined-chat` 代码为准(2026-06)。 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a focused Undefined Chat proof of concept that validates the risky Runtime/Tauri/React paths before full product implementation. + +**Architecture:** Add a minimal `apps/undefined-chat` Tauri v2 + React app that talks to Runtime through Tauri commands. Add small Runtime attachment capability/upload endpoints only to validate the thin-client upload contract; use existing Runtime WebChat job/events endpoints for SSE validation. + +**Tech Stack:** Python 3.12/aiohttp/pytest, Tauri v2/Rust, React + TypeScript + Vite, Biome, Vitest, `tauri-plugin-stronghold`, Rust `keyring`, Tauri HTTP plugin `reqwest`, `tokio-util` streaming I/O. + +--- + +## Scope + +This plan implements the Phase 0 PoC only. It does not build the full Undefined Chat product UI, full Runtime attachment persistence, release packaging, or full Android background behavior. The design spec intentionally covers several independent subsystems; after this PoC passes, write separate plans for the full Runtime API upgrade, full chat UI, and CI/release/version-script integration. The PoC must prove these critical paths: + +- Runtime exposes upload capability and streaming upload endpoints with tests. +- Tauri app scaffolds cleanly as `apps/undefined-chat`. +- API Key storage round-trips through Stronghold with a keyring-backed vault password strategy and explicit degraded status. +- Runtime requests go through Tauri commands rather than direct browser fetch. +- SSE event streaming can connect, emit events to React, and resume with `seq`. +- File upload streams from a local path through Rust without JS Blob/base64 IPC. +- HTML preview opens in an isolated surface with strict CSP. +- React shell displays connection/storage/SSE/upload/preview status and has tests for connection states. + +## File Structure + +Create: + +- `apps/undefined-chat/package.json` — npm scripts and React/Tauri dependencies. +- `apps/undefined-chat/package-lock.json` — generated by `npm install --package-lock-only`. +- `apps/undefined-chat/index.html` — Vite root document. +- `apps/undefined-chat/biome.json` — app-level Biome config, mirroring Console style. +- `apps/undefined-chat/tsconfig.json` — strict TS config. +- `apps/undefined-chat/vite.config.ts` — Vite config for port `1430`. +- `apps/undefined-chat/src/main.tsx` — React entrypoint. +- `apps/undefined-chat/src/App.tsx` — PoC UI shell. +- `apps/undefined-chat/src/app.test.tsx` — UI state tests. +- `apps/undefined-chat/src/runtime.ts` — typed Tauri command wrappers. +- `apps/undefined-chat/src/secureStorage.ts` — Stronghold API Key round-trip helper using a keyring-provided vault password. +- `apps/undefined-chat/src/styles.css` — PoC layout and status styles. +- `apps/undefined-chat/src-tauri/Cargo.toml` — Tauri Rust crate config. +- `apps/undefined-chat/src-tauri/build.rs` — Tauri build script. +- `apps/undefined-chat/src-tauri/tauri.conf.json` — app and CSP config. +- `apps/undefined-chat/src-tauri/capabilities/default.json` — Tauri capability allowlist. +- `apps/undefined-chat/src-tauri/src/main.rs` — Rust binary entrypoint. +- `apps/undefined-chat/src-tauri/src/lib.rs` — Tauri builder and command registration. +- `apps/undefined-chat/src-tauri/src/config.rs` — runtime URL/API key config types. +- `apps/undefined-chat/src-tauri/src/secret.rs` — Stronghold hash derivation, keyring vault password, and storage status commands. +- `apps/undefined-chat/src-tauri/src/runtime_client.rs` — Runtime health and SSE commands. +- `apps/undefined-chat/src-tauri/src/upload.rs` — streaming upload command. +- `apps/undefined-chat/src-tauri/src/preview.rs` — isolated HTML preview command. +- `apps/undefined-chat/src-tauri/src/poc_tests.rs` — Rust unit tests for URL, secret, and preview helpers. +- `apps/undefined-chat/README.md` — PoC setup, commands, and known platform checks. +- `tests/test_runtime_api_chat_attachments_poc.py` — Runtime upload capability and upload tests. + +Modify: + +- `src/Undefined/api/app.py` — register PoC attachment capability/upload routes. +- `src/Undefined/api/routes/chat.py` — add PoC attachment handlers and constants. +- `docs/superpowers/specs/2026-06-07-undefined-chat-design.md` — append PoC result note after implementation. + +## Task 1: Runtime Attachment PoC Endpoints + +**Files:** +- Modify: `src/Undefined/api/app.py` +- Modify: `src/Undefined/api/routes/chat.py` +- Create: `tests/test_runtime_api_chat_attachments_poc.py` + +- [ ] **Step 1: Write failing Runtime tests** + +Create `tests/test_runtime_api_chat_attachments_poc.py`: + +```python +from __future__ import annotations + +from types import SimpleNamespace + +from aiohttp.test_utils import make_mocked_request +import pytest + +from Undefined.api._context import RuntimeAPIContext +from Undefined.api.routes import chat + + +class _DummyConfig: + messages_send_url_file_max_size_mb: int = 7 + + +def _ctx() -> RuntimeAPIContext: + return RuntimeAPIContext( + config_getter=lambda: _DummyConfig(), + ai=SimpleNamespace(), + onebot=SimpleNamespace(), + scheduler=None, + command_dispatcher=SimpleNamespace(), + history_manager=None, + naga_store=None, + ) + + +@pytest.mark.asyncio +async def test_chat_attachment_capabilities_reports_runtime_limit() -> None: + request = make_mocked_request("GET", "/api/v1/chat/attachments/capabilities") + + response = await chat.chat_attachment_capabilities_handler(_ctx(), request) + + assert response.status == 200 + payload = response.text + assert payload is not None + assert '"max_upload_size_bytes": 7340032' in payload + assert '"multipart_field": "file"' in payload + + +@pytest.mark.asyncio +async def test_chat_attachment_upload_requires_multipart() -> None: + request = make_mocked_request("POST", "/api/v1/chat/attachments") + + response = await chat.chat_attachment_upload_handler(_ctx(), request) + + assert response.status == 400 + assert response.text is not None + assert "multipart" in response.text.lower() +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +uv run pytest tests/test_runtime_api_chat_attachments_poc.py -q +``` + +Expected: FAIL because `chat_attachment_capabilities_handler` and `chat_attachment_upload_handler` do not exist. + +- [ ] **Step 3: Add Runtime handlers** + +In `src/Undefined/api/routes/chat.py`, add imports near existing imports: + +```python +import mimetypes +``` + +Add constants near `_PREVIEW_LIMIT`: + +```python +_CHAT_ATTACHMENT_MAX_NAME_LENGTH = 128 +_CHAT_ATTACHMENT_UPLOAD_FIELD = "file" +``` + +Add helper functions near other helper functions: + +```python +def _chat_attachment_max_upload_size_bytes(ctx: RuntimeAPIContext) -> int: + cfg = ctx.config_getter() + max_size_mb = int(getattr(cfg, "messages_send_url_file_max_size_mb", 100) or 100) + return max(1, max_size_mb) * 1024 * 1024 + + +def _sanitize_chat_attachment_name(raw_name: str) -> str: + name = Path(str(raw_name or "").strip() or "attachment").name or "attachment" + if len(name) <= _CHAT_ATTACHMENT_MAX_NAME_LENGTH: + return name + suffix = "".join(Path(name).suffixes[-2:]) or Path(name).suffix + suffix = suffix if len(suffix) <= 16 else "" + return f"attachment{suffix}" +``` + +Add handlers before `chat_conversations_handler`: + +```python +async def chat_attachment_capabilities_handler( + ctx: RuntimeAPIContext, + request: web.Request, +) -> Response: + _ = request + return web.json_response( + { + "max_upload_size_bytes": _chat_attachment_max_upload_size_bytes(ctx), + "multipart_field": _CHAT_ATTACHMENT_UPLOAD_FIELD, + } + ) + + +async def chat_attachment_upload_handler( + ctx: RuntimeAPIContext, + request: web.Request, +) -> Response: + max_size = _chat_attachment_max_upload_size_bytes(ctx) + try: + reader = await request.multipart() + except Exception: + return _json_error("multipart request required", status=400) + + field = await reader.next() + if field is None or getattr(field, "name", "") != _CHAT_ATTACHMENT_UPLOAD_FIELD: + return _json_error("file field is required", status=400) + + display_name = _sanitize_chat_attachment_name( + str(getattr(field, "filename", "") or "attachment") + ) + total_size = 0 + while True: + chunk = await field.read_chunk(size=1024 * 256) + if not chunk: + break + total_size += len(chunk) + if total_size > max_size: + return web.json_response( + {"error": "file too large", "max_upload_size_bytes": max_size}, + status=413, + ) + + media_type = ( + mimetypes.guess_type(display_name)[0] or "application/octet-stream" + ) + attachment_id = uuid4().hex + return web.json_response( + { + "attachment": { + "id": attachment_id, + "name": display_name, + "size": total_size, + "media_type": media_type, + "kind": "image" if media_type.startswith("image/") else "file", + "poc_discarded": True, + } + }, + status=201, + ) +``` + +- [ ] **Step 4: Register Runtime routes** + +In `src/Undefined/api/app.py`, add routes after chat history routes: + +```python + web.get( + "/api/v1/chat/attachments/capabilities", + self._chat_attachment_capabilities_handler, + ), + web.post( + "/api/v1/chat/attachments", + self._chat_attachment_upload_handler, + ), +``` + +Add delegation wrappers near other chat wrappers: + +```python + async def _chat_attachment_capabilities_handler( + self, request: web.Request + ) -> Response: + return await chat.chat_attachment_capabilities_handler(self._ctx, request) + + async def _chat_attachment_upload_handler(self, request: web.Request) -> Response: + return await chat.chat_attachment_upload_handler(self._ctx, request) +``` + +- [ ] **Step 5: Run Runtime tests** + +Run: + +```bash +uv run pytest tests/test_runtime_api_chat_attachments_poc.py -q +``` + +Expected: PASS. + +- [ ] **Step 6: Run focused existing chat tests** + +Run: + +```bash +uv run pytest tests/test_runtime_api_chat_jobs.py tests/test_runtime_api_chat_history.py -q +``` + +Expected: PASS. + +- [ ] **Step 7: Commit Runtime PoC endpoints** + +```bash +git add src/Undefined/api/app.py src/Undefined/api/routes/chat.py tests/test_runtime_api_chat_attachments_poc.py +git commit -m "feat(runtime): add chat attachment poc endpoints" +``` + +## Task 2: Undefined Chat App Scaffold + +**Files:** +- Create: `apps/undefined-chat/package.json` +- Create: `apps/undefined-chat/index.html` +- Create: `apps/undefined-chat/biome.json` +- Create: `apps/undefined-chat/tsconfig.json` +- Create: `apps/undefined-chat/vite.config.ts` +- Create: `apps/undefined-chat/src/main.tsx` +- Create: `apps/undefined-chat/src/App.tsx` +- Create: `apps/undefined-chat/src/styles.css` +- Create: `apps/undefined-chat/src/runtime.ts` +- Create: `apps/undefined-chat/src-tauri/Cargo.toml` +- Create: `apps/undefined-chat/src-tauri/build.rs` +- Create: `apps/undefined-chat/src-tauri/tauri.conf.json` +- Create: `apps/undefined-chat/src-tauri/capabilities/default.json` +- Create: `apps/undefined-chat/src-tauri/src/main.rs` +- Create: `apps/undefined-chat/src-tauri/src/lib.rs` + +- [ ] **Step 1: Create npm package** + +Create `apps/undefined-chat/package.json`: + +```json +{ + "name": "undefined-chat", + "private": true, + "version": "3.5.1", + "type": "module", + "scripts": { + "tauri": "tauri", + "dev": "vite --host 0.0.0.0 --port 1430", + "lint": "biome check src package.json tsconfig.json vite.config.ts src-tauri/tauri.conf.json src-tauri/capabilities/default.json", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "tauri:fmt:check": "cargo fmt --manifest-path src-tauri/Cargo.toml --all --check", + "tauri:check": "cargo check --manifest-path src-tauri/Cargo.toml", + "check": "npm run lint && npm run typecheck && npm run test && npm run tauri:fmt:check && npm run tauri:check", + "build": "npm run typecheck && vite build", + "preview": "vite preview --host 0.0.0.0 --port 4183", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build", + "tauri:android:init": "tauri android init --ci", + "tauri:android": "tauri android build", + "tauri:android:debug": "tauri android build --debug" + }, + "dependencies": { + "@tauri-apps/api": "^2.3.0", + "@tauri-apps/plugin-stronghold": "^2.3.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@tauri-apps/cli": "^2.3.1", + "@vitejs/plugin-react": "^4.3.4", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "jsdom": "^26.0.0", + "typescript": "^5.7.3", + "vite": "^6.2.1", + "vitest": "^3.0.8" + } +} +``` + +- [ ] **Step 2: Create Vite/TS/Biome config** + +Create `apps/undefined-chat/vite.config.ts`: + +```ts +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 1430, + strictPort: true, + }, + preview: { + port: 4183, + strictPort: true, + }, + build: { + target: ["es2022", "chrome110", "safari16"], + }, + test: { + environment: "jsdom", + globals: true, + }, +}); +``` + +Create `apps/undefined-chat/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src"] +} +``` + +Create `apps/undefined-chat/biome.json`: + +```json +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "package.json", + "tsconfig.json", + "vite.config.ts", + "src-tauri/tauri.conf.json", + "src-tauri/capabilities/**/*.json" + ] + }, + "linter": { + "rules": { + "correctness": { + "noUnusedVariables": "warn" + } + } + } +} +``` + +- [ ] **Step 3: Create React entrypoint** + +Create `apps/undefined-chat/index.html`: + +```html + + + + + + + Undefined Chat + + +
+ + + +``` + +Create `apps/undefined-chat/src/main.tsx`: + +```tsx +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("Missing root element"); +} + +createRoot(root).render( + + + , +); +``` + +Create `apps/undefined-chat/src/runtime.ts`: + +```ts +import { invoke } from "@tauri-apps/api/core"; + +export type ConnectionState = + | "idle" + | "connecting" + | "connected" + | "streaming" + | "resuming" + | "json_fallback" + | "disconnected"; + +export type SecretStatus = { + available: boolean; + degraded: boolean; + detail: string; +}; + +export type RuntimeHealth = { + ok: boolean; + status: number; + body: string; +}; + +export async function probeSecretStorage(): Promise { + return await invoke("probe_secret_storage"); +} + +export async function probeRuntime(runtimeUrl: string): Promise { + return await invoke("probe_runtime", { runtimeUrl }); +} +``` + +Create `apps/undefined-chat/src/App.tsx`: + +```tsx +import { useState } from "react"; +import { + type ConnectionState, + type RuntimeHealth, + type SecretStatus, + probeRuntime, + probeSecretStorage, +} from "./runtime"; + +const defaultRuntimeUrl = "http://127.0.0.1:8788"; + +function statusLabel(state: ConnectionState): string { + const labels: Record = { + idle: "待连接", + connecting: "正在连接", + connected: "已连接", + streaming: "正在接收事件", + resuming: "正在续接", + json_fallback: "已降级查询", + disconnected: "连接断开", + }; + return labels[state]; +} + +export function App() { + const [runtimeUrl, setRuntimeUrl] = useState(defaultRuntimeUrl); + const [connectionState, setConnectionState] = + useState("idle"); + const [secretStatus, setSecretStatus] = useState(null); + const [runtimeHealth, setRuntimeHealth] = useState(null); + const [error, setError] = useState(""); + + async function runSecretProbe(): Promise { + setError(""); + const result = await probeSecretStorage(); + setSecretStatus(result); + } + + async function runRuntimeProbe(): Promise { + setError(""); + setConnectionState("connecting"); + try { + const result = await probeRuntime(runtimeUrl); + setRuntimeHealth(result); + setConnectionState(result.ok ? "connected" : "disconnected"); + } catch (err) { + setConnectionState("disconnected"); + setError(String(err)); + } + } + + return ( +
+
+

Undefined Chat PoC

+

原生优先 WebChat 客户端验证

+

+ 当前 PoC 只验证连接、安全存储、事件流、上传和 HTML 预览关键路径。 +

+
+
+ +
+ setRuntimeUrl(event.currentTarget.value)} + /> + +
+
+ {statusLabel(connectionState)} +
+ {runtimeHealth ? ( +
{JSON.stringify(runtimeHealth, null, 2)}
+ ) : null} +
+
+
+ Secret Storage + +
+ {secretStatus ? ( +
{JSON.stringify(secretStatus, null, 2)}
+ ) : ( +

尚未探测 Stronghold/keyring 状态。

+ )} +
+ {error ?
{error}
: null} +
+ ); +} +``` + +Create `apps/undefined-chat/src/styles.css`: + +```css +:root { + color: #1d242b; + background: #f5f7f8; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +body { + margin: 0; +} + +button, +input { + font: inherit; +} + +.app-shell { + box-sizing: border-box; + min-height: 100vh; + padding: 32px; + display: grid; + gap: 18px; + grid-template-columns: minmax(0, 1fr); + max-width: 920px; + margin: 0 auto; +} + +.hero, +.panel, +.error { + border: 1px solid #d7dee3; + background: #ffffff; + border-radius: 8px; + padding: 20px; +} + +.eyebrow { + margin: 0 0 8px; + color: #53616d; + font-size: 0.85rem; + text-transform: uppercase; +} + +.row { + display: flex; + gap: 10px; + align-items: center; +} + +input { + flex: 1; + min-width: 0; + border: 1px solid #bcc8d1; + border-radius: 6px; + padding: 10px 12px; +} + +button { + border: 0; + border-radius: 6px; + padding: 10px 14px; + background: #1f6feb; + color: #ffffff; +} + +.status { + margin-top: 12px; + font-weight: 700; +} + +.status-connected, +.status-streaming { + color: #16794f; +} + +.status-disconnected, +.error { + color: #b42318; +} + +pre { + overflow: auto; + max-height: 220px; + background: #101316; + color: #d7e2ea; + border-radius: 6px; + padding: 12px; +} + +@media (max-width: 640px) { + .app-shell { + padding: 16px; + } + + .row { + align-items: stretch; + flex-direction: column; + } +} +``` + +- [ ] **Step 4: Create Tauri scaffold** + +Create `apps/undefined-chat/src-tauri/Cargo.toml`: + +```toml +[package] +name = "undefined_chat" +version = "3.5.1" +description = "Undefined native chat client" +authors = ["Undefined contributors"] +license = "MIT" +edition = "2021" +rust-version = "1.77" + +[lib] +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2.0.2", features = [] } + +[dependencies] +futures-util = "0.3" +keyring = "3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tauri = { version = "2.2.5", features = [] } +tauri-plugin-http = "2" +tauri-plugin-stronghold = "2" +tokio = { version = "1", features = ["fs", "io-util"] } +tokio-util = { version = "0.7", features = ["io"] } +url = "2" +urlencoding = "2" +uuid = { version = "1", features = ["v4"] } +``` + +Create `apps/undefined-chat/src-tauri/build.rs`: + +```rust +fn main() { + tauri_build::build(); +} +``` + +Create `apps/undefined-chat/src-tauri/src/main.rs`: + +```rust +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + undefined_chat::run(); +} +``` + +Create `apps/undefined-chat/src-tauri/src/lib.rs`: + +```rust +fn derive_stronghold_key(password: &str) -> Vec { + use sha2::{Digest, Sha256}; + Sha256::digest(password.as_bytes()).to_vec() +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_stronghold::Builder::new(derive_stronghold_key).build()) + .run(tauri::generate_context!()) + .expect("failed to run Undefined Chat app"); +} +``` + +Create `apps/undefined-chat/src-tauri/tauri.conf.json`: + +```json +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Undefined Chat", + "version": "3.5.1", + "identifier": "com.undefined.chat", + "build": { + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build", + "devUrl": "http://localhost:1430", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "Undefined Chat", + "width": 1180, + "height": 780, + "minWidth": 360, + "minHeight": 640, + "resizable": true, + "fullscreen": false + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset:; font-src 'self' data: asset:; connect-src 'self' ipc: tauri: asset:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" + } + }, + "bundle": { + "active": true, + "category": "DeveloperTool", + "shortDescription": "Native chat client for Undefined Runtime.", + "longDescription": "A desktop and Android chat client for Undefined Runtime WebChat.", + "targets": ["appimage", "deb", "dmg", "msi", "nsis"], + "icon": [] + } +} +``` + +Create `apps/undefined-chat/src-tauri/capabilities/default.json`: + +```json +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main-capability", + "description": "Default capability for the Undefined Chat main window.", + "windows": ["main"], + "permissions": ["core:default", "stronghold:default"] +} +``` + +- [ ] **Step 5: Install dependencies and generate locks** + +Run: + +```bash +cd apps/undefined-chat +npm install --package-lock-only +cargo generate-lockfile --manifest-path src-tauri/Cargo.toml +``` + +Expected: `package-lock.json` and `src-tauri/Cargo.lock` are created. + +- [ ] **Step 6: Run initial checks** + +Run: + +```bash +cd apps/undefined-chat +npm run typecheck +npm run tauri:check +``` + +Expected: PASS. The scaffold initializes Tauri, HTTP, and Stronghold plugins but does not register PoC commands until later tasks. + +- [ ] **Step 7: Commit app scaffold** + +```bash +git add apps/undefined-chat +git commit -m "feat(chat): scaffold undefined chat poc app" +``` + +## Task 3: Stronghold + Keyring PoC + +**Files:** +- Create: `apps/undefined-chat/src-tauri/src/config.rs` +- Create: `apps/undefined-chat/src-tauri/src/secret.rs` +- Create: `apps/undefined-chat/src-tauri/src/poc_tests.rs` +- Create: `apps/undefined-chat/src/secureStorage.ts` +- Modify: `apps/undefined-chat/src-tauri/src/lib.rs` + +- [ ] **Step 1: Write Rust unit tests and wire secret commands** + +Create `apps/undefined-chat/src-tauri/src/poc_tests.rs`: + +```rust +use crate::config::normalize_runtime_url; +use crate::secret::{classify_secret_storage, derive_stronghold_key}; + +#[test] +fn normalize_runtime_url_removes_trailing_slashes() { + let value = normalize_runtime_url("http://127.0.0.1:8788///").unwrap(); + assert_eq!(value, "http://127.0.0.1:8788"); +} + +#[test] +fn normalize_runtime_url_rejects_empty_input() { + let err = normalize_runtime_url(" ").unwrap_err(); + assert!(err.contains("runtime_url is required")); +} + +#[test] +fn secret_status_marks_degraded_detail() { + let status = classify_secret_storage(false, "no native store"); + assert!(!status.available); + assert!(status.degraded); + assert_eq!(status.detail, "no native store"); +} + +#[test] +fn stronghold_key_derivation_returns_32_bytes() { + let derived = derive_stronghold_key("vault-password"); + assert_eq!(derived.len(), 32); + assert_ne!(derived, b"vault-password".to_vec()); +} +``` + +Replace `apps/undefined-chat/src-tauri/src/lib.rs` with: + +```rust +mod config; +mod secret; + +#[cfg(test)] +mod poc_tests; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_stronghold::Builder::new(secret::derive_stronghold_key).build()) + .invoke_handler(tauri::generate_handler![ + secret::probe_secret_storage, + secret::ensure_vault_password, + ]) + .run(tauri::generate_context!()) + .expect("failed to run Undefined Chat app"); +} +``` + +- [ ] **Step 2: Run tests to verify failure** + +Run: + +```bash +cd apps/undefined-chat +cargo test --manifest-path src-tauri/Cargo.toml +``` + +Expected: FAIL because `config.rs` and `secret.rs` do not exist yet. + +- [ ] **Step 3: Implement config helpers** + +Create `apps/undefined-chat/src-tauri/src/config.rs`: + +```rust +use url::Url; + +pub fn normalize_runtime_url(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("runtime_url is required".to_string()); + } + let parsed = Url::parse(trimmed).map_err(|err| format!("invalid runtime_url: {err}"))?; + match parsed.scheme() { + "http" | "https" => {} + scheme => return Err(format!("unsupported runtime_url scheme: {scheme}")), + } + let mut normalized = parsed.to_string(); + while normalized.ends_with('/') { + normalized.pop(); + } + Ok(normalized) +} +``` + +- [ ] **Step 4: Implement Stronghold hash and keyring vault password commands** + +Create `apps/undefined-chat/src-tauri/src/secret.rs`: + +```rust +use serde::Serialize; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +const KEYRING_SERVICE: &str = "com.undefined.chat"; +const KEYRING_USER: &str = "stronghold-vault"; + +#[derive(Debug, Clone, Serialize)] +pub struct SecretStatus { + pub available: bool, + pub degraded: bool, + pub detail: String, +} + +pub fn classify_secret_storage(available: bool, detail: &str) -> SecretStatus { + SecretStatus { + available, + degraded: !available, + detail: detail.to_string(), + } +} + +pub fn derive_stronghold_key(password: &str) -> Vec { + Sha256::digest(password.as_bytes()).to_vec() +} + +fn activate_native_keyring() -> Result<(), String> { + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + { + keyring::use_native_store(true) + .map_err(|err| format!("native keyring activation failed: {err}")) + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + Ok(()) + } +} + +fn vault_entry() -> Result { + activate_native_keyring()?; + keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) + .map_err(|err| format!("keyring unavailable: {err}")) +} + +#[tauri::command] +pub fn probe_secret_storage() -> SecretStatus { + match vault_entry() { + Ok(entry) => match entry.get_password() { + Ok(_) => classify_secret_storage(true, "system keyring available"), + Err(keyring::Error::NoEntry) => classify_secret_storage( + true, + "system keyring available; vault password not initialized", + ), + Err(err) => classify_secret_storage(false, &format!("keyring read failed: {err}")), + }, + Err(err) => classify_secret_storage(false, &format!("keyring unavailable: {err}")), + } +} + +#[tauri::command] +pub fn ensure_vault_password() -> Result { + let entry = vault_entry()?; + match entry.get_password() { + Ok(password) => Ok(password), + Err(keyring::Error::NoEntry) => { + let password = Uuid::new_v4().to_string(); + entry + .set_password(&password) + .map_err(|err| format!("keyring write failed: {err}"))?; + Ok(password) + } + Err(err) => Err(format!("keyring read failed: {err}")), + } +} +``` + +- [ ] **Step 5: Add Stronghold API Key round-trip helper** + +Create `apps/undefined-chat/src/secureStorage.ts`: + +```ts +import { appDataDir } from "@tauri-apps/api/path"; +import { invoke } from "@tauri-apps/api/core"; +import { Client, Stronghold } from "@tauri-apps/plugin-stronghold"; + +const CLIENT_NAME = "undefined-chat"; +const API_KEY_RECORD = "runtime-api-key"; + +async function loadClient(): Promise<{ stronghold: Stronghold; client: Client }> { + const vaultPassword = await invoke("ensure_vault_password"); + const vaultPath = `${await appDataDir()}/undefined-chat.vault.hold`; + const stronghold = await Stronghold.load(vaultPath, vaultPassword); + try { + return { stronghold, client: await stronghold.loadClient(CLIENT_NAME) }; + } catch { + return { stronghold, client: await stronghold.createClient(CLIENT_NAME) }; + } +} + +export async function saveRuntimeApiKey(apiKey: string): Promise { + const { stronghold, client } = await loadClient(); + const store = client.getStore(); + const data = Array.from(new TextEncoder().encode(apiKey)); + await store.insert(API_KEY_RECORD, data); + await stronghold.save(); +} + +export async function loadRuntimeApiKey(): Promise { + const { client } = await loadClient(); + const store = client.getStore(); + const data = await store.get(API_KEY_RECORD); + if (!data) { + return null; + } + return new TextDecoder().decode(new Uint8Array(data)); +} +``` + +- [ ] **Step 6: Run Rust tests** + +Run: + +```bash +cd apps/undefined-chat +cargo test --manifest-path src-tauri/Cargo.toml +``` + +Expected: PASS. + +- [ ] **Step 7: Run Tauri check** + +Run: + +```bash +cd apps/undefined-chat +npm run tauri:check +``` + +Expected: PASS. + +- [ ] **Step 8: Commit secret PoC** + +```bash +git add apps/undefined-chat/src/secureStorage.ts apps/undefined-chat/src-tauri/src/config.rs apps/undefined-chat/src-tauri/src/lib.rs apps/undefined-chat/src-tauri/src/secret.rs apps/undefined-chat/src-tauri/src/poc_tests.rs +git commit -m "feat(chat): add secret storage poc" +``` + +## Task 4: Runtime Health and SSE PoC + +**Files:** +- Create: `apps/undefined-chat/src-tauri/src/runtime_client.rs` +- Modify: `apps/undefined-chat/src/runtime.ts` +- Modify: `apps/undefined-chat/src-tauri/src/lib.rs` + +- [ ] **Step 1: Add typed frontend wrappers** + +Update `apps/undefined-chat/src/runtime.ts` to include SSE command types: + +```ts +import { invoke } from "@tauri-apps/api/core"; + +export type ConnectionState = + | "idle" + | "connecting" + | "connected" + | "streaming" + | "resuming" + | "json_fallback" + | "disconnected"; + +export type SecretStatus = { + available: boolean; + degraded: boolean; + detail: string; +}; + +export type RuntimeHealth = { + ok: boolean; + status: number; + body: string; +}; + +export type StartJobEventStreamInput = { + runtimeUrl: string; + apiKey: string; + jobId: string; + afterSeq: number; +}; + +export async function probeSecretStorage(): Promise { + return await invoke("probe_secret_storage"); +} + +export async function probeRuntime(runtimeUrl: string): Promise { + return await invoke("probe_runtime", { runtimeUrl }); +} + +export async function startJobEventStream( + input: StartJobEventStreamInput, +): Promise { + await invoke("start_job_event_stream", { input }); +} +``` + +- [ ] **Step 2: Implement Runtime commands** + +Create `apps/undefined-chat/src-tauri/src/runtime_client.rs`: + +```rust +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; + +use crate::config::normalize_runtime_url; + +#[derive(Debug, Serialize)] +pub struct RuntimeHealth { + pub ok: bool, + pub status: u16, + pub body: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartJobEventStreamInput { + pub runtime_url: String, + pub api_key: String, + pub job_id: String, + pub after_seq: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeStreamEvent { + pub job_id: String, + pub raw: String, +} + +#[tauri::command] +pub async fn probe_runtime(runtime_url: String) -> Result { + let base = normalize_runtime_url(&runtime_url)?; + let url = format!("{base}/health"); + let response = tauri_plugin_http::reqwest::get(url) + .await + .map_err(|err| format!("runtime request failed: {err}"))?; + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + Ok(RuntimeHealth { + ok: (200..300).contains(&status), + status, + body, + }) +} + +#[tauri::command] +pub async fn start_job_event_stream( + app: AppHandle, + input: StartJobEventStreamInput, +) -> Result<(), String> { + let base = normalize_runtime_url(&input.runtime_url)?; + let url = format!( + "{base}/api/v1/chat/jobs/{}/events?after={}", + input.job_id, input.after_seq + ); + let client = tauri_plugin_http::reqwest::Client::new(); + let response = client + .get(url) + .header("X-Undefined-API-Key", input.api_key) + .header("Accept", "text/event-stream") + .header("Last-Event-ID", input.after_seq.to_string()) + .send() + .await + .map_err(|err| format!("sse request failed: {err}"))?; + if !response.status().is_success() { + return Err(format!("sse request failed with status {}", response.status())); + } + + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let bytes = chunk.map_err(|err| format!("sse stream failed: {err}"))?; + let raw = String::from_utf8_lossy(&bytes).to_string(); + app.emit( + "runtime-sse-chunk", + RuntimeStreamEvent { + job_id: input.job_id.clone(), + raw, + }, + ) + .map_err(|err| format!("emit failed: {err}"))?; + } + Ok(()) +} +``` + +Replace `apps/undefined-chat/src-tauri/src/lib.rs` with: + +```rust +mod config; +mod runtime_client; +mod secret; + +#[cfg(test)] +mod poc_tests; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_stronghold::Builder::new(secret::derive_stronghold_key).build()) + .invoke_handler(tauri::generate_handler![ + secret::probe_secret_storage, + secret::ensure_vault_password, + runtime_client::probe_runtime, + runtime_client::start_job_event_stream, + ]) + .run(tauri::generate_context!()) + .expect("failed to run Undefined Chat app"); +} +``` + +- [ ] **Step 3: Run Rust checks** + +Run: + +```bash +cd apps/undefined-chat +cargo test --manifest-path src-tauri/Cargo.toml +npm run tauri:check +``` + +Expected: PASS. + +- [ ] **Step 4: Commit Runtime/SSE PoC** + +```bash +git add apps/undefined-chat/src/runtime.ts apps/undefined-chat/src-tauri/src/lib.rs apps/undefined-chat/src-tauri/src/runtime_client.rs +git commit -m "feat(chat): add runtime sse poc" +``` + +## Task 5: Streaming Upload PoC + +**Files:** +- Create: `apps/undefined-chat/src-tauri/src/upload.rs` +- Modify: `apps/undefined-chat/src/runtime.ts` +- Modify: `apps/undefined-chat/src-tauri/src/lib.rs` + +- [ ] **Step 1: Add frontend upload wrapper** + +Append to `apps/undefined-chat/src/runtime.ts`: + +```ts +export type UploadAttachmentInput = { + runtimeUrl: string; + apiKey: string; + filePath: string; +}; + +export type UploadAttachmentResult = { + status: number; + body: string; +}; + +export async function uploadAttachmentStreaming( + input: UploadAttachmentInput, +): Promise { + return await invoke("upload_attachment_streaming", { + input, + }); +} +``` + +- [ ] **Step 2: Implement Rust streaming upload command** + +Create `apps/undefined-chat/src-tauri/src/upload.rs`: + +```rust +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tokio_util::io::ReaderStream; + +use crate::config::normalize_runtime_url; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadAttachmentInput { + pub runtime_url: String, + pub api_key: String, + pub file_path: String, +} + +#[derive(Debug, Serialize)] +pub struct UploadAttachmentResult { + pub status: u16, + pub body: String, +} + +#[tauri::command] +pub async fn upload_attachment_streaming( + input: UploadAttachmentInput, +) -> Result { + let base = normalize_runtime_url(&input.runtime_url)?; + let path = PathBuf::from(&input.file_path); + if !path.is_file() { + return Err("file_path must point to a file".to_string()); + } + + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("attachment") + .to_string(); + let file = tokio::fs::File::open(&path) + .await + .map_err(|err| format!("file open failed: {err}"))?; + let stream = ReaderStream::with_capacity(file, 256 * 1024); + let body = tauri_plugin_http::reqwest::Body::wrap_stream(stream); + let part = tauri_plugin_http::reqwest::multipart::Part::stream(body) + .file_name(file_name); + let form = tauri_plugin_http::reqwest::multipart::Form::new().part("file", part); + let client = tauri_plugin_http::reqwest::Client::new(); + let response = client + .post(format!("{base}/api/v1/chat/attachments")) + .header("X-Undefined-API-Key", input.api_key) + .multipart(form) + .send() + .await + .map_err(|err| format!("upload failed: {err}"))?; + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + Ok(UploadAttachmentResult { status, body }) +} +``` + +Replace `apps/undefined-chat/src-tauri/src/lib.rs` with: + +```rust +mod config; +mod runtime_client; +mod secret; +mod upload; + +#[cfg(test)] +mod poc_tests; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_stronghold::Builder::new(secret::derive_stronghold_key).build()) + .invoke_handler(tauri::generate_handler![ + secret::probe_secret_storage, + secret::ensure_vault_password, + runtime_client::probe_runtime, + runtime_client::start_job_event_stream, + upload::upload_attachment_streaming, + ]) + .run(tauri::generate_context!()) + .expect("failed to run Undefined Chat app"); +} +``` + +- [ ] **Step 3: Verify upload code has no full-file read** + +Run: + +```bash +rg -n "std::fs::read|read_to_end|base64|Blob" apps/undefined-chat/src-tauri/src/upload.rs apps/undefined-chat/src +``` + +Expected: no matches. + +- [ ] **Step 4: Run Rust checks** + +Run: + +```bash +cd apps/undefined-chat +cargo test --manifest-path src-tauri/Cargo.toml +npm run tauri:check +``` + +Expected: PASS. + +- [ ] **Step 5: Commit streaming upload PoC** + +```bash +git add apps/undefined-chat/src/runtime.ts apps/undefined-chat/src-tauri/src/lib.rs apps/undefined-chat/src-tauri/src/upload.rs +git commit -m "feat(chat): add streaming upload poc" +``` + +## Task 6: HTML Preview CSP PoC + +**Files:** +- Create: `apps/undefined-chat/src-tauri/src/preview.rs` +- Modify: `apps/undefined-chat/src/runtime.ts` +- Modify: `apps/undefined-chat/src-tauri/src/lib.rs` + +- [ ] **Step 1: Add preview wrapper** + +Append to `apps/undefined-chat/src/runtime.ts`: + +```ts +export type HtmlPreviewInput = { + title: string; + html: string; +}; + +export async function openHtmlPreview(input: HtmlPreviewInput): Promise { + await invoke("open_html_preview", { input }); +} +``` + +- [ ] **Step 2: Implement isolated preview command** + +Create `apps/undefined-chat/src-tauri/src/preview.rs`: + +```rust +use serde::Deserialize; +use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder}; + +#[derive(Debug, Deserialize)] +pub struct HtmlPreviewInput { + pub title: String, + pub html: String, +} + +pub(crate) fn preview_document(title: &str, html: &str) -> String { + let escaped_title = title + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + format!( + r#" + + + + +{escaped_title} + + +{html} + +"# + ) +} + +#[tauri::command] +pub async fn open_html_preview( + app: AppHandle, + input: HtmlPreviewInput, +) -> Result<(), String> { + let document = preview_document(&input.title, &input.html); + let encoded = urlencoding::encode(&document); + let url = format!("data:text/html;charset=utf-8,{encoded}"); + let label = format!("html-preview-{}", uuid::Uuid::new_v4()); + let parsed_url = url + .parse() + .map_err(|err| format!("preview url failed: {err}"))?; + WebviewWindowBuilder::new(&app, label, WebviewUrl::External(parsed_url)) + .title(input.title) + .inner_size(980.0, 720.0) + .resizable(true) + .build() + .map_err(|err| format!("preview window failed: {err}"))?; + Ok(()) +} +``` + +Replace `apps/undefined-chat/src-tauri/src/lib.rs` with: + +```rust +mod config; +mod preview; +mod runtime_client; +mod secret; +mod upload; + +#[cfg(test)] +mod poc_tests; + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_stronghold::Builder::new(secret::derive_stronghold_key).build()) + .invoke_handler(tauri::generate_handler![ + secret::probe_secret_storage, + secret::ensure_vault_password, + runtime_client::probe_runtime, + runtime_client::start_job_event_stream, + upload::upload_attachment_streaming, + preview::open_html_preview, + ]) + .run(tauri::generate_context!()) + .expect("failed to run Undefined Chat app"); +} +``` + +- [ ] **Step 3: Add CSP unit test** + +Append to `apps/undefined-chat/src-tauri/src/poc_tests.rs`: + +```rust +#[test] +fn html_preview_csp_blocks_network_and_eval() { + let doc = crate::preview::preview_document("Test", "

Hello

"); + assert!(doc.contains("default-src 'none'")); + assert!(doc.contains("connect-src 'none'")); + assert!(doc.contains("form-action 'none'")); + assert!(doc.contains("object-src 'none'")); + assert!(!doc.contains("unsafe-eval")); +} +``` + +- [ ] **Step 4: Run checks** + +Run: + +```bash +cd apps/undefined-chat +cargo test --manifest-path src-tauri/Cargo.toml +npm run tauri:check +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 5: Commit preview PoC** + +```bash +git add apps/undefined-chat/src/runtime.ts apps/undefined-chat/src-tauri/src/lib.rs apps/undefined-chat/src-tauri/src/preview.rs apps/undefined-chat/src-tauri/src/poc_tests.rs +git commit -m "feat(chat): add isolated html preview poc" +``` + +## Task 7: React State Tests and PoC UI Completion + +**Files:** +- Create: `apps/undefined-chat/src/app.test.tsx` +- Modify: `apps/undefined-chat/src/App.tsx` + +- [ ] **Step 1: Write frontend tests** + +Create `apps/undefined-chat/src/app.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +const storageMocks = vi.hoisted(() => ({ + saveRuntimeApiKey: vi.fn(async (_apiKey: string) => undefined), + loadRuntimeApiKey: vi.fn(async () => "stored-key"), +})); + +vi.mock("./runtime", () => ({ + probeSecretStorage: vi.fn(async () => ({ + available: true, + degraded: false, + detail: "system keyring available", + })), + probeRuntime: vi.fn(async () => ({ + ok: true, + status: 200, + body: "OK", + })), +})); + +vi.mock("./secureStorage", () => storageMocks); + +describe("Undefined Chat PoC shell", () => { + it("shows connected state after runtime probe", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "测试连接" })); + + expect(await screen.findByText("已连接")).toBeInTheDocument(); + }); + + it("shows secret storage probe result", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "探测" })); + + expect(await screen.findByText(/system keyring available/)).toBeInTheDocument(); + }); + + it("round-trips the runtime api key through secure storage controls", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("API Key"), "secret-key"); + await user.click(screen.getByRole("button", { name: "保存 API Key" })); + + expect(storageMocks.saveRuntimeApiKey).toHaveBeenCalledWith("secret-key"); + expect(await screen.findByText("API Key 已保存")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "读取 API Key" })); + + expect(storageMocks.loadRuntimeApiKey).toHaveBeenCalled(); + expect(await screen.findByDisplayValue("stored-key")).toBeInTheDocument(); + expect(await screen.findByText("API Key 已读取")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Add test setup dependency** + +Modify `apps/undefined-chat/package.json` devDependencies: + +```json +"@testing-library/jest-dom": "^6.6.3" +``` + +Create `apps/undefined-chat/src/test-setup.ts`: + +```ts +import "@testing-library/jest-dom/vitest"; +``` + +Modify `apps/undefined-chat/vite.config.ts` test block: + +```ts + test: { + environment: "jsdom", + globals: true, + setupFiles: "./src/test-setup.ts", + }, +``` + +- [ ] **Step 3: Add API Key storage controls to the PoC shell** + +Replace `apps/undefined-chat/src/App.tsx` with: + +```tsx +import { useState } from "react"; +import { + type ConnectionState, + type RuntimeHealth, + type SecretStatus, + probeRuntime, + probeSecretStorage, +} from "./runtime"; +import { loadRuntimeApiKey, saveRuntimeApiKey } from "./secureStorage"; + +const defaultRuntimeUrl = "http://127.0.0.1:8788"; + +function statusLabel(state: ConnectionState): string { + const labels: Record = { + idle: "待连接", + connecting: "正在连接", + connected: "已连接", + streaming: "正在接收事件", + resuming: "正在续接", + json_fallback: "已降级查询", + disconnected: "连接断开", + }; + return labels[state]; +} + +export function App() { + const [runtimeUrl, setRuntimeUrl] = useState(defaultRuntimeUrl); + const [apiKey, setApiKey] = useState(""); + const [connectionState, setConnectionState] = + useState("idle"); + const [secretStatus, setSecretStatus] = useState(null); + const [runtimeHealth, setRuntimeHealth] = useState(null); + const [storageMessage, setStorageMessage] = useState(""); + const [error, setError] = useState(""); + + async function runSecretProbe(): Promise { + setError(""); + const result = await probeSecretStorage(); + setSecretStatus(result); + } + + async function runRuntimeProbe(): Promise { + setError(""); + setConnectionState("connecting"); + try { + const result = await probeRuntime(runtimeUrl); + setRuntimeHealth(result); + setConnectionState(result.ok ? "connected" : "disconnected"); + } catch (err) { + setConnectionState("disconnected"); + setError(String(err)); + } + } + + async function saveApiKey(): Promise { + setError(""); + try { + await saveRuntimeApiKey(apiKey); + setStorageMessage("API Key 已保存"); + } catch (err) { + setStorageMessage(""); + setError(String(err)); + } + } + + async function loadApiKey(): Promise { + setError(""); + try { + const stored = await loadRuntimeApiKey(); + if (stored) { + setApiKey(stored); + setStorageMessage("API Key 已读取"); + } else { + setStorageMessage("没有已保存的 API Key"); + } + } catch (err) { + setStorageMessage(""); + setError(String(err)); + } + } + + return ( +
+
+

Undefined Chat PoC

+

原生优先 WebChat 客户端验证

+

+ 当前 PoC 只验证连接、安全存储、事件流、上传和 HTML 预览关键路径。 +

+
+
+ +
+ setRuntimeUrl(event.currentTarget.value)} + /> + +
+
+ {statusLabel(connectionState)} +
+ {runtimeHealth ? ( +
{JSON.stringify(runtimeHealth, null, 2)}
+ ) : null} +
+
+
+ Secret Storage + +
+ {secretStatus ? ( +
{JSON.stringify(secretStatus, null, 2)}
+ ) : ( +

尚未探测 Stronghold/keyring 状态。

+ )} +
+
+ +
+ setApiKey(event.currentTarget.value)} + /> + + +
+ {storageMessage ?

{storageMessage}

: null} +
+ {error ?
{error}
: null} +
+ ); +} +``` + +- [ ] **Step 4: Run tests to verify pass** + +Run: + +```bash +cd apps/undefined-chat +npm install --package-lock-only +npm run test +npm run typecheck +npm run lint +``` + +Expected: PASS. + +- [ ] **Step 5: Run all app checks** + +Run: + +```bash +cd apps/undefined-chat +npm run check +``` + +Expected: PASS. + +- [ ] **Step 6: Commit React PoC tests** + +```bash +git add apps/undefined-chat/package.json apps/undefined-chat/package-lock.json apps/undefined-chat/src/App.tsx apps/undefined-chat/src/app.test.tsx apps/undefined-chat/src/test-setup.ts apps/undefined-chat/vite.config.ts +git commit -m "test(chat): cover poc shell states" +``` + +## Task 8: PoC Documentation and Result Recording + +**Files:** +- Create: `apps/undefined-chat/README.md` +- Modify: `docs/superpowers/specs/2026-06-07-undefined-chat-design.md` + +- [ ] **Step 1: Create PoC README** + +Create `apps/undefined-chat/README.md`: + +```markdown +# Undefined Chat PoC + +This app is the Phase 0 proof of concept for the native Undefined Chat client. + +## Verified paths + +- Tauri v2 + React app scaffold +- Runtime health request through Tauri commands +- Stronghold/keyring availability probe and API Key round-trip +- SSE command path for Runtime job events +- Streaming attachment upload command path +- Isolated HTML preview with strict CSP + +## Commands + +```bash +npm install +npm run check +npm run tauri:dev +``` + +## Runtime requirement + +Run the Runtime API with a configured `X-Undefined-API-Key`. The PoC connects to `http://127.0.0.1:8788` by default. + +## Platform notes + +- Linux keyring behavior depends on Secret Service/keyutils availability. +- Android background behavior is only validated after `npm run tauri:android:init` and device testing. +- The PoC does not implement the full production chat UI. +``` + +- [ ] **Step 2: Add PoC result section to design spec** + +Append to `docs/superpowers/specs/2026-06-07-undefined-chat-design.md`: + +```markdown + +## PoC 结果记录 + +PoC implementation plan: [2026-06-07-undefined-chat-poc.md](../plans/2026-06-07-undefined-chat-poc.md) + +PoC 通过后在本节记录: + +- 桌面 Stronghold/keyring 验证结果。 +- Linux Secret Service/keyutils 可用性和降级体验。 +- Android 安全存储、SSE、后台生命周期和 HTML 预览验证结果。 +- streaming upload 是否避免 JS Blob/base64/IPC 全量传输。 +- SSE 大型事件流和断线续接验证结果。 +- 阻塞项和规格调整。 +``` + +- [ ] **Step 3: Run documentation checks** + +Run: + +```bash +uv run ruff check . +uv run mypy . +cd apps/undefined-chat +npm run check +``` + +Expected: PASS. + +- [ ] **Step 4: Commit PoC docs** + +```bash +git add apps/undefined-chat/README.md docs/superpowers/specs/2026-06-07-undefined-chat-design.md +git commit -m "docs(chat): record poc scope" +``` + +## Task 9: Android Smoke Checklist + +**Files:** +- Modify: `apps/undefined-chat/README.md` + +- [ ] **Step 1: Add Android smoke checklist** + +Append to `apps/undefined-chat/README.md`: + +```markdown + +## Android smoke checklist + +Run after desktop PoC checks pass: + +```bash +npm run tauri:android:init +npm run tauri:android:debug -- --apk +``` + +On a device or emulator: + +- Open the app and verify the main screen renders. +- Probe secret storage and record whether secure storage is available. +- Connect to Runtime over LAN and verify `/health`. +- Start an SSE stream against a known running job and verify events arrive. +- Upload a file larger than 25 MB and confirm the process does not freeze the UI. +- Open HTML preview and verify it uses the dedicated Android page/window surface. +- Background the app during a running job and record whether events resume after reopening. +``` + +- [ ] **Step 2: Run README lint and app checks** + +Run: + +```bash +cd apps/undefined-chat +npm run check +``` + +Expected: PASS. + +- [ ] **Step 3: Commit Android checklist** + +```bash +git add apps/undefined-chat/README.md +git commit -m "docs(chat): add android poc checklist" +``` + +## Final Verification + +Run from repository root: + +```bash +uv run pytest tests/test_runtime_api_chat_attachments_poc.py tests/test_runtime_api_chat_jobs.py tests/test_runtime_api_chat_history.py -q +uv run ruff check . +uv run ruff format --check . +uv run mypy . +cd apps/undefined-chat +npm run check +``` + +Expected: + +- All Python tests pass. +- Ruff passes. +- Mypy passes. +- Undefined Chat app lint/typecheck/test/cargo checks pass. + +## Handoff Notes + +If the PoC fails on Android, Linux keyring, SSE stability, streaming upload, or HTML preview CSP, stop and update `docs/superpowers/specs/2026-06-07-undefined-chat-design.md` before implementing full product tasks. diff --git a/docs/superpowers/plans/2026-06-08-undefined-chat-full.md b/docs/superpowers/plans/2026-06-08-undefined-chat-full.md new file mode 100644 index 00000000..4ae5e6df --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-undefined-chat-full.md @@ -0,0 +1,301 @@ +# Undefined Chat Full Product Implementation Plan + +> 状态:已实现并归档(point-in-time 文档)。本计划记录将 PoC 推进为完整产品的历史步骤;其后又经历了一轮原生优先重写与平台/移动端打磨(i18n 中英双语、平台抽象层、移动端适配、HTML 对齐基线等)。最新实现以 [docs/undefined-chat.md](../../undefined-chat.md) 与 `apps/undefined-chat` 代码为准(2026-06)。 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn `apps/undefined-chat` from the Phase 0 PoC into the complete native-first WebChat client backed by Runtime as the only source of truth. + +**Architecture:** Runtime owns conversations, history, jobs, events, attachments, and command metadata. Tauri owns local connection config, API key custody, Runtime HTTP/SSE/upload/download bridges, and platform-specific preview behavior. React owns only UI state, drafts, current selection, event cursors, and rendering of Runtime-returned data. + +**Tech Stack:** Python aiohttp Runtime API, pytest/ruff/mypy, Tauri v2 Rust, React 19 + TypeScript + Vitest, existing GitHub Actions release pipeline. + +--- + +## File Structure + +- Modify `src/Undefined/api/routes/chat.py`: formal WebChat job contract, per-conversation concurrency, active jobs array, attachment upload/download/preview, structured send normalization, `waiting_input`/`requires_action` reservation. +- Modify `src/Undefined/api/webchat_store.py`: stable `message_id`, references storage, richer attachment metadata, old-history compatibility. +- Modify `src/Undefined/api/app.py`: register new attachment download/preview routes if missing. +- Modify `src/Undefined/api/_openapi.py` and `docs/openapi.md`: document the promoted Runtime contract. +- Add/modify `tests/test_runtime_api_chat_*.py`: red-green coverage for message IDs, structured send, attachments, active jobs, and per-conversation locking. +- Refactor `apps/undefined-chat/src-tauri/src/*.rs`: replace PoC commands with production commands for config, secrets, runtime calls, SSE subscription lifecycle, streaming upload/download, platform info, and HTML preview. +- Refactor `apps/undefined-chat/src/**/*.tsx|ts|css`: split PoC single screen into typed runtime client, store/reducer, conversation list, message timeline, composer, rendering, settings, and i18n modules. +- Modify `.github/workflows/ci.yml`, `.github/workflows/release.yml`, `.githooks/pre-commit`, `.githooks/pre-tag`, `scripts/bump_version.py`, `scripts/release_notes.py`, and tests: add Undefined Chat to quality, release, and version synchronization. +- Add/update `docs/undefined-chat.md`, `apps/undefined-chat/README.md`, `README.md`, `docs/app.md`, `docs/build.md`, `docs/webui-guide.md`, `scripts/README.md`. + +## Task 1: Runtime Stable History IDs + +**Files:** +- Modify: `src/Undefined/api/webchat_store.py` +- Modify: `src/Undefined/api/routes/chat.py` +- Test: `tests/test_runtime_api_chat_history.py` + +- [ ] **Step 1: Write failing tests** + - Add coverage that newly appended WebChat records include stable string `message_id`. + - Add coverage that old records without `message_id` return deterministic IDs in `GET /api/v1/chat/history`. + - Add coverage that repeated history reads return the same IDs. + +- [ ] **Step 2: Verify red** + - Run: `uv run pytest tests/test_runtime_api_chat_history.py -q` + - Expected before implementation: assertions fail because `message_id` is missing. + +- [ ] **Step 3: Implement** + - Generate `message_id` in `WebChatConversationStore.append_message()`. + - Add a deterministic legacy helper based on conversation id, record timestamp, role, index, and content hash. + - Include `message_id` in `_history_record_to_item()`. + - Preserve old JSON files by lazy-normalizing in memory and on next write only. + +- [ ] **Step 4: Verify green** + - Run: `uv run pytest tests/test_runtime_api_chat_history.py -q` + +## Task 2: Runtime Per-Conversation Job Concurrency + +**Files:** +- Modify: `src/Undefined/api/routes/chat.py` +- Test: `tests/test_runtime_api_chat_jobs.py` + +- [ ] **Step 1: Write failing tests** + - Different conversations can each create a running job. + - Same conversation returns `409`. + - Delete and clear only block the target conversation. + - `GET /api/v1/chat/jobs/active` returns `jobs[]` and keeps compatible `job`. + +- [ ] **Step 2: Verify red** + - Run: `uv run pytest tests/test_runtime_api_chat_jobs.py -q` + - Expected before implementation: global lock behavior rejects different-conversation job creation and active response lacks `jobs`. + +- [ ] **Step 3: Implement** + - Replace global blocking check in `ChatJobManager.create_job()` with conversation-scoped blocking. + - Add `get_active_jobs(conversation_id: str | None = None)`. + - Change `has_running_job()` and `clear_history_when_idle()` to accept a conversation id and only inspect matching jobs. + - Update conversation list `is_running` using active job set. + - Update delete and clear handlers to use target conversation lock. + +- [ ] **Step 4: Verify green** + - Run: `uv run pytest tests/test_runtime_api_chat_jobs.py tests/test_runtime_api_chat_history.py -q` + +## Task 3: Runtime Native Attachment API + +**Files:** +- Modify: `src/Undefined/api/routes/chat.py` +- Modify: `src/Undefined/api/app.py` +- Test: `tests/test_runtime_api_chat_attachments.py` + +- [ ] **Step 1: Write failing tests** + - Upload persists metadata and bytes, returns `discarded: false`, `id`, `name`, `size`, `media_type`, `kind`, and download URLs. + - `GET /api/v1/chat/attachments/{id}` downloads exact bytes with safe `Content-Disposition`. + - `GET /api/v1/chat/attachments/{id}/preview` returns image bytes for images and `415` for non-previewable files. + - Oversize upload returns `413` with `max_upload_size_bytes`. + - Path traversal style filenames are sanitized. + +- [ ] **Step 2: Verify red** + - Run: `uv run pytest tests/test_runtime_api_chat_attachments.py -q` + +- [ ] **Step 3: Implement** + - Add a small Runtime WebChat attachment store rooted under the existing data path. + - Stream multipart chunks to a temporary file, enforce Runtime max size while reading, then atomically move into attachment storage. + - Store metadata JSON separately from bytes. + - Expose download and preview handlers with strict id validation, safe filename, MIME detection, and no local path disclosure. + - Keep `GET /api/v1/chat/attachments/capabilities` as the client source for upload limits. + +- [ ] **Step 4: Verify green** + - Run: `uv run pytest tests/test_runtime_api_chat_attachments.py -q` + +## Task 4: Runtime Structured Send, References, and Action Reservation + +**Files:** +- Modify: `src/Undefined/api/routes/chat.py` +- Modify: `src/Undefined/api/webchat_store.py` +- Test: `tests/test_runtime_api_chat_jobs.py` +- Test: `tests/test_runtime_api_chat_history.py` +- Test: `tests/test_runtime_api_chat_stream.py` + +- [ ] **Step 1: Write failing tests** + - `POST /api/v1/chat/jobs` accepts old `{ "message": "text" }`. + - `POST /api/v1/chat/jobs` accepts new `{ "message": { "text": "...", "attachment_ids": [], "references": [] } }`. + - Empty text plus no attachment returns `400`. + - Unknown conversation returns `404`. + - Unknown attachment returns `404`; attachment uploaded for another conversation/scope returns `403` when scope is available. + - History user item includes `references` and normalized attachment metadata. + - Job snapshot includes `waiting_input: null`. + - Event stream accepts a reserved `requires_action` event without breaking existing event types. + +- [ ] **Step 2: Verify red** + - Run: `uv run pytest tests/test_runtime_api_chat_jobs.py tests/test_runtime_api_chat_history.py tests/test_runtime_api_chat_stream.py -q` + +- [ ] **Step 3: Implement** + - Add structured payload parser that returns normalized text, attachment ids, references, and display history metadata. + - Convert references into Runtime-owned history metadata and AI-visible quoted text. + - Convert attachment ids into AI-visible attachment XML and history attachment metadata. + - Keep old string message compatibility for WebUI. + - Add `waiting_input` field to snapshots, defaulting to `None`. + - Allow sanitized `requires_action` events in buffers and history display where present. + +- [ ] **Step 4: Verify green** + - Run: `uv run pytest tests/test_runtime_api_chat_jobs.py tests/test_runtime_api_chat_history.py tests/test_runtime_api_chat_stream.py tests/test_runtime_api_chat_attachments.py -q` + +## Task 5: Tauri Production Runtime Bridge + +**Files:** +- Modify: `apps/undefined-chat/src-tauri/src/lib.rs` +- Modify: `apps/undefined-chat/src-tauri/src/config.rs` +- Modify: `apps/undefined-chat/src-tauri/src/runtime_client.rs` +- Modify: `apps/undefined-chat/src-tauri/src/secret.rs` +- Modify: `apps/undefined-chat/src-tauri/src/upload.rs` +- Add: `apps/undefined-chat/src-tauri/src/state.rs` +- Add: `apps/undefined-chat/src-tauri/src/download.rs` +- Add: `apps/undefined-chat/src-tauri/src/platform.rs` +- Test: `apps/undefined-chat/src-tauri/src/poc_tests.rs` or focused Rust unit tests. + +- [ ] **Step 1: Write failing tests** + - Config normalizes Runtime URL and rejects non-HTTP origins. + - API key commands never return the vault password to React. + - Runtime request URL builder only targets configured Runtime origin. + - SSE parser turns chunks into typed events and tracks sequence. + - Upload still streams from Rust file handle. + +- [ ] **Step 2: Verify red** + - Run: `cd apps/undefined-chat && cargo test --manifest-path src-tauri/Cargo.toml` + +- [ ] **Step 3: Implement** + - Add commands: `get_runtime_config`, `save_runtime_config`, `clear_runtime_config`, `save_api_key`, `load_api_key_status`, `delete_api_key`, `unlock_vault`, `confirm_insecure_storage_fallback`. + - Add Runtime commands: `runtime_request`, `list_conversations`, `get_history`, `get_active_jobs`, `send_message`, `cancel_job`, `list_commands`, `fetch_job_events_json`. + - Replace PoC `start_job_event_stream` with subscription id lifecycle: `start_job_event_stream` emits parsed events and returns immediately; `stop_job_event_stream` cancels it. + - Keep JSON fallback command for platforms or failures where SSE is not stable. + - Add download helper for attachment save/preview bytes without exposing arbitrary local paths to React. + +- [ ] **Step 4: Verify green** + - Run: `cd apps/undefined-chat && npm run tauri:fmt:check && npm run tauri:check` + +## Task 6: React App Architecture and Runtime Store + +**Files:** +- Replace: `apps/undefined-chat/src/App.tsx` +- Add: `apps/undefined-chat/src/runtime-client/*` +- Add: `apps/undefined-chat/src/chat-store/*` +- Add: `apps/undefined-chat/src/i18n/*` +- Test: `apps/undefined-chat/src/**/*.test.ts` + +- [ ] **Step 1: Write failing tests** + - Store bootstraps config, health, conversations, active jobs, and current history. + - Store blocks send only for the selected conversation with an active job. + - Store handles `connecting`, `connected`, `streaming`, `resuming`, `json_fallback`, `disconnected`. + - Store applies job events by `seq` without duplicating events after reconnect. + - i18n defaults to Chinese and exposes English keys. + +- [ ] **Step 2: Verify red** + - Run: `cd apps/undefined-chat && npm run test -- --run` + +- [ ] **Step 3: Implement** + - Add typed Runtime DTOs matching Runtime API. + - Add reducer/actions for connection, conversations, history pages, active jobs, composer drafts, attachment uploads, references, commands, and settings. + - Add Tauri event listener glue for SSE chunks/events and JSON fallback polling. + - Keep local drafts per conversation; do not persist history as source of truth. + +- [ ] **Step 4: Verify green** + - Run: `cd apps/undefined-chat && npm run typecheck && npm run test` + +## Task 7: Chat-First UI, Timeline, Composer, and Rendering + +**Files:** +- Add: `apps/undefined-chat/src/conversation-list/*` +- Add: `apps/undefined-chat/src/message-timeline/*` +- Add: `apps/undefined-chat/src/message-composer/*` +- Add: `apps/undefined-chat/src/rendering/*` +- Modify: `apps/undefined-chat/src/styles.css` +- Test: `apps/undefined-chat/src/**/*.test.tsx` + +- [ ] **Step 1: Write failing tests** + - Conversation list renders running states for multiple conversations. + - Timeline renders text, Markdown-ish content, attachments, references, tool/Agent calls, job status, and errors. + - Timeline uses windowed rendering for large histories. + - Composer supports text, Enter send, Shift+Enter newline, attachment queue, references, command suggestions, and disabled state for current-conversation running job. + - HTML preview action calls Tauri preview command. + +- [ ] **Step 2: Verify red** + - Run: `cd apps/undefined-chat && npm run test -- --run` + +- [ ] **Step 3: Implement** + - Visual thesis: quiet native chat workspace, dense but readable, with conversation state visible and the message flow dominant. + - Content plan: left conversation rail on desktop; chat timeline center; settings/detail drawer only on demand; mobile starts directly in chat with conversation/settings as separate page states. + - Interaction thesis: restrained drawer transitions, stable auto-scroll, hover/tap affordances for tool details and attachments. + - Implement responsive layout using CSS media queries, safe-area insets, stable toolbar and composer dimensions. + - Use accessible buttons, icon-sized controls where existing dependencies allow, and concise Chinese UI labels. + - Render safe HTML with sanitization strategy and put executable HTML only through isolated Tauri preview. + +- [ ] **Step 4: Verify green** + - Run: `cd apps/undefined-chat && npm run lint && npm run typecheck && npm run test` + +## Task 8: CI, Release, Version, Hooks + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `.githooks/pre-commit` +- Modify: `.githooks/pre-tag` +- Modify: `scripts/bump_version.py` +- Modify: `scripts/release_notes.py` +- Test: `tests/test_release_notes_script.py` +- Add: `tests/test_bump_version_script.py` + +- [ ] **Step 1: Write failing tests** + - Release validation fails when Undefined Chat `package.json`, `package-lock.json`, `Cargo.toml`, `tauri.conf.json`, or `Cargo.lock` version differs from `pyproject.toml`. + - Bump script updates Console and Chat manifests and lock files. + +- [ ] **Step 2: Verify red** + - Run: `uv run pytest tests/test_release_notes_script.py tests/test_bump_version_script.py -q` + +- [ ] **Step 3: Implement** + - Add shared app manifest metadata for `undefined-console` and `undefined-chat`. + - Add Chat quality job in CI. + - Release both products with distinct artifact names. + - Add Chat to hooks and pre-tag validation. + - Keep version source of truth in `pyproject.toml`. + +- [ ] **Step 4: Verify green** + - Run: `uv run pytest tests/test_release_notes_script.py tests/test_bump_version_script.py -q` + +## Task 9: Product Documentation + +**Files:** +- Add: `docs/undefined-chat.md` +- Modify: `docs/openapi.md` +- Modify: `docs/app.md` +- Modify: `docs/build.md` +- Modify: `docs/webui-guide.md` +- Modify: `README.md` +- Modify: `apps/undefined-chat/README.md` +- Modify: `scripts/README.md` + +- [ ] **Step 1: Update docs** + - Add “Undefined Chat vs WebUI WebChat” capability table. + - Document Runtime as source of truth, local-only drafts, SSE-first/JSON fallback, secure storage and Linux fallback, upload limits, HTML preview isolation, and Android lifecycle expectations. + - Document CI/release/version behavior for Chat. + +- [ ] **Step 2: Verify docs references** + - Run: `rg "Undefined Chat|undefined-chat|WebUI WebChat|bump_version" README.md docs apps/undefined-chat scripts -n` + +## Task 10: Final Verification, Review, Commit, Push + +**Files:** +- All touched files. + +- [ ] **Step 1: Run full verification** + - Run: `uv run pytest tests/` + - Run: `uv run ruff check .` + - Run: `uv run ruff format --check .` + - Run: `uv run mypy .` + - Run: `uv build --wheel` + - Run: `cd apps/undefined-console && npm run check` + - Run: `cd apps/undefined-chat && npm run check` + - If `apps/undefined-chat/src-tauri/target` introduces generated Python files, run `cargo clean --manifest-path apps/undefined-chat/src-tauri/Cargo.toml` before root mypy. + +- [ ] **Step 2: Request final code review** + - Dispatch a final reviewer over the diff from the starting SHA to HEAD. + - Fix Critical and Important findings. + +- [ ] **Step 3: Commit and push** + - Commit with a Conventional Commit subject. + - Include `Co-authored-by: GPT-5.5 Codex ` unless hooks or repo policy reject it. + - Push to `origin feature/chat-app`. diff --git a/docs/superpowers/specs/2026-06-07-undefined-chat-design.md b/docs/superpowers/specs/2026-06-07-undefined-chat-design.md new file mode 100644 index 00000000..ad8fbe2c --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-undefined-chat-design.md @@ -0,0 +1,455 @@ +# Undefined Chat 独立 App 设计规格 + +日期:2026-06-07 +状态:设计已确认并已实现(point-in-time 设计文档)。PoC 与完整产品均已落地,并经历一轮原生优先重写与平台/移动端打磨;最新实现以 [docs/undefined-chat.md](../../undefined-chat.md) 与 `apps/undefined-chat` 代码为准(2026-06)。 + +> 最终实现要点(与下文设计选项的对应):i18n 首期即提供完整中英双语切换;HTML 采用「正文 sanitize 内联 + 预览窗口/页面隔离运行脚本(IPC 隔离依赖 capability 缺失)」;桌面端凭据走系统 keyring,Linux 含 Secret Service 降级,Android 走移动端安全存储策略;发布矩阵覆盖 Windows / macOS / Linux / Android,iOS 当前不在发布矩阵内(iOS 为诚实降级处理)。下文“PoC 结果记录”为当时预留的归档清单,相关验证已随实现落地。 + +## 目标 + +新增一个独立跨平台聊天客户端 `Undefined Chat`,目录为 `apps/undefined-chat`。它只做 WebChat 体验,不承载 WebUI 管理功能,也不复用 `apps/undefined-console` 作为入口。客户端直连远端 Runtime API,产品形态覆盖 Windows、macOS、Linux 和 Android。 + +核心原则: + +- Runtime API 是唯一后端真源。 +- 客户端是 thin client,只采集输入、调用 API、展示 Runtime 返回的数据。 +- 客户端不维护聊天历史真源,不做本地 job 队列,不判断命令权限,不生成最终附件/引用表达。 +- 换客户端、换设备或重新打开 app 后,连接同一个 Runtime 时应看到一致的会话、历史、运行态和事件续接结果。 + +## 非目标 + +- 首期不做浏览器 Web 版。 +- 首期不做多连接档案。 +- 首期不做桌面多窗口并排聊天,但架构保留以后扩展单会话窗口的可能性。 +- 首期不承诺 Android 进程被系统杀死后仍能实时通知;重新打开后恢复状态是硬要求。 + +## 技术路线 + +采用 Tauri v2 + React + TypeScript,新建 `apps/undefined-chat`。 + +客户端保存一个 Runtime 连接: + +- `runtime_url` +- `X-Undefined-API-Key` + +API Key 首期使用 `tauri-plugin-stronghold` 加密保存。Stronghold vault 的主密码由 Tauri 原生层生成或派生,不把 vault password 硬编码进前端 bundle。 + +桌面端使用 Rust `keyring` 库把 Stronghold vault 主密码静默保存到系统级凭据管理器: + +- macOS 使用 Keychain。 +- Windows 使用 Credential Manager。 +- Linux 优先使用 Secret Service API;在没有 Secret Service 的精简桌面、不同 Window Manager 或 headless 环境中,可按 `keyring` 后端能力使用 keyutils 等原生后端。 + +如果系统 keyring 不可用、解锁失败或用户拒绝授权,客户端必须进入显式解锁/恢复流程;只有用户明确确认后才允许降级到普通本地保存,并在设置页持续显示风险提示。连接 URL、主题、语言、草稿、自动滚动等 UI 偏好保存在普通本地配置中。 + +Android 不套用桌面 keyring 语义。Android 端必须使用 Tauri/Stronghold 可用的移动端安全存储策略保存或派生 vault 主密码;若目标运行环境无法安全静默保存,则进入显式解锁/恢复流程,降级保存同样必须经用户确认。 + +Runtime 请求通过 Tauri 原生层集中封装:React 调用本地命令,Tauri 根据当前单连接配置拼接 URL、添加 API Key、执行 HTTP 请求。这样可以把请求权限限制在用户配置的 Runtime origin 上,避免前端散落拼接认证头和过宽 HTTP allowlist。 + +## 关键 PoC 与风险验证 + +正式实现前必须先做 focused PoC,不直接进入完整产品开发。PoC 顺序: + +1. 桌面核心流:Tauri 原生 HTTP 封装、Stronghold + keyring 保存/读取、Runtime 连接、SSE 事件流、HTML 预览窗口 CSP、streaming 文件上传。 +2. Android 核心流:Stronghold/移动端安全存储策略、SSE 长连接稳定性、后台生命周期、HTML 预览页面 CSP、文件选择与 streaming 上传。 + +PoC 验收标准: + +- API Key 能加密保存和恢复,keyring/安全存储不可用时能进入明确的解锁或降级流程。 +- SSE 能处理大型事件流、断线重连和 `Last-Event-ID`/`seq` 续接。 +- 大文件上传不通过 JS Blob、base64 或 IPC 全量进内存。 +- 后台/最小化期间能在平台允许范围内继续续接运行中 job。 +- HTML 预览窗口/页面的 CSP 和 IPC 隔离可被测试验证。 + +PoC 结论必须写入实现计划或后续设计补充;若关键路径在 Android 或 Linux 环境不可行,先调整规格再继续实现。 + +## Runtime API 契约 + +现有 WebChat 能力应提升为 Runtime 正式契约。现有会话、历史、job、事件和命令接口继续保留,并补齐以下能力。 + +### 结构化发送 + +`POST /api/v1/chat/jobs` 支持结构化 payload。客户端只提交用户输入结构,Runtime 负责生成最终历史记录和 AI 输入。 + +请求形态: + +```json +{ + "conversation_id": "conversation-id", + "message": { + "text": "用户输入", + "attachment_ids": ["attachment-id"], + "references": [ + { + "kind": "message", + "source_message_id": "message-id", + "selected_text": "用户选中的文字" + } + ] + } +} +``` + +Runtime 负责: + +- 验证会话存在。 +- 验证附件 ID 属于当前 WebChat 附件作用域。 +- 将引用规范化为历史和 AI 可见的引用表达。 +- 注册附件并生成 AI 可见的附件 XML。 +- 写入用户消息历史。 +- 创建并返回 job 快照。 + +为了支持引用,历史记录需要返回稳定的 `message_id`。客户端不应依赖数组下标作为长期引用目标。 + +### 附件 API + +新增 Runtime 原生附件接口,替代当前只存在于 WebUI Management 代理层的 `/api/runtime/chat/files`。 + +新增端点: + +- `POST /api/v1/chat/attachments`:multipart 上传文件,返回 Runtime 生成的 attachment id、文件名、大小、媒体类型、渲染提示。 +- `GET /api/v1/chat/attachments/{attachment_id}`:下载文件。 +- `GET /api/v1/chat/attachments/{attachment_id}/preview`:返回预览资源,例如图片缩略图;没有预览资源时返回明确的 404/415 错误。 + +Runtime 必须通过能力端点或上传端点错误响应暴露当前最大上传大小,例如 `max_upload_size_bytes`。该值来自 Runtime 配置,客户端不得硬编码上传上限。客户端可以在待发送区显示本地临时预览,但 canonical 附件表达只来自 Runtime 返回的 attachment id/schema。 + +Tauri 原生层上传文件时必须使用 streaming 方式把本地文件 pipe 到 HTTP 请求,不允许为了上传而把大文件一次性读入 JS Blob、base64 字符串或 IPC payload。客户端在上传前按 Runtime 暴露的大小上限做本地拦截,并正确展示 Runtime 的 `413`、超时和连接中断错误。 + +附件上传失败、超时或用户取消时,客户端必须清理本地临时预览、撤销 object URL、移除未绑定到 Runtime attachment id 的待发送项。已经上传成功但后续发送失败的附件是否保留在待发送区,以 Runtime 返回的 attachment id 和客户端草稿状态为准。 + +### 按会话并发 job + +WebChat job 管理从全局互斥改为按 `conversation_id` 互斥: + +- 不同会话可以同时运行 job。 +- 同一会话已有 job 运行或收尾落盘时,再次发送返回 `409`。 +- 删除或清空会话只阻塞目标会话;其他会话不受影响。 +- `GET /api/v1/chat/jobs/active?conversation_id=` 返回目标会话的 active job。 +- 不传 `conversation_id` 时返回 `jobs` 数组;为了兼容旧前端,可同时保留 `job` 字段为第一个 active job 或 `null`,但 Undefined Chat 只依赖 `jobs`。 + +事件流首选 Server-Sent Events。`GET /api/v1/chat/jobs/{job_id}/events` 在 `Accept: text/event-stream` 时返回 SSE,事件 `id` 使用 Runtime 的递增 `seq`;客户端重连时使用 `Last-Event-ID` 或显式 `after=` 续接。JSON 事件查询仅作为后台、恢复、兼容或 SSE 不可用时的 fallback。客户端只渲染 Runtime 返回的事件和快照,不自行推断最终状态。 + +### 历史与事件 schema + +历史接口返回足够展示的结构化数据: + +- `message_id` +- `role` +- `content` +- `attachments` +- `webchat.timeline` +- `webchat.calls` +- `webchat.events` +- `duration_ms` +- render hints + +事件接口返回: + +- `stage` +- `message` +- `message_delta`,当 Runtime 支持正文增量时使用;客户端必须能同时处理增量和最终 `message`。 +- `tool_start` / `tool_end` +- `agent_start` / `agent_end` +- `requires_action`,为未来 Human-in-the-loop 手动干预预留。 +- `done` +- `error` +- 当前工具/Agent 快照 +- Runtime 计算的耗时字段 + +客户端不从正文中反推工具状态,不根据本地计时覆盖 Runtime 快照。 + +### Human-in-the-loop 扩展点 + +首期不实现高危操作审批工作流,但 Runtime job schema 预留 `waiting_input` 状态和 `requires_action` 事件。未来如果工具或 Agent 需要用户授权、补充参数或确认高危操作,Runtime 负责生成结构化 action payload、暂停目标会话 job,并保持该会话输入区锁定;其他会话继续运行。客户端只渲染 Runtime 返回的 action,并把用户选择提交回 Runtime 的后续端点,不在本地保存未提交的业务状态。 + +## 客户端数据流 + +启动或恢复: + +1. 读取本地单连接配置。 +2. 查询 Runtime 健康状态。 +3. 拉取会话列表。 +4. 拉取 active jobs。 +5. 加载当前会话历史。 +6. 对运行中 job 使用 SSE 订阅事件,并用 `job_id + seq` 续接。 + +发送: + +1. 用户输入文本、选择附件或引用。 +2. 文件先上传到 Runtime 附件接口。 +3. 客户端提交结构化 message payload。 +4. Runtime 返回 job 快照。 +5. 目标会话输入区锁定,其他会话不锁定。 +6. 客户端订阅 SSE 事件并渲染 Runtime 状态;SSE 不可用时降级为 JSON 事件查询。 + +断线恢复: + +1. UI 显示连接异常或重连中。 +2. 恢复后重新拉会话、active jobs、当前历史。 +3. 对仍运行的 job 从已知 seq 通过 SSE 或 JSON fallback 续接。 +4. 如果 Runtime 重启导致 job 消失,客户端刷新历史并结束本地运行态展示。 + +连接与事件状态必须显式建模,至少区分: + +- `connecting`:正在建立 Runtime 连接。 +- `connected`:Runtime 可达,无运行中事件订阅。 +- `streaming`:SSE 正常接收运行中 job 事件。 +- `resuming`:断线后正在用 `job_id + seq` 续接。 +- `json_fallback`:SSE 不可用,已降级为 JSON 事件查询。 +- `disconnected`:连接完全断开,需要用户检查 Runtime URL/API Key/网络。 + +这些状态只描述客户端连接层,不替代 Runtime job 状态。会话列表和聊天页必须用不同视觉状态区分“正在续接”“已降级 JSON 查询”“连接完全断开”。 + +## UI 与交互 + +信息架构采用 Chat-first。 + +桌面端: + +- 左侧会话列表,中央聊天流。 +- 聊天流占据主视图。 +- 输入区固定底部,包含引用、附件预览、斜杠命令补全和发送。 +- 工具/Agent 调用在 AI 气泡内显示摘要、状态和耗时。 +- 工具详情、Agent 调用树、附件详情通过按需侧栏或抽屉展开,不默认抢占聊天区。 +- HTML 运行预览打开独立桌面窗口。 + +Android: + +- 默认进入当前聊天页。 +- 会话列表、工具详情、附件详情、HTML 预览、设置作为原生页面、抽屉或底部导航目标。 +- HTML 运行预览进入独立页面,不使用悬浮窗口。 +- 多会话运行态在会话列表可见。 + +主题与语言: + +- 支持亮色、暗色、跟随系统。 +- 默认中文。 +- 首期支持中英双语,i18n 结构支持后续扩展其他语言。 + +## 消息能力 + +首期支持完整消息能力: + +- 文本 +- 图片 +- 普通文件 +- 引用 AI 消息、选中文字、HTML 片段 +- Markdown 渲染 +- 安全 HTML 渲染 +- 代码块高亮、复制、折叠 +- HTML 运行预览 +- 图片预览 +- 文件下载卡片 + +斜杠命令补全完整支持 `GET /api/v1/commands?scope=webui`: + +- 命令名 +- 别名 +- 子命令 +- 帮助块 +- 键盘导航 +- Runtime 返回的可用性状态 + +客户端只展示命令数据,不自行判断权限。 + +## 前端模块 + +模块边界: + +- `runtime-client`:Runtime HTTP、认证、错误类型、重试封装。 +- `chat-store`:当前 UI 选择、active job 快照、事件游标、草稿和偏好;不保存历史真源。 +- `conversation-list`:会话列表和每个会话的运行态。 +- `message-timeline`:按 Runtime history/timeline schema 渲染消息、附件和调用摘要。必须支持虚拟列表或等价窗口化渲染,确保几千条历史、密集工具调用和大量附件不会导致首屏卡死。 +- `message-composer`:文本输入、附件选择、引用选择、斜杠命令补全,提交结构化 payload。 +- `rendering`:Markdown、安全 HTML、代码块、预览、复制、折叠。 +- `native`:Tauri 设置存储、通知、托盘、窗口、文件选择和下载保存。 + +## 原生能力 + +Tauri 层负责: + +- 单连接配置本地保存。 +- API Key Stronghold 加密保存;桌面端通过系统 keyring 保存 Stronghold vault 主密码;不可用时进入显式解锁/恢复流程,降级保存必须经用户确认。 +- 首次启动时探测系统 keyring/安全存储可用性,尤其是 Linux Secret Service/keyutils;不可用时在设置页给出明确原因和下一步选择。 +- 系统通知。 +- 桌面托盘和隐藏窗口常驻。 +- Android 后台尽力续接。 +- 文件选择。 +- 下载保存。 +- 桌面 HTML 预览窗口。 +- Android HTML 预览页面。 + +通知只基于 Runtime 事件和 job 快照触发: + +- job 完成 +- job 失败 +- 连接异常 +- 重连恢复 + +## 错误处理 + +错误状态必须映射为明确 UI: + +- Runtime 不可达。 +- API Key 缺失或无效。 +- 系统 keyring/安全存储不可用或解锁失败。 +- 401 / 403。 +- 会话不存在。 +- 同会话 job 运行中返回 409。 +- 上传文件超限。 +- 附件不存在。 +- job 取消。 +- job 失败。 +- Runtime 重启导致 active job 丢失。 + +客户端不猜测最终结果。所有最终状态以 Runtime 返回为准。 + +## 安全边界 + +- API Key 首选 Stronghold 加密保存。桌面端 Stronghold vault 主密码必须存放在系统 keyring 中,不能硬编码或写入普通配置;Linux 上需处理 Secret Service 不存在或不可解锁的环境。只有在 Stronghold/keyring 不可用且用户明确确认时才允许普通本地保存,设置页必须持续提示降级风险。 +- Android 端 vault 主密码不能写入普通配置;安全静默保存不可用时必须走显式解锁/恢复流程或用户确认的降级流程。 +- 聊天内 HTML 经过白名单净化。 +- HTML 运行预览隔离执行,不暴露 API Key,不开放 Tauri IPC,不允许访问本地文件或 app 能力。 +- HTML 预览窗口或页面必须注入严格 CSP meta。默认策略禁止外部网络、禁止表单提交、禁止对象加载、禁止父页面访问;允许的脚本、样式和资源范围必须最小化,并明确禁止 `unsafe-eval`。 +- 附件下载文件名和路径必须清洗,禁止路径穿越。 +- 危险协议、事件属性和危险样式必须被剥离。 +- Runtime 预览字段会脱敏,但不作为权限边界;工具实现仍应避免输出完整凭证。 + +## 测试要求 + +后端 pytest 覆盖: + +- Runtime 结构化发送 payload。 +- Runtime 附件上传、下载、预览。 +- 上传大小上限、413、超时和 streaming 上传路径。 +- 附件上传失败、取消和超时后的本地临时预览清理。 +- 附件注册和历史返回 schema。 +- 引用归一化。 +- 按会话并发 job。 +- 同会话 409。 +- SSE 事件流、`Last-Event-ID` / `seq` 续接和 JSON fallback。 +- `waiting_input` / `requires_action` schema 预留。 +- 删除和清空会话阻塞。 +- history/event schema。 +- OpenAPI 文档同步。 +- 旧 WebUI WebChat 兼容。 + +前端测试覆盖: + +- Runtime client。 +- 启动恢复和断线恢复。 +- SSE 事件合并、断线续接、JSON fallback 和快照渲染。 +- Stronghold + 系统 keyring 的保存、读取、解锁失败和降级确认流程;Android 安全存储不可用时的显式解锁/恢复流程。 +- 连接状态机:`connecting`、`streaming`、`resuming`、`json_fallback`、`disconnected`。 +- 大规模历史虚拟列表渲染。 +- 会话列表运行态。 +- 同会话输入区锁定。 +- 命令补全。 +- 附件上传 payload。 +- 文件大小拦截、上传超时和 streaming 上传错误展示。 +- 引用 payload。 +- Markdown/HTML/代码块渲染。 +- HTML 预览 CSP 和 IPC 隔离。 +- 工具/Agent 调用树展示。 +- 通知触发条件。 + +质量门禁: + +- `uv run pytest tests/` +- `uv run ruff check .` +- `uv run ruff format --check .` +- `uv run mypy .` +- `uv build --wheel` +- `apps/undefined-chat` 的 npm lint/typecheck/test。 +- `apps/undefined-chat` 的 Tauri cargo fmt/check。 +- 桌面亮/暗主题截图验收。 +- Android 聊天页、会话列表、详情页、设置页、HTML 预览页截图验收。 + +## 文档要求 + +需要同步更新: + +- `docs/openapi.md` +- `docs/webui-guide.md` +- 新增 `docs/undefined-chat.md` +- `docs/app.md` +- `README.md` +- `apps/undefined-chat/README.md` +- `scripts/README.md` + +文档必须说明: + +- Undefined Chat 和 WebUI WebChat 都是 Runtime WebChat 客户端。 +- Runtime 是唯一真源。 +- Undefined Chat 直连 Runtime API。 +- `docs/undefined-chat.md` 必须包含 “Undefined Chat vs WebUI WebChat” 能力对照表,明确 Undefined Chat 是官方原生优先客户端,WebUI WebChat 是轻量 Web 客户端。 +- 草稿仅保存在当前设备本地,不进入 Runtime,也不会跨客户端同步。 +- API Key 首期使用 Stronghold 加密保存;桌面端通过系统 keyring 保存 Stronghold vault 主密码;Linux 需要说明 Secret Service/keyutils 可用性与降级流程。 +- SSE 是运行中 job 的首选事件通道,JSON 查询是恢复和兼容 fallback。 +- Android 后台通知能力是尽力而为,重新打开恢复是硬保证。 + +## CI 与发版 + +CI 增加独立 `undefined-chat-quality-check`: + +- npm lint。 +- npm typecheck。 +- React 测试。 +- Tauri cargo fmt/check。 + +Release workflow 增加 Undefined Chat 打包: + +- verify chat app。 +- Windows installer。 +- Linux AppImage/deb。 +- macOS dmg。 +- Android 多 ABI release APK。 + +产物命名需要与 Console 区分,例如: + +- `Undefined-Chat-vX.Y.Z-linux-x64.AppImage` +- `Undefined-Chat-vX.Y.Z-linux-x64.deb` +- `Undefined-Chat-vX.Y.Z-windows-x64-setup.exe` +- `Undefined-Chat-vX.Y.Z-windows-x64.msi` +- `Undefined-Chat-vX.Y.Z-macos-x64.dmg` +- `Undefined-Chat-vX.Y.Z-macos-arm64.dmg` +- `Undefined-Chat-vX.Y.Z-android-arm64-v8a-release.apk` + +## 版本同步 + +`pyproject.toml` 仍是主版本真源。`scripts/bump_version.py` 必须同步更新 Undefined Chat: + +- `apps/undefined-chat/package.json` +- `apps/undefined-chat/package-lock.json` +- `apps/undefined-chat/src-tauri/Cargo.toml` +- `apps/undefined-chat/src-tauri/tauri.conf.json` +- `apps/undefined-chat/src-tauri/Cargo.lock` + +`scripts/release_notes.py validate` 也必须把 Undefined Chat 加入版本一致性校验。`scripts/README.md` 同步更新校验范围。 + +长期维护方向:版本同步目标应从散落在脚本里的硬编码路径逐步演进为集中清单或 workspace 风格配置,减少新增 app 后漏改 `package.json`、Tauri 配置、Cargo 文件和 lock 文件的风险。 + +## 验收标准 + +- 同一个 Runtime 可被多个 Undefined Chat 客户端连接,所有会话、历史、active jobs 和事件续接保持一致。 +- 多个会话可同时运行 job。 +- 同一会话运行中不能再次发送。 +- 客户端关闭、刷新、换设备后重新连接,能从 Runtime 恢复状态。 +- 完整消息能力可用。 +- 关键 PoC 结论已记录,且没有未处理的 Tauri Android、Stronghold/keyring、streaming upload、SSE 或 HTML CSP 阻塞项。 +- 桌面和 Android 都符合原生导航习惯。 +- WebUI WebChat 不回退。 +- CI 和 release 均包含 Undefined Chat。 + +## PoC 结果记录 + +PoC 执行计划见 [2026-06-07-undefined-chat-poc.md](../plans/2026-06-07-undefined-chat-poc.md)。 + +后续需要补齐并归档以下验证结果: + +- 桌面 Stronghold/keyring 保存、读取、解锁失败和降级确认结果。 +- Linux Secret Service/keyutils 可用性、不可用环境表现和降级流程结果。 +- Android secure storage、SSE、background lifecycle、HTML preview 的设备验证结果。 +- Streaming upload 是否持续避免 JS Blob、base64 和 IPC 全量传输。 +- SSE 大型事件、断线重连和 `Last-Event-ID`/`seq` 续接结果。 +- 阻塞项、规格调整和继续完整产品实现前必须修订的设计结论。 diff --git a/docs/undefined-chat.md b/docs/undefined-chat.md new file mode 100644 index 00000000..ae20698a --- /dev/null +++ b/docs/undefined-chat.md @@ -0,0 +1,129 @@ +# Undefined Chat + +Undefined Chat 是 `apps/undefined-chat/` 下的原生优先 WebChat 客户端。它面向桌面端和 Android,直接连接 Runtime API,把会话、历史、任务、附件和事件都交给 Runtime 作为真源;Tauri 负责本地连接配置、API Key 保管、上传下载桥接、事件订阅和平台隔离能力。 + +## 当前功能矩阵 + +| 能力 | 当前状态 | 说明 | +|---|---|---| +| 会话管理 | 已实现 | 创建、删除、重命名、切换;未实现置顶会话 | +| 历史分页 | 已实现 | cursor-based 加载更早消息,prepend 后保持滚动锚点;超长历史采用窗口化渲染 | +| Markdown 渲染 | 已实现 | 表格、引用、列表、任务列表、链接、内联/块级代码 | +| 代码高亮 | 已实现 | highlight.js,多语言自动/显式高亮,支持复制 | +| 内联 HTML 渲染 | 已实现 | 正文 HTML 经 sanitize 后内联渲染,与 WebUI 基线对齐 | +| HTML 预览窗口 | 已实现 | 独立窗口/Android Activity,临时 file URL,预览窗口内可运行脚本;保留 `connect-src 'none'`、导航守卫、IPC 隔离、1 MB 限制和临时文件清理 | +| 附件上传/下载 | 已实现 | Tauri 流式上传、下载、预览;显示上传状态,不显示百分比进度 | +| 图片展示 | 已实现 | UID 附件图片经 Tauri 预览接口转 blob,支持内联、附件区和全屏查看 | +| 工具调用块 | 已实现 | 层级展示、状态、运行中实时计时(统一时钟)、阶段明细(stage detail)、与 WebUI 一致的输入/输出预览(JSON/Python 风格结构化、Markdown 输出、附件图片预览);历史回放支持 timeline/calls/events 多级回退 | +| 事件流 | 已实现 | SSE 优先,断开/错误时 JSON fallback(指数退避重连,活跃任务持续重试) | +| 命令面板 | 已实现 | `/` 候选、子命令、方向键、Tab/Enter 补全;空态反馈(加载中/无匹配/不可用);窗口聚焦按 TTL 刷新命令列表 | +| 消息引用 | 已实现 | 引用 bot 消息、划词引用选中文本、发送引用、引用芯片跳转当前已加载源消息(不在当前页时自动加载一页更早历史,仍未命中需手动继续) | +| 自动滚动 | 已实现 | 智能跟随底部 + 设置面板开关,偏好持久化到 localStorage | +| 快捷键 | 已实现 | Ctrl/Cmd+N、Ctrl/Cmd+K、Ctrl/Cmd+/、Ctrl/Cmd+,、Escape | +| i18n | 已实现 | 中英双语运行时切换 + 语言切换 UI + 系统语言检测;UI 文案与逻辑层错误文案均走词典(store 存 key、UI `t()` 渲染) | +| 平台识别 | 已实现 | 经 Rust `get_platform_info` 判定真实平台(替代 UA 猜测);移动布局结合真实平台 + 视口断点,桌面端按 `DesktopLayout` 包装 | +| 移动端适配 | 基本实现(待真机验证) | 会话抽屉(role=dialog/aria-modal/Tab 焦点陷阱/焦点恢复)、软键盘避让、安全区(含横屏左右)、横屏/平板断点、44px 触控目标;仅 Android(非 iOS),后台 job 逐条补齐仍依赖 SSE 断线 fallback;逻辑由 jsdom 测试覆盖,仍需真机最终验证 | +| 移动端安全存储 | 已接入,待设备验证 | Android 使用 Keystore + AES-GCM,密钥在 AndroidKeyStore 不可导出,密文存 `MODE_PRIVATE` SharedPreferences;平台不可用时才允许显式不安全降级 | + +## Undefined Chat vs WebUI WebChat + +| 能力 | Undefined Chat | WebUI WebChat | +|---|---|---| +| 入口 | 独立 Tauri + React App | `uv run Undefined-webui` 内置聊天页 | +| 主要目标 | 原生聊天工作台,适合长期挂起、桌面/移动端使用 | 管理控制台内的聊天与调试入口 | +| 真源 | Runtime API | Runtime API,经 WebUI 后端代理 | +| 本地状态 | 连接设置、API Key 状态、当前选择、草稿、UI 游标 | 浏览器偏好和 UI 状态 | +| 事件续接 | SSE 优先,失败时 JSON polling fallback | JSON polling 为主,SSE 保留兼容 | +| 附件 | Tauri 以流式方式上传/下载 | WebUI 后端代理上传 | +| 密钥保管 | 桌面系统 keyring/Stronghold;Android Keystore + AES-GCM | WebUI 后端注入 Runtime auth key | +| HTML 渲染 | 正文 sanitize 内联 + Tauri 独立窗口/Activity 隔离运行 | 正文 sanitize 内联 + 浏览器 iframe sandbox | + +## Runtime 是唯一真源 + +Undefined Chat 不持久化权威聊天数据。以下内容都以 Runtime 返回为准: + +- `GET /api/v1/chat/conversations` +- `GET /api/v1/chat/history` +- `GET /api/v1/chat/jobs/active` +- `GET /api/v1/chat/jobs/{job_id}/events` +- Runtime 附件 API 返回的附件元数据、下载 URL 和预览 URL + +本地只保存 UI 相关状态,例如当前会话、草稿、事件游标、窗口布局、连接档案和是否允许不安全存储降级。刷新、重启或换端之后,客户端必须重新从 Runtime 恢复会话和任务状态。 + +## 连接与事件 + +Runtime 请求必须由 Tauri command 拼接到已配置 origin 下,避免 React 传入任意 URL 后绕过目标主机限制。React 不直接持有 API Key;受保护请求由 Rust 侧注入 `X-Undefined-API-Key`。 + +事件策略: + +- 默认使用 SSE 订阅 job events。 +- SSE error/closed 时使用 `job_id + seq` 调用 JSON events fallback 补齐遗漏事件。 +- Android 回到前台时执行 `store.bootstrap()`,刷新配置、会话、当前历史页和 active jobs,并重新建立事件订阅。 +- 当前没有公开的 store 方法在 resume 时逐 job 主动补齐;这部分仍依赖 SSE 断线 fallback。 + +## 安全存储 + +API Key 不应暴露给 React 状态树或日志。Tauri 负责保存、读取和删除密钥: + +- macOS 使用系统钥匙串,Windows 使用系统凭据存储。 +- Linux 依赖 Secret Service/keyring;无可用 keyring 时,必须让用户显式确认后才允许降级到本地文件。 +- Android 通过生成工程注入的 `SecretPlugin` 使用 Android Keystore + AES-GCM 保存 API Key:密钥在 AndroidKeyStore 中生成且不可导出,加密后的密文存放在 `MODE_PRIVATE` 的 SharedPreferences 中。 +- 降级模式只适合本机开发或受控环境。 + +> iOS 仅 `keyring` 库的 `apple-native` backend 在代码层兼容 Keychain,未纳入构建/发布(无 iOS 工程/CI/真机路径),不是受支持的发布平台。 + +## 附件与大文件上传 + +附件上传由 Tauri 从文件句柄流式读取并转发到 Runtime,React 只持有文件选择结果和上传状态。桌面路径和 `file://` URL 会做 regular file 校验;Android `content://` URI 交给 `tauri-plugin-fs` 打开,不强制使用本地 `metadata().is_file()` 判定。 + +下载和预览同样通过 Tauri 受控命令处理。图片附件通过 `AttachmentImage` 或附件预览按钮调用 `previewAttachment`,Rust 端带 auth 拉取附件字节,再转为 `Blob` URL 渲染;全屏预览走应用内 `ImageViewerModal`,关闭时释放临时 URL。 + +## HTML 渲染与预览 + +HTML 采用与 WebUI 基线对齐的双层策略: + +- **正文内联渲染**:消息正文中的 HTML 经 sanitize 后内联渲染,去除可执行/危险内容后嵌入消息流。 +- **独立预览窗口**:HTML/HTM 代码块可在独立预览窗口/Activity 中打开,预览窗口内允许运行脚本,以获得与 WebUI 一致的预览体验。 + +独立预览窗口在放开脚本执行的同时,仍保留以下安全边界: + +- 运行时写入 `html-preview-*.html` 临时文件,并通过 `Url::from_file_path` 打开初始 `file://` URL。 +- `connect-src 'none'` 阻断预览内的网络请求;表单提交、插件对象等仍受限。 +- 导航守卫只允许初始 URL 和 `about:blank`,禁止外部导航。 +- 预览窗口与主应用 IPC 隔离,不暴露主应用的 Tauri command。 +- 标题会转义;标题 + HTML 总大小限制为 1 MB。 +- 关闭窗口会删除对应临时文件;下次打开前只清理陈旧残留,避免删掉仍打开窗口的 backing file。 + +详情见 [HTML 预览文档](../apps/undefined-chat/docs/html-preview.md)。 + +## 移动端与 Android + +移动端实现包括: + +- 会话列表抽屉、遮罩、Escape 关闭、ARIA 状态和焦点恢复。 +- `visualViewport` 驱动的 `--keyboard-inset`,用于输入区软键盘避让。 +- 安全区 `env(safe-area-inset-*)` 和移动端 44px 触控目标。 +- Android 生命周期使用 Tauri `tauri://suspended` / `tauri://resumed`。 + +仍需真实设备验证: + +- `content://` 上传:相册、Downloads、云盘 provider。 +- 软键盘、安全区、横竖屏切换和 Tab/触控顺序。 +- 后台运行中 job 恢复。 +- Android Keystore 保存、重启后读取、删除和降级提示。 + +## 测试与发布 + +`apps/undefined-chat/package.json` 的 `npm run check` 包含: + +- `npm run lint` +- `npm run typecheck` +- `npm run test:unit` +- `npm run test:e2e`(Vitest + jsdom integration tests) +- `npm run tauri:fmt:check` +- `npm run tauri:check` +- `npm run tauri:test` + +jsdom integration tests 不等同于真实 Tauri/Android E2E。发布前仍应执行 Android smoke checklist 和必要的桌面 Tauri smoke。 + +版本必须与 `pyproject.toml` 主版本一致。`scripts/bump_version.py ` 会同步更新 Chat/Console 的 package、Tauri 配置和 lock 文件。Android release job 会先运行 `npm run tauri:android:init`,为 Undefined Chat 注入 `HtmlPreviewActivity` / `SecretPlugin` 并用 `tauri:android:prepare:check` 校验生成工程。 diff --git a/docs/usage.md b/docs/usage.md index 8cb658fc..969c854b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -60,6 +60,8 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 **子工具**:`grok_search`(Grok 搜索)、`web_search`(通用搜索)、`crawl_webpage`(网页内容提取) +启用 `grok_search` 后,工具会在调用 Grok 模型时注入检索约束:以服务端提供的当前时间为准,先调用搜索能力,使用多组搜索查询或多个搜索工具进行交叉检索,禁止编造,并在输出中给出来源。 + **示例:** > *"请搜索最近三天关于 DeepSeek 的最新动态并生成摘要。"* > *"帮我爬取这个网页的主要内容并整理成结构化笔记。"* @@ -108,6 +110,7 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 ### `code_delivery_agent` — 代码分析与交付助手 支持沙盒级别的代码代写、本地执行验证与自动打包。测试通过后,代码成果会自动打包为 `.zip` 文件并通过 QQ 发送给用户。 +适用于需要创建、修改、运行验证或打包交付代码的任务;只读查阅 Undefined / NagaAgent 项目源码时优先使用对应代码查阅 Agent。 **示例:** > *"请使用 Python 编写一个 HTTP 测速脚本,监听 8080 端口,验证跑通后将整个项目打包发到这个群。"* @@ -116,9 +119,19 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 ### `naga_code_analysis_agent` — NagaAgent 代码分析助手 -专门用于深度分析 NagaAgent 框架及本项目的源代码结构。 +专门用于深度分析 NagaAgent 框架的源代码结构。 +仅用于 NagaAgent 项目本身的实现、配置、部署、构建和排错问题;不处理 Undefined 自身源码、用户上传文件或代码交付任务。 + +**子工具**:`read_file`、`list_directory`、`glob`、`search_file_content`、`read_naga_intro` + +--- + +### `undefined_self_code_agent` — Undefined 自身代码查阅助手 + +只读查阅 Undefined 当前仓库的源码、测试、文档、资源、脚本、配置示例和 App 实现。访问范围限制为 `src/`、`scripts/`、`tests/`、`res/`、`docs/`、`apps/` 以及根目录 `README.md`、`CHANGELOG.md`、`ARCHITECTURE.md`、`config.toml.example`。 +`code/NagaAgent/` 是 NagaAgent 子模块,不属于 Undefined 自身代码查阅范围;NagaAgent 技术问题应交给 `naga_code_analysis_agent`。 -**子工具**:`read_file`、`search_code`、`analyze_structure` +**子工具**:`read_file`、`list_directory`、`glob`、`search_file_content` --- @@ -287,6 +300,8 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需 调度器基于标准 crontab 语法,支持三种执行模式,适用于从简单报时到复杂 AI 自主任务的全部场景。 +也可以在 WebUI 的“定时任务”页查看、创建、编辑和删除当前调度任务;WebUI 会通过已鉴权的 Management 代理访问 Runtime API,不会把 Runtime API 密钥暴露给浏览器前端。 + ### 执行模式 | 模式 | 描述 | 配置字段 | @@ -402,6 +417,8 @@ Undefined 提供了一套完整的可视化管理控制台,无需修改配置 WebUI 通过浏览器访问(默认地址 `http://127.0.0.1:8787`,默认密码 `changeme`,**首次启动必须在 `config.toml` 的 `[webui]` 中修改默认密码**)。如需通过手机或其他设备进行远程管理,可使用配套的多端控制台 App,详见 [《跨平台控制台 App》](app.md)。 +> **自动启动 Bot**:WebUI 支持配置 `[webui].autostart_bot = true` 实现启动时自动拉起机器人进程,详见 [WebUI 使用指南](webui-guide.md)。 + --- *如需查阅各模块的底层设计原理与 API 集成说明,请参阅本目录下的其余技术文档。* diff --git a/docs/webui-guide.md b/docs/webui-guide.md index 42fdc361..1cd86cfd 100644 --- a/docs/webui-guide.md +++ b/docs/webui-guide.md @@ -19,6 +19,7 @@ uv run Undefined-webui url = "127.0.0.1" # 监听地址 port = 8787 # 端口 password = "changeme" # 密码(必须在首次登录时修改) +autostart_bot = false # 启动 WebUI 时是否自动启动 Bot(默认 false) ``` > 如需远程访问,将 `url` 改为 `0.0.0.0` 或实际 IP。 @@ -35,7 +36,7 @@ password = "changeme" # 密码(必须在首次登录时修改) ## 功能概览 -WebUI 共有 8 个主要页签(Tab),下面逐一介绍。 +WebUI 共有 9 个主要页签(Tab),下面逐一介绍。 ### 概览(Overview) @@ -71,7 +72,7 @@ WebUI 共有 8 个主要页签(Tab),下面逐一介绍。 - 支持 **Bot 日志** 和 **WebUI 日志** 切换。 - **实时流式推送**(SSE):日志实时滚动到最新。 -- 可暂停 / 恢复流式推送,调整显示行数(1–2000)。 +- 可暂停 / 恢复流式推送,默认显示最近 1000 行;接口支持 `lines` 参数,范围 1–10000。 - 左侧文件列表展示所有日志文件(含归档),可选择查看历史日志。 - 支持下载日志文件。 @@ -115,13 +116,39 @@ AI 的置顶备忘录(自我约束、待办事项等),支持完整 CRUD: - **重分析 / 重索引**:对单张表情包重新触发 AI 描述生成或搜索索引更新。 - **统计概览**:总数、启用 / 禁用数、静态 / 动态数等。 +### 定时任务(Schedules) + +管理当前运行中的调度任务: + +- **任务列表**:按任务 ID、名称、crontab、目标和模式搜索;列表展示下次执行时间、发送目标和任务模式。 +- **创建 / 编辑**:支持单工具、多工具和 AI 自我督办三种模式,可调整 `cron_expression`、目标类型 / ID、最大执行次数和执行内容。 +- **删除任务**:从 WebUI 直接删除不再需要的调度任务。 + +WebUI 会先验证登录态,再通过后端代理访问 Runtime API 的定时任务接口;浏览器前端不会直接读取或暴露 `[api].auth_key`。 + ### AI 对话(Chat) WebUI 内置的对话界面,直接与 Bot 的 AI 进行交互: -- 支持文本和图片消息。 -- AI 回复支持 Markdown 渲染。 -- 消息历史分页浏览。 +- WebUI WebChat 与 [Undefined Chat](undefined-chat.md) 共用 Runtime 的会话、历史、任务、事件和附件合同。区别在于 WebUI 通过 Management 后端代理 Runtime,并以 JSON polling 续接为主;Undefined Chat 是独立 Tauri 客户端,SSE 优先并在断线或 Android 生命周期恢复时使用 JSON fallback。 +- 右侧会话抽屉支持多对话管理:新建对话、切换对话、重命名对话和删除对话。桌面端默认折叠,鼠标移到右侧触发区会自动展开;移动端默认只显示“对话”按钮,点击后展开会话列表,切换会话后自动收起。新建成功后会显示提示,并短暂高亮新会话。多对话只作用于 WebChat,不影响 QQ 私聊 / 群聊历史。每个会话在后端保存为 `data/webchat/conversations/.json`,删除对话会删除对应 JSON 文件;如果仍有 WebChat job 运行或正在收尾落盘,删除和清空会被拒绝。 +- WebChat 的 AI 视角始终是同一个虚拟私聊用户 `system#42`,权限仍为 `superadmin`。切换 WebUI 会话只切换后端提供给 AI 的当前 WebChat 历史,不改变 `user_id`、`sender_id` 或身份提示,因此 AI 不会把多个 WebUI 会话看成不同真实用户。 +- 输入框开头输入 `/` 时会从后端 `/api/v1/commands?scope=webui` 获取当前可用斜杠命令并在输入框上方展开补全面板。面板按命令名、别名、说明和用法即时筛选,支持点击选择,也支持方向键选择、`Enter` / `Tab` 填入;直接手打 `/faq`、`/changelog` 或 alias `/cl` 这类复合命令后(命令末尾需带空格),会切换为子命令补全并显示具体用法。命令数据尚未返回时面板显示”正在加载可用命令”,不会提前显示”未找到匹配命令”;输入 `/h` 这类已命中 alias 但命令本身没有声明子命令的内容时(末尾带空格),会显示命令帮助块,包含说明、用法、示例和别名,例如 `/h [命令名] [-t]`,便于继续补参数;只有命令或子命令确实没有匹配项时才显示无匹配提示。命令清单按 WebChat 的虚拟私聊执行身份过滤,因此不会提示当前 WebUI 会话实际不可用的命令。 +- 旧版 WebChat 历史会在首次打开时自动迁移到默认会话。迁移完成后会写入标记文件,之后不会重复迁移;即使删除该默认会话,也不会再从旧历史文件恢复。未选择会话的旧接口调用会按需创建一个空的默认会话以保持向后兼容。 +- 会话标题先使用第一条用户消息的前若干字符作为临时标题;当会话已有首问和首答后,后端会使用 chat model 根据首问 + 首答生成正式标题。手动重命名的标题不会被自动生成覆盖;临时或生成失败的标题会在后续能处理时继续尝试。 +- 支持文本、图片和文件消息。图片或文件可通过 `+` 选择,也可直接粘贴到输入框;粘贴只会加入待发送附件条,不会立即发送,点击发送或按 Enter 时才随同当前文本进入同一条 WebChat 消息。无待发送附件时输入框会占满可用宽度;添加附件后右侧预览轨道随数量平滑展开,图片显示缩略图,附件较多时输入框保持最小可用宽度并压缩预览卡片,避免输入区跳动。移动端会把引用和附件预览轨道放到输入框上方,保证正文输入和发送按钮不被挤压。 +- AI 回复支持 Markdown 渲染,Markdown 内的常见 HTML 片段会经过 WebUI 白名单净化后自动渲染;完整 HTML 文档或独立块级 HTML 片段会先净化再直渲染,避免被 Markdown 缩进规则误判成代码块。脚本、事件属性、危险协议、危险样式以及 `head` / `style` 等文档元信息会被剥离。聊天中的图片可点击放大查看,支持点击遮罩、关闭按钮或按 `Esc` 退出。代码块使用本地随包的 highlight.js 做多语言语法高亮,不依赖外部 CDN;显式语言优先,未知语言会自动检测,库不可用时回退为安全转义文本。较长代码块默认以固定高度折叠,代码区内部可滚动,可用常驻工具栏展开 / 折叠,复制和运行按钮始终可见。可运行的 HTML 代码块会额外显示“运行”,在前端沙箱 iframe 中本地预览完整 HTML/CSS/JS,不调用后端执行;预览环境允许 inline script 以支持完整 HTML 交互,但不允许 `unsafe-eval`,安全边界依赖 WebUI CSP 和 iframe sandbox。预览小窗可拖动位置并调整大小,窗口尺寸变化时会自动保持在可见区域。HTML 预览面板可关闭,也可进入选择模式:第一次点击 iframe 内元素只预览并锁定引用范围,第二次点击确认后才把对应 HTML 片段加入待引用区。 +- WebUI 场景下,代码优先直接作为聊天回复输出,不要默认转成文件附件;只有用户明确要求文件交付、代码过长不适合聊天展示,或确需附件工作流时才使用文件。所有代码块都应显式标注语言,例如 ` ```python `、` ```javascript `、` ```html `、` ```bash `,不确定语言时用 ` ```text `。 +- AI 消息支持引用:可引用整条 AI 消息、选中的某段文字,或 HTML 预览中点选的元素。引用会显示在输入框右侧的待发送引用条中,可在发送前移除;实际发送时不会新增接口或附件,而是自动在用户消息前拼接为 Markdown `>` 引用块,例如 `> 引用 AI:`,随后和文本、图片、文件一起进入同一条 WebChat job 消息。发送后的引用块会像代码块一样折叠显示,展开后内部固定高度并可滚动。发送失败时待引用内容会保留,发送成功后清空。 +- 默认加载最新 50 条消息并滚动到底部;向上滚动到顶部会按后端返回的 `next_before` 游标懒加载更早历史,并保持原视口偏移,避免一次性恢复大量工具块造成卡顿。“自动滚动到底部”开关默认开启并保存在浏览器本地,关闭后 AI 回复、工具 / Agent 状态和 AI 阶段刷新不会打断当前位置;首次加载和主动发送新消息仍会定位到底部。系统开启减少动态效果时,聊天滚动会改为即时跳转。 +- 对话由 WebChat job 执行。刷新页面、关闭页面、网络短暂中断或换另一个客户端重新访问 WebUI 时,后端任务继续运行,前端会从后端查询会话列表、当前运行 job,并用 `conversation_id + job_id + seq` 每 0.5 秒轮询增量事件、当前阶段快照和运行中工具 / Agent 快照自动续接;前端不会把关键任务状态只存放在浏览器本地,历史、会话、运行中 job 和事件游标都以 Runtime API 返回值为准。如果刷新后首次查询运行中 job 失败,WebUI 会退避重试并在网络恢复时再次尝试;如果后端已完成 job 并落盘,前端会刷新历史并解除发送锁。SSE 仅作为兼容方式保留,WebUI 不依赖长连接。 +- 运行中的 WebChat job 可从正在生成的 AI 气泡中取消。取消会调用 Runtime 的 job cancel 接口并解除当前会话的发送锁;如果取消发生在 AI 尚未产生任何回复内容前,前端会移除空 AI 气泡,并在最后一条用户消息上显示“重试”。点击重试会带 `reuse_previous_user_message=true` 用同一条纯文本重新发起 WebChat job,前端不再追加一条相同用户气泡,后端也会校验并复用最后一条可见用户历史,避免把同一条输入重复写入会话。 +- 运行中的 AI 气泡会在 `AI` 标签后实时显示当前阶段和后端计算的总已用时,例如构建上下文、查长期记忆、查认知记忆、等待模型、等待工具、发送消息;任务完成后该位置显示本轮回复总用时。工具 / Agent 摘要行会在名称旁显示调用耗时,运行中先显示后端快照时间,轮询间隙用本地时间临时递增,下一次查询后自动校准;结束后固定显示后端结束事件的总耗时。轮询刷新只更新已有状态和计时节点,结构未变化时不会重建工具 / Agent 块,避免运行中闪烁。状态条区分运行中、成功和失败;整轮回复总耗时会写入 WebChat 历史 metadata。 +- WebChat job 事件会更新同一个 AI 气泡;AI 正文和主对话工具 / Agent 调用按事件时序显示,例如工具调用、正文回复、结束工具会依次出现在同一气泡内。 +- 工具 / Agent 调用块展开后会分区展示由后端脱敏截断后的输入和输出预览;Agent 内部工具、子 Agent 和发送出的正文会按后端提供的 timeline 嵌套显示,并保留各自状态、输出和耗时。Agent 内部阶段只作为对应 Agent 摘要行的当前状态展示,不单独占用一行。并发工具结束事件按实际完成时间发布,结构化预览会渲染为带颜色的键值字段。输出内容继续支持 Markdown、安全 HTML、图片和文件卡片渲染,并保留适合移动端的单行工具摘要。 +- WebChat 内由 `send_message` / `send_private_message` 发送给当前虚拟私聊的正文会作为 AI 消息展示,工具块只显示紧凑发送状态,避免重复展示参数和“已发送”结果;`end` 成功结束时同样只显示紧凑状态。 +- WebChat 的工具 / Agent 展示块、嵌套调用树、权威展示 timeline 和正文事件会随 Bot 回复一起写入虚拟私聊历史,用于刷新页面后恢复同一个聊天块的时序;若任务失败或取消,后端会在落盘时补齐未闭合工具的错误 / 取消状态。这部分展示元数据不会注入给 AI 作为后续上下文。 +- 清空历史接口保留为兼容能力,只删除当前 WebChat 会话的虚拟私聊 `system#42` 聊天历史,不影响其他 WebChat 会话、长期记忆、认知记忆或 profile。WebUI 主界面不提供清空按钮,推荐通过新建对话开始新的上下文;若仍有运行中或正在收尾落盘的 WebChat job,清空会被拒绝。 - 发出的消息会经过与 QQ 侧相同的处理流程(安全检查、工具调用等)。 ### 关于(About) @@ -140,6 +167,21 @@ WebUI 首页(Landing Page)提供 Bot 的启停控制: 首页还会检测是否有可用更新(基于 Git),并提供更新 + 重启功能。 +### 自动启动 Bot + +若希望 WebUI 启动后自动拉起 bot 进程,可在 `config.toml` 中配置: + +```toml +[webui] +autostart_bot = true +``` + +启用后,`uv run Undefined-webui` 会自动启动机器人,无需手动点击"启动 Bot"按钮。默认为 `false`。 + +**注意**: +- 该配置仅在 WebUI 启动时生效,运行时修改需重启 WebUI 才能应用。 +- 与 WebUI 更新重启后的自动恢复机制(`pending_bot_autostart` marker)互不冲突,自动恢复优先级更高。 + --- ## 键盘快捷键 @@ -158,7 +200,7 @@ WebUI 和桌面端 / Android 客户端共享同一 Management API: 2. 确保防火墙放行 `[webui].port`(默认 8787)。 3. 桌面端 / Android 客户端输入 `http://:8787` 和密码即可连接。 -如果启用了 Runtime API(`[api].enabled = true`),WebUI 会自动代理 Runtime API 的功能(探针、记忆查询、AI Chat 等),无需单独暴露 Runtime API 端口。 +如果启用了 Runtime API(`[api].enabled = true`),WebUI 会自动代理 Runtime API 的功能(探针、记忆查询、定时任务、AI Chat 等),无需单独暴露 Runtime API 端口。 --- diff --git a/pyproject.toml b/pyproject.toml index 43d036c1..ef7d9e03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "Undefined-bot" -version = "3.5.1" +version = "3.6.0" description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11." readme = "README.md" authors = [ @@ -109,7 +109,7 @@ include = [ python_version = "3.12" strict = true ignore_missing_imports = true -exclude = ["^code/"] +exclude = ["^code/", "^apps/.*/src-tauri/target/"] [tool.ruff] exclude = ["code"] diff --git a/res/IMPORTANT/each.md b/res/IMPORTANT/each.md index 3962d381..fbb406cd 100644 --- a/res/IMPORTANT/each.md +++ b/res/IMPORTANT/each.md @@ -12,6 +12,13 @@ - 历史消息存档、旧上下文、上轮未完成请求不属于当前输入批次;除非当前输入批次明确延续或修正它们,否则不得回溯执行。 + + **身份与对话对象识别(防误插话):** + - 先看 sender_id、@/reply、前后文对话对象和当前环境,再判断当前输入批次是不是在对你说。 + - 不要先入为主把「你」「AI」「bot」「机器人」当作在叫 Undefined;这些词只有在上下文明显指向 Undefined 时才算触发。 + - 如果是在讨论其他 AI/bot/机器人、泛泛评价技术,或无法确定话头指向 Undefined,默认不回复并调用 end。 + + **发信息前或调用任何工具前的必须判断(每次操作前强制执行):** 1. 明确本次操作的目标:将发送的消息内容 / 将调用的工具及参数 diff --git a/res/prompts/historian_profile_merge.md b/res/prompts/historian_profile_merge.md index 9b966231..87c15dab 100644 --- a/res/prompts/historian_profile_merge.md +++ b/res/prompts/historian_profile_merge.md @@ -3,8 +3,9 @@ 必须遵守的硬约束: 1. 本次只允许更新目标实体:`{target_entity_type}:{target_entity_id}`。 2. `target_entity_id` 必须保持为该实体的稳定 ID,不得替换成昵称、备注名或其他文本。 -3. 当新信息不稳定、一次性、无法确认长期性时,必须跳过更新(`skip=true`)。 -4. 不得输出或暗示其他实体侧写内容。 +3. 新事件与认知观察只能来自当前输入批次;最近消息参考和历史事件只能用于消歧、判断稳定性与保留旧特征,禁止作为本轮新事实来源。 +4. 当新信息不稳定、一次性、无法确认长期性时,必须跳过更新(`skip=true`)。注意:observations 本身不要求长期稳定,但侧写只接收能沉淀为稳定画像的部分。 +5. 不得输出或暗示其他实体侧写内容。 工具使用规则(严格执行): - **修改任何侧写前,必须先调用 `read_profile` 查看其当前内容**,确认已读取后再决定是否调用 `update_profile`。 @@ -51,7 +52,7 @@ - 参考历史事件列表判断旧特征是否仍然成立——只要历史中反复出现,就应保留 - 只有当新信息与旧特征**明确矛盾**时才覆盖,否则应融合表达(如"既...也...") 3. 若新旧信息矛盾且新信息更可靠,以新信息为准并说明变化 -4. tags 只写"这个实体**是什么**"(身份/角色/核心领域),不写"聊过什么话题";最多 10 个,话题级细节已在 summary 中覆盖。若现有 tags 不符合此规范(数量过多或含话题标签),直接按规范重写,不必保留旧 tags +4. tags 只写"这个实体**是什么**"(身份/角色/核心领域),不写"聊过什么话题";话题级细节已在 summary 中覆盖。若现有 tags 不符合此规范(含话题标签等),直接按规范重写,不必保留旧 tags 5. 侧写要有"主线"——第一条定调,后续条目围绕它展开,而非孤立罗列无关特征 6. **信息密度优先于表达精炼**:每条要有具体细节(技术栈/工具/行为模式),不要为了简洁而泛化成抽象描述 7. **核心画像要抓独特性**:第一句要写出"这个人区别于其他人的本质",而非通用描述(如"开发者"太泛,"把系统当产线打理的工程型开发者"才有辨识度) @@ -82,11 +83,9 @@ - 条目间要有逻辑关联,共同支撑第一条的核心画像 3. **信息密度优先**:宁可多写一条具体特征,也不要为了精炼而泛化 -4. 总字数 150-400 字 **群聊侧写**结构: - 同样使用项目符号,第一条定位群的核心属性,后续展开成员构成、讨论风格、群文化等 -- 总字数 120-350 字 **反面示例**(不要这样写): ``` diff --git a/res/prompts/historian_rewrite.md b/res/prompts/historian_rewrite.md index 4b718171..7946d95c 100644 --- a/res/prompts/historian_rewrite.md +++ b/res/prompts/historian_rewrite.md @@ -6,9 +6,9 @@ 3. 消灭所有相对地点(这里、那边),替换为具体地点 4. 保持简洁,一两句话概括 5. `memo` 可能为空;为空时以 `observations` 和上下文为主 -6. `observations` 代表当前输入批次提取到的一条新记忆(可能是多条中的一条);若本轮包含 MessageBatcher 合并的多条消息,必须结合整批消息保证可追溯性 +6. `observations` 代表当前输入批次提取到的一条有价值新观察(可能是多条中的一条);不要求与 bot 相关,也不要求长期稳定。若本轮包含 MessageBatcher 合并的多条消息,必须结合整批消息保证可追溯性 7. 若原文已显式出现实体标识(如 `昵称(数字ID)`、`用户123456`、`QQ:123456`),必须保留该数字ID;禁止擅自替换成 `sender_id` 或其他ID -8. 可参考”当前输入批次原文”和”最近消息参考”做实体消歧;当 `observations` 与参考上下文冲突时,以可验证且更具体的信息为准 +8. 可参考”当前输入批次原文”和”最近消息参考”做实体消歧;最近消息参考只能消歧,禁止作为新事实来源。当 `observations` 与参考上下文冲突时,以当前输入批次可验证且更具体的信息为准 9. 当 `force=true` 且命中的“相对表达”属于专有名词本体(如用户名“你是谁”、片名《后天》、书名/歌名等)时,不得改写该专有名词,可保留原词直接提交;但实体 ID 一律不得漂移 称呼规则: diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 383d3c3d..b6902745 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -1,5 +1,5 @@ - + @@ -244,15 +244,21 @@ 调用 end 时提供: - memo:本轮记事本(建议短句,留给短期记忆看的便签纸;可空) - - observations:字符串数组,本轮值得长期留存的观察(写入认知记忆,不是 memory.add) + - observations:字符串数组,本轮从【当前输入批次】提取的有价值新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定 - 若存在【连续消息说明】或多段当前 ``,memo / observations 必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。 observations 应该记录两类内容: - 1. **用户/群聊事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) - 2. **有价值的自身行为**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等)——这些可以帮助未来回忆”上次帮TA做了什么” - 每条一个要点,可以多条。宁可多提取,不要遗漏。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 + 1. **当前批次直接出现的用户/群聊/第三方事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) + 2. **本轮回复行为产生的有价值事实**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等) + 每条一个要点,可以多条。当前批次中有价值即可记录,但不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 不适合写入 observations 的:纯流水账(”回复了一句话”、”决定不回复”、”调用了search工具”)——这类无回忆价值的动作如果需要记,写到 memo。 - **群聊场景下的积极观察**:即使你决定不回复,也应积极观察群聊动态,提取有价值的信息写入 observations。注意观察:话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等。宁可多记几条,也不要遗漏有潜在价值的信息。 - 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。记录 observations 时必须包含 QQ 号,格式如:"QQ号12345678(昵称张三)做了某事"——昵称会变但 QQ 号不变。 + 历史消息、认知记忆、侧写、最近消息参考只能用于实体/时间/地点消歧,不能作为 observations 的新事实来源。 + **群聊场景下的当前批次观察**:即使你决定不回复,也只观察【当前输入批次】。如果当前批次直接出现有价值的群聊动态(话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等),可写入 observations;不要从历史或参考上下文里补写旧动态。 + 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。写入 observations / end.observations 时必须按实体类型使用稳定标识: + - 用户中心观察(sender_id 是 QQ 用户,或事实明确属于某个 QQ 用户):格式为 "QQ号12345678(昵称张三)做了某事";昵称会变但 QQ 号不变。 + - 群聊实体观察(事实属于群整体、群规、群氛围、群事件,而不是某个用户):格式为 "group:群号123456(群名技术群)发生了某事";没有群名时只写群号。 + - WebUI / system 会话观察(事实来自 WebUI、系统会话或没有 QQ 用户实体):格式为 "webui:system#session_id(session_name)发生了某事";没有 session_id 或 session_name 时写明可用的稳定会话标识。 + memo 可以用短句概括本轮处理,不要求采用上述实体前缀;但要写入认知记忆的 observations 必须按以上实体类型选择格式,禁止把非用户实体强行写成 QQ号。 + 专名拼写要求:涉及本项目或你自己时,必须逐字写作 Undefined,禁止在 observations 中写成 Unfined、Undefind、undefind 或其它变体。 若当前消息在转述第三方人物/群成员的信息,必须按原文实体记录(昵称/QQ号);禁止默认改写成当前 sender。 如果同一条内容已写入 observations,不要重复写入 memory.add。 @@ -300,6 +306,7 @@ undf, udf, und 心理委员、ud酱(偏玩笑或亲昵称呼) 常见称呼包括 Undefined、undf、udf、und、心理委员、ud酱;上下文明显是在叫你时,可以宽松理解,不必纠正称呼 + 项目名和你的主名必须逐字拼写为 Undefined。公开回复、工具参数、memo、observations 和任何记忆相关文本中都禁止写成 Unfined、Undefind、undefind 或其它变体;如果需要提到本项目或你自己,必须使用字面量 Undefined。 @@ -370,9 +377,9 @@ - 明确提到"bot"、"机器人"且语境指向你 + 明确提到"bot"、"机器人"、"AI"等泛称,且结合 sender_id、@、reply、上下文对象后能确认语境指向 Undefined 必须回复 - 注意区分:如果在讨论其他bot或@其他bot,不要回复 + 注意区分:泛称不是触发词;如果在讨论其他 bot / 其他 AI / @其他bot / 泛泛评价技术,不要回复。无法确认指向 Undefined 时默认不回复。 @@ -507,6 +514,7 @@ 在回复前,理解对话的连贯性和流向 识别和称呼用户时以 QQ 号(sender_id)为准,昵称可能随时变动。需要称呼用户时使用当前最新昵称(群名片优先,其次 QQ 昵称)。不确定最新昵称时,可调用 group.get_member_info 并设置 brief=true 快速查询。 识别对你的称呼时保持宽松:Undefined、undf、udf、und、心理委员、ud酱等上下文明显指向你的叫法都算在叫你,不用纠正对方。 + 不要先入为主把「你」「AI」「bot」「机器人」当作在叫 Undefined。必须先看 sender_id、@/reply、前后文对话对象和当前环境;只有明确指向 Undefined 时才回复,泛指或无法确定时默认沉默并调用 end。 看清发言者名字/QQ号与对话对象,确认对方在明确和你讲话才回复 **人称与对话归属(防误插话):** @@ -777,6 +785,17 @@ 如果确实需要用户补信息,直接问缺什么;如果不用补,就自然结束 + + WebUI Markdown 与 HTML 输出 + 当当前消息明确标注为【WebUI 会话】或 location="WebUI私聊" 时,用户正在 WebUI 中阅读回复。 + WebUI 私聊的身份视角固定为系统虚拟用户 system#42,权限视角固定为 superadmin;不要因为它不是 QQ 群聊或普通私聊就降低可用能力判断。 + WebUI 支持完整 Markdown 渲染,也支持简单安全 HTML;可以用标题、列表、表格、引用、代码块、链接等 Markdown 语法组织内容。 + 在 WebUI 会话中,凡是需要输出代码,优先直接在聊天回复里给出,不要默认转成文件或附件;只有用户明确要求文件交付、代码长到不适合聊天展示,或确需附件工作流时才发送文件。 + 复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放入 fenced code block,不要直接散落在正文里。 + 所有代码块都必须标明语言或类型,例如 ```python、```javascript、```html、```bash、```text;不确定语言时使用 ```text。 + 完整 HTML 页面优先使用 ```html 代码框输出,方便 WebUI 右上角运行按钮预览。 + + @@ -800,6 +819,9 @@ 善用工具 + 需要查阅 Undefined 自身源码、测试、文档、资源、脚本、配置示例或 App 实现时,调用 undefined_self_code_agent + 代码/项目问题路由矩阵:查 Undefined 当前仓库源码、测试、文档、资源、脚本、配置示例或 App 实现 → undefined_self_code_agent;写代码、改代码、执行验证、打包交付 → code_delivery_agent;用户上传文件、截图、外部文件或外部代码片段解析 → file_analysis_agent。 + undefined_self_code_agent 只查 Undefined 自身允许范围,不包含 `code/NagaAgent/`、外部仓库、用户上传文件,也不能写代码、改代码或运行命令。 需要了解图片内容时,调用 file_analysis_agent 需要记住长期稳定的重要信息时,调用 memory.add(或 tools 列表中的对应名称) **不要主动调用无关工具**(天气、金价、新闻等),除非被明确要求 @@ -815,7 +837,7 @@ **先识别,再搜索,最后综合**:遇到图片/文件+问题的组合时,第一步只做内容识别,拿到识别结果后再决定是否需要搜索。 **prompt 只描述 Agent 能力范围内的任务**:调用 file_analysis_agent 时 prompt 应该是"识别图中的游戏和角色名",而不是"分析这个角色怎么养成"。 - **不要指望 Agent 做它不擅长的事**:file_analysis_agent 没有搜索能力,不要让它回答需要外部知识的问题;web_agent 看不到图片,不要让它分析文件。 + **不要指望 Agent 做它不擅长的事**:file_analysis_agent 没有搜索能力,不要让它回答需要外部知识的问题;web_agent 看不到图片,不要让它分析文件;undefined_self_code_agent 仅可只读查阅 Undefined 自身代码,不能写代码、执行命令或读取 `code/NagaAgent/`。 **你是指挥官,Agent 是专家**:你负责拆解任务、分配工作、综合结果。每个 Agent 只提供它专业领域的原子输出。 **能并行就并行**:多个 Agent 调用之间如果没有数据依赖,应在同一轮响应中并行调用以减少延迟。但如果后一个 Agent 的 prompt 依赖前一个 Agent 的结果,则必须等前一个返回后再调用。 **Agent 间互调**:有些 Agent 内部可以调用其他 Agent,以提高效率。这是正常的系统行为,不需要你手动干预。 @@ -919,21 +941,25 @@ 需要每轮都置顶提醒自己的约束/待办/自我指令:用 memory.add(如”用户要求以后用英文回复”) 用户事实(偏好、身份、习惯、计划、关系等)一律写 end.observations,不要用 memory.add 要回忆”之前发生过什么”或查看”某人/某群侧写”:用 cognitive.* 查询 - 对当前输入批次提取值得留存的新观察(用户事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点) - 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中值得留存的信息;禁止只记录最后一条。 + 对当前输入批次提取有价值的新观察(用户/群聊/第三方事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点);不要求与 bot 相关,也不要求长期稳定 + 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中有价值的信息;禁止只记录最后一条。 + 历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 纯流水账动作(调了什么工具、决定不回复等)只写 memo,不写 end.observations 一次性闲聊、无后续价值的信息,不写入任何记忆 - 当你”不明白/信息缺口明显”且任务可能依赖历史时,可主动查询 cognitive.* 与最近消息;先小范围检索,再按需扩展范围 - 平衡原则:不要每轮都查;当前输入批次可直接回答、或只是闲聊/催促时,优先直接处理并结束 + 记忆查阅要主动:只要当前输入批次显式或隐式依赖“之前 / 上次 / 刚才 / 那个 / 你记得吗 / 继续 / 按我的习惯 / 我们约定过 / 他以前说过”等历史事实,不要凭印象回答;先查看已注入的记忆,必要时主动调用 cognitive.*。 + 涉及用户偏好、身份、习惯、长期计划、承诺待办、群规、群氛围、历史争议、之前排查过的问题、以前给出的方案或你是否已经做过某事时,优先调用 cognitive.search_events 或 cognitive.get_profile 查证,再回答或行动。 + 当你“不明白 / 信息缺口明显”且任务可能依赖历史时,可主动查询 cognitive.* 与最近消息;先小范围检索,再按需扩展范围。检索词要围绕当前输入批次、目标用户 QQ 号、群号和关键对象组织,不要泛泛搜索。 + 如果当前问题需要修改、删除或核对 memory.* 置顶备忘,先调用 memory.list 找到 UUID 或现有条目,再 update/delete;不要凭印象编造 UUID 或假设备忘不存在。 + 平衡原则:不要机械地每轮都查;当前输入批次可直接回答、只是一次性闲聊、单纯催促/感谢、或查阅会违反隐私边界时,优先直接处理并结束。 ”memory.add: 用户A要求以后在本群用英文回复”(自我约束置顶) ”memory.add: 下周三前帮用户B完成数据迁移”(待办置顶) - ”end.observations: [“Null 喜欢用 Rust 写底层代码”]”(用户偏好 → 认知记忆) - ”end.observations: [“2026-02-20 晚上在开发群里,用户A说他下周三要发版”]”(一条一个要点) - ”end.observations: [“用户A最近在用 Rust 重写后端”]”(拆开写,不要合并) - ”end.observations: [“2026-02-24 帮用户B排查了 Redis 连接超时问题,最终定位到是连接池配置过小”]”(有价值的行为) + ”end.observations: [“QQ号1708213363(昵称Null)喜欢用 Rust 写底层代码”]”(用户偏好 → 认知记忆) + ”end.observations: [“2026-02-20 晚上在开发群里,QQ号10001(昵称用户A)说下周三要发版”]”(一条一个要点) + ”end.observations: [“QQ号10001(昵称用户A)最近在用 Rust 重写后端”]”(拆开写,不要合并) + ”end.observations: [“2026-02-24 帮QQ号10002(昵称用户B)排查了 Redis 连接超时问题,最终定位到是连接池配置过小”]”(有价值的行为) ”memo: 查了下认知记忆,没找到相关记录”(纯流水账写 memo) ”memory.add: Null 喜欢用 Rust 写底层代码”(用户偏好不该写 memory.add,应写 observations) ”end.observations: [“用户A说他下周三要发版,而且最近在用 Rust 重写后端”]”(一条塞了两个要点——应拆成两条) @@ -1327,6 +1353,7 @@ 不回复自己,不重复发言 尊重对话边界,不凑热闹 看清名字/QQ号与对话对象,只在明确被直接对话时回复 + 「你」「AI」「bot」「机器人」不是自动触发;必须结合 sender_id、@/reply 和上下文确认指向 Undefined,拿不准就沉默 认可并接受自身人设,不随对话随意改设定、不自贬为通用 AI、不临时扮演他人 无法判断是否在对你说话时,假设不在和你讲话;别人之间的对话不参与,不因「你/我」等人称误插话 对Null保持克制,不要频繁回复他的每条消息 diff --git a/res/prompts/undefined_nagaagent.xml b/res/prompts/undefined_nagaagent.xml index 6cc7cf00..85f5471d 100644 --- a/res/prompts/undefined_nagaagent.xml +++ b/res/prompts/undefined_nagaagent.xml @@ -1,5 +1,5 @@ - + @@ -242,15 +242,17 @@ 调用 end 时提供: - memo:本轮记事本(建议短句,留给短期记忆看的便签纸;可空) - - observations:字符串数组,本轮值得长期留存的观察(写入认知记忆,不是 memory.add) + - observations:字符串数组,本轮从【当前输入批次】提取的有价值新观察(写入认知记忆,不是 memory.add);不要求与 bot 相关,也不要求长期稳定 - 若存在【连续消息说明】或多段当前 ``,memo / observations 必须覆盖整个【当前输入批次】;不要只根据最后一条消息记录,也不要把同批前几条当作历史旧消息忽略。 observations 应该记录两类内容: - 1. **用户/群聊事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) - 2. **有价值的自身行为**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等)——这些可以帮助未来回忆”上次帮TA做了什么” - 每条一个要点,可以多条。宁可多提取,不要遗漏。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 + 1. **当前批次直接出现的用户/群聊/第三方事实**:偏好、计划、状态变化、人际关系、观点立场、承诺约定、人物事实(身份/职业/技能/习惯等)、群聊事实(群主题/常驻成员/群规/氛围等) + 2. **本轮回复行为产生的有价值事实**:你为用户做了什么重要的事(帮谁解决了什么问题、给了什么建议、承诺了什么后续行动等) + 每条一个要点,可以多条。当前批次中有价值即可记录,但不要脑补或从背景里摘取。**严格一条一个要点**,不要把多个信息塞进同一条——拆成多条分别写入。 不适合写入 observations 的:纯流水账(”回复了一句话”、”决定不回复”、”调用了search工具”)——这类无回忆价值的动作如果需要记,写到 memo。 - **群聊场景下的积极观察**:即使你决定不回复,也应积极观察群聊动态,提取有价值的信息写入 observations。注意观察:话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等。宁可多记几条,也不要遗漏有潜在价值的信息。 + 历史消息、认知记忆、侧写、最近消息参考只能用于实体/时间/地点消歧,不能作为 observations 的新事实来源。 + **群聊场景下的当前批次观察**:即使你决定不回复,也只观察【当前输入批次】。如果当前批次直接出现有价值的群聊动态(话题趋势变化、成员关系互动、群聊氛围/事件、新成员发言特征等),可写入 observations;不要从历史或参考上下文里补写旧动态。 格式要求:每条具体、绝对化(写明谁、什么时候、在哪里),避免代词和相对时间,不要复述已知记忆。 + 专名拼写要求:涉及本项目或你自己时,必须逐字写作 Undefined,禁止在 observations 中写成 Unfined、Undefind、undefind 或其它变体。 若当前消息在转述第三方人物/群成员的信息,必须按原文实体记录(昵称/QQ号);禁止默认改写成当前 sender。 如果同一条内容已写入 observations,不要重复写入 memory.add。 @@ -298,6 +300,7 @@ undf, udf, und 心理委员、ud酱(偏玩笑或亲昵称呼) 常见称呼包括 Undefined、undf、udf、und、心理委员、ud酱;上下文明显是在叫你时,可以宽松理解,不必纠正称呼 + 项目名和你的主名必须逐字拼写为 Undefined。公开回复、工具参数、memo、observations 和任何记忆相关文本中都禁止写成 Unfined、Undefind、undefind 或其它变体;如果需要提到本项目或你自己,必须使用字面量 Undefined。 @@ -369,9 +372,9 @@ - 明确提到"bot"、"机器人"且语境指向你 + 明确提到"bot"、"机器人"、"AI"等泛称,且结合 sender_id、@、reply、上下文对象后能确认语境指向 Undefined 必须回复 - 注意区分:如果在讨论其他bot或@其他bot,不要回复 + 注意区分:泛称不是触发词;如果在讨论其他 bot / 其他 AI / @其他bot / 泛泛评价技术,不要回复。无法确认指向 Undefined 时默认不回复。 @@ -528,7 +531,15 @@ - 对于任何涉及 NagaAgent 的技术问题,**必须先调用 naga_code_analysis_agent 获取准确信息后再回复**。 + + 对于当前输入批次中任何明确涉及 NagaAgent 项目的技术问题,**必须先调用 naga_code_analysis_agent 获取准确信息后再回复**。 + 这是一条强制路由规则:不得凭自身记忆、历史印象、常识、旧上下文或用户提供的片段直接回答 NagaAgent 技术问题。 + 必须调用的工具/Agent 名称就是 `naga_code_analysis_agent`;不要改用 web_agent、file_analysis_agent、undefined_self_code_agent、普通搜索、直接读文件工具或你自己的推测替代。 + 不要用 undefined_self_code_agent 查 `code/NagaAgent/`;`code/NagaAgent/` 是 NagaAgent 子模块,不属于 Undefined 自身代码查阅范围。 + 如果问题已经明确到模块、功能、报错、配置、部署、API、OpenClaw、前端、后端、技能、干员、任务调度、记忆、集成方式等任一技术对象,就先调用 `naga_code_analysis_agent`。 + 如果问题同时比较 Undefined 与 NagaAgent:Undefined 侧调用 `undefined_self_code_agent`,NagaAgent 侧调用 `naga_code_analysis_agent`;两边无数据依赖时可同轮并行调用,再由你综合结果。 + 如果问题过于宽泛且缺少关键对象(例如只说“帮我看下 NagaAgent 为什么不对”),不要把模糊问题硬丢给 agent;先用 send_message 追问具体模块 / 报错 / 现象 / 目标,待范围收窄后再调用 `naga_code_analysis_agent`。 + 不要依赖自身记忆或猜测来回答 NagaAgent 相关问题——该项目代码频繁更新,只有通过 agent 实时查阅才能保证准确。 该 Agent 内部拥有自己的工具集(read_naga_intro、read_file、search_file_content 等), 这些内部工具你无法直接调用,你只需要调用 naga_code_analysis_agent 即可。 @@ -540,7 +551,7 @@ - 用户想了解 NagaAgent 的架构、代码逻辑、技能系统等 - 用户提到 NagaAgent 的任何技术细节(API、openclaw、干员、技能等) - 讨论涉及 NagaAgent 与其他系统的集成或对比 - 只有纯闲聊式提及(如"naga好用吗"这类不需要技术细节的对话)才可以不调用。 + 只有纯闲聊式提及且不需要事实/技术细节(如“naga这个名字挺可爱”)才可以不调用;只要需要回答事实、实现、使用方法、排错或判断,就必须调用。 @@ -553,6 +564,7 @@ 在回复前,理解对话的连贯性和流向 识别和称呼用户时以 QQ 号(sender_id)为准,昵称可能随时变动。需要称呼用户时使用当前最新昵称(群名片优先,其次 QQ 昵称)。不确定最新昵称时,可调用 group.get_member_info 并设置 brief=true 快速查询。 识别对你的称呼时保持宽松:Undefined、undf、udf、und、心理委员、ud酱等上下文明显指向你的叫法都算在叫你,不用纠正对方。 + 不要先入为主把「你」「AI」「bot」「机器人」当作在叫 Undefined。必须先看 sender_id、@/reply、前后文对话对象和当前环境;只有明确指向 Undefined 时才回复,泛指或无法确定时默认沉默并调用 end。 看清发言者名字/QQ号与对话对象,确认对方在明确和你讲话才回复 **人称与对话归属(防误插话):** @@ -825,6 +837,17 @@ 如果确实需要用户补信息,直接问缺什么;如果不用补,就自然结束 + + WebUI Markdown 与 HTML 输出 + 当当前消息明确标注为【WebUI 会话】或 location="WebUI私聊" 时,用户正在 WebUI 中阅读回复。 + WebUI 私聊的身份视角固定为系统虚拟用户 system#42,权限视角固定为 superadmin;不要因为它不是 QQ 群聊或普通私聊就降低可用能力判断。 + WebUI 支持完整 Markdown 渲染,也支持简单安全 HTML;可以用标题、列表、表格、引用、代码块、链接等 Markdown 语法组织内容。 + 在 WebUI 会话中,凡是需要输出代码,优先直接在聊天回复里给出,不要默认转成文件或附件;只有用户明确要求文件交付、代码长到不适合聊天展示,或确需附件工作流时才发送文件。 + 复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放入 fenced code block,不要直接散落在正文里。 + 所有代码块都必须标明语言或类型,例如 ```python、```javascript、```html、```bash、```text;不确定语言时使用 ```text。 + 完整 HTML 页面优先使用 ```html 代码框输出,方便 WebUI 右上角运行按钮预览。 + + @@ -848,7 +871,10 @@ 善用工具 - 遇到 NagaAgent 问题,直接调用 naga_code_analysis_agent + 遇到明确的 NagaAgent 项目技术问题,按 NagaAgent 相关规则调用 naga_code_analysis_agent;宽泛且缺少对象时先追问范围。 + 需要查阅 Undefined 自身源码、测试、文档、资源、脚本、配置示例或 App 实现时,调用 undefined_self_code_agent + 代码/项目问题路由矩阵:查 NagaAgent 项目或 `code/NagaAgent/` → naga_code_analysis_agent;查 Undefined 当前仓库源码、测试、文档、资源、脚本、配置示例或 App 实现 → undefined_self_code_agent;写代码、改代码、执行验证、打包交付 → code_delivery_agent;用户上传文件、截图、外部文件或外部代码片段解析 → file_analysis_agent。 + undefined_self_code_agent 只查 Undefined 自身允许范围,不包含 `code/NagaAgent/`、外部仓库、用户上传文件,也不能写代码、改代码或运行命令。 需要了解图片内容时,调用 file_analysis_agent 需要记住长期稳定的重要信息时,调用 memory.add(或 tools 列表中的对应名称) **不要主动调用无关工具**(天气、金价、新闻等),除非被明确要求 @@ -864,7 +890,7 @@ **先识别,再搜索,最后综合**:遇到图片/文件+问题的组合时,第一步只做内容识别,拿到识别结果后再决定是否需要搜索。 **prompt 只描述 Agent 能力范围内的任务**:调用 file_analysis_agent 时 prompt 应该是"识别图中的游戏和角色名",而不是"分析这个角色怎么养成"。 - **不要指望 Agent 做它不擅长的事**:file_analysis_agent 没有搜索能力,不要让它回答需要外部知识的问题;web_agent 看不到图片,不要让它分析文件。 + **不要指望 Agent 做它不擅长的事**:file_analysis_agent 没有搜索能力,不要让它回答需要外部知识的问题;web_agent 看不到图片,不要让它分析文件;undefined_self_code_agent 仅可只读查阅 Undefined 自身代码,不能写代码、执行命令或读取 `code/NagaAgent/`;naga_code_analysis_agent 只负责 NagaAgent 项目。 **你是指挥官,Agent 是专家**:你负责拆解任务、分配工作、综合结果。每个 Agent 只提供它专业领域的原子输出。 **能并行就并行**:多个 Agent 调用之间如果没有数据依赖,应在同一轮响应中并行调用以减少延迟。但如果后一个 Agent 的 prompt 依赖前一个 Agent 的结果,则必须等前一个返回后再调用。 **Agent 间互调**:有些 Agent 内部可以调用其他 Agent,以提高效率。这是正常的系统行为,不需要你手动干预。 @@ -968,21 +994,25 @@ 需要每轮都置顶提醒自己的约束/待办/自我指令:用 memory.add(如”用户要求以后用英文回复”) 用户事实(偏好、身份、习惯、计划、关系等)一律写 end.observations,不要用 memory.add 要回忆”之前发生过什么”或查看”某人/某群侧写”:用 cognitive.* 查询 - 对当前输入批次提取值得留存的新观察(用户事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点) - 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中值得留存的信息;禁止只记录最后一条。 + 对当前输入批次提取有价值的新观察(用户/群聊/第三方事实 + 有价值的自身行为):写到 end.observations(数组,严格一条一个要点);不要求与 bot 相关,也不要求长期稳定 + 当前输入批次若包含多条连续消息,end.observations 必须覆盖整批消息中有价值的信息;禁止只记录最后一条。 + 历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。 纯流水账动作(调了什么工具、决定不回复等)只写 memo,不写 end.observations 一次性闲聊、无后续价值的信息,不写入任何记忆 - 当你”不明白/信息缺口明显”且任务可能依赖历史时,可主动查询 cognitive.* 与最近消息;先小范围检索,再按需扩展范围 - 平衡原则:不要每轮都查;当前输入批次可直接回答、或只是闲聊/催促时,优先直接处理并结束 + 记忆查阅要主动:只要当前输入批次显式或隐式依赖“之前 / 上次 / 刚才 / 那个 / 你记得吗 / 继续 / 按我的习惯 / 我们约定过 / 他以前说过”等历史事实,不要凭印象回答;先查看已注入的记忆,必要时主动调用 cognitive.*。 + 涉及用户偏好、身份、习惯、长期计划、承诺待办、群规、群氛围、历史争议、之前排查过的问题、以前给出的方案或你是否已经做过某事时,优先调用 cognitive.search_events 或 cognitive.get_profile 查证,再回答或行动。 + 当你“不明白 / 信息缺口明显”且任务可能依赖历史时,可主动查询 cognitive.* 与最近消息;先小范围检索,再按需扩展范围。检索词要围绕当前输入批次、目标用户 QQ 号、群号和关键对象组织,不要泛泛搜索。 + 如果当前问题需要修改、删除或核对 memory.* 置顶备忘,先调用 memory.list 找到 UUID 或现有条目,再 update/delete;不要凭印象编造 UUID 或假设备忘不存在。 + 平衡原则:不要机械地每轮都查;当前输入批次可直接回答、只是一次性闲聊、单纯催促/感谢、或查阅会违反隐私边界时,优先直接处理并结束。 ”memory.add: 用户A要求以后在本群用英文回复”(自我约束置顶) ”memory.add: 下周三前帮用户B完成数据迁移”(待办置顶) - ”end.observations: [“Null 喜欢用 Rust 写底层代码”]”(用户偏好 → 认知记忆) - ”end.observations: [“2026-02-20 晚上在开发群里,用户A说他下周三要发版”]”(一条一个要点) - ”end.observations: [“用户A最近在用 Rust 重写后端”]”(拆开写,不要合并) - ”end.observations: [“2026-02-24 帮用户B排查了 Redis 连接超时问题,最终定位到是连接池配置过小”]”(有价值的行为) + ”end.observations: [“QQ号1708213363(昵称Null)喜欢用 Rust 写底层代码”]”(用户偏好 → 认知记忆) + ”end.observations: [“2026-02-20 晚上在开发群里,QQ号10001(昵称用户A)说下周三要发版”]”(一条一个要点) + ”end.observations: [“QQ号10001(昵称用户A)最近在用 Rust 重写后端”]”(拆开写,不要合并) + ”end.observations: [“2026-02-24 帮QQ号10002(昵称用户B)排查了 Redis 连接超时问题,最终定位到是连接池配置过小”]”(有价值的行为) ”memo: 查了下认知记忆,没找到相关记录”(纯流水账写 memo) ”memory.add: Null 喜欢用 Rust 写底层代码”(用户偏好不该写 memory.add,应写 observations) ”end.observations: [“用户A说他下周三要发版,而且最近在用 Rust 重写后端”]”(一条塞了两个要点——应拆成两条) @@ -1389,6 +1419,7 @@ 不回复自己,不重复发言 尊重对话边界,不凑热闹 看清名字/QQ号与对话对象,只在明确被直接对话时回复 + 「你」「AI」「bot」「机器人」不是自动触发;必须结合 sender_id、@/reply 和上下文确认指向 Undefined,拿不准就沉默 认可并接受自身人设,不随对话随意改设定、不自贬为通用 AI、不临时扮演他人 无法判断是否在对你说话时,假设不在和你讲话;别人之间的对话不参与,不因「你/我」等人称误插话 对Null保持克制,不要频繁回复他的每条消息 diff --git a/scripts/README.md b/scripts/README.md index 25d99fa6..3330daa7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,6 +4,35 @@ ## 脚本列表 +### [`build_native_apps.py`](build_native_apps.py) — 本地原生 App 构建 + +统一编排本机可构建的 Console / Chat 原生产物。脚本不会自动安装系统依赖、Android SDK 包或 Rust target;`check` 子命令只报告缺口并给出修复提示。 + +```bash +# 查看将要执行的本地构建矩阵 +uv run python scripts/build_native_apps.py list --product all --targets all --android-abi all + +# 检查 Android arm64 debug APK 构建环境 +uv run python scripts/build_native_apps.py check --targets android --android-abi arm64-v8a + +# 构建 Chat arm64 debug APK +uv run python scripts/build_native_apps.py build --product chat --targets android --android-abi arm64-v8a + +# 构建 Console + Chat 的 Linux deb +uv run python scripts/build_native_apps.py build --product all --targets desktop --desktop-bundles deb +``` + +常用参数: + +- `--product chat|console|all`:选择 App,默认 `chat`。 +- `--targets android|desktop|all`:选择本机目标,默认 `android`。 +- `--android-abi arm64-v8a|armeabi-v7a|x86|x86_64|all`:选择 Android ABI,默认 `arm64-v8a`。 +- `--desktop-bundles deb|appimage|all`:Linux 桌面包类型,默认 `deb`。 +- `--android-init auto|always|skip`:Android 生成工程初始化策略,默认 `auto`。 +- `--output-dir PATH`:产物收集目录,默认 `dist/native//`。 +- `--dry-run`:只打印命令,不执行。 +- `--no-install-deps`:不在缺少 `node_modules/.bin/tauri` 时自动执行 `npm ci`。 + ### [`sync_config_template.py`](sync_config_template.py) — 同步配置模板与注释 保留当前 `config.toml` 已有配置值,同时把 `config.toml.example` 中新增的配置项、默认空表和双语注释同步回来。 @@ -80,4 +109,48 @@ python3 scripts/release_notes.py notes --tag v3.4.0 --output release_notes.md - `apps/undefined-console/package-lock.json` - `apps/undefined-console/src-tauri/Cargo.toml` - `apps/undefined-console/src-tauri/tauri.conf.json` +- `apps/undefined-console/src-tauri/Cargo.lock` 根包版本 +- `apps/undefined-chat/package.json` +- `apps/undefined-chat/package-lock.json` +- `apps/undefined-chat/src-tauri/Cargo.toml` +- `apps/undefined-chat/src-tauri/tauri.conf.json` +- `apps/undefined-chat/src-tauri/Cargo.lock` 根包版本 - `CHANGELOG.md` 最新版本条目 + +### bump_version.py — 同步项目版本号 + +统一以 `pyproject.toml` 的主版本为源,更新 Python 包、Console 和 Chat 的版本文件。 + +```bash +uv run python scripts/bump_version.py 3.6.0 +uv run python scripts/bump_version.py 3.6.0 --dry-run +uv run python scripts/bump_version.py 3.6.0 --commit +``` + +同步范围: + +- `pyproject.toml` +- `src/Undefined/__init__.py` +- `apps/undefined-console/package.json` +- `apps/undefined-console/package-lock.json` +- `apps/undefined-console/src-tauri/Cargo.toml` +- `apps/undefined-console/src-tauri/tauri.conf.json` +- `apps/undefined-console/src-tauri/Cargo.lock` +- `apps/undefined-chat/package.json` +- `apps/undefined-chat/package-lock.json` +- `apps/undefined-chat/src-tauri/Cargo.toml` +- `apps/undefined-chat/src-tauri/tauri.conf.json` +- `apps/undefined-chat/src-tauri/Cargo.lock` + +非 dry-run 时脚本还会执行 `uv sync`,并分别在 Console / Chat 下执行 `npm install --package-lock-only` 与 `cargo update --workspace`,保证 lock 文件和 manifest 不漂移。 + +### prepare_tauri_android.py — 生成后 Android 修补 + +Tauri 的 `src-tauri/gen/` 是生成目录,不提交到仓库。Undefined Chat 需要移动端 HTML 预览使用独立 Android Activity,并需要 Android Keystore 安全存储插件,因此 Chat 的 `npm run tauri:android:init` 会在生成后运行: + +```bash +python3 ../../scripts/prepare_tauri_android.py . +python3 ../../scripts/prepare_tauri_android.py . --check +``` + +脚本只对 `apps/undefined-chat` 生效,会向生成的 Android app 注入 `HtmlPreviewActivity.kt`、`SecretPlugin.kt`,并在 `AndroidManifest.xml` 中声明预览 Activity 的 `android:exported="false"`。Console 保持 no-op。 diff --git a/scripts/build_native_apps.py b/scripts/build_native_apps.py new file mode 100644 index 00000000..9bbb27df --- /dev/null +++ b/scripts/build_native_apps.py @@ -0,0 +1,732 @@ +#!/usr/bin/env python3 +"""Build local native app artifacts for Undefined Console and Chat.""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys + + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +_SCRIPTS_DIR = _PROJECT_ROOT / "scripts" +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + +from release_apps import NATIVE_APPS, NativeApp # noqa: E402 + + +ANDROID_NDK_VERSION = "27.2.12479018" +ANDROID_PLATFORM_PACKAGE = "platforms;android-34" +ANDROID_BUILD_TOOLS_PACKAGE = "build-tools;34.0.0" +ANDROID_REQUIRED_PACKAGES = ( + "platform-tools", + ANDROID_PLATFORM_PACKAGE, + ANDROID_BUILD_TOOLS_PACKAGE, + f"ndk;{ANDROID_NDK_VERSION}", +) + +PRODUCT_CHOICES = ("chat", "console", "all") +TARGET_CHOICES = ("desktop", "android", "all") +ANDROID_ABI_CHOICES = ("arm64-v8a", "armeabi-v7a", "x86", "x86_64", "all") +ANDROID_INIT_CHOICES = ("auto", "always", "skip") +DESKTOP_BUNDLE_CHOICES = ("deb", "appimage", "all") + + +@dataclass(frozen=True, slots=True) +class AndroidTarget: + abi_label: str + tauri_target: str + rust_target: str + + +@dataclass(frozen=True, slots=True) +class BuildCommand: + description: str + command: tuple[str, ...] + cwd: Path + env: dict[str, str] + + +@dataclass(frozen=True, slots=True) +class BuildTask: + product: str + target_kind: str + label: str + app: NativeApp + commands: tuple[BuildCommand, ...] + artifact_patterns: tuple[str, ...] + artifact_search_root: Path + + +@dataclass(frozen=True, slots=True) +class CheckItem: + name: str + ok: bool + detail: str + fix_hint: str | None = None + + +@dataclass(frozen=True, slots=True) +class BuildOptions: + product: str + targets: str + android_abi: str + desktop_bundles: str + output_dir: Path + dry_run: bool + no_install_deps: bool + android_init: str + + +ANDROID_TARGETS: tuple[AndroidTarget, ...] = ( + AndroidTarget("arm64-v8a", "aarch64", "aarch64-linux-android"), + AndroidTarget("armeabi-v7a", "armv7", "armv7-linux-androideabi"), + AndroidTarget("x86", "i686", "i686-linux-android"), + AndroidTarget("x86_64", "x86_64", "x86_64-linux-android"), +) + + +def _app_product(app: NativeApp) -> str: + return app.app_dir.removeprefix("undefined-") + + +def selected_apps(product: str) -> tuple[NativeApp, ...]: + if product == "all": + return NATIVE_APPS + apps = tuple(app for app in NATIVE_APPS if _app_product(app) == product) + if not apps: + raise ValueError(f"Unknown product: {product}") + return apps + + +def selected_android_targets(android_abi: str) -> tuple[AndroidTarget, ...]: + if android_abi == "all": + return ANDROID_TARGETS + targets = tuple( + target for target in ANDROID_TARGETS if target.abi_label == android_abi + ) + if not targets: + raise ValueError(f"Unknown Android ABI: {android_abi}") + return targets + + +def selected_target_kinds(targets: str) -> tuple[str, ...]: + if targets == "all": + return ("desktop", "android") + if targets not in ("desktop", "android"): + raise ValueError(f"Unknown target kind: {targets}") + return (targets,) + + +def _is_linux() -> bool: + return platform.system().lower() == "linux" + + +def desktop_bundle_arg(desktop_bundles: str) -> str: + if desktop_bundles == "all": + return "appimage,deb" + return desktop_bundles + + +def android_home(env: dict[str, str] | None = None) -> Path: + current_env = os.environ if env is None else env + value = current_env.get("ANDROID_HOME") or current_env.get("ANDROID_SDK_ROOT") + if value: + return Path(value).expanduser() + opt_sdk = Path("/opt/android-sdk") + if opt_sdk.exists(): + return opt_sdk + return Path.home() / "Android" / "Sdk" + + +def build_environment( + project_root: Path, + *, + env: dict[str, str] | None = None, +) -> dict[str, str]: + del project_root + base = dict(os.environ if env is None else env) + sdk_root = android_home(base) + java_home = base.get("JAVA_HOME") or "/usr/lib/jvm/java-17-openjdk" + build_tools = sdk_root / "build-tools" / "34.0.0" + cmdline_tools = sdk_root / "cmdline-tools" / "latest" / "bin" + system_cmdline_tools = Path("/opt/android-sdk/cmdline-tools/latest/bin") + platform_tools = sdk_root / "platform-tools" + ndk_home = Path(base.get("NDK_HOME", sdk_root / "ndk" / ANDROID_NDK_VERSION)) + java_bin = Path(java_home) / "bin" + path_parts = ( + str(java_bin), + str(cmdline_tools), + str(system_cmdline_tools) if system_cmdline_tools.exists() else "", + str(platform_tools), + str(build_tools), + base.get("PATH", ""), + ) + base.update( + { + "JAVA_HOME": java_home, + "ANDROID_HOME": str(sdk_root), + "ANDROID_SDK_ROOT": str(sdk_root), + "NDK_HOME": str(ndk_home), + "GRADLE_USER_HOME": base.get("GRADLE_USER_HOME", str(sdk_root / "gradle")), + "PATH": os.pathsep.join(part for part in path_parts if part), + } + ) + return base + + +def _npm_command(script: str, extra_args: Iterable[str] = ()) -> tuple[str, ...]: + args = ("npm", "run", script) + extra = tuple(extra_args) + if extra: + return (*args, "--", *extra) + return args + + +def _needs_npm_ci(app: NativeApp, project_root: Path, no_install_deps: bool) -> bool: + if no_install_deps: + return False + return not (app.app_root(project_root) / "node_modules" / ".bin" / "tauri").exists() + + +def _android_gen_exists(app: NativeApp, project_root: Path) -> bool: + return (app.tauri_root(project_root) / "gen" / "android").exists() + + +def _android_init_commands( + app: NativeApp, + project_root: Path, + env: dict[str, str], + android_init: str, +) -> tuple[BuildCommand, ...]: + if android_init == "skip": + if _app_product(app) == "chat": + return ( + BuildCommand( + description=f"Verify Android native patches for {app.app_dir}", + command=_npm_command("tauri:android:prepare:check"), + cwd=app.app_root(project_root), + env=env, + ), + ) + return () + + should_init = android_init == "always" or not _android_gen_exists(app, project_root) + commands: list[BuildCommand] = [] + if should_init: + commands.append( + BuildCommand( + description=f"Initialize Android project for {app.app_dir}", + command=("npm", "run", "tauri:android:init"), + cwd=app.app_root(project_root), + env=env, + ) + ) + if _app_product(app) == "chat": + commands.append( + BuildCommand( + description="Verify Chat Android native patches", + command=("npm", "run", "tauri:android:prepare:check"), + cwd=app.app_root(project_root), + env=env, + ) + ) + return tuple(commands) + + +def _build_output_dir(project_root: Path) -> Path: + suffix = "local" + result = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=project_root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + suffix = result.stdout.strip() + return project_root / "dist" / "native" / suffix + + +def make_build_tasks( + options: BuildOptions, + *, + project_root: Path = _PROJECT_ROOT, +) -> tuple[BuildTask, ...]: + root = project_root.resolve() + env = build_environment(root) + tasks: list[BuildTask] = [] + for app in selected_apps(options.product): + app_root = app.app_root(root) + install_once_commands: tuple[BuildCommand, ...] + install_planned = False + android_init_planned = False + if _needs_npm_ci(app, root, options.no_install_deps): + install_once_commands = ( + BuildCommand( + description=f"Install npm dependencies for {app.app_dir}", + command=("npm", "ci"), + cwd=app_root, + env=env, + ), + ) + else: + install_once_commands = () + + for target_kind in selected_target_kinds(options.targets): + install_commands = install_once_commands if not install_planned else () + if install_commands: + install_planned = True + if target_kind == "desktop": + if not _is_linux(): + raise RuntimeError( + "Local desktop builds are only supported on Linux" + ) + bundle_arg = desktop_bundle_arg(options.desktop_bundles) + tasks.append( + BuildTask( + product=_app_product(app), + target_kind="desktop", + label="linux-x64", + app=app, + commands=( + *install_commands, + BuildCommand( + description=f"Build Linux desktop bundles for {app.app_dir}", + command=_npm_command( + "tauri:build", + ("--ci", "--bundles", bundle_arg), + ), + cwd=app_root, + env={**env, "NO_STRIP": "true"}, + ), + ), + artifact_patterns=tuple( + pattern + for bundle in bundle_arg.split(",") + for pattern in ( + ("*.deb",) if bundle == "deb" else ("*.AppImage",) + ) + ), + artifact_search_root=app.tauri_root(root) + / "target" + / "release" + / "bundle", + ) + ) + elif target_kind == "android": + for android_target in selected_android_targets(options.android_abi): + android_init_commands = ( + () + if android_init_planned + else _android_init_commands( + app, + root, + env, + options.android_init, + ) + ) + android_init_planned = True + tasks.append( + BuildTask( + product=_app_product(app), + target_kind="android", + label=android_target.abi_label, + app=app, + commands=( + *install_commands, + *android_init_commands, + BuildCommand( + description=( + f"Build Android debug APK for {app.app_dir} " + f"{android_target.abi_label}" + ), + command=_npm_command( + "tauri:android:debug", + ( + "--ci", + "--apk", + "--target", + android_target.tauri_target, + ), + ), + cwd=app_root, + env=env, + ), + ), + artifact_patterns=("*.apk",), + artifact_search_root=app.tauri_root(root), + ) + ) + install_commands = () + else: + raise AssertionError(f"Unhandled target kind: {target_kind}") + return tuple(tasks) + + +def _which(command: str, env: dict[str, str]) -> str | None: + return shutil.which(command, path=env.get("PATH")) + + +def _command_output(command: Sequence[str], env: dict[str, str]) -> tuple[int, str]: + try: + result = subprocess.run( + list(command), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + check=False, + ) + except FileNotFoundError as exc: + return 127, str(exc) + return result.returncode, result.stdout.strip() + + +def _java_version_ok(env: dict[str, str]) -> CheckItem: + java = _which("java", env) + if java is None: + return CheckItem( + "Java 17", + False, + "java not found", + "安装 jdk17-openjdk,并设置 JAVA_HOME=/usr/lib/jvm/java-17-openjdk", + ) + code, output = _command_output(("java", "-version"), env) + ok = code == 0 and ('version "17.' in output or "openjdk 17." in output.lower()) + return CheckItem( + "Java 17", + ok, + output.splitlines()[0] if output else java, + None if ok else "设置 JAVA_HOME=/usr/lib/jvm/java-17-openjdk 后重试", + ) + + +def _rust_target_installed(rust_target: str, env: dict[str, str]) -> bool: + code, output = _command_output(("rustup", "target", "list", "--installed"), env) + if code != 0: + return False + return rust_target in output.splitlines() + + +def check_environment( + *, + project_root: Path = _PROJECT_ROOT, + targets: str, + android_abi: str, +) -> tuple[CheckItem, ...]: + root = project_root.resolve() + env = build_environment(root) + items: list[CheckItem] = [] + for command in ("node", "npm", "cargo", "rustup"): + path = _which(command, env) + items.append( + CheckItem( + command, + path is not None, + path or f"{command} not found", + None if path else f"请先安装 {command}", + ) + ) + + target_kinds = selected_target_kinds(targets) + if "desktop" in target_kinds: + items.append( + CheckItem( + "Linux desktop host", + _is_linux(), + platform.system(), + None if _is_linux() else "本地桌面构建只支持当前 Linux 主机", + ) + ) + + if "android" in target_kinds: + items.append(_java_version_ok(env)) + sdk_root = Path(env["ANDROID_HOME"]) + ndk_home = Path(env["NDK_HOME"]) + sdkmanager = _which("sdkmanager", env) + items.append( + CheckItem( + "sdkmanager", + sdkmanager is not None, + sdkmanager or "sdkmanager not found", + None + if sdkmanager + else "source /etc/profile 或把 Android cmdline-tools/latest/bin 加入 PATH", + ) + ) + for relative in ( + Path("platform-tools"), + Path("platforms") / "android-34", + Path("build-tools") / "34.0.0", + ): + sdk_path = sdk_root / relative + items.append( + CheckItem( + str(relative), + sdk_path.exists(), + str(sdk_path), + None + if sdk_path.exists() + else "运行 sdkmanager 安装 platform-tools/platforms;android-34/build-tools;34.0.0", + ) + ) + items.append( + CheckItem( + "Android NDK", + ndk_home.exists(), + str(ndk_home), + None + if ndk_home.exists() + else f'运行 sdkmanager --install "ndk;{ANDROID_NDK_VERSION}"', + ) + ) + for android_target in selected_android_targets(android_abi): + ok = _rust_target_installed(android_target.rust_target, env) + items.append( + CheckItem( + f"Rust target {android_target.rust_target}", + ok, + "installed" if ok else "missing", + None if ok else f"rustup target add {android_target.rust_target}", + ) + ) + return tuple(items) + + +def _run_command(command: BuildCommand, dry_run: bool) -> None: + printable = " ".join(command.command) + print(f"\n==> {command.description}") + print(f" cd {command.cwd}") + print(f" {printable}") + if dry_run: + return + subprocess.run( + list(command.command), + cwd=command.cwd, + env=command.env, + check=True, + ) + + +def _artifact_matches(task: BuildTask) -> tuple[Path, ...]: + matches: list[Path] = [] + for pattern in task.artifact_patterns: + matches.extend( + path + for path in task.artifact_search_root.rglob(pattern) + if not path.name.endswith("-unsigned.apk") + ) + return tuple(sorted(set(matches))) + + +def _artifact_signature(path: Path) -> tuple[int, int]: + stat = path.stat() + return stat.st_mtime_ns, stat.st_size + + +def _artifact_snapshot(task: BuildTask) -> dict[Path, tuple[int, int]]: + return {path: _artifact_signature(path) for path in _artifact_matches(task)} + + +def _copy_artifacts( + task: BuildTask, + output_dir: Path, + matches: Iterable[Path], + *, + dry_run: bool, +) -> tuple[Path, ...]: + collected: list[Path] = [] + if not dry_run: + output_dir.mkdir(parents=True, exist_ok=True) + for source in matches: + destination = ( + output_dir + / f"{task.app.artifact_prefix}-{task.target_kind}-{task.label}-{source.name}" + ) + print(f"Collect {source} -> {destination}") + if not dry_run: + shutil.copy2(source, destination) + collected.append(destination) + return tuple(collected) + + +def collect_new_artifacts( + task: BuildTask, + output_dir: Path, + before: dict[Path, tuple[int, int]], + *, + dry_run: bool, +) -> tuple[Path, ...]: + after = _artifact_snapshot(task) + matches = tuple( + path + for path, signature in sorted(after.items()) + if before.get(path) != signature + ) + if not matches: + raise FileNotFoundError( + f"No new artifacts found for {task.app.app_dir} {task.target_kind} {task.label} " + f"under {task.artifact_search_root}" + ) + return _copy_artifacts(task, output_dir, matches, dry_run=dry_run) + + +def collect_artifacts( + tasks: Iterable[BuildTask], + output_dir: Path, + *, + dry_run: bool, +) -> tuple[Path, ...]: + collected: list[Path] = [] + for task in tasks: + matches = _artifact_matches(task) + if not matches: + raise FileNotFoundError( + f"No artifacts found for {task.app.app_dir} {task.target_kind} {task.label} " + f"under {task.artifact_search_root}" + ) + collected.extend(_copy_artifacts(task, output_dir, matches, dry_run=dry_run)) + return tuple(collected) + + +def print_check_items(items: Iterable[CheckItem]) -> bool: + all_ok = True + for item in items: + status = "OK" if item.ok else "MISS" + print(f"[{status}] {item.name}: {item.detail}") + if not item.ok: + all_ok = False + if item.fix_hint: + print(f" fix: {item.fix_hint}") + return all_ok + + +def command_list(args: argparse.Namespace) -> int: + options = options_from_args(args) + tasks = make_build_tasks(options) + for task in tasks: + print(f"{task.product} {task.target_kind} {task.label}:") + for command in task.commands: + print(f" - {command.description}: {' '.join(command.command)}") + return 0 + + +def command_check(args: argparse.Namespace) -> int: + items = check_environment(targets=args.targets, android_abi=args.android_abi) + return 0 if print_check_items(items) else 1 + + +def command_build(args: argparse.Namespace) -> int: + options = options_from_args(args) + check_items = check_environment( + targets=options.targets, android_abi=options.android_abi + ) + if not print_check_items(check_items): + print("\n环境检查未通过;脚本不会自动安装依赖。请按 fix 提示补齐后重试。") + return 1 + tasks = make_build_tasks(options) + collected: list[Path] = [] + for task in tasks: + before = _artifact_snapshot(task) if not options.dry_run else {} + for command in task.commands: + _run_command(command, options.dry_run) + if not options.dry_run: + collected.extend( + collect_new_artifacts( + task, + options.output_dir, + before, + dry_run=False, + ) + ) + if options.dry_run: + return 0 + print("\nArtifacts:") + for path in collected: + print(f" {path}") + return 0 + + +def options_from_args(args: argparse.Namespace) -> BuildOptions: + output_dir = ( + Path(args.output_dir) if args.output_dir else _build_output_dir(_PROJECT_ROOT) + ) + if not output_dir.is_absolute(): + output_dir = _PROJECT_ROOT / output_dir + return BuildOptions( + product=args.product, + targets=args.targets, + android_abi=args.android_abi, + desktop_bundles=args.desktop_bundles, + output_dir=output_dir, + dry_run=args.dry_run, + no_install_deps=args.no_install_deps, + android_init=args.android_init, + ) + + +def _add_common_args(parser: argparse.ArgumentParser, *, include_build: bool) -> None: + parser.add_argument("--product", choices=PRODUCT_CHOICES, default="chat") + parser.add_argument("--targets", choices=TARGET_CHOICES, default="android") + parser.add_argument( + "--android-abi", choices=ANDROID_ABI_CHOICES, default="arm64-v8a" + ) + if include_build: + parser.add_argument( + "--desktop-bundles", choices=DESKTOP_BUNDLE_CHOICES, default="deb" + ) + parser.add_argument("--output-dir") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--no-install-deps", action="store_true") + parser.add_argument( + "--android-init", choices=ANDROID_INIT_CHOICES, default="auto" + ) + else: + parser.set_defaults( + desktop_bundles="deb", + output_dir=None, + dry_run=False, + no_install_deps=False, + android_init="auto", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Build local Undefined native app artifacts.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list", help="Print selected local build tasks") + _add_common_args(list_parser, include_build=True) + list_parser.set_defaults(func=command_list) + + check_parser = subparsers.add_parser( + "check", help="Check local build prerequisites" + ) + _add_common_args(check_parser, include_build=False) + check_parser.set_defaults(func=command_check) + + build = subparsers.add_parser("build", help="Build selected local artifacts") + _add_common_args(build, include_build=True) + build.set_defaults(func=command_build) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index a22653d5..de49ddcb 100644 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -1,169 +1,305 @@ #!/usr/bin/env python3 -"""统一更新项目所有版本号。 - -用法: - uv run python scripts/bump_version.py 3.3.0 - uv run python scripts/bump_version.py 3.3.0 --dry-run - uv run python scripts/bump_version.py 3.3.0 --commit -""" +"""统一更新项目所有版本号。""" from __future__ import annotations import argparse +from dataclasses import dataclass +import json +from pathlib import Path import re import subprocess import sys -from pathlib import Path +import tomllib +from typing import Any, cast + _PROJECT_ROOT = Path(__file__).resolve().parent.parent +_SCRIPTS_DIR = _PROJECT_ROOT / "scripts" +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) -# 所有需要更新版本号的源文件及其匹配模式、替换模板 -_VERSION_TARGETS: list[tuple[Path, str, str]] = [ - ( - _PROJECT_ROOT / "pyproject.toml", - r'^version\s*=\s*"[^"]+"', - 'version = "{version}"', - ), - ( - _PROJECT_ROOT / "src" / "Undefined" / "__init__.py", - r'^__version__\s*=\s*"[^"]+"', - '__version__ = "{version}"', - ), - ( - _PROJECT_ROOT / "apps" / "undefined-console" / "src-tauri" / "Cargo.toml", - r'^version\s*=\s*"[^"]+"', - 'version = "{version}"', - ), -] +from release_apps import NATIVE_APPS, NativeApp # noqa: E402 -# JSON 文件中的版本字段(顶层 "version" key) -_JSON_TARGETS: list[Path] = [ - _PROJECT_ROOT / "apps" / "undefined-console" / "package.json", - _PROJECT_ROOT / "apps" / "undefined-console" / "src-tauri" / "tauri.conf.json", -] _SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[\w.]+)?(?:\+[\w.]+)?$") -def _read_current_version() -> str: +@dataclass(frozen=True, slots=True) +class BumpResult: + old_version: str + new_version: str + changed_paths: tuple[str, ...] + + +def _read_current_version(project_root: Path) -> str: """从 pyproject.toml 读取当前版本。""" - text = (_PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") - m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) - if not m: - print("错误: 无法从 pyproject.toml 读取当前版本", file=sys.stderr) - sys.exit(1) - return m.group(1) + text = (project_root / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) + if match is None: + raise ValueError("无法从 pyproject.toml 读取当前版本") + return match.group(1) + + +def _relative(project_root: Path, path: Path) -> str: + return str(path.relative_to(project_root)) + + +def _write_if_changed(path: Path, text: str, dry_run: bool) -> bool: + old_text = path.read_text(encoding="utf-8") + if old_text == text: + return False + if not dry_run: + path.write_text(text, encoding="utf-8") + return True def _update_text_file( - path: Path, pattern: str, replacement: str, dry_run: bool + path: Path, + pattern: str, + replacement: str, + dry_run: bool, ) -> bool: """用正则替换文本文件中的版本号,返回是否有变更。""" text = path.read_text(encoding="utf-8") new_text, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) if count == 0: - print( - f" 警告: {path.relative_to(_PROJECT_ROOT)} 未匹配到版本模式", - file=sys.stderr, - ) + raise ValueError(f"{path} 未匹配到版本模式") + return _write_if_changed(path, new_text, dry_run) + + +def _update_json_version(path: Path, version: str, dry_run: bool) -> bool: + data = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) + old_version = data.get("version") + if old_version == version: return False - if text == new_text: + data["version"] = version + text = json.dumps(data, ensure_ascii=False, indent="\t") + "\n" + return _write_if_changed(path, text, dry_run) + + +def _update_package_lock(path: Path, version: str, dry_run: bool) -> bool: + data = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8"))) + packages = data.get("packages") + if not isinstance(packages, dict): + raise ValueError(f"{path} 缺少 packages") + root_package = packages.get("") + if not isinstance(root_package, dict): + raise ValueError(f'{path} 缺少 packages[""]') + + changed = False + if data.get("version") != version: + data["version"] = version + changed = True + if root_package.get("version") != version: + root_package["version"] = version + changed = True + if not changed: return False - if not dry_run: - path.write_text(new_text, encoding="utf-8") - return True + text = json.dumps(data, ensure_ascii=False, indent="\t") + "\n" + return _write_if_changed(path, text, dry_run) -def _update_json_file(path: Path, version: str, dry_run: bool) -> bool: - """更新 JSON 文件中顶层 "version" 字段(正则替换,保留原始格式)。""" - pattern = r'^(\s*"version"\s*:\s*)"[^"]+"' - replacement = rf'\g<1>"{version}"' - return _update_text_file(path, pattern, replacement, dry_run) +def _update_cargo_manifest(path: Path, version: str, dry_run: bool) -> bool: + data = tomllib.loads(path.read_text(encoding="utf-8")) + package = data.get("package") + if not isinstance(package, dict) or not isinstance(package.get("version"), str): + raise ValueError(f"{path} 缺少 [package].version") + return _update_text_file( + path, + r'^version\s*=\s*"[^"]+"', + f'version = "{version}"', + dry_run, + ) -def _sync_lock_files(dry_run: bool) -> None: - """重新生成 lock 文件以同步版本号。""" + +def _update_cargo_lock_root_package( + path: Path, + package_name: str, + version: str, + dry_run: bool, +) -> bool: + data = tomllib.loads(path.read_text(encoding="utf-8")) + packages = data.get("package") + if not isinstance(packages, list): + raise ValueError(f"{path} 缺少 package 列表") + if not any( + isinstance(package, dict) and package.get("name") == package_name + for package in packages + ): + raise ValueError(f"{path} 缺少 {package_name}") + + text = path.read_text(encoding="utf-8") + package_pattern = re.compile( + rf'(\[\[package\]\]\nname = "{re.escape(package_name)}"\nversion = )"[^"]+"', + re.MULTILINE, + ) + new_text, count = package_pattern.subn(rf'\g<1>"{version}"', text, count=1) + if count == 0: + raise ValueError(f"{path} 未匹配到 {package_name} 版本") + return _write_if_changed(path, new_text, dry_run) + + +def _update_native_app_versions( + project_root: Path, + app: NativeApp, + version: str, + dry_run: bool, +) -> tuple[str, ...]: + changed: list[str] = [] + app_root = app.app_root(project_root) + tauri_root = app.tauri_root(project_root) + package_json = app_root / "package.json" + package_lock = app_root / "package-lock.json" + cargo_toml = tauri_root / "Cargo.toml" + tauri_conf = tauri_root / "tauri.conf.json" + cargo_lock = tauri_root / "Cargo.lock" + + updates: tuple[tuple[Path, bool], ...] = ( + ( + package_json, + _update_json_version(package_json, version, dry_run), + ), + ( + package_lock, + _update_package_lock(package_lock, version, dry_run), + ), + ( + cargo_toml, + _update_cargo_manifest(cargo_toml, version, dry_run), + ), + ( + tauri_conf, + _update_json_version(tauri_conf, version, dry_run), + ), + ( + cargo_lock, + _update_cargo_lock_root_package( + cargo_lock, + app.cargo_package, + version, + dry_run, + ), + ), + ) + for path, did_change in updates: + if did_change: + changed.append(_relative(project_root, path)) + return tuple(changed) + + +def sync_lock_files(project_root: Path, *, dry_run: bool) -> None: + """重新生成 lock 文件以同步依赖锁定内容。""" if dry_run: - print("\n(dry-run) 将执行以下命令同步 lock 文件:") - print(" uv sync") - print(" npm install --package-lock-only (in apps/undefined-console/)") - print( - " cargo update --workspace (in apps/undefined-console/src-tauri/)" - ) return print("\n同步 lock 文件...") print(" uv sync") - subprocess.run(["uv", "sync"], cwd=_PROJECT_ROOT, check=True) + subprocess.run(["uv", "sync"], cwd=project_root, check=True) - console_dir = _PROJECT_ROOT / "apps" / "undefined-console" - print(" npm install --package-lock-only") - subprocess.run( - ["npm", "install", "--package-lock-only"], cwd=console_dir, check=True + for app in NATIVE_APPS: + app_root = app.app_root(project_root) + tauri_root = app.tauri_root(project_root) + print(f" npm install --package-lock-only ({app.app_dir})") + subprocess.run( + ["npm", "install", "--package-lock-only"], + cwd=app_root, + check=True, + ) + print(f" cargo update --workspace ({app.app_dir})") + subprocess.run(["cargo", "update", "--workspace"], cwd=tauri_root, check=True) + + +def bump_project_versions( + version: str, + *, + project_root: Path = _PROJECT_ROOT, + dry_run: bool, +) -> BumpResult: + root = project_root.resolve() + if not _SEMVER_RE.match(version): + raise ValueError(f"'{version}' 不是合法的语义版本号 (x.y.z)") + + current = _read_current_version(root) + if version == current: + return BumpResult( + old_version=current, + new_version=version, + changed_paths=(), + ) + + changed: list[str] = [] + text_targets: tuple[tuple[Path, str, str], ...] = ( + ( + root / "pyproject.toml", + r'^version\s*=\s*"[^"]+"', + f'version = "{version}"', + ), + ( + root / "src" / "Undefined" / "__init__.py", + r'^__version__\s*=\s*"[^"]+"', + f'__version__ = "{version}"', + ), ) + for path, pattern, replacement in text_targets: + if _update_text_file(path, pattern, replacement, dry_run): + changed.append(_relative(root, path)) - tauri_dir = console_dir / "src-tauri" - print(" cargo update --workspace") - subprocess.run(["cargo", "update", "--workspace"], cwd=tauri_dir, check=True) + for app in NATIVE_APPS: + changed.extend(_update_native_app_versions(root, app, version, dry_run)) + if changed and not dry_run: + sync_lock_files(root, dry_run=False) -def main() -> None: + return BumpResult( + old_version=current, + new_version=version, + changed_paths=tuple(changed), + ) + + +def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="统一更新项目版本号") parser.add_argument("version", help="新版本号,如 3.3.0") parser.add_argument("--dry-run", action="store_true", help="仅预览变更,不写入文件") parser.add_argument("--commit", action="store_true", help="更新后自动 git commit") - args = parser.parse_args() + return parser - new_version: str = args.version - if not _SEMVER_RE.match(new_version): - print(f"错误: '{new_version}' 不是合法的语义版本号 (x.y.z)", file=sys.stderr) - sys.exit(1) - current = _read_current_version() - if new_version == current: - print(f"版本已经是 {current},无需更新") - return - - prefix = "(dry-run) " if args.dry_run else "" - print(f"{prefix}版本 {current} → {new_version}\n") - - changed: list[str] = [] +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + new_version = cast(str, args.version) + dry_run = cast(bool, args.dry_run) + should_commit = cast(bool, args.commit) - # 文本文件 - for path, pattern, template in _VERSION_TARGETS: - rel = str(path.relative_to(_PROJECT_ROOT)) - replacement = template.format(version=new_version) - if _update_text_file(path, pattern, replacement, args.dry_run): - print(f" ✓ {rel}") - changed.append(rel) - else: - print(f" - {rel} (无变更)") - - # JSON 文件 - for path in _JSON_TARGETS: - rel = str(path.relative_to(_PROJECT_ROOT)) - if _update_json_file(path, new_version, args.dry_run): - print(f" ✓ {rel}") - changed.append(rel) - else: - print(f" - {rel} (无变更)") + try: + result = bump_project_versions( + new_version, + project_root=_PROJECT_ROOT, + dry_run=dry_run, + ) + except Exception as exc: + print(f"错误: {exc}", file=sys.stderr) + return 1 - if not changed: - print("\n所有文件已是目标版本,无需更新") - return + if not result.changed_paths: + print(f"版本已经是 {result.old_version},无需更新") + return 0 - _sync_lock_files(args.dry_run) + prefix = "(dry-run) " if dry_run else "" + print(f"{prefix}版本 {result.old_version} → {result.new_version}\n") + for path in result.changed_paths: + print(f" ✓ {path}") - if args.commit and not args.dry_run: + if should_commit and not dry_run: print("\n创建 git commit...") - lock_files = [ - "uv.lock", - "apps/undefined-console/package-lock.json", - "apps/undefined-console/src-tauri/Cargo.lock", - ] subprocess.run( - ["git", "add", *changed, *lock_files], cwd=_PROJECT_ROOT, check=True + ["git", "add", *result.changed_paths, "uv.lock"], + cwd=_PROJECT_ROOT, + check=True, ) subprocess.run( ["git", "commit", "-m", f"chore(version): bump version to {new_version}"], @@ -171,10 +307,12 @@ def main() -> None: check=True, ) print(f"\n完成! 已提交版本 {new_version}") - elif not args.dry_run: + elif not dry_run: print(f"\n完成! 版本已更新为 {new_version}") print("提示: 使用 --commit 可自动提交,或手动 git add + commit") + return 0 + if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scripts/prepare_tauri_android.py b/scripts/prepare_tauri_android.py new file mode 100644 index 00000000..7756daa2 --- /dev/null +++ b/scripts/prepare_tauri_android.py @@ -0,0 +1,359 @@ +"""Prepare generated Tauri Android projects for app-specific native code.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +from typing import Any + + +CHAT_APP_NAME = "undefined-chat" +CHAT_PREVIEW_ACTIVITY = "HtmlPreviewActivity" +CHAT_SECRET_PLUGIN = "SecretPlugin" +MAIN_ACTIVITY_FILE = "MainActivity.kt" + + +def _read_json_object(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a JSON object") + return data + + +def _app_name(app_dir: Path) -> str: + package_json = app_dir / "package.json" + data = _read_json_object(package_json) + name = data.get("name") + if not isinstance(name, str) or not name: + raise ValueError(f"{package_json} is missing string field 'name'") + return name + + +def _tauri_identifier(app_dir: Path) -> str: + tauri_conf = app_dir / "src-tauri" / "tauri.conf.json" + data = _read_json_object(tauri_conf) + identifier = data.get("identifier") + if not isinstance(identifier, str) or not identifier: + raise ValueError(f"{tauri_conf} is missing string field 'identifier'") + return identifier + + +def _android_root(app_dir: Path) -> Path: + return app_dir / "src-tauri" / "gen" / "android" + + +def _find_android_manifest(android_root: Path) -> Path: + candidates = sorted(android_root.glob("**/app/src/main/AndroidManifest.xml")) + if not candidates: + candidates = sorted(android_root.glob("**/AndroidManifest.xml")) + if not candidates: + raise FileNotFoundError( + f"AndroidManifest.xml not found under {android_root}; " + "run `npm run tauri:android:init` first" + ) + return candidates[0] + + +def _package_from_kotlin_file(path: Path) -> str | None: + text = path.read_text(encoding="utf-8") + match = re.search(r"(?m)^\s*package\s+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*$", text) + return match.group(1) if match else None + + +def _find_kotlin_package(android_root: Path, fallback: str) -> tuple[str, Path]: + main_activity = sorted(android_root.glob(f"**/{MAIN_ACTIVITY_FILE}")) + for path in main_activity: + package_name = _package_from_kotlin_file(path) + if package_name: + return package_name, path.parent + + java_roots = sorted(android_root.glob("**/app/src/main/java")) + if java_roots: + package_dir = java_roots[0].joinpath(*fallback.split(".")) + return fallback, package_dir + + raise FileNotFoundError( + f"Android Java/Kotlin source root not found under {android_root}; " + "run `npm run tauri:android:init` first" + ) + + +def _activity_source(package_name: str) -> str: + return ( + f"package {package_name}\n\nclass {CHAT_PREVIEW_ACTIVITY} : TauriActivity()\n" + ) + + +def _secret_plugin_source(package_name: str) -> str: + return f"""package {package_name} + +import android.app.Activity +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +@InvokeArg +internal class SecretPayload {{ + lateinit var key: String +}} + +@InvokeArg +internal class SetSecretPayload {{ + lateinit var key: String + lateinit var value: String +}} + +@TauriPlugin +class {CHAT_SECRET_PLUGIN}(private val activity: Activity) : Plugin(activity) {{ + private val prefs by lazy {{ + activity.getSharedPreferences("undefined_chat_secure_secrets", Context.MODE_PRIVATE) + }} + + @Command + fun isAvailable(invoke: Invoke) {{ + try {{ + ensureKey() + val ret = JSObject() + ret.put("available", true) + invoke.resolve(ret) + }} catch (error: Exception) {{ + invoke.reject("Android secure storage unavailable: ${{error.message}}") + }} + }} + + @Command + fun getSecret(invoke: Invoke) {{ + val args = invoke.parseArgs(SecretPayload::class.java) + if (args.key.isBlank()) {{ + invoke.reject("key is required") + return + }} + try {{ + val stored = prefs.getString(args.key, null) + val value = if (stored == null) null else decrypt(stored) + val ret = JSObject() + ret.put("value", value) + invoke.resolve(ret) + }} catch (error: Exception) {{ + invoke.reject("Android secure storage read failed: ${{error.message}}") + }} + }} + + @Command + fun setSecret(invoke: Invoke) {{ + val args = invoke.parseArgs(SetSecretPayload::class.java) + if (args.key.isBlank()) {{ + invoke.reject("key is required") + return + }} + if (args.value.isBlank()) {{ + invoke.reject("value is required") + return + }} + try {{ + if (!prefs.edit().putString(args.key, encrypt(args.value)).commit()) {{ + invoke.reject("Android secure storage write failed") + return + }} + invoke.resolve(JSObject()) + }} catch (error: Exception) {{ + invoke.reject("Android secure storage write failed: ${{error.message}}") + }} + }} + + @Command + fun deleteSecret(invoke: Invoke) {{ + val args = invoke.parseArgs(SecretPayload::class.java) + if (args.key.isBlank()) {{ + invoke.reject("key is required") + return + }} + if (!prefs.edit().remove(args.key).commit()) {{ + invoke.reject("Android secure storage delete failed") + return + }} + invoke.resolve(JSObject()) + }} + + private fun ensureKey(): SecretKey {{ + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {{ load(null) }} + keyStore.getKey(KEY_ALIAS, null)?.let {{ return it as SecretKey }} + + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore", + ) + val spec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build() + keyGenerator.init(spec) + return keyGenerator.generateKey() + }} + + private fun encrypt(value: String): String {{ + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, ensureKey()) + val ciphertext = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + return listOf(cipher.iv, ciphertext) + .joinToString(":") {{ Base64.encodeToString(it, Base64.NO_WRAP) }} + }} + + private fun decrypt(stored: String): String {{ + val parts = stored.split(":", limit = 2) + require(parts.size == 2) {{ "invalid encrypted secret payload" }} + val iv = Base64.decode(parts[0], Base64.NO_WRAP) + val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, ensureKey(), GCMParameterSpec(128, iv)) + return String(cipher.doFinal(ciphertext), Charsets.UTF_8) + }} + + private companion object {{ + const val KEY_ALIAS = "undefined_chat_runtime_api_key" + const val TRANSFORMATION = "AES/GCM/NoPadding" + }} +}} +""" + + +def _write_activity(package_dir: Path, package_name: str, dry_run: bool) -> Path | None: + activity_path = package_dir / f"{CHAT_PREVIEW_ACTIVITY}.kt" + expected = _activity_source(package_name) + if activity_path.exists() and activity_path.read_text(encoding="utf-8") == expected: + return None + if not dry_run: + package_dir.mkdir(parents=True, exist_ok=True) + activity_path.write_text(expected, encoding="utf-8") + return activity_path + + +def _write_secret_plugin( + package_dir: Path, package_name: str, dry_run: bool +) -> Path | None: + plugin_path = package_dir / f"{CHAT_SECRET_PLUGIN}.kt" + expected = _secret_plugin_source(package_name) + if plugin_path.exists() and plugin_path.read_text(encoding="utf-8") == expected: + return None + if not dry_run: + package_dir.mkdir(parents=True, exist_ok=True) + plugin_path.write_text(expected, encoding="utf-8") + return plugin_path + + +def _activity_declared(manifest_text: str, package_name: str) -> bool: + names = { + f".{CHAT_PREVIEW_ACTIVITY}", + f"{package_name}.{CHAT_PREVIEW_ACTIVITY}", + CHAT_PREVIEW_ACTIVITY, + } + for name in names: + if re.search(rf'android:name\s*=\s*"{re.escape(name)}"', manifest_text): + return True + return False + + +def _patch_manifest( + manifest_path: Path, package_name: str, dry_run: bool +) -> Path | None: + text = manifest_path.read_text(encoding="utf-8") + if _activity_declared(text, package_name): + return None + activity = ( + "\n \n' + ) + marker = "" + if marker not in text: + raise ValueError(f"{manifest_path} is missing an block") + patched = text.replace(marker, f"{activity} {marker}", 1) + if not dry_run: + manifest_path.write_text(patched, encoding="utf-8") + return manifest_path + + +def prepare_tauri_android(app_dir: Path, *, dry_run: bool = False) -> list[Path]: + """Apply app-specific patches after `tauri android init`. + + Returns the paths that were changed, or would change in dry-run mode. + """ + + resolved_app_dir = app_dir.resolve() + if _app_name(resolved_app_dir) != CHAT_APP_NAME: + return [] + + android_root = _android_root(resolved_app_dir) + if not android_root.exists(): + raise FileNotFoundError( + f"Android project not found under {android_root}; " + "run `npm run tauri:android:init` first" + ) + + package_name, package_dir = _find_kotlin_package( + android_root, _tauri_identifier(resolved_app_dir) + ) + manifest_path = _find_android_manifest(android_root) + changed: list[Path] = [] + activity_path = _write_activity(package_dir, package_name, dry_run) + if activity_path is not None: + changed.append(activity_path) + secret_plugin_path = _write_secret_plugin(package_dir, package_name, dry_run) + if secret_plugin_path is not None: + changed.append(secret_plugin_path) + patched_manifest = _patch_manifest(manifest_path, package_name, dry_run) + if patched_manifest is not None: + changed.append(patched_manifest) + return changed + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Prepare generated Tauri Android projects." + ) + parser.add_argument( + "app_dir", + type=Path, + help="Native app directory, for example apps/undefined-chat or .", + ) + parser.add_argument( + "--check", + action="store_true", + help="Exit non-zero if generated Android patches are missing.", + ) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + changed = prepare_tauri_android(args.app_dir, dry_run=args.check) + if args.check and changed: + print("Generated Android project is missing required patches:") + for path in changed: + print(path) + return 1 + for path in changed: + print(f"prepared {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_apps.py b/scripts/release_apps.py new file mode 100644 index 00000000..a6d730f2 --- /dev/null +++ b/scripts/release_apps.py @@ -0,0 +1,53 @@ +"""Shared native app metadata for release and version scripts.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class NativeApp: + app_dir: str + cargo_package: str + artifact_prefix: str + + @property + def package_json(self) -> str: + return f"apps/{self.app_dir}/package.json" + + @property + def package_lock(self) -> str: + return f"apps/{self.app_dir}/package-lock.json" + + @property + def cargo_toml(self) -> str: + return f"apps/{self.app_dir}/src-tauri/Cargo.toml" + + @property + def cargo_lock(self) -> str: + return f"apps/{self.app_dir}/src-tauri/Cargo.lock" + + @property + def tauri_conf(self) -> str: + return f"apps/{self.app_dir}/src-tauri/tauri.conf.json" + + def app_root(self, project_root: Path) -> Path: + return project_root / "apps" / self.app_dir + + def tauri_root(self, project_root: Path) -> Path: + return self.app_root(project_root) / "src-tauri" + + +NATIVE_APPS: tuple[NativeApp, ...] = ( + NativeApp( + app_dir="undefined-console", + cargo_package="undefined_console", + artifact_prefix="Undefined-Console", + ), + NativeApp( + app_dir="undefined-chat", + cargo_package="undefined_chat", + artifact_prefix="Undefined-Chat", + ), +) diff --git a/scripts/release_notes.py b/scripts/release_notes.py index 6eab8a7b..46766272 100644 --- a/scripts/release_notes.py +++ b/scripts/release_notes.py @@ -15,10 +15,16 @@ _PROJECT_ROOT = Path(__file__).resolve().parent.parent +_SCRIPTS_DIR = _PROJECT_ROOT / "scripts" +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + _SRC_DIR = _PROJECT_ROOT / "src" if str(_SRC_DIR) not in sys.path: sys.path.insert(0, str(_SRC_DIR)) +from release_apps import NATIVE_APPS, NativeApp # noqa: E402 + from Undefined.changelog import ( # noqa: E402 ChangelogEntry, normalize_version, @@ -120,8 +126,7 @@ def _read_json_version(project_root: Path, relative_path: str) -> str: return _require_non_empty_string(data.get("version"), f"{relative_path} version") -def _read_package_lock_root_version(project_root: Path) -> str: - relative_path = "apps/undefined-console/package-lock.json" +def _read_package_lock_root_version(project_root: Path, relative_path: str) -> str: data = _read_json_file(project_root / relative_path) packages = data.get("packages") if not isinstance(packages, dict): @@ -134,8 +139,7 @@ def _read_package_lock_root_version(project_root: Path) -> str: ) -def _read_cargo_version(project_root: Path) -> str: - relative_path = "apps/undefined-console/src-tauri/Cargo.toml" +def _read_cargo_manifest_version(project_root: Path, relative_path: str) -> str: path = project_root / relative_path data = tomllib.loads(_read_required_text(path)) package = data.get("package") @@ -146,33 +150,73 @@ def _read_cargo_version(project_root: Path) -> str: ) -def read_build_version_sources( - project_root: Path = _PROJECT_ROOT, +def _read_cargo_lock_root_version( + project_root: Path, + relative_path: str, + package_name: str, +) -> str: + data = tomllib.loads(_read_required_text(project_root / relative_path)) + packages = data.get("package") + if not isinstance(packages, list): + raise ReleaseValidationError(f"{relative_path} is missing package entries") + for package in packages: + if isinstance(package, dict) and package.get("name") == package_name: + return _require_non_empty_string( + package.get("version"), + f"{relative_path} {package_name}.version", + ) + raise ReleaseValidationError(f"{relative_path} is missing {package_name}") + + +def _read_native_app_version_sources( + project_root: Path, + app: NativeApp, ) -> tuple[VersionSource, ...]: - root = project_root.resolve() return ( - VersionSource("pyproject.toml", _read_pyproject_version(root)), - VersionSource("src/Undefined/__init__.py", _read_init_version(root)), VersionSource( - "apps/undefined-console/package.json", - _read_json_version(root, "apps/undefined-console/package.json"), + app.package_json, + _read_json_version(project_root, app.package_json), ), VersionSource( - 'apps/undefined-console/package-lock.json packages[""]', - _read_package_lock_root_version(root), + app.package_lock, + _read_json_version(project_root, app.package_lock), ), VersionSource( - "apps/undefined-console/src-tauri/Cargo.toml", _read_cargo_version(root) + f'{app.package_lock} packages[""]', + _read_package_lock_root_version(project_root, app.package_lock), ), VersionSource( - "apps/undefined-console/src-tauri/tauri.conf.json", - _read_json_version( - root, "apps/undefined-console/src-tauri/tauri.conf.json" + app.cargo_toml, + _read_cargo_manifest_version(project_root, app.cargo_toml), + ), + VersionSource( + app.tauri_conf, + _read_json_version(project_root, app.tauri_conf), + ), + VersionSource( + f"{app.cargo_lock} {app.cargo_package}", + _read_cargo_lock_root_version( + project_root, + app.cargo_lock, + app.cargo_package, ), ), ) +def read_build_version_sources( + project_root: Path = _PROJECT_ROOT, +) -> tuple[VersionSource, ...]: + root = project_root.resolve() + sources: list[VersionSource] = [ + VersionSource("pyproject.toml", _read_pyproject_version(root)), + VersionSource("src/Undefined/__init__.py", _read_init_version(root)), + ] + for app in NATIVE_APPS: + sources.extend(_read_native_app_version_sources(root, app)) + return tuple(sources) + + def read_latest_changelog_entry(project_root: Path = _PROJECT_ROOT) -> ChangelogEntry: path = project_root.resolve() / "CHANGELOG.md" entries = parse_changelog_text(_read_required_text(path)) diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py index 248440e1..2187a8be 100644 --- a/src/Undefined/__init__.py +++ b/src/Undefined/__init__.py @@ -24,7 +24,7 @@ from .skills.registry import BaseRegistry as BaseRegistry from .skills.tools import ToolRegistry as ToolRegistry -__version__ = "3.5.1" +__version__ = "3.6.0" # symbol -> (module_path, attribute_name);首次访问时才 importlib 加载 _LAZY_IMPORTS: dict[str, tuple[str, str]] = { diff --git a/src/Undefined/ai/client/ask_loop.py b/src/Undefined/ai/client/ask_loop.py index bd75f01b..52a3f5de 100644 --- a/src/Undefined/ai/client/ask_loop.py +++ b/src/Undefined/ai/client/ask_loop.py @@ -15,13 +15,49 @@ from Undefined.ai.transports.openai_transport import RESPONSES_OUTPUT_ITEMS_KEY from Undefined.ai.tooling import END_CO_CALL_REJECT_CONTENT from Undefined.context import RequestContext +from Undefined.render import render_html_to_image, render_markdown_to_html from Undefined.services.message_summary_fetch import fetch_session_messages +from Undefined.attachments import scope_from_context +from Undefined.utils.io import write_bytes from Undefined.utils.logging import log_debug_json, redact_string +from Undefined.utils.message_turn import mark_message_sent_this_turn +from Undefined.utils.paths import DOWNLOAD_CACHE_DIR, ensure_dir from Undefined.utils.tool_calls import parse_tool_arguments logger = logging.getLogger(__name__) +def _webchat_agent_path(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + +def _webchat_depth(value: Any) -> int: + try: + return max(0, int(value)) + except (TypeError, ValueError): + return 0 + + +def _webchat_call_id(parent_call_id: str, call_id: str, fallback: str) -> str: + local_id = str(call_id or fallback or "tool").strip() or "tool" + return f"{parent_call_id}/{local_id}" if parent_call_id else local_id + + +async def _emit_webchat_event_safely( + callback: Callable[[str, dict[str, Any]], Awaitable[None]] | None, + event: str, + payload: dict[str, Any], +) -> None: + if callback is None: + return + try: + await callback(event, payload) + except Exception: + logger.exception("[WebChat事件] 回调发送失败: event=%s", event) + + class ClientAskLoopMixin(ClientQueueMixin): """``ask()`` 多轮工具调用主循环。""" @@ -77,13 +113,28 @@ async def ask( pre_context["request_id"] = ctx.request_id if extra_context: pre_context.update(extra_context) + webchat_event_callback = pre_context.get("webchat_event_callback") + if not callable(webchat_event_callback): + webchat_event_callback = None + + async def emit_webchat_stage(stage: str, detail: Any | None = None) -> None: + payload: dict[str, Any] = {"stage": stage} + if detail is not None: + payload["detail"] = detail + await _emit_webchat_event_safely( + webchat_event_callback, + "stage", + payload, + ) # ===== 阶段二:构建 LLM messages 与 OpenAI tools schema ===== + await emit_webchat_stage("building_context") messages = await self._prompt_builder.build_messages( question, get_recent_messages_callback=get_recent_messages_callback, extra_context=extra_context, ) + await emit_webchat_stage("context_ready") tools = self.tool_manager.get_openai_tools() tools = self._filter_tools_for_runtime_config(tools) @@ -128,6 +179,9 @@ async def ask( ) tool_context.setdefault("end_summary_storage", self._end_summary_storage) tool_context.setdefault("end_summaries", self._prompt_builder.end_summaries) + tool_context.setdefault("webchat_parent_call_id", "") + tool_context.setdefault("webchat_depth", 0) + tool_context.setdefault("webchat_agent_path", []) tool_context.setdefault( "send_private_message_callback", self._send_private_message_callback ) @@ -162,11 +216,21 @@ async def fetch_session_messages_callback( tool_context.setdefault("history_manager", history_manager) tool_context.setdefault("onebot_client", onebot_client) tool_context.setdefault("scheduler", scheduler) + tool_context.setdefault("render_html_to_image", render_html_to_image) + tool_context.setdefault("render_markdown_to_html", render_markdown_to_html) tool_context.setdefault("send_image_callback", self._send_image_callback) tool_context.setdefault( "attachment_registry", getattr(self, "attachment_registry", None), ) + tool_context.setdefault("get_scope_from_context", scope_from_context) + tool_context.setdefault("download_cache_dir", DOWNLOAD_CACHE_DIR) + tool_context.setdefault("ensure_dir_fn", ensure_dir) + tool_context.setdefault("write_bytes_fn", write_bytes) + tool_context.setdefault( + "mark_message_sent_this_turn", + mark_message_sent_this_turn, + ) tool_context.setdefault("memory_storage", self.memory_storage) tool_context.setdefault("knowledge_manager", self._knowledge_manager) tool_context.setdefault("cognitive_service", self._cognitive_service) @@ -183,6 +247,7 @@ async def fetch_session_messages_callback( message_ids.append(trigger_message_id_text) # ===== 阶段四:模型选择、思维链/重试参数与主循环状态初始化 ===== + await emit_webchat_stage("selecting_model") await self.model_selector.wait_ready() selected_model_name = pre_context.get("selected_model_name") if selected_model_name: @@ -205,6 +270,14 @@ async def fetch_session_messages_callback( missing_tool_call_count = 0 last_missing_tool_call_content = "" runtime_config = self._get_runtime_config() + agent_registry = getattr(self, "agent_registry", None) + get_agent_schemas = getattr(agent_registry, "get_agents_schema", None) + raw_agent_schemas = get_agent_schemas() if callable(get_agent_schemas) else [] + agent_tool_names = { + str(schema.get("function", {}).get("name") or "") + for schema in raw_agent_schemas + if isinstance(schema, dict) + } max_pre_tool_retries = max( 0, int(getattr(runtime_config, "ai_request_max_retries", 0) or 0), @@ -223,6 +296,10 @@ async def fetch_session_messages_callback( tool_execution_started = False try: + await emit_webchat_stage( + "waiting_model", + f"iteration={iteration} model={effective_chat_config.model_name}", + ) result = await self.submit_queued_llm_call( model_config=effective_chat_config, messages=messages, @@ -311,6 +388,7 @@ async def fetch_session_messages_callback( # 无 tool_calls 与有 tool_calls 走不同分支 if not tool_calls: if conversation_ended: + await emit_webchat_stage("finalizing") logger.info( "[AI回复] 会话结束,返回最终内容: length=%s", len(content), @@ -332,6 +410,7 @@ async def fetch_session_messages_callback( fallback_content = last_missing_tool_call_content if fallback_content and send_message_callback is not None: try: + await emit_webchat_stage("sending_message") await send_message_callback(fallback_content) tool_context["message_sent_this_turn"] = True current_ctx = RequestContext.current() @@ -366,6 +445,7 @@ async def fetch_session_messages_callback( ) continue + await emit_webchat_stage("preparing_tools", len(tool_calls)) assistant_message: dict[str, Any] = { "role": "assistant", "content": content, @@ -383,12 +463,13 @@ async def fetch_session_messages_callback( assistant_message["reasoning_content"] = reasoning_content messages.append(assistant_message) - tool_tasks = [] + tool_tasks: list[asyncio.Task[Any]] = [] tool_call_ids = [] tool_api_names: list[str] = [] tool_internal_names: list[str] = [] end_tool_call: dict[str, Any] | None = None end_tool_args: dict[str, Any] = {} + end_webchat_event_base: dict[str, Any] = {} tool_results: list[Any] = [] # 逐个处理模型返回的 tool_call @@ -446,6 +527,37 @@ async def fetch_session_messages_callback( if not isinstance(function_args, dict): function_args = {} + is_agent_call = internal_function_name in agent_tool_names + webchat_parent_call_id = str( + tool_context.get("webchat_parent_call_id") or "" + ).strip() + webchat_depth = _webchat_depth(tool_context.get("webchat_depth")) + webchat_agent_path = _webchat_agent_path( + tool_context.get("webchat_agent_path") + ) + webchat_call_id = _webchat_call_id( + webchat_parent_call_id, + call_id, + internal_function_name, + ) + webchat_event_base: dict[str, Any] = { + "webchat_call_id": webchat_call_id, + "parent_webchat_call_id": webchat_parent_call_id, + "depth": webchat_depth, + "agent_path": webchat_agent_path, + } + await _emit_webchat_event_safely( + webchat_event_callback, + "tool_start", + { + "tool_call_id": call_id, + "name": internal_function_name, + "api_name": api_function_name, + "arguments": function_args, + "is_agent": is_agent_call, + **webchat_event_base, + }, + ) # 检测 end 工具,暂存后统一处理 if internal_function_name == "end": @@ -457,14 +569,77 @@ async def fetch_session_messages_callback( ) end_tool_call = tool_call end_tool_args = function_args + end_webchat_event_base = webchat_event_base continue tool_call_ids.append(call_id) tool_api_names.append(str(api_function_name)) tool_internal_names.append(str(internal_function_name)) + call_context = tool_context.copy() + if is_agent_call: + call_context["webchat_parent_call_id"] = webchat_call_id + call_context["webchat_call_parent_id"] = webchat_parent_call_id + call_context["webchat_depth"] = webchat_depth + 1 + call_context["webchat_agent_path"] = [ + *webchat_agent_path, + internal_function_name, + ] + + async def _execute_tool_with_webchat_event( + *, + call_id: str, + api_name: str, + internal_name: str, + args: dict[str, Any], + context: dict[str, Any], + webchat_event_base: dict[str, Any], + is_agent_call: bool, + ) -> Any: + try: + result = await self.tool_manager.execute_tool( + internal_name, args, context + ) + except Exception as exc: + await _emit_webchat_event_safely( + webchat_event_callback, + "tool_end", + { + "tool_call_id": call_id, + "name": internal_name, + "api_name": api_name, + "ok": False, + "result": f"执行失败: {str(exc)}", + "is_agent": is_agent_call, + **webchat_event_base, + }, + ) + raise + await _emit_webchat_event_safely( + webchat_event_callback, + "tool_end", + { + "tool_call_id": call_id, + "name": internal_name, + "api_name": api_name, + "ok": True, + "result": str(result), + "is_agent": is_agent_call, + **webchat_event_base, + }, + ) + return result + tool_tasks.append( - self.tool_manager.execute_tool( - str(internal_function_name), function_args, tool_context + asyncio.create_task( + _execute_tool_with_webchat_event( + call_id=call_id, + api_name=str(api_function_name), + internal_name=str(internal_function_name), + args=function_args, + context=call_context, + webchat_event_base=webchat_event_base, + is_agent_call=is_agent_call, + ) ) ) @@ -475,6 +650,9 @@ async def fetch_session_messages_callback( len(tool_tasks), ", ".join(tool_internal_names), ) + await emit_webchat_stage( + "waiting_tools", ", ".join(tool_internal_names) + ) tool_results = await asyncio.gather( *tool_tasks, return_exceptions=True, @@ -507,7 +685,6 @@ async def fetch_session_messages_callback( f"[工具响应体] {internal_fname} (ID={call_id})", content_str, ) - messages.append( { "role": "tool", @@ -544,6 +721,19 @@ async def fetch_session_messages_callback( end_call_id = end_tool_call.get("id", "") end_api_name = end_tool_call.get("function", {}).get("name", "end") if tool_tasks: + if webchat_event_callback is not None: + await webchat_event_callback( + "tool_end", + { + "tool_call_id": end_call_id, + "name": "end", + "api_name": end_api_name, + "ok": False, + "result": END_CO_CALL_REJECT_CONTENT, + "is_agent": False, + **end_webchat_event_base, + }, + ) messages.append( { "role": "tool", @@ -559,15 +749,36 @@ async def fetch_session_messages_callback( else: # end 单独调用,正常执行(参数已在循环中解析) tool_execution_started = True - end_result = await self.tool_manager.execute_tool( - "end", end_tool_args, tool_context - ) + await emit_webchat_stage("waiting_tools", "end") + try: + end_result_raw = await self.tool_manager.execute_tool( + "end", end_tool_args, tool_context + ) + end_result = str(end_result_raw) + end_ok = True + except Exception as exc: + logger.exception("[工具异常] end 执行抛出异常: %s", exc) + end_result = f"执行失败: {str(exc)}" + end_ok = False + if webchat_event_callback is not None: + await webchat_event_callback( + "tool_end", + { + "tool_call_id": end_call_id, + "name": "end", + "api_name": end_api_name, + "ok": end_ok, + "result": end_result, + "is_agent": False, + **end_webchat_event_base, + }, + ) messages.append( { "role": "tool", "tool_call_id": end_call_id, "name": end_api_name, - "content": str(end_result), + "content": end_result, } ) # 会话是否已由 end 工具标记结束 @@ -577,6 +788,7 @@ async def fetch_session_messages_callback( # 会话是否已由 end 工具标记结束 if conversation_ended: + await emit_webchat_stage("finalizing") logger.info("[会话状态] 对话已结束(调用 end 工具)") return "" pre_tool_failure_count = 0 @@ -598,6 +810,7 @@ async def fetch_session_messages_callback( iteration, exc, ) + await emit_webchat_stage("retrying_model", str(exc)) continue logger.exception( "[chat.suppressed_error] model=%s lane=%s iteration=%s error=%s", diff --git a/src/Undefined/ai/llm/requester.py b/src/Undefined/ai/llm/requester.py index 10329d0e..90ad9725 100644 --- a/src/Undefined/ai/llm/requester.py +++ b/src/Undefined/ai/llm/requester.py @@ -472,7 +472,10 @@ async def request( log_debug_json(logger, "[API请求体]", request_body) try: - raw_result = await self._request_with_openai(model_config, request_body) + raw_result = await self._request_with_openai( + model_config, + request_body, + ) except APIStatusError as exc: # Responses 续轮失败:自动切换 stateless replay 重发全量 input if ( @@ -507,7 +510,8 @@ async def request( logger, "[API请求体][stateless replay]", request_body ) raw_result = await self._request_with_openai( - model_config, request_body + model_config, + request_body, ) else: raise @@ -666,7 +670,9 @@ def _maybe_log_thinking( ) async def _request_with_openai( - self, model_config: ModelConfig, request_body: dict[str, Any] + self, + model_config: ModelConfig, + request_body: dict[str, Any], ) -> dict[str, Any]: client = self._get_openai_client_for_model(model_config) if bool(getattr(model_config, "stream_enabled", False)): @@ -710,7 +716,10 @@ async def _request_with_openai_streaming( stream_body = dict(request_body) stream_body["stream"] = True if api_mode == API_MODE_RESPONSES: - return await self._stream_responses_request(client, stream_body) + return await self._stream_responses_request( + client, + stream_body, + ) ensure_chat_stream_usage_options(stream_body) return await self._stream_chat_completions_request( # client, stream_body, model_config @@ -735,14 +744,17 @@ async def _stream_chat_completions_request( ) chunks: list[dict[str, Any]] = [] async for chunk in response: - chunks.append(self._response_to_dict(chunk)) + chunk_dict = self._response_to_dict(chunk) + chunks.append(chunk_dict) return aggregate_chat_completions_stream( chunks, reasoning_replay=reasoning_replay, ) async def _stream_responses_request( - self, client: AsyncOpenAI, request_body: dict[str, Any] + self, + client: AsyncOpenAI, + request_body: dict[str, Any], ) -> dict[str, Any]: params, extra_body = split_responses_params(request_body) if extra_body: @@ -751,7 +763,8 @@ async def _stream_responses_request( events: list[dict[str, Any]] = [] async for event in stream: - events.append(self._response_to_dict(event)) + event_dict = self._response_to_dict(event) + events.append(event_dict) return aggregate_responses_stream(events) async def embed( diff --git a/src/Undefined/ai/prompts/builder.py b/src/Undefined/ai/prompts/builder.py index 57984071..d4c54dbc 100644 --- a/src/Undefined/ai/prompts/builder.py +++ b/src/Undefined/ai/prompts/builder.py @@ -22,6 +22,7 @@ from Undefined.utils.resources import read_text_resource from Undefined.utils.xml import format_message_xml from Undefined.ai.prompts.cognitive import ( + build_cognitive_per_message_queries, build_cognitive_query, drop_current_message_if_duplicated, ) @@ -33,6 +34,18 @@ logger = logging.getLogger(__name__) +def _is_display_only_history_record(msg: dict[str, Any]) -> bool: + if str(msg.get("message", "") or "").strip(): + return False + webchat = msg.get("webchat") + if not isinstance(webchat, dict): + return False + events = webchat.get("events") + return ( + bool(webchat.get("display_only")) and isinstance(events, list) and bool(events) + ) + + class PromptBuilder: """Prompt 构建器。 @@ -122,6 +135,19 @@ async def _load_system_prompt(self) -> str: async with aiofiles.open(system_prompt_path, "r", encoding="utf-8") as f: return await f.read() + @staticmethod + def _format_current_input_batch(question: str) -> str: + """Format the only live user input block for this turn.""" + return ( + "【当前输入批次】\n" + "\n" + f"{question}\n" + "\n\n" + "注意:以上才是本轮正在发生、允许你回应和写入 end.observations 的当前输入。" + "历史消息、认知记忆、侧写、短期行动记录和系统说明都只是只读背景," + "只能用于消歧、防重复和理解上下文,不能作为 end.observations 的新事实来源。" + ) + async def build_messages( self, question: str, @@ -141,6 +167,22 @@ async def build_messages( 返回: 构建好的消息列表 (role/content 结构) """ + webchat_event_callback = ( + extra_context.get("webchat_event_callback") + if isinstance(extra_context, dict) + else None + ) + if not callable(webchat_event_callback): + webchat_event_callback = None + + async def emit_webchat_stage(stage: str, detail: Any | None = None) -> None: + if webchat_event_callback is None: + return + payload: dict[str, Any] = {"stage": stage} + if detail is not None: + payload["detail"] = detail + await webchat_event_callback("stage", payload) + system_prompt = await self._load_system_prompt() logger.debug( "[Prompt] system_prompt_len=%s path=%s", @@ -277,9 +319,10 @@ async def build_messages( ) deferred_messages: list[dict[str, Any]] = [] - # 长期记忆 / 认知 / end 摘要 / 历史等延迟注入块(排在主 system 之后) + # 缓存友好:固定/低频系统块排在前面;按轮变化的记忆、认知、摘要、历史延迟注入。 if self._memory_storage: + await emit_webchat_stage("checking_long_term_memory") memories = self._memory_storage.get_all() if memories: memory_lines = [f"- {mem.fact}" for mem in memories] @@ -357,18 +400,25 @@ async def build_messages( cognitive_query, query_enhanced = build_cognitive_query( question, extra_context ) + recall_queries, recall_queries_enhanced = ( + build_cognitive_per_message_queries(question, extra_context) + ) logger.info( - "[AI会话] 开始自动检索认知记忆: raw_query_len=%s effective_query_len=%s query_enhanced=%s type=%s group=%s user=%s sender=%s", + "[AI会话] 开始自动检索认知记忆: raw_query_len=%s effective_query_len=%s recall_queries=%s query_enhanced=%s recall_enhanced=%s type=%s group=%s user=%s sender=%s", len(question), len(cognitive_query), + len(recall_queries), query_enhanced, + recall_queries_enhanced, resolved_request_type or "", resolved_group_id or "", resolved_user_id or "", resolved_sender_id or "", ) + await emit_webchat_stage("searching_cognitive_memory") cognitive_context = await self._cognitive_service.build_context( query=cognitive_query, + recall_queries=recall_queries, group_id=resolved_group_id, user_id=resolved_user_id, sender_id=resolved_sender_id, @@ -462,6 +512,7 @@ async def build_messages( ) if get_recent_messages_callback: + await emit_webchat_stage("loading_chat_history") await self._inject_recent_messages( deferred_messages, get_recent_messages_callback, extra_context, question ) @@ -477,7 +528,9 @@ async def build_messages( } ) - messages.append({"role": "user", "content": f"【当前消息】\n{question}"}) + messages.append( + {"role": "user", "content": self._format_current_input_batch(question)} + ) logger.debug( "[Prompt] messages_ready=%s question_len=%s", len(messages), @@ -570,6 +623,9 @@ async def _inject_recent_messages( recent_limit, ) recent_msgs = drop_current_message_if_duplicated(recent_msgs, question) + recent_msgs = [ + msg for msg in recent_msgs if not _is_display_only_history_record(msg) + ] context_lines: list[str] = [format_message_xml(msg) for msg in recent_msgs] formatted_context = "\n---\n".join(context_lines) @@ -577,11 +633,15 @@ async def _inject_recent_messages( if formatted_context: messages.append( { - "role": "user", + "role": "system", "content": ( - "【历史消息存档】\n" - f"{formatted_context}\n\n" - "注意:以上是之前的聊天记录,用于提供背景信息。每个消息之间使用 --- 分隔。接下来的用户消息才是当前正在发生的对话。" + "【历史消息存档】(只读上下文)\n" + "以下是之前的聊天记录,仅用于背景理解、实体消歧和防重复检查。" + "它们不属于当前输入批次,不是新请求,也不能作为 end.observations 的新事实来源。\n" + '\n' + f"{formatted_context}\n" + "\n\n" + "注意:每个历史消息之间使用 --- 分隔;后续单独的当前输入块才是本轮正在发生的对话。" ), } ) diff --git a/src/Undefined/ai/prompts/cognitive.py b/src/Undefined/ai/prompts/cognitive.py index bd799ee0..f8943f9e 100644 --- a/src/Undefined/ai/prompts/cognitive.py +++ b/src/Undefined/ai/prompts/cognitive.py @@ -2,15 +2,19 @@ from __future__ import annotations -import html import logging from typing import Any from Undefined.ai.prompts.constants import ( COGNITIVE_CONTEXT_VALUE_MAX_LEN, COGNITIVE_QUERY_SHORT_THRESHOLD, - CURRENT_MESSAGE_RE, - XML_ATTR_RE, +) +from Undefined.ai.prompts.current_input import ( + build_current_input_per_message_query_texts, + build_current_input_query_text, + drop_current_input_batch_if_duplicated, + extract_current_message_signature, + extract_current_message_signatures, ) logger = logging.getLogger(__name__) @@ -24,43 +28,26 @@ def normalize_cognitive_context_value(value: Any) -> str: return text[: COGNITIVE_CONTEXT_VALUE_MAX_LEN - 3].rstrip() + "..." -def extract_current_message_signature(question: str) -> dict[str, str]: - """从当前消息 XML 中提取 sender/time/content 签名。""" - matched = CURRENT_MESSAGE_RE.search(str(question or "")) - if not matched: - return {} - - attrs_text = str(matched.group("attrs") or "") - attrs: dict[str, str] = {} - for attr_match in XML_ATTR_RE.finditer(attrs_text): - key = str(attr_match.group("key") or "").strip() - if not key: - continue - attrs[key] = html.unescape(str(attr_match.group("value") or "")).strip() - - content = html.unescape(str(matched.group("content") or "")).strip() - return { - "sender_id": attrs.get("sender_id", ""), - "timestamp": attrs.get("time", ""), - "content": content, - } - - def build_cognitive_query( question: str, extra_context: dict[str, Any] | None = None ) -> tuple[str, bool]: """构建认知记忆检索 query,短消息时追加少量会话语境。""" question_text = str(question or "").strip() - signature = extract_current_message_signature(question_text) - current_content = str(signature.get("content", "")).strip() - base_query = current_content or question_text + base_query, from_current_messages = build_current_input_query_text(question_text) if not base_query: return "", False - if not current_content or len(current_content) > COGNITIVE_QUERY_SHORT_THRESHOLD: + if not from_current_messages or len(base_query) > COGNITIVE_QUERY_SHORT_THRESHOLD: return base_query, False # 短消息检索质量差,追加轻量会话语境提升向量召回 + context_suffix = _cognitive_context_suffix(extra_context) + if not context_suffix: + return base_query, False + return f"{base_query}\n{context_suffix}", True + + +def _cognitive_context_suffix(extra_context: dict[str, Any] | None) -> str: context_parts: list[str] = [] if extra_context: if bool(extra_context.get("is_private_chat", False)): @@ -83,55 +70,56 @@ def build_cognitive_query( context_parts.append(f"群:{group_name}") if not context_parts: - return base_query, False - return f"{base_query}\n语境: {'; '.join(context_parts)}", True + return "" + return f"语境: {'; '.join(context_parts)}" + + +def build_cognitive_per_message_queries( + question: str, extra_context: dict[str, Any] | None = None +) -> tuple[list[str], bool]: + """构建逐条认知记忆召回 query,短消息时分别追加少量会话语境。""" + question_text = str(question or "").strip() + base_queries, from_current_messages = build_current_input_per_message_query_texts( + question_text + ) + if not base_queries: + return [], False + if not from_current_messages: + return base_queries, False + + context_suffix = _cognitive_context_suffix(extra_context) + if not context_suffix: + return base_queries, False + + enhanced = False + queries: list[str] = [] + for base_query in base_queries: + if len(base_query) <= COGNITIVE_QUERY_SHORT_THRESHOLD: + queries.append(f"{base_query}\n{context_suffix}") + enhanced = True + else: + queries.append(base_query) + return queries, enhanced def drop_current_message_if_duplicated( recent_msgs: list[dict[str, Any]], question: str ) -> list[dict[str, Any]]: - """若历史末尾与当前帧重复,则剔除最后一条避免双重注入。""" - if not recent_msgs: - return recent_msgs - - signature = extract_current_message_signature(question) - if not signature: - return recent_msgs - - last_msg = recent_msgs[-1] - last_sender_id = str(last_msg.get("user_id", "")).strip() - last_timestamp = str(last_msg.get("timestamp", "")).strip() - last_content = str(last_msg.get("message", "")).strip() - - sig_sender_id = str(signature.get("sender_id", "")).strip() - sig_timestamp = str(signature.get("timestamp", "")).strip() - sig_content = str(signature.get("content", "")).strip() - if not sig_sender_id or not sig_content: - return recent_msgs - - if last_sender_id != sig_sender_id: - return recent_msgs - if last_content != sig_content: - return recent_msgs - - if sig_timestamp and last_timestamp and sig_timestamp != last_timestamp: - # 秒级时间戳不一致时,比较到分钟粒度,避免格式差异误杀 - if sig_timestamp[:16] != last_timestamp[:16]: - return recent_msgs - - logger.info( - "[Prompt] 历史注入剔除当前帧: sender=%s sig_time=%s history_time=%s content_preview=%s", - sig_sender_id, - sig_timestamp, - last_timestamp, - sig_content[:60], - ) - return recent_msgs[:-1] + """若历史末尾与当前输入批次重复,则整批剔除避免双重注入。""" + filtered, dropped = drop_current_input_batch_if_duplicated(recent_msgs, question) + if dropped: + logger.info( + "[Prompt] 历史注入剔除当前输入批次重复消息: count=%s", + dropped, + ) + return filtered __all__ = [ + "build_cognitive_per_message_queries", "build_cognitive_query", "drop_current_message_if_duplicated", "extract_current_message_signature", + "extract_current_message_signatures", "normalize_cognitive_context_value", ] diff --git a/src/Undefined/ai/prompts/current_input.py b/src/Undefined/ai/prompts/current_input.py new file mode 100644 index 00000000..39770e45 --- /dev/null +++ b/src/Undefined/ai/prompts/current_input.py @@ -0,0 +1,142 @@ +"""Helpers for parsing the current input batch from prompt XML.""" + +from __future__ import annotations + +from dataclasses import dataclass +import html +from typing import Any + +from Undefined.ai.prompts.constants import CURRENT_MESSAGE_RE, XML_ATTR_RE + + +@dataclass(frozen=True) +class CurrentMessageSignature: + """Stable identity for one current ```` block.""" + + sender_id: str + timestamp: str + content: str + message_id: str = "" + + +def _parse_attrs(attrs_text: str) -> dict[str, str]: + attrs: dict[str, str] = {} + for attr_match in XML_ATTR_RE.finditer(attrs_text): + key = str(attr_match.group("key") or "").strip() + if not key: + continue + attrs[key] = html.unescape(str(attr_match.group("value") or "")).strip() + return attrs + + +def extract_current_message_signatures( + question: str, +) -> list[CurrentMessageSignature]: + """Extract all current ```` signatures from prompt text.""" + signatures: list[CurrentMessageSignature] = [] + for matched in CURRENT_MESSAGE_RE.finditer(str(question or "")): + attrs = _parse_attrs(str(matched.group("attrs") or "")) + content = html.unescape(str(matched.group("content") or "")).strip() + signatures.append( + CurrentMessageSignature( + sender_id=attrs.get("sender_id", ""), + timestamp=attrs.get("time", ""), + content=content, + message_id=attrs.get("message_id", ""), + ) + ) + return signatures + + +def extract_current_message_signature(question: str) -> dict[str, str]: + """Compatibility helper returning the first current message signature.""" + signatures = extract_current_message_signatures(question) + if not signatures: + return {} + first = signatures[0] + return { + "sender_id": first.sender_id, + "timestamp": first.timestamp, + "content": first.content, + "message_id": first.message_id, + } + + +def build_current_input_query_text(question: str) -> tuple[str, bool]: + """Return query text from the full current input batch. + + The boolean indicates whether the query came from explicit ```` + content instead of falling back to the raw question text. + """ + signatures = extract_current_message_signatures(question) + contents = [sig.content for sig in signatures if sig.content] + if contents: + return "\n".join(contents), True + return str(question or "").strip(), False + + +def build_current_input_per_message_query_texts( + question: str, +) -> tuple[list[str], bool]: + """Return one query text per current ```` block.""" + signatures = extract_current_message_signatures(question) + contents = [sig.content for sig in signatures if sig.content] + if contents: + return contents, True + fallback = str(question or "").strip() + return ([fallback] if fallback else []), False + + +def _history_msg_matches_signature( + msg: dict[str, Any], signature: CurrentMessageSignature +) -> bool: + sig_sender_id = signature.sender_id.strip() + sig_content = signature.content.strip() + if not sig_sender_id or not sig_content: + return False + + history_message_id = str(msg.get("message_id", "") or "").strip() + if signature.message_id and history_message_id: + return history_message_id == signature.message_id + + last_sender_id = str(msg.get("user_id", "") or "").strip() + last_content = str(msg.get("message", "") or "").strip() + if last_sender_id != sig_sender_id or last_content != sig_content: + return False + + sig_timestamp = signature.timestamp.strip() + last_timestamp = str(msg.get("timestamp", "") or "").strip() + if sig_timestamp and last_timestamp and sig_timestamp != last_timestamp: + # 秒级时间戳不一致时,比较到分钟粒度,避免格式差异误杀。 + return sig_timestamp[:16] == last_timestamp[:16] + return True + + +def drop_current_input_batch_if_duplicated( + recent_msgs: list[dict[str, Any]], question: str +) -> tuple[list[dict[str, Any]], int]: + """Drop trailing history records that duplicate the whole current batch.""" + signatures = extract_current_message_signatures(question) + if not recent_msgs or not signatures: + return recent_msgs, 0 + + remaining = list(recent_msgs) + dropped = 0 + cursor = len(signatures) - 1 + while remaining and cursor >= 0: + if not _history_msg_matches_signature(remaining[-1], signatures[cursor]): + break + remaining.pop() + dropped += 1 + cursor -= 1 + return remaining, dropped + + +__all__ = [ + "CurrentMessageSignature", + "build_current_input_per_message_query_texts", + "build_current_input_query_text", + "drop_current_input_batch_if_duplicated", + "extract_current_message_signature", + "extract_current_message_signatures", +] diff --git a/src/Undefined/ai/tooling.py b/src/Undefined/ai/tooling.py index bcc88cce..58fcaaa0 100644 --- a/src/Undefined/ai/tooling.py +++ b/src/Undefined/ai/tooling.py @@ -9,10 +9,14 @@ from typing import Any from Undefined.context import RequestContext +from Undefined.attachments import scope_from_context from Undefined.skills.agents import AgentRegistry from Undefined.skills.anthropic_skills import AnthropicSkillRegistry from Undefined.skills.tools import ToolRegistry +from Undefined.utils.io import write_bytes from Undefined.utils.logging import log_debug_json, redact_string +from Undefined.utils.message_turn import mark_message_sent_this_turn +from Undefined.utils.paths import DOWNLOAD_CACHE_DIR, ensure_dir logger = logging.getLogger(__name__) @@ -208,6 +212,12 @@ async def execute_tool( context.setdefault("request_type", ctx.request_type) context.setdefault("request_id", ctx.request_id) + context.setdefault("get_scope_from_context", scope_from_context) + context.setdefault("download_cache_dir", DOWNLOAD_CACHE_DIR) + context.setdefault("ensure_dir_fn", ensure_dir) + context.setdefault("write_bytes_fn", write_bytes) + context.setdefault("mark_message_sent_this_turn", mark_message_sent_this_turn) + agents_schema = self.agent_registry.get_agents_schema() agent_names = [s.get("function", {}).get("name") for s in agents_schema] is_agent = function_name in agent_names diff --git a/src/Undefined/api/_helpers.py b/src/Undefined/api/_helpers.py index 0ae579a5..6cc736ce 100644 --- a/src/Undefined/api/_helpers.py +++ b/src/Undefined/api/_helpers.py @@ -205,9 +205,12 @@ def _build_chat_response_payload(mode: str, outputs: list[str]) -> dict[str, Any } -def _sse_event(event: str, payload: dict[str, Any]) -> bytes: +def _sse_event( + event: str, payload: dict[str, Any], event_id: int | str | None = None +) -> bytes: data = json.dumps(payload, ensure_ascii=False) - return f"event: {event}\ndata: {data}\n\n".encode("utf-8") + id_line = f"id: {event_id}\n" if event_id is not None else "" + return f"{id_line}event: {event}\ndata: {data}\n\n".encode("utf-8") def _mask_url(url: str) -> str: diff --git a/src/Undefined/api/_openapi.py b/src/Undefined/api/_openapi.py index a6c5134c..53f70a85 100644 --- a/src/Undefined/api/_openapi.py +++ b/src/Undefined/api/_openapi.py @@ -38,7 +38,7 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st "description": ( "Returns system info (version, Python, platform, uptime), " "OneBot connection status, request queue snapshot, " - "memory count, cognitive service status, API config, " + "memory count, cognitive service status, scheduler summary, API config, " "skill statistics (tools/toolsets/agents/pipelines/commands/anthropic_skills), " "and model configuration (names, masked URLs, thinking flags)." ), @@ -78,6 +78,15 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st "/api/v1/memes/{uid}/reindex": { "post": {"summary": "Queue a meme reindex job"} }, + "/api/v1/schedules": { + "get": {"summary": "List scheduled tasks"}, + "post": {"summary": "Create a scheduled task"}, + }, + "/api/v1/schedules/{task_id}": { + "get": {"summary": "Get a scheduled task"}, + "patch": {"summary": "Update a scheduled task"}, + "delete": {"summary": "Delete a scheduled task"}, + }, "/api/v1/cognitive/events": { "get": {"summary": "Search cognitive event memories"} }, @@ -85,17 +94,113 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st "/api/v1/cognitive/profile/{entity_type}/{entity_id}": { "get": {"summary": "Get a profile by entity type/id"} }, + "/api/v1/commands": { + "get": { + "summary": "List slash command metadata", + "description": ( + "Returns slash commands, aliases, subcommands, usage, " + "permission and WebUI/private/group availability. " + "Use scope=webui for the WebChat virtual private session." + ), + } + }, + "/api/v1/commands/{command_name}": { + "get": { + "summary": "Get slash command metadata by name or alias", + "description": ( + "Returns one canonical slash command with aliases, " + "subcommands, usage and availability metadata." + ), + } + }, "/api/v1/chat": { "post": { "summary": "WebUI special private chat", "description": ( - "POST JSON {message, stream?}. " - "When stream=true, response is SSE with keep-alive comments." + "POST JSON {message, stream?, conversation_id?}. " + "stream=false waits for an internal WebChat job and " + "returns JSON; stream=true uses the same WebChat job " + "lifecycle and streams events as SSE. WebChat jobs use " + "per-conversation single-flight while running or finalizing; " + "different conversations may run concurrently." ), } }, + "/api/v1/chat/conversations": { + "get": {"summary": "List WebChat conversations"}, + "post": {"summary": "Create a WebChat conversation"}, + }, + "/api/v1/chat/conversations/{conversation_id}": { + "patch": {"summary": "Rename a WebChat conversation"}, + "delete": {"summary": "Delete a WebChat conversation"}, + }, "/api/v1/chat/history": { - "get": {"summary": "Get virtual private chat history for WebUI"} + "get": {"summary": "Get paged WebChat conversation history"}, + "delete": {"summary": "Clear a WebChat conversation history"}, + }, + "/api/v1/chat/attachments/capabilities": { + "get": { + "summary": "Get WebChat attachment upload capabilities", + "description": ( + "Returns the Runtime WebChat attachment upload limit and " + "multipart field name." + ), + } + }, + "/api/v1/chat/attachments": { + "post": { + "summary": "Upload a WebChat attachment", + "description": ( + "Accepts multipart/form-data with a file field, validates size, " + "persists the attachment, and returns Runtime attachment metadata." + ), + } + }, + "/api/v1/chat/attachments/{attachment_id}": { + "get": {"summary": "Download a WebChat attachment"} + }, + "/api/v1/chat/attachments/{attachment_id}/preview": { + "get": {"summary": "Get a WebChat attachment preview"} + }, + "/api/v1/chat/jobs": { + "post": { + "summary": "Create a WebUI chat job", + "description": ( + "Accepts legacy {message: string} or structured " + "{message: {text, attachment_ids, references}}. " + "Uploaded attachments are referenced by attachment_ids; " + "the client must not inline local file content. " + "For retrying the last visible text-only user message, " + "reuse_previous_user_message=true reuses the existing " + "history record after validating the tail message. " + "The job event stream may include requires_action for " + "future human-in-the-loop workflows." + ), + } + }, + "/api/v1/chat/jobs/active": { + "get": { + "summary": "List active WebUI chat jobs", + "description": ( + "Returns a compatible job field plus jobs[] for all active " + "WebChat jobs. conversation_id filters to one conversation." + ), + } + }, + "/api/v1/chat/jobs/{job_id}": { + "get": {"summary": "Get a WebUI chat job by id"} + }, + "/api/v1/chat/jobs/{job_id}/events": { + "get": { + "summary": "Subscribe to or query WebUI chat job events", + "description": ( + "Returns SSE by default. With Accept: application/json or " + "format=json, returns a JSON snapshot with events after seq." + ), + } + }, + "/api/v1/chat/jobs/{job_id}/cancel": { + "post": {"summary": "Cancel a WebUI chat job"} }, "/api/v1/tools": { "get": { diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 7ad5e56c..2a563669 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -24,7 +24,18 @@ _AUTH_HEADER, ) from ._naga_state import NagaState -from .routes import chat, cognitive, health, memes, memory, naga, system, tools +from .routes import ( + chat, + cognitive, + commands, + health, + memes, + memory, + naga, + schedules, + system, + tools, +) logger = logging.getLogger(__name__) @@ -43,6 +54,7 @@ def __init__( self._sites: list[web.TCPSite] = [] self._background_tasks: set[asyncio.Task[Any]] = set() self._naga_state = NagaState() + self._chat_job_manager = chat.ChatJobManager(context) async def start(self) -> None: from Undefined.config.models import resolve_bind_hosts @@ -58,6 +70,8 @@ async def start(self) -> None: logger.info("[RuntimeAPI] 已启动: %s", cfg.api.display_url) async def stop(self) -> None: + await self._chat_job_manager.stop() + for task in self._background_tasks: task.cancel() if self._background_tasks: @@ -123,6 +137,20 @@ async def _auth_middleware( "/api/v1/memes/{uid}/reindex", self._meme_reindex_handler, ), + web.get("/api/v1/schedules", self._schedules_list_handler), + web.post("/api/v1/schedules", self._schedules_create_handler), + web.get( + "/api/v1/schedules/{task_id}", + self._schedule_detail_handler, + ), + web.patch( + "/api/v1/schedules/{task_id}", + self._schedule_update_handler, + ), + web.delete( + "/api/v1/schedules/{task_id}", + self._schedule_delete_handler, + ), web.get("/api/v1/cognitive/events", self._cognitive_events_handler), web.get( "/api/v1/cognitive/profiles", @@ -132,8 +160,57 @@ async def _auth_middleware( "/api/v1/cognitive/profile/{entity_type}/{entity_id}", self._cognitive_profile_handler, ), + web.get("/api/v1/commands", self._commands_list_handler), + web.get( + "/api/v1/commands/{command_name}", + self._command_detail_handler, + ), + web.get( + "/api/v1/chat/conversations", + self._chat_conversations_handler, + ), + web.post( + "/api/v1/chat/conversations", + self._chat_conversation_create_handler, + ), + web.patch( + "/api/v1/chat/conversations/{conversation_id}", + self._chat_conversation_update_handler, + ), + web.delete( + "/api/v1/chat/conversations/{conversation_id}", + self._chat_conversation_delete_handler, + ), web.post("/api/v1/chat", self._chat_handler), web.get("/api/v1/chat/history", self._chat_history_handler), + web.delete("/api/v1/chat/history", self._chat_history_clear_handler), + web.get( + "/api/v1/chat/attachments/capabilities", + self._chat_attachment_capabilities_handler, + ), + web.post( + "/api/v1/chat/attachments", + self._chat_attachment_upload_handler, + ), + web.get( + "/api/v1/chat/attachments/{attachment_id}", + self._chat_attachment_download_handler, + ), + web.get( + "/api/v1/chat/attachments/{attachment_id}/preview", + self._chat_attachment_preview_handler, + ), + web.post("/api/v1/chat/jobs", self._chat_job_create_handler), + web.get("/api/v1/chat/jobs/active", self._chat_job_active_handler), + web.get("/api/v1/chat/jobs/{job_id}", self._chat_job_detail_handler), + web.get( + "/api/v1/chat/jobs/{job_id}/events", + self._chat_job_events_handler, + ), + web.post( + "/api/v1/chat/jobs/{job_id}/cancel", + self._chat_job_cancel_handler, + ), web.get("/api/v1/tools", self._tools_list_handler), web.post("/api/v1/tools/invoke", self._tools_invoke_handler), ] @@ -231,6 +308,22 @@ async def _meme_reanalyze_handler(self, request: web.Request) -> Response: async def _meme_reindex_handler(self, request: web.Request) -> Response: return await memes.meme_reindex_handler(self._ctx, request) + # Schedules + async def _schedules_list_handler(self, request: web.Request) -> Response: + return await schedules.schedules_list_handler(self._ctx, request) + + async def _schedules_create_handler(self, request: web.Request) -> Response: + return await schedules.schedules_create_handler(self._ctx, request) + + async def _schedule_detail_handler(self, request: web.Request) -> Response: + return await schedules.schedule_detail_handler(self._ctx, request) + + async def _schedule_update_handler(self, request: web.Request) -> Response: + return await schedules.schedule_update_handler(self._ctx, request) + + async def _schedule_delete_handler(self, request: web.Request) -> Response: + return await schedules.schedule_delete_handler(self._ctx, request) + # Cognitive async def _cognitive_events_handler(self, request: web.Request) -> Response: return await cognitive.cognitive_events_handler(self._ctx, request) @@ -241,6 +334,13 @@ async def _cognitive_profiles_handler(self, request: web.Request) -> Response: async def _cognitive_profile_handler(self, request: web.Request) -> Response: return await cognitive.cognitive_profile_handler(self._ctx, request) + # Commands + async def _commands_list_handler(self, request: web.Request) -> Response: + return await commands.commands_list_handler(self._ctx, request) + + async def _command_detail_handler(self, request: web.Request) -> Response: + return await commands.command_detail_handler(self._ctx, request) + # Chat async def _run_webui_chat( self, @@ -250,11 +350,83 @@ async def _run_webui_chat( ) -> str: return await chat.run_webui_chat(self._ctx, text=text, send_output=send_output) + async def _chat_conversations_handler(self, request: web.Request) -> Response: + return await chat.chat_conversations_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_conversation_create_handler(self, request: web.Request) -> Response: + return await chat.chat_conversation_create_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_conversation_update_handler(self, request: web.Request) -> Response: + return await chat.chat_conversation_update_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_conversation_delete_handler(self, request: web.Request) -> Response: + return await chat.chat_conversation_delete_handler( + self._ctx, self._chat_job_manager, request + ) + async def _chat_history_handler(self, request: web.Request) -> Response: - return await chat.chat_history_handler(self._ctx, request) + return await chat.chat_history_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_history_clear_handler(self, request: web.Request) -> Response: + return await chat.chat_history_clear_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_attachment_capabilities_handler( + self, request: web.Request + ) -> Response: + return await chat.chat_attachment_capabilities_handler(self._ctx, request) + + async def _chat_attachment_upload_handler(self, request: web.Request) -> Response: + return await chat.chat_attachment_upload_handler(self._ctx, request) + + async def _chat_attachment_download_handler( + self, request: web.Request + ) -> web.StreamResponse: + return await chat.chat_attachment_download_handler(self._ctx, request) + + async def _chat_attachment_preview_handler( + self, request: web.Request + ) -> web.StreamResponse: + return await chat.chat_attachment_preview_handler(self._ctx, request) async def _chat_handler(self, request: web.Request) -> web.StreamResponse: - return await chat.chat_handler(self._ctx, request) + return await chat.chat_handler(self._ctx, self._chat_job_manager, request) + + async def _chat_job_create_handler(self, request: web.Request) -> Response: + return await chat.chat_job_create_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_job_active_handler(self, request: web.Request) -> Response: + return await chat.chat_job_active_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_job_detail_handler(self, request: web.Request) -> Response: + return await chat.chat_job_detail_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_job_events_handler( + self, request: web.Request + ) -> web.StreamResponse: + return await chat.chat_job_events_handler( + self._ctx, self._chat_job_manager, request + ) + + async def _chat_job_cancel_handler(self, request: web.Request) -> Response: + return await chat.chat_job_cancel_handler( + self._ctx, self._chat_job_manager, request + ) # Tools def _get_filtered_tools(self) -> list[dict[str, Any]]: diff --git a/src/Undefined/api/routes/chat.py b/src/Undefined/api/routes/chat.py index 536435ca..93349c4b 100644 --- a/src/Undefined/api/routes/chat.py +++ b/src/Undefined/api/routes/chat.py @@ -3,11 +3,23 @@ from __future__ import annotations import asyncio +import hashlib +import inspect +import json import logging +import mimetypes +import os +import re from contextlib import suppress +from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path +import time from typing import Any, Awaitable, Callable +from urllib.parse import unquote +from uuid import uuid4 +import aiofiles from aiohttp import web from aiohttp.web_response import Response @@ -20,23 +32,2335 @@ _sse_event, _to_bool, ) +from Undefined.api.webchat_store import ( + DEFAULT_WEBCHAT_CONVERSATION_ID, + WebChatConversationStore, + format_webchat_message_xml, + generate_webchat_title, + webchat_title_basis_hash, +) from Undefined.attachments import ( attachment_refs_to_xml, build_attachment_scope, register_message_attachments, - render_message_with_pic_placeholders, ) from Undefined.context import RequestContext from Undefined.context_resource_registry import collect_context_resources from Undefined.services.queue_manager import QUEUE_LANE_SUPERADMIN from Undefined.utils.common import message_to_segments +from Undefined.utils import io as async_io +from Undefined.utils.paths import WEBCHAT_DIR, ensure_dir from Undefined.utils.recent_messages import get_recent_messages_prefer_local -from Undefined.utils.xml import escape_xml_attr, escape_xml_text -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) + +_VIRTUAL_USER_NAME = "system" +_DEFAULT_CONVERSATION_ID = DEFAULT_WEBCHAT_CONVERSATION_ID +_CHAT_SSE_KEEPALIVE_SECONDS = 10.0 +_CHAT_STAGE_REFRESH_SECONDS = 1.0 +_CHAT_JOB_EVENT_BUFFER_LIMIT = 1000 +SHUTDOWN_TASK_TIMEOUT = 5.0 + +# 兼容旧 register_message_attachments 产出的可读占位(如 ``[图片 uid=pic_xxx name=foo]``) +_WEBCHAT_BRACKET_REF_PATTERN = re.compile(r"\[[^\]]*?\buid=(?P[^\s\]]+)[^\]]*?\]") +# 文本中所有 / 引用 +_WEBCHAT_ATTACHMENT_TAG_PATTERN = re.compile( + r"<(?:attachment|pic)\s+[^>]*?\buid=[\"']?(?P[^\"'\s/>]+)[\"']?[^>]*?/?>", + re.IGNORECASE, +) + + +async def _normalize_webchat_output( + content: str, + *, + registry: Any, + scope_key: str | None, + resolve_image_url: Callable[[str], Awaitable[str | None]] | None, + get_forward_messages: Callable[[str], Awaitable[list[dict[str, Any]]]] | None, +) -> tuple[str, list[dict[str, str]]]: + """归一化 webchat 命令输出中的内联媒体,统一为 ````。 + + 命令输出可能包含原始 ``[CQ:image,file=base64://...]`` / ``file://`` 图片。 + 若原样写入历史,会把整段 base64 喂给后续 LLM(导致 token 爆炸),也让 API + 返回 base64。这里先把内联媒体注册为附件、转成 ```` 占位, + 客户端再按 UID 经 ``/api/v1/chat/attachments/{uid}/preview`` 拉取渲染。 + + Args: + content: 命令输出原文。 + registry: 附件注册表。 + scope_key: 当前 webchat 会话作用域键。 + resolve_image_url: 将 ``file`` 字段解析为可下载 URL 的回调。 + get_forward_messages: 拉取合并转发子消息的回调。 + + Returns: + ``(归一化文本, 附件引用列表)``;附件为 :meth:`AttachmentRecord.prompt_ref` 字典。 + """ + if registry is None or not scope_key or not content.strip(): + return content, [] + segments = message_to_segments(content) + registered = await register_message_attachments( + registry=registry, + segments=segments, + scope_key=scope_key, + resolve_image_url=resolve_image_url, + get_forward_messages=get_forward_messages, + ) + text = registered.normalized_text or content + # ``[图片 uid=X name=Y]`` / ``[文件 uid=X]`` → ```` + text = _WEBCHAT_BRACKET_REF_PATTERN.sub( + lambda match: f'', text + ) + # 收集文中所有附件引用(含技能预先注册、已是 / 标签的) + attachments: list[dict[str, str]] = [] + seen: set[str] = set() + for match in _WEBCHAT_ATTACHMENT_TAG_PATTERN.finditer(text): + uid = str(match.group("uid") or "").strip() + if not uid or uid in seen: + continue + seen.add(uid) + record = await registry.resolve_async(uid, scope_key) + if record is not None: + attachments.append(record.prompt_ref()) + return text, attachments + + +_PREVIEW_LIMIT = 800 +_CHAT_ATTACHMENT_MAX_NAME_LENGTH = 128 +_CHAT_ATTACHMENT_UPLOAD_FIELD = "file" +_CHAT_ATTACHMENT_CHUNK_SIZE = 1024 * 256 +_CHAT_ATTACHMENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,96}$") +_CHAT_ATTACHMENT_STORAGE_DIR = WEBCHAT_DIR / "attachments" +_CHAT_ATTACHMENT_BLOB_DIR = _CHAT_ATTACHMENT_STORAGE_DIR / "blobs" +_CHAT_ATTACHMENT_META_DIR = _CHAT_ATTACHMENT_STORAGE_DIR / "metadata" +_WEBCHAT_SEND_MESSAGE_TOOLS = frozenset( + { + "messages.send_message", + "send_message", + "messages.send_private_message", + "send_private_message", + } +) +_WEBCHAT_LIFECYCLE_EVENTS = frozenset( + {"tool_start", "tool_end", "agent_start", "agent_end"} +) +_WEBCHAT_AGENT_STAGE_EVENTS = frozenset({"agent_stage"}) +_WEBCHAT_ACTION_EVENTS = frozenset({"requires_action"}) +_WEBCHAT_HISTORY_EVENTS = ( + _WEBCHAT_LIFECYCLE_EVENTS + | _WEBCHAT_AGENT_STAGE_EVENTS + | _WEBCHAT_ACTION_EVENTS + | frozenset({"message"}) +) +_WEBCHAT_STAGE_EVENTS = frozenset({"stage"}) +_REDACTED_PREVIEW_VALUE = "[redacted]" +_SENSITIVE_KEY_EXACT = frozenset( + { + "apikey", + "authorization", + "authtoken", + "bearertoken", + "clientsecret", + "cookie", + "credentials", + "idtoken", + "password", + "passwd", + "privatekey", + "refreshtoken", + "secret", + "secretkey", + "sessioncookie", + "sessionid", + "sessiontoken", + "setcookie", + "token", + } +) +_SENSITIVE_KEY_SUFFIXES = ( + "apikey", + "authtoken", + "bearertoken", + "clientsecret", + "idtoken", + "privatekey", + "refreshtoken", + "secretkey", + "sessioncookie", + "sessionid", + "sessiontoken", +) +_SECRET_TEXT_PATTERNS = ( + re.compile( + r"(?i)\b(authorization)\s*[:=]\s*(bearer\s+)?([^\s,;]+)", + ), + re.compile( + r"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|" + r"client[_-]?secret|password|passwd|secret|private[_-]?key|session[_-]?id|" + r"session[_-]?token|cookie|set-cookie)\s*[:=]\s*(['\"]?)([^,\s;&\n'\"]+)", + ), + re.compile(r"(?i)\b(bearer)\s+([A-Za-z0-9._~+/=-]{16,})"), +) + + +@dataclass +class ChatJobEvent: + seq: int + event: str + payload: dict[str, Any] + + +@dataclass(frozen=True) +class StructuredChatMessage: + text: str + attachments: list[dict[str, str]] + references: list[dict[str, Any]] + + +class ChatAttachmentNotFoundError(LookupError): + """Raised when a structured WebChat payload references a missing attachment.""" + + +@dataclass +class ChatJob: + job_id: str + text: str + created_at: float + updated_at: float + conversation_id: str = _DEFAULT_CONVERSATION_ID + status: str = "queued" + mode: str = "chat" + finished_at: float | None = None + duration_ms: int | None = None + current_stage: str = "queued" + current_stage_detail: str = "" + current_stage_started_at: float = 0.0 + outputs: list[str] = field(default_factory=list) + history_outputs: list[str] = field(default_factory=list) + history_attachments: list[dict[str, str]] = field(default_factory=list) + user_history_attachments: list[dict[str, str]] = field(default_factory=list) + user_history_references: list[dict[str, Any]] = field(default_factory=list) + webchat_events: list[ChatJobEvent] = field(default_factory=list) + events: list[ChatJobEvent] = field(default_factory=list) + next_seq: int = 1 + task: asyncio.Task[None] | None = None + error: str = "" + history_finalized: bool = False + cancel_finalizer_scheduled: bool = False + history_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + done: asyncio.Event = field(default_factory=asyncio.Event) + changed: asyncio.Condition = field(default_factory=asyncio.Condition) + tool_started_at: dict[str, float] = field(default_factory=dict) + tool_start_payloads: dict[str, dict[str, Any]] = field(default_factory=dict) + agent_current_stage: dict[str, str] = field(default_factory=dict) + agent_stage_started_at: dict[str, float] = field(default_factory=dict) + agent_stage_payloads: dict[str, dict[str, Any]] = field(default_factory=dict) + user_history_pre_recorded: bool = False + + def snapshot(self) -> dict[str, Any]: + now = time.time() + elapsed_ms = _job_elapsed_ms(self, now) + stage_elapsed_ms = _stage_elapsed_ms(self, now) + return { + "job_id": self.job_id, + "conversation_id": self.conversation_id, + "status": self.status, + "mode": self.mode, + "created_at": self.created_at, + "updated_at": self.updated_at, + "finished_at": self.finished_at, + "elapsed_ms": elapsed_ms, + "duration_ms": self.duration_ms, + "current_stage": self.current_stage, + "current_stage_detail": self.current_stage_detail or None, + "current_stage_started_at": self.current_stage_started_at or None, + "current_stage_elapsed_ms": stage_elapsed_ms, + "last_seq": self.next_seq - 1, + "error": self.error or None, + "reply": "\n\n".join(self.outputs).strip(), + "messages": list(self.outputs), + "current_agent_stages": self.current_agent_stage_snapshots(now), + "current_tool_calls": self.current_tool_call_snapshots(now), + "history_finalized": self.history_finalized, + "waiting_input": None, + } + + def current_stage_event(self) -> ChatJobEvent | None: + if self.done.is_set() or not self.current_stage: + return None + now = time.time() + payload: dict[str, Any] = { + "job_id": self.job_id, + "stage": self.current_stage, + "elapsed_ms": _job_elapsed_ms(self, now), + } + if self.current_stage_started_at > 0: + payload["started_at"] = self.current_stage_started_at + payload["stage_elapsed_ms"] = _stage_elapsed_ms(self, now) + if self.current_stage_detail: + payload["detail"] = self.current_stage_detail + return ChatJobEvent(seq=self.next_seq - 1, event="stage", payload=payload) + + def current_agent_stage_events(self) -> list[ChatJobEvent]: + if self.done.is_set(): + return [] + now = time.time() + payloads = self.current_agent_stage_snapshots(now) + return [ + ChatJobEvent(seq=self.next_seq - 1, event="agent_stage", payload=payload) + for payload in payloads + ] + + def current_agent_stage_snapshots( + self, now: float | None = None + ) -> list[dict[str, Any]]: + if self.done.is_set(): + return [] + measured_at = time.time() if now is None else now + payloads: list[dict[str, Any]] = [] + for call_id, stage in self.agent_current_stage.items(): + if not stage: + continue + payload = dict(self.agent_stage_payloads.get(call_id, {})) + started_at = self.agent_stage_started_at.get(call_id, measured_at) + payload.update( + { + "job_id": self.job_id, + "webchat_call_id": call_id, + "stage": stage, + "transient": True, + "started_at": started_at, + "stage_elapsed_ms": max(0, int((measured_at - started_at) * 1000)), + "elapsed_ms": _job_elapsed_ms(self, measured_at), + } + ) + payloads.append(payload) + return payloads + + def current_tool_call_snapshots( + self, now: float | None = None + ) -> list[dict[str, Any]]: + if self.done.is_set(): + return [] + measured_at = time.time() if now is None else now + payloads: list[dict[str, Any]] = [] + for call_id, started_at in self.tool_started_at.items(): + payload = dict(self.tool_start_payloads.get(call_id, {})) + if not payload: + continue + payload.update( + { + "job_id": self.job_id, + "webchat_call_id": call_id, + "status": "running", + "started_at": started_at, + "duration_ms": max(0, int((measured_at - started_at) * 1000)), + "elapsed_ms": _job_elapsed_ms(self, measured_at), + } + ) + if bool(payload.get("is_agent")): + stage_payload = self.agent_stage_payloads.get(call_id, {}) + stage_started_at = self.agent_stage_started_at.get(call_id, measured_at) + current_stage = self.agent_current_stage.get(call_id, "") + if current_stage: + payload.update( + { + "current_stage": current_stage, + "current_stage_detail": str( + stage_payload.get("detail") or "" + ).strip(), + "current_stage_elapsed_ms": max( + 0, + int((measured_at - stage_started_at) * 1000), + ), + } + ) + payloads.append(payload) + return payloads + + +class ChatJobManager: + def __init__(self, ctx: RuntimeAPIContext) -> None: + self._ctx = ctx + self._jobs: dict[str, ChatJob] = {} + self._lock = asyncio.Lock() + self._title_schedule_lock = asyncio.Lock() + self.conversation_store = WebChatConversationStore() + + async def create_job( + self, + text: str, + conversation_id: str | None = None, + *, + user_history_attachments: list[dict[str, str]] | None = None, + user_history_references: list[dict[str, Any]] | None = None, + pre_record_user_history: bool = False, + reuse_previous_user_history: bool = False, + ) -> ChatJob: + await self.conversation_store.ensure_ready(self._ctx.history_manager) + requested_conversation_id = str(conversation_id or "").strip() + resolved_conversation_id = requested_conversation_id or _DEFAULT_CONVERSATION_ID + conversation = await self.conversation_store.get_conversation( + resolved_conversation_id + ) + if conversation is None: + if requested_conversation_id: + raise KeyError(resolved_conversation_id) + conversation = await self.conversation_store.ensure_default_conversation() + resolved_conversation_id = str(conversation["id"]) + now = time.time() + job = ChatJob( + job_id=uuid4().hex, + text=text, + created_at=now, + updated_at=now, + conversation_id=resolved_conversation_id, + user_history_attachments=list(user_history_attachments or []), + user_history_references=list(user_history_references or []), + user_history_pre_recorded=pre_record_user_history + or reuse_previous_user_history, + ) + async with self._lock: + if any( + self._job_blocks_history_mutation(existing) + for existing in self._jobs.values() + if existing.conversation_id == resolved_conversation_id + ): + raise RuntimeError("Chat job is still running") + self._jobs[job.job_id] = job + if pre_record_user_history: + await self.conversation_store.append_message( + resolved_conversation_id, + role="user", + text_content=text, + display_name=_VIRTUAL_USER_NAME, + user_name=_VIRTUAL_USER_NAME, + attachments=job.user_history_attachments or None, + references=job.user_history_references or None, + ) + logger.info( + "[RuntimeAPI][WebChat] 创建 job: job_id=%s conversation_id=%s text_len=%s", + job.job_id, + job.conversation_id, + len(text), + ) + await self._append_event( + job, + "meta", + { + "job_id": job.job_id, + "conversation_id": job.conversation_id, + "virtual_user_id": _VIRTUAL_USER_ID, + "permission": "superadmin", + }, + ) + await self._append_stage(job, "received") + job.task = asyncio.create_task(self._run_job(job), name=f"webchat:{job.job_id}") + return job + + async def stop(self) -> None: + async with self._lock: + jobs = list(self._jobs.values()) + for job in jobs: + if self._job_blocks_history_mutation(job): + await self.cancel_job(job.job_id) + tasks: list[asyncio.Task[None]] = [] + for job in jobs: + task = job.task + if task is not None and not task.done(): + tasks.append(task) + if tasks: + task_wait = asyncio.gather(*tasks, return_exceptions=True) + try: + await asyncio.wait_for( + asyncio.shield(task_wait), + timeout=SHUTDOWN_TASK_TIMEOUT, + ) + except asyncio.TimeoutError: + logger.warning( + "[RuntimeAPI][WebChat] stop 等待 job task 超时,重新取消未完成任务: tasks=%s", + len(tasks), + ) + for task in tasks: + if not task.done(): + task.cancel() + await task_wait + for job in jobs: + if not job.done.is_set(): + with suppress(asyncio.TimeoutError): + await asyncio.wait_for(job.done.wait(), timeout=5.0) + await self.conversation_store.stop() + + async def get_job(self, job_id: str) -> ChatJob | None: + async with self._lock: + return self._jobs.get(job_id) + + async def get_active_job( + self, conversation_id: str | None = None + ) -> ChatJob | None: + candidates = await self.get_active_jobs(conversation_id) + if not candidates: + return None + return max(candidates, key=lambda item: item.created_at) + + async def get_active_jobs( + self, conversation_id: str | None = None + ) -> list[ChatJob]: + resolved_conversation_id = str(conversation_id or "").strip() + async with self._lock: + candidates = [ + job + for job in self._jobs.values() + if self._job_blocks_history_mutation(job) + and ( + not resolved_conversation_id + or job.conversation_id == resolved_conversation_id + ) + ] + return sorted(candidates, key=lambda item: item.created_at) + + async def snapshot(self, job: ChatJob) -> dict[str, Any]: + async with job.changed: + return job.snapshot() + + async def has_running_job(self, conversation_id: str | None = None) -> bool: + resolved_conversation_id = str(conversation_id or "").strip() + async with self._lock: + return any( + self._job_blocks_history_mutation(job) + and ( + not resolved_conversation_id + or job.conversation_id == resolved_conversation_id + ) + for job in self._jobs.values() + ) + + def _job_blocks_history_mutation(self, job: ChatJob) -> bool: + if job.status in {"queued", "running"}: + return True + return not job.done.is_set() or not job.history_finalized + + async def clear_history_when_idle( + self, conversation_id: str | None = None + ) -> int | None: + await self.conversation_store.ensure_ready(self._ctx.history_manager) + resolved_conversation_id = ( + str(conversation_id or _DEFAULT_CONVERSATION_ID).strip() + or _DEFAULT_CONVERSATION_ID + ) + async with self._lock: + if any( + self._job_blocks_history_mutation(job) + and job.conversation_id == resolved_conversation_id + for job in self._jobs.values() + ): + logger.info( + "[RuntimeAPI][WebChat] 清空历史被拒绝,存在运行中 job: conversation_id=%s", + resolved_conversation_id, + ) + return None + return int( + await self.conversation_store.clear_conversation( + resolved_conversation_id + ) + or 0 + ) + + async def cancel_job(self, job_id: str) -> ChatJob | None: + job = await self.get_job(job_id) + if job is None: + return None + if job.status in {"done", "error", "cancelled"}: + return job + logger.info( + "[RuntimeAPI][WebChat] 取消 job: job_id=%s conversation_id=%s status=%s", + job.job_id, + job.conversation_id, + job.status, + ) + async with job.changed: + job.status = "cancelled" + self._mark_job_finished(job) + job.changed.notify_all() + if job.task is not None and not job.task.done(): + job.task.cancel() + self._schedule_cancel_finalizer(job) + await self._append_cancelled_event_once(job) + if job.task is None or job.task.done(): + async with job.changed: + job.history_finalized = True + job.done.set() + job.changed.notify_all() + return job + + def _schedule_cancel_finalizer(self, job: ChatJob) -> None: + if job.cancel_finalizer_scheduled: + return + if job.task is None: + return + job.cancel_finalizer_scheduled = True + loop = asyncio.get_running_loop() + + def _on_done(_task: asyncio.Task[None]) -> None: + loop.create_task( + self._complete_cancelled_job(job), + name=f"webchat-cancel-finalize:{job.job_id}", + ) + + job.task.add_done_callback(_on_done) + + async def _complete_cancelled_job(self, job: ChatJob) -> None: + try: + await self._finalize_job_history(job) + except Exception as exc: + logger.exception( + "[RuntimeAPI] cancelled chat job history finalize failed: %s", exc + ) + job.history_finalized = True + finally: + async with job.changed: + job.done.set() + job.changed.notify_all() + + async def events_after(self, job: ChatJob, after: int) -> list[ChatJobEvent]: + async with job.changed: + return [event for event in job.events if event.seq > after] + + async def events_after_with_snapshot( + self, + job: ChatJob, + after: int, + ) -> tuple[list[ChatJobEvent], dict[str, Any], list[ChatJobEvent]]: + async with job.changed: + events = [event for event in job.events if event.seq > after] + snapshot = job.snapshot() + live_events = _current_webchat_live_events(job, after, events) + return events, snapshot, live_events + + async def update_agent_stage( + self, job: ChatJob, payload: dict[str, Any] + ) -> ChatJobEvent | None: + event_payload = _sanitize_webchat_event_payload("agent_stage", payload) + event_time = time.time() + call_id = _webchat_tool_event_key(event_payload) + stage_key = str(event_payload.get("stage") or "").strip() + if not stage_key: + return None + async with job.changed: + previous_stage = job.agent_current_stage.get(call_id) + if previous_stage != stage_key: + job.agent_current_stage[call_id] = stage_key + job.agent_stage_started_at[call_id] = event_time + started_at = job.agent_stage_started_at.get(call_id, event_time) + event_payload["started_at"] = started_at + event_payload["stage_elapsed_ms"] = max( + 0, int((event_time - started_at) * 1000) + ) + event_payload["elapsed_ms"] = _job_elapsed_ms(job, event_time) + event_payload["job_id"] = job.job_id + job.agent_stage_payloads[call_id] = dict(event_payload) + return self._append_event_locked(job, "agent_stage", event_payload) + + async def append_lifecycle_event( + self, job: ChatJob, event: str, payload: dict[str, Any] + ) -> ChatJobEvent | None: + if event not in _WEBCHAT_LIFECYCLE_EVENTS: + return None + event_payload = _sanitize_webchat_event_payload(event, payload) + event_time = time.time() + output_event = str(event_payload.get("_event", event) or event) + tool_key = _webchat_tool_event_key(event_payload) + logger.debug( + "[RuntimeAPI][WebChat] 生命周期事件: job_id=%s conversation_id=%s event=%s tool_key=%s", + job.job_id, + job.conversation_id, + output_event, + tool_key, + ) + async with job.changed: + if output_event in {"tool_start", "agent_start"}: + job.tool_started_at[tool_key] = event_time + event_payload["started_at"] = event_time + job.tool_start_payloads[tool_key] = dict(event_payload) + elif output_event in {"tool_end", "agent_end"}: + lifecycle_started_at = job.tool_started_at.get(tool_key) + job.tool_started_at.pop(tool_key, 0.0) + job.tool_start_payloads.pop(tool_key, None) + if lifecycle_started_at is not None: + event_payload["duration_ms"] = max( + 0, int((event_time - lifecycle_started_at) * 1000) + ) + if output_event == "agent_end": + job.agent_current_stage.pop(tool_key, None) + job.agent_stage_started_at.pop(tool_key, None) + job.agent_stage_payloads.pop(tool_key, None) + event_payload["elapsed_ms"] = _job_elapsed_ms(job, event_time) + event_payload["job_id"] = job.job_id + return self._append_event_locked(job, event, event_payload) + + async def append_action_event( + self, job: ChatJob, event: str, payload: dict[str, Any] + ) -> ChatJobEvent | None: + if event not in _WEBCHAT_ACTION_EVENTS: + return None + event_time = time.time() + event_payload = _sanitize_webchat_event_payload(event, payload) + event_payload["elapsed_ms"] = _job_elapsed_ms(job, event_time) + event_payload["job_id"] = job.job_id + async with job.changed: + return self._append_event_locked(job, event, event_payload) + + async def wait_for_events_after( + self, + job: ChatJob, + after: int, + *, + timeout: float, + ) -> list[ChatJobEvent]: + async with job.changed: + current = [event for event in job.events if event.seq > after] + if current: + return current + try: + await asyncio.wait_for(job.changed.wait(), timeout=timeout) + except asyncio.TimeoutError: + return [] + return [event for event in job.events if event.seq > after] + + async def _run_job(self, job: ChatJob) -> None: + job.status = "running" + job.updated_at = time.time() + logger.info( + "[RuntimeAPI][WebChat] job 开始: job_id=%s conversation_id=%s text_len=%s", + job.job_id, + job.conversation_id, + len(job.text), + ) + outputs: list[str] = [] + webui_scope_key = build_attachment_scope( + user_id=_VIRTUAL_USER_ID, + request_type="private", + webui_session=True, + ) + + async def _capture_private_message(user_id: int, message: str) -> None: + _ = user_id + content = str(message or "").strip() + if not content: + return + await self._append_stage(job, "sending_message") + # 将输出中的内联媒体([CQ:image,file=base64://…]、file:// 等)注册为附件并 + # 统一为 ,避免把 base64/本地路径写入历史或喂给后续 LLM; + # 客户端按 UID 经 /api/v1/chat/attachments/{uid}/preview 拉取渲染。 + output_text, output_attachments = await _normalize_webchat_output( + content, + registry=self._ctx.ai.attachment_registry, + scope_key=webui_scope_key, + resolve_image_url=self._ctx.onebot.get_image, + get_forward_messages=self._ctx.onebot.get_forward_msg, + ) + if not output_text.strip() and not output_attachments: + return + outputs.append(output_text) + job.outputs.append(output_text) + job.history_outputs.append(output_text) + job.history_attachments.extend(output_attachments) + logger.info( + "[RuntimeAPI][WebChat] job 输出消息: job_id=%s conversation_id=%s text_len=%s attachments=%s", + job.job_id, + job.conversation_id, + len(output_text), + len(output_attachments), + ) + now = time.time() + await self._append_event( + job, + "message", + { + "content": output_text, + "attachments": [ + _chat_attachment_response_metadata( + {**ref, "id": str(ref.get("uid") or "")} + ) + for ref in output_attachments + ], + "job_id": job.job_id, + "elapsed_ms": _job_elapsed_ms(job, now), + "parent_webchat_call_id": _current_webchat_agent_call_id(job), + }, + ) + + async def _webchat_event_callback(event: str, payload: dict[str, Any]) -> None: + if event in _WEBCHAT_STAGE_EVENTS: + await self._append_stage( + job, + str(payload.get("stage") or payload.get("key") or ""), + detail=payload.get("detail"), + ) + return + if event in _WEBCHAT_AGENT_STAGE_EVENTS: + await self.update_agent_stage(job, payload) + return + if event in _WEBCHAT_ACTION_EVENTS: + await self.append_action_event(job, event, payload) + return + if event not in _WEBCHAT_LIFECYCLE_EVENTS: + return + await self.append_lifecycle_event(job, event, payload) + + try: + run_kwargs: dict[str, Any] = { + "text": job.text, + "send_output": _capture_private_message, + } + if "webchat_event_callback" in inspect.signature(run_webui_chat).parameters: + run_kwargs["webchat_event_callback"] = _webchat_event_callback + if "conversation_store" in inspect.signature(run_webui_chat).parameters: + run_kwargs["conversation_store"] = self.conversation_store + if "conversation_id" in inspect.signature(run_webui_chat).parameters: + run_kwargs["conversation_id"] = job.conversation_id + if "input_attachments" in inspect.signature(run_webui_chat).parameters: + run_kwargs["input_attachments"] = job.user_history_attachments + if "input_references" in inspect.signature(run_webui_chat).parameters: + run_kwargs["input_references"] = job.user_history_references + if "record_input_history" in inspect.signature(run_webui_chat).parameters: + run_kwargs["record_input_history"] = not job.user_history_pre_recorded + await self._append_stage(job, "processing") + mode = await run_webui_chat(self._ctx, **run_kwargs) + job.mode = mode + job.status = "done" + self._mark_job_finished(job) + logger.info( + "[RuntimeAPI][WebChat] job 完成: job_id=%s conversation_id=%s mode=%s duration_ms=%s outputs=%s", + job.job_id, + job.conversation_id, + mode, + job.duration_ms, + len(outputs), + ) + done_payload = _build_chat_response_payload(mode, outputs) + done_payload.update( + { + "job_id": job.job_id, + "status": job.status, + "duration_ms": job.duration_ms, + } + ) + await self._append_stage(job, "done") + await self._append_event( + job, + "done", + done_payload, + ) + except asyncio.CancelledError: + job.status = "cancelled" + self._mark_job_finished(job) + logger.info( + "[RuntimeAPI][WebChat] job 已取消: job_id=%s conversation_id=%s duration_ms=%s", + job.job_id, + job.conversation_id, + job.duration_ms, + ) + await self._append_cancelled_event_once(job) + except Exception as exc: + logger.exception("[RuntimeAPI] chat job failed: %s", exc) + job.status = "error" + job.error = str(exc) + self._mark_job_finished(job) + await self._append_event( + job, + "error", + { + "error": str(exc), + "job_id": job.job_id, + "duration_ms": job.duration_ms, + }, + ) + finally: + try: + await self._finalize_job_history(job) + await self.maybe_schedule_title_generation(job.conversation_id) + except Exception as exc: + logger.exception( + "[RuntimeAPI] chat job history finalize failed: %s", exc + ) + job.history_finalized = True + async with job.changed: + job.done.set() + job.changed.notify_all() + + async def _append_event( + self, job: ChatJob, event: str, payload: dict[str, Any] + ) -> ChatJobEvent: + async with job.changed: + return self._append_event_locked(job, event, payload) + + async def _append_cancelled_event_once(self, job: ChatJob) -> None: + async with job.changed: + if any( + event.event == "error" and event.payload.get("error") == "cancelled" + for event in job.events + ): + return + self._append_event_locked( + job, + "error", + { + "error": "cancelled", + "job_id": job.job_id, + "duration_ms": job.duration_ms, + }, + ) + + def _append_event_locked( + self, job: ChatJob, event: str, payload: dict[str, Any] + ) -> ChatJobEvent: + payload_copy = dict(payload) + normalized_event = str(payload_copy.pop("_event", event) or event) + payload_copy.setdefault("conversation_id", job.conversation_id) + item = ChatJobEvent( + seq=job.next_seq, event=normalized_event, payload=payload_copy + ) + job.next_seq += 1 + job.updated_at = time.time() + job.events.append(item) + if len(job.events) > _CHAT_JOB_EVENT_BUFFER_LIMIT: + job.events = job.events[-_CHAT_JOB_EVENT_BUFFER_LIMIT:] + if item.event in _WEBCHAT_HISTORY_EVENTS: + job.webchat_events.append(item) + if len(job.webchat_events) > _CHAT_JOB_EVENT_BUFFER_LIMIT: + job.webchat_events = job.webchat_events[-_CHAT_JOB_EVENT_BUFFER_LIMIT:] + job.changed.notify_all() + return item + + async def _append_stage( + self, + job: ChatJob, + stage: str, + *, + detail: Any | None = None, + ) -> ChatJobEvent | None: + stage_key = str(stage or "").strip() + if not stage_key: + return None + now = time.time() + payload: dict[str, Any] = { + "job_id": job.job_id, + "conversation_id": job.conversation_id, + "stage": stage_key, + "started_at": now, + "elapsed_ms": _job_elapsed_ms(job, now), + } + detail_text = _preview(detail, 120) + if detail_text: + payload["detail"] = detail_text + async with job.changed: + job.current_stage = stage_key + job.current_stage_detail = detail_text + job.current_stage_started_at = now + return self._append_event_locked(job, "stage", payload) + + def _mark_job_finished(self, job: ChatJob) -> None: + now = time.time() + job.finished_at = now + job.duration_ms = _job_elapsed_ms(job, now) + job.updated_at = now + + async def _finalize_job_history(self, job: ChatJob) -> None: + async with job.history_lock: + if job.history_finalized: + logger.debug( + "[RuntimeAPI][WebChat] job 历史已落盘,跳过: job_id=%s conversation_id=%s", + job.job_id, + job.conversation_id, + ) + return + text_content = "\n\n".join(job.history_outputs).strip() + webchat = _build_webchat_history_payload(job) + if text_content or webchat["events"]: + await self.conversation_store.append_message( + job.conversation_id, + role="bot", + text_content=text_content, + display_name="Bot", + user_name="Bot", + attachments=job.history_attachments or None, + webchat=webchat, + ) + logger.info( + "[RuntimeAPI][WebChat] job 历史落盘: job_id=%s conversation_id=%s text_len=%s events=%s attachments=%s", + job.job_id, + job.conversation_id, + len(text_content), + len(webchat["events"]), + len(job.history_attachments), + ) + else: + logger.info( + "[RuntimeAPI][WebChat] job 无需落盘 bot 历史: job_id=%s conversation_id=%s", + job.job_id, + job.conversation_id, + ) + job.history_finalized = True + + async def maybe_schedule_title_generation(self, conversation_id: str) -> None: + async with self._title_schedule_lock: + if self.conversation_store.title_task_running(conversation_id): + logger.debug( + "[RuntimeAPI][WebChat] 标题生成任务已存在: conversation_id=%s", + conversation_id, + ) + return + first_pair = await self.conversation_store.first_question_answer( + conversation_id + ) + if first_pair is None: + logger.debug( + "[RuntimeAPI][WebChat] 标题生成跳过,缺少首问首答: conversation_id=%s", + conversation_id, + ) + return + if not await self.conversation_store.mark_title_pending(conversation_id): + logger.debug( + "[RuntimeAPI][WebChat] 标题生成跳过,状态不允许: conversation_id=%s", + conversation_id, + ) + return + question, answer = first_pair + basis_hash = webchat_title_basis_hash(question, answer) + logger.info( + "[RuntimeAPI][WebChat] 调度标题生成: conversation_id=%s question_len=%s answer_len=%s", + conversation_id, + len(question), + len(answer), + ) + + async def _run_title() -> None: + try: + title = await generate_webchat_title(self._ctx.ai, question, answer) + if title: + await self.conversation_store.apply_generated_title( + conversation_id, + title=title, + basis_hash=basis_hash, + ) + logger.info( + "[RuntimeAPI][WebChat] 标题生成完成: conversation_id=%s title_len=%s", + conversation_id, + len(title), + ) + return + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "[RuntimeAPI] webchat title generation failed: %s", exc + ) + await self.conversation_store.mark_title_failed( + conversation_id, basis_hash + ) + + task = asyncio.create_task( + _run_title(), name=f"webchat-title:{conversation_id}" + ) + self.conversation_store.register_title_task(conversation_id, task) + + +def _job_elapsed_ms(job: ChatJob, now: float | None = None) -> int: + measured_at = time.time() if now is None else now + return max(0, int((measured_at - job.created_at) * 1000)) + + +def _stage_elapsed_ms(job: ChatJob, now: float | None = None) -> int: + if job.current_stage_started_at <= 0: + return 0 + measured_at = time.time() if now is None else now + return max(0, int((measured_at - job.current_stage_started_at) * 1000)) + + +def _current_webchat_live_events( + job: ChatJob, + after: int, + events: list[ChatJobEvent], +) -> list[ChatJobEvent]: + live_events: list[ChatJobEvent] = [] + current_stage_event = job.current_stage_event() + if ( + current_stage_event is not None + and current_stage_event.seq >= after + and not any( + existing.event == current_stage_event.event + and existing.payload.get("stage") + == current_stage_event.payload.get("stage") + for existing in events + ) + ): + live_events.append(current_stage_event) + live_events.extend( + event + for event in job.current_agent_stage_events() + if event.seq >= after + and not any( + existing.event == event.event + and existing.payload.get("webchat_call_id") + == event.payload.get("webchat_call_id") + and existing.payload.get("stage") == event.payload.get("stage") + for existing in events + ) + ) + return live_events + + +def _current_webchat_agent_call_id(job: ChatJob) -> str: + open_calls: list[tuple[str, bool]] = [] + for item in job.webchat_events: + if item.event not in _WEBCHAT_LIFECYCLE_EVENTS: + continue + payload = item.payload + call_id = _webchat_tool_event_key(payload) + if not call_id: + continue + if item.event in {"tool_start", "agent_start"}: + open_calls.append((call_id, bool(payload.get("is_agent")))) + continue + if item.event in {"tool_end", "agent_end"}: + for index in range(len(open_calls) - 1, -1, -1): + if open_calls[index][0] == call_id: + open_calls.pop(index) + break + for call_id, is_agent in reversed(open_calls): + if is_agent: + return call_id + return "" + + +def _webchat_tool_event_key(payload: dict[str, Any]) -> str: + return ( + str(payload.get("webchat_call_id") or "").strip() + or str(payload.get("tool_call_id") or "").strip() + or str(payload.get("name") or "").strip() + or str(payload.get("api_name") or "").strip() + or "tool" + ) + + +def _webchat_payload_lineage(payload: dict[str, Any]) -> dict[str, Any]: + call_id = ( + str(payload.get("webchat_call_id") or "").strip() + or str(payload.get("tool_call_id") or "").strip() + or str(payload.get("name") or "").strip() + or "tool" + ) + parent_call_id = str(payload.get("parent_webchat_call_id") or "").strip() + try: + depth = max(0, int(payload.get("depth", 0) or 0)) + except (TypeError, ValueError): + depth = 0 + raw_path = payload.get("agent_path") + agent_path = ( + [str(item) for item in raw_path if str(item).strip()] + if isinstance(raw_path, list) + else [] + ) + return { + "webchat_call_id": call_id, + "parent_webchat_call_id": parent_call_id, + "depth": depth, + "agent_path": agent_path, + } + + +def _legacy_webchat_tool_event_key(payload: dict[str, Any]) -> str: + return ( + str(payload.get("tool_call_id") or "").strip() + or str(payload.get("name") or "").strip() + or str(payload.get("api_name") or "").strip() + or "tool" + ) + + +def _preview(value: Any, limit: int = _PREVIEW_LIMIT) -> str: + redacted = _redact_preview_value(value) + if isinstance(redacted, dict | list): + text = json.dumps(redacted, ensure_ascii=False, separators=(",", ":")) + else: + text = _redact_secret_text(str(redacted or "")) + compact = " ".join(text.split()) + if len(compact) <= limit: + return compact + return compact[:limit] + "..." + + +def _preview_existing_text(raw: Any, limit: int = _PREVIEW_LIMIT) -> str: + text = str(raw or "").strip() + if not text: + return "" + with suppress(json.JSONDecodeError, TypeError, ValueError): + return _preview(json.loads(text), limit) + return _preview(text, limit) + + +def _chat_attachment_max_upload_size_bytes(ctx: RuntimeAPIContext) -> int: + cfg = ctx.config_getter() + raw_max_size_mb = getattr(cfg, "messages_send_url_file_max_size_mb", None) + max_size_mb = 100 if raw_max_size_mb is None else int(raw_max_size_mb) + return max(1, max_size_mb) * 1024 * 1024 + + +def _chat_attachment_blob_path(attachment_id: str) -> Path: + return _CHAT_ATTACHMENT_BLOB_DIR / attachment_id + + +def _chat_attachment_meta_path(attachment_id: str) -> Path: + return _CHAT_ATTACHMENT_META_DIR / f"{attachment_id}.json" + + +def _valid_chat_attachment_id(raw: Any) -> str: + attachment_id = str(raw or "").strip() + if not _CHAT_ATTACHMENT_ID_PATTERN.fullmatch(attachment_id): + return "" + return attachment_id + + +def _normalize_chat_media_type(raw_media_type: Any, display_name: str = "") -> str: + """将附件 ``media_type`` 规范为 MIME 类型。 + + AttachmentRegistry 的 ``media_type`` 是粗分类(``image``/``file`` 等,见 + ``registry.register_bytes``),并非 MIME;真正的 MIME 存在独立的 ``mime_type`` + 字段。这里统一规范:已是 MIME(含 ``/``)原样返回;否则按文件名扩展名推断; + 兜底 ``application/octet-stream``。 + """ + text_value = str(raw_media_type or "").strip().lower() + if "/" in text_value: + return text_value + guessed = mimetypes.guess_type(str(display_name or ""))[0] + return guessed or "application/octet-stream" + + +async def _resolve_registry_attachment(ctx: RuntimeAPIContext, uid: str) -> Any | None: + """webchat 上传存储未命中时,按 webui 作用域解析全局附件注册表。 + + 命令/AI 输出的图片注册在 ``AttachmentRegistry``(非 webchat 上传存储)。 + 固定 ``scope_key="webui"``,``resolve_async`` 内置 scope 校验,不会跨作用域 + 读到 QQ 群/私聊附件。 + """ + registry = getattr(getattr(ctx, "ai", None), "attachment_registry", None) + if registry is None: + return None + scope_key = build_attachment_scope( + user_id=_VIRTUAL_USER_ID, request_type="private", webui_session=True + ) + load = getattr(registry, "load", None) + if callable(load): + with suppress(Exception): + await load() + try: + return await registry.resolve_async(uid, scope_key) + except Exception as exc: + logger.debug( + "[RuntimeAPI] registry attachment resolve failed uid=%s err=%s", uid, exc + ) + return None + + +def _registry_attachment_metadata(record: Any) -> dict[str, Any] | None: + """将 AttachmentRegistry 记录转为 chat 附件 metadata(含本地 blob 路径)。""" + local_path = str(getattr(record, "local_path", "") or "").strip() + uid = str(getattr(record, "uid", "") or "").strip() + if not local_path or not uid: + return None + display_name = str(getattr(record, "display_name", "") or "") or "attachment" + mime_type = str(getattr(record, "mime_type", "") or "").strip().lower() + media_type = ( + mime_type + if "/" in mime_type + else _normalize_chat_media_type(getattr(record, "media_type", ""), display_name) + ) + kind = str(getattr(record, "kind", "") or "").strip() or ( + "image" if media_type.startswith("image/") else "file" + ) + metadata: dict[str, Any] = { + "id": uid, + "uid": uid, + "name": display_name, + "display_name": display_name, + "media_type": media_type, + "kind": kind, + "download_url": f"/api/v1/chat/attachments/{uid}", + "_blob_path": local_path, + } + if media_type.startswith("image/"): + metadata["preview_url"] = f"/api/v1/chat/attachments/{uid}/preview" + return metadata + + +def _chat_attachment_response_metadata(raw: dict[str, Any]) -> dict[str, Any]: + attachment_id = str(raw.get("id") or "").strip() + display_name = str(raw.get("display_name") or raw.get("name") or "attachment") + media_type = _normalize_chat_media_type(raw.get("media_type"), display_name) + kind = str(raw.get("kind") or "").strip() or ( + "image" if media_type.startswith("image/") else "file" + ) + metadata: dict[str, Any] = { + "id": attachment_id, + "uid": attachment_id, + "name": str(raw.get("name") or "attachment"), + "display_name": display_name, + "size": int(raw.get("size") or 0), + "media_type": media_type, + "kind": kind, + "sha256": str(raw.get("sha256") or ""), + "created_at": str(raw.get("created_at") or ""), + "download_url": f"/api/v1/chat/attachments/{attachment_id}", + "discarded": False, + "source_kind": "runtime_webchat_attachment", + "source_ref": f"/api/v1/chat/attachments/{attachment_id}", + } + if media_type.startswith("image/"): + metadata["preview_url"] = f"/api/v1/chat/attachments/{attachment_id}/preview" + return metadata + + +async def _load_chat_attachment_metadata( + attachment_id: str, *, ctx: RuntimeAPIContext | None = None +) -> dict[str, Any] | None: + clean_id = _valid_chat_attachment_id(attachment_id) + if not clean_id: + return None + # 1) webchat 上传存储 + raw = await async_io.read_json(_chat_attachment_meta_path(clean_id), use_lock=True) + if isinstance(raw, dict): + raw["id"] = clean_id + metadata = _chat_attachment_response_metadata(raw) + metadata["_blob_path"] = str(_chat_attachment_blob_path(clean_id)) + return metadata + # 2) fallback 到全局附件注册表(命令/AI 输出图片注册在此,非 webchat 上传存储) + if ctx is not None: + record = await _resolve_registry_attachment(ctx, clean_id) + if record is not None: + return _registry_attachment_metadata(record) + return None + + +async def _load_chat_attachment_from_request( + request: web.Request, + *, + ctx: RuntimeAPIContext | None = None, +) -> dict[str, Any] | None: + attachment_id = _valid_chat_attachment_id( + request.match_info.get("attachment_id", "") + ) + if not attachment_id: + return None + return await _load_chat_attachment_metadata(attachment_id, ctx=ctx) + + +def _content_disposition_attachment(display_name: str) -> str: + clean_name = _sanitize_chat_attachment_name(display_name) + ascii_name = "".join( + char if 32 <= ord(char) < 127 and char not in {'"', "\\", ";"} else "_" + for char in clean_name + ).strip("_") + if not ascii_name: + ascii_name = "attachment" + return f'attachment; filename="{ascii_name}"' + + +def _sanitize_chat_attachment_name(raw_name: str) -> str: + raw_text = unquote(str(raw_name or "").strip() or "attachment") + without_controls = "".join( + char for char in raw_text if ord(char) >= 32 and ord(char) != 127 + ) + name = ( + Path(without_controls.replace("\\", "/").strip() or "attachment").name + or "attachment" + ) + if len(name) <= _CHAT_ATTACHMENT_MAX_NAME_LENGTH: + return name + suffix = "".join(Path(name).suffixes[-2:]) or Path(name).suffix + suffix = suffix if len(suffix) <= 16 else "" + return f"attachment{suffix}" + + +def _normalize_sensitive_key(key: Any) -> str: + return re.sub(r"[^a-z0-9]", "", str(key or "").lower()) + + +def _is_sensitive_preview_key(key: Any) -> bool: + normalized = _normalize_sensitive_key(key) + if not normalized: + return False + if normalized in _SENSITIVE_KEY_EXACT: + return True + return any(normalized.endswith(suffix) for suffix in _SENSITIVE_KEY_SUFFIXES) + + +def _redact_secret_text(text: str) -> str: + redacted = str(text or "") + redacted = _SECRET_TEXT_PATTERNS[0].sub( + lambda match: ( + f"{match.group(1)}: {match.group(2) or ''}{_REDACTED_PREVIEW_VALUE}" + ), + redacted, + ) + redacted = _SECRET_TEXT_PATTERNS[1].sub( + lambda match: ( + f"{match.group(1)}={match.group(2)}" + f"{_REDACTED_PREVIEW_VALUE}{match.group(2) or ''}" + ), + redacted, + ) + redacted = _SECRET_TEXT_PATTERNS[2].sub( + lambda match: f"{match.group(1)} {_REDACTED_PREVIEW_VALUE}", + redacted, + ) + return redacted + + +def _redact_preview_value(value: Any) -> Any: + if isinstance(value, dict): + redacted_dict: dict[str, Any] = {} + for key, item in value.items(): + text_key = str(key) + redacted_dict[text_key] = ( + _REDACTED_PREVIEW_VALUE + if _is_sensitive_preview_key(text_key) + else _redact_preview_value(item) + ) + return redacted_dict + if isinstance(value, list): + return [_redact_preview_value(item) for item in value] + if isinstance(value, tuple): + return [_redact_preview_value(item) for item in value] + if isinstance(value, str): + return _redact_secret_text(value) + return value + + +def _redact_webchat_display_payload(payload: dict[str, Any]) -> dict[str, Any]: + result = dict(payload) + for key in ("arguments_preview", "result_preview", "current_stage_detail"): + if key in result: + result[key] = _preview_existing_text(result.get(key)) + if "detail" in result: + result["detail"] = _preview_existing_text(result.get("detail"), 160) + return result + + +def _redact_webchat_display_tree(value: Any) -> Any: + if isinstance(value, list): + return [_redact_webchat_display_tree(item) for item in value] + if not isinstance(value, dict): + return value + result = { + str(key): _redact_webchat_display_tree(item) for key, item in value.items() + } + return _redact_webchat_display_payload(result) + + +def _webchat_tool_ui_hint( + event: str, + *, + name: str, + api_name: str, + arguments: Any | None = None, + result: Any | None = None, + is_agent: bool = False, +) -> str | None: + if is_agent: + return None + tool_names = {name, api_name} + if tool_names & _WEBCHAT_SEND_MESSAGE_TOOLS: + if tool_names & {"messages.send_private_message", "send_private_message"}: + return "webchat_private_send" + if not isinstance(arguments, dict): + return None + target_type = str(arguments.get("target_type") or "").strip().lower() + if target_type in {"", "private"}: + return "webchat_private_send" + return None + if "end" in tool_names and event in {"tool_end", "agent_end"}: + result_text = str(result or "").strip() + if result_text == "对话已结束": + return "webchat_end" + return None + + +def _sanitize_webchat_event_payload( + event: str, payload: dict[str, Any] +) -> dict[str, Any]: + if event in {"tool_start", "agent_start"}: + is_agent = bool(payload.get("is_agent")) or event == "agent_start" + output_event = "agent_start" if is_agent else "tool_start" + name = str(payload.get("name") or "") + api_name = str(payload.get("api_name") or "") + arguments = payload.get("arguments") + ui_hint = _webchat_tool_ui_hint( + output_event, + name=name, + api_name=api_name, + arguments=arguments, + is_agent=is_agent, + ) + return { + "_event": output_event, + "tool_call_id": str(payload.get("tool_call_id") or ""), + "name": name, + "api_name": api_name, + "status": "running", + "arguments_preview": "" + if ui_hint == "webchat_private_send" + else _preview(arguments), + "is_agent": is_agent, + **_webchat_payload_lineage(payload), + **({"ui_hint": ui_hint} if ui_hint else {}), + } + if event in {"tool_end", "agent_end"}: + is_agent = bool(payload.get("is_agent")) or event == "agent_end" + output_event = "agent_end" if is_agent else "tool_end" + name = str(payload.get("name") or "") + api_name = str(payload.get("api_name") or "") + result = payload.get("result") + ui_hint = _webchat_tool_ui_hint( + output_event, + name=name, + api_name=api_name, + result=result, + is_agent=is_agent, + ) + return { + "_event": output_event, + "tool_call_id": str(payload.get("tool_call_id") or ""), + "name": name, + "api_name": api_name, + "ok": bool(payload.get("ok", True)), + "status": "error" if payload.get("ok") is False else "done", + "result_preview": _preview(result), + "is_agent": is_agent, + **_webchat_payload_lineage(payload), + **({"ui_hint": ui_hint} if ui_hint else {}), + } + if event == "agent_stage": + stage = str(payload.get("stage") or payload.get("key") or "").strip() + agent_name = str(payload.get("agent_name") or payload.get("name") or "") + call_id = str(payload.get("webchat_call_id") or "").strip() + parent_call_id = str(payload.get("parent_webchat_call_id") or "").strip() + if not call_id: + call_id = parent_call_id or agent_name or "agent" + payload = {**payload, "webchat_call_id": call_id} + return { + "stage": stage, + "detail": _preview(payload.get("detail"), 160), + "status": str(payload.get("status") or "running"), + "name": agent_name, + "agent_name": agent_name, + "is_agent": True, + **_webchat_payload_lineage(payload), + } + if event in _WEBCHAT_ACTION_EVENTS: + clean_payload = { + str(key): value for key, value in payload.items() if key != "arguments" + } + redacted_payload = _redact_preview_value(clean_payload) + return redacted_payload if isinstance(redacted_payload, dict) else {} + return {key: value for key, value in payload.items() if key != "arguments"} + + +def _build_webchat_history_payload(job: ChatJob) -> dict[str, Any]: + events = _finalize_webchat_history_events(job) + return { + "display_only": True, + "job_id": job.job_id, + "conversation_id": job.conversation_id, + "mode": job.mode, + "status": job.status, + "created_at": job.created_at, + "finished_at": job.finished_at, + "duration_ms": job.duration_ms, + "events": events, + "calls": _build_webchat_call_tree(events), + "timeline": _build_webchat_timeline(events), + } + + +def _finalize_webchat_history_events(job: ChatJob) -> list[dict[str, Any]]: + events = [ + { + "seq": item.seq, + "event": item.event, + "payload": dict(item.payload), + } + for item in job.webchat_events + ] + if job.status == "done": + return events + started: dict[str, dict[str, Any]] = {} + closed: set[str] = set() + for item in events: + event = str(item.get("event") or "") + if event not in _WEBCHAT_LIFECYCLE_EVENTS: + continue + call_id = _webchat_event_call_id(item) + if not call_id: + continue + payload = item.get("payload") + payload_dict = payload if isinstance(payload, dict) else {} + if event in {"tool_start", "agent_start"}: + started[call_id] = dict(payload_dict) + continue + if event in {"tool_end", "agent_end"}: + closed.add(call_id) + unfinished = [call_id for call_id in started if call_id not in closed] + if not unfinished: + return events + reason = "cancelled" if job.status == "cancelled" else "interrupted" + finished_at = job.finished_at or time.time() + max_seq = 0 + for item in events: + seq_raw = item.get("seq", 0) + if not isinstance(seq_raw, str | bytes | int | float): + continue + try: + max_seq = max(max_seq, int(seq_raw)) + except (TypeError, ValueError): + continue + next_seq = max_seq + 1 + for call_id in unfinished: + start_payload = started[call_id] + started_at = start_payload.get("started_at") + duration_ms = None + if isinstance(started_at, int | float): + duration_ms = max(0, int((finished_at - float(started_at)) * 1000)) + events.append( + { + "seq": next_seq, + "event": "agent_end" if start_payload.get("is_agent") else "tool_end", + "payload": { + "tool_call_id": str(start_payload.get("tool_call_id") or ""), + "name": str(start_payload.get("name") or ""), + "api_name": str(start_payload.get("api_name") or ""), + "ok": False, + "status": "cancelled" if reason == "cancelled" else "error", + "result_preview": reason, + "is_agent": bool(start_payload.get("is_agent")), + "webchat_call_id": call_id, + "parent_webchat_call_id": str( + start_payload.get("parent_webchat_call_id") or "" + ), + "depth": start_payload.get("depth", 0), + "agent_path": start_payload.get("agent_path") + if isinstance(start_payload.get("agent_path"), list) + else [], + "duration_ms": duration_ms, + "elapsed_ms": _job_elapsed_ms(job, finished_at), + "job_id": job.job_id, + }, + } + ) + next_seq += 1 + return events + + +def _call_preview_node(payload: dict[str, Any]) -> dict[str, Any]: + return { + "webchat_call_id": str(payload.get("webchat_call_id") or "").strip(), + "parent_webchat_call_id": str( + payload.get("parent_webchat_call_id") or "" + ).strip(), + "tool_call_id": str(payload.get("tool_call_id") or "").strip(), + "name": str(payload.get("name") or "").strip(), + "api_name": str(payload.get("api_name") or "").strip(), + "is_agent": bool(payload.get("is_agent")), + "status": str(payload.get("status") or "running"), + "ok": None, + "arguments_preview": str(payload.get("arguments_preview") or ""), + "result_preview": "", + "ui_hint": str(payload.get("ui_hint") or "").strip(), + "duration_ms": payload.get("duration_ms"), + "elapsed_ms": payload.get("elapsed_ms"), + "started_at": payload.get("started_at"), + "current_stage": str(payload.get("current_stage") or "").strip(), + "current_stage_detail": str(payload.get("current_stage_detail") or "").strip(), + "current_stage_elapsed_ms": payload.get("current_stage_elapsed_ms"), + "depth": payload.get("depth", 0), + "agent_path": payload.get("agent_path") + if isinstance(payload.get("agent_path"), list) + else [], + "children": [], + "timeline": [], + } + + +def _webchat_event_call_id(event: dict[str, Any]) -> str: + payload = event.get("payload") + if not isinstance(payload, dict): + return "" + call_id = str(payload.get("webchat_call_id") or "").strip() + return call_id or _legacy_webchat_tool_event_key(payload) + + +def _history_agent_stage_seq(event: dict[str, Any]) -> int: + if str(event.get("event") or "") != "agent_stage": + return _webchat_event_seq(event) + seq = _webchat_event_seq(event) + return max(0, seq - 1) + + +def _build_webchat_call_graph( + events: list[dict[str, Any]], +) -> tuple[dict[str, dict[str, Any]], list[str], list[dict[str, Any]]]: + nodes: dict[str, dict[str, Any]] = {} + order: list[str] = [] + for item in events: + event = str(item.get("event") or "") + if event not in _WEBCHAT_LIFECYCLE_EVENTS and event != "agent_stage": + continue + payload = item.get("payload") + if not isinstance(payload, dict): + payload = {} + call_id = _webchat_event_call_id(item) + if not call_id: + continue + node = nodes.get(call_id) + if node is None: + node = _call_preview_node({**payload, "webchat_call_id": call_id}) + nodes[call_id] = node + if event == "agent_stage": + insert_after = _history_agent_stage_seq(item) + insert_at = len(order) + for index, existing_call_id in enumerate(order): + existing = nodes.get(existing_call_id) + if existing is None: + continue + started_seq = int(existing.get("_started_seq", 0) or 0) + if started_seq > insert_after: + insert_at = index + break + order.insert(insert_at, call_id) + else: + order.append(call_id) + if event == "agent_stage": + node.update( + { + "current_stage": str(payload.get("stage") or "").strip(), + "current_stage_detail": str(payload.get("detail") or "").strip(), + "current_stage_elapsed_ms": payload.get("stage_elapsed_ms"), + "elapsed_ms": payload.get("elapsed_ms"), + "is_agent": True, + "name": str( + payload.get("agent_name") + or payload.get("name") + or node.get("name") + or "" + ).strip(), + } + ) + continue + if event in {"tool_start", "agent_start"}: + node.update(_call_preview_node({**payload, "webchat_call_id": call_id})) + node["status"] = "running" + node["_started_seq"] = _webchat_event_seq(item) + continue + if event in {"tool_end", "agent_end"}: + node.update( + { + "status": str( + payload.get("status") + or ("error" if payload.get("ok") is False else "done") + ), + "ok": bool(payload.get("ok", True)), + "result_preview": str(payload.get("result_preview") or ""), + "duration_ms": payload.get("duration_ms"), + "elapsed_ms": payload.get("elapsed_ms"), + "ui_hint": str(payload.get("ui_hint") or node.get("ui_hint") or ""), + "is_agent": bool(payload.get("is_agent") or node.get("is_agent")), + } + ) + + for call_id in order: + nodes[call_id]["children"] = [] + roots: list[dict[str, Any]] = [] + for call_id in order: + node = nodes[call_id] + parent_id = str(node.get("parent_webchat_call_id") or "").strip() + parent = nodes.get(parent_id) + if parent is not None and parent is not node: + parent.setdefault("children", []).append(node) + else: + roots.append(node) + _populate_webchat_node_timelines(nodes, events) + for node in nodes.values(): + node.pop("_started_seq", None) + return nodes, order, roots + + +def _build_webchat_call_tree(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + _nodes, _order, roots = _build_webchat_call_graph(events) + return roots + + +def _webchat_event_seq(event: dict[str, Any]) -> int: + seq_raw = event.get("seq", 0) + try: + return max(0, int(seq_raw)) + except (TypeError, ValueError): + return 0 + + +def _webchat_message_timeline_item( + *, + event: dict[str, Any], + payload: dict[str, Any], +) -> dict[str, Any] | None: + content = str(payload.get("content") or payload.get("message") or "") + if not content: + return None + return { + "type": "message", + "seq": _webchat_event_seq(event), + "content": content, + "elapsed_ms": payload.get("elapsed_ms"), + } + + +def _webchat_agent_stage_timeline_item( + *, + event: dict[str, Any], + payload: dict[str, Any], +) -> dict[str, Any] | None: + stage = str(payload.get("stage") or "").strip() + if not stage: + return None + detail = str(payload.get("detail") or "").strip() + return { + "type": "stage", + "seq": _webchat_event_seq(event), + "stage": stage, + "detail": detail, + "elapsed_ms": payload.get("elapsed_ms"), + "stage_elapsed_ms": payload.get("stage_elapsed_ms"), + } + + +def _populate_webchat_node_timelines( + nodes: dict[str, dict[str, Any]], + events: list[dict[str, Any]], +) -> None: + emitted_child_calls: set[str] = set() + for node in nodes.values(): + node["timeline"] = [] + for item in events: + event = str(item.get("event") or "") + payload = item.get("payload") + if not isinstance(payload, dict): + payload = {} + if event == "message": + parent_id = str(payload.get("parent_webchat_call_id") or "").strip() + parent = nodes.get(parent_id) + if parent is None: + continue + message_item = _webchat_message_timeline_item(event=item, payload=payload) + if message_item is not None: + parent.setdefault("timeline", []).append(message_item) + continue + if event == "agent_stage": + call_id = _webchat_event_call_id(item) + parent = nodes.get(call_id) + if parent is None: + continue + stage_item = _webchat_agent_stage_timeline_item(event=item, payload=payload) + if stage_item is not None: + parent.setdefault("timeline", []).append(stage_item) + continue + if event not in _WEBCHAT_LIFECYCLE_EVENTS: + continue + call_id = _webchat_event_call_id(item) + if not call_id or call_id in emitted_child_calls: + continue + call_node = nodes.get(call_id) + if call_node is None: + continue + parent_id = str(call_node.get("parent_webchat_call_id") or "").strip() + call_parent = nodes.get(parent_id) + if call_parent is None: + continue + emitted_child_calls.add(call_id) + call_parent.setdefault("timeline", []).append( + {"type": "call", "seq": _webchat_event_seq(item), "call": call_node} + ) + for node in nodes.values(): + timeline = node.get("timeline") + if isinstance(timeline, list): + timeline.sort(key=lambda entry: int(entry.get("seq", 0) or 0)) + + +def _build_webchat_timeline(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + nodes, _order, _roots = _build_webchat_call_graph(events) + emitted_calls: set[str] = set() + timeline: list[dict[str, Any]] = [] + for item in events: + event = str(item.get("event") or "") + payload = item.get("payload") + if not isinstance(payload, dict): + payload = {} + if event == "message": + if str(payload.get("parent_webchat_call_id") or "").strip(): + continue + message_item = _webchat_message_timeline_item(event=item, payload=payload) + if message_item is not None: + timeline.append(message_item) + continue + if event == "agent_stage": + call_id = _webchat_event_call_id(item) + if call_id and call_id in nodes: + continue + stage_item = _webchat_agent_stage_timeline_item(event=item, payload=payload) + if stage_item is not None: + timeline.append(stage_item) + continue + if event not in _WEBCHAT_LIFECYCLE_EVENTS: + continue + call_id = _webchat_event_call_id(item) + if not call_id or call_id in emitted_calls: + continue + node = nodes.get(call_id) + if node is None: + continue + parent_id = str(node.get("parent_webchat_call_id") or "").strip() + if parent_id and parent_id in nodes: + continue + emitted_calls.add(call_id) + timeline.append({"type": "call", "seq": _webchat_event_seq(item), "call": node}) + return timeline + + +def _webchat_history_events(webchat: Any) -> list[dict[str, Any]]: + if not isinstance(webchat, dict): + return [] + raw_events = webchat.get("events") + if not isinstance(raw_events, list): + return [] + events: list[dict[str, Any]] = [] + for item in raw_events: + if not isinstance(item, dict): + continue + event = str(item.get("event", "") or "").strip() + if event not in _WEBCHAT_HISTORY_EVENTS: + continue + payload = item.get("payload") + if not isinstance(payload, dict): + payload = {} + seq_raw = item.get("seq", 0) + try: + seq = max(0, int(seq_raw)) + except (TypeError, ValueError): + seq = 0 + events.append( + { + "seq": seq, + "event": event, + "payload": _redact_webchat_display_payload(payload), + } + ) + return events + + +def _webchat_history_calls(webchat: Any) -> list[dict[str, Any]]: + if not isinstance(webchat, dict): + return [] + raw_calls = webchat.get("calls") + if isinstance(raw_calls, list): + return [ + _redact_webchat_display_tree(item) + for item in raw_calls + if isinstance(item, dict) + ] + return _build_webchat_call_tree(_webchat_history_events(webchat)) + + +def _webchat_history_timeline(webchat: Any) -> list[dict[str, Any]]: + if not isinstance(webchat, dict): + return [] + raw_timeline = webchat.get("timeline") + if isinstance(raw_timeline, list): + return [ + _redact_webchat_display_tree(item) + for item in raw_timeline + if isinstance(item, dict) + ] + return _build_webchat_timeline(_webchat_history_events(webchat)) + + +def _is_webchat_display_only_record(item: dict[str, Any]) -> bool: + if str(item.get("message", "") or "").strip(): + return False + webchat = item.get("webchat") + if not isinstance(webchat, dict): + return False + return bool(webchat.get("display_only")) and bool(_webchat_history_events(webchat)) + + +def _filter_webchat_display_only_records( + records: list[dict[str, Any]], +) -> list[dict[str, Any]]: + return [item for item in records if not _is_webchat_display_only_record(item)] + + +async def _write_sse_event(response: web.StreamResponse, item: ChatJobEvent) -> None: + await response.write(_sse_event(item.event, item.payload, item.seq)) + + +def _parse_limit(request: web.Request, default: int = 50, maximum: int = 500) -> int: + limit_raw = str(request.query.get("limit", str(default)) or str(default)).strip() + try: + limit = int(limit_raw) + except ValueError: + limit = default + return max(1, min(limit, maximum)) + + +def _parse_before(request: web.Request) -> int | None: + raw = request.query.get("before") + if raw is None: + return None + text = str(raw or "").strip() + if not text: + return None + try: + return max(0, int(text)) + except ValueError: + return None + + +def _parse_after(request: web.Request) -> int: + raw = request.query.get("after") + if raw is None: + raw = request.headers.get("Last-Event-ID") + try: + return max(0, int(str(raw or "0").strip())) + except ValueError: + return 0 + + +def _query_conversation_id(request: web.Request) -> str: + return str(request.query.get("conversation_id", "") or "").strip() + + +def _body_conversation_id(body: dict[str, Any]) -> str: + return str(body.get("conversation_id", "") or "").strip() + + +def _is_structured_chat_message(raw: Any) -> bool: + return isinstance(raw, dict) + + +async def _parse_chat_job_message( + body: dict[str, Any], + *, + conversation_id: str, +) -> StructuredChatMessage: + raw_message = body.get("message") + if not isinstance(raw_message, dict): + return StructuredChatMessage( + text=str(raw_message or "").strip(), + attachments=[], + references=[], + ) + text = str(raw_message.get("text") or "").strip() + references = _normalize_chat_references(raw_message.get("references")) + attachments = await _normalize_chat_attachment_ids( + raw_message.get("attachment_ids") + ) + reference_prefix = _references_to_prompt_text(references) + parts = [part for part in [reference_prefix, text] if part] + return StructuredChatMessage( + text="\n\n".join(parts).strip(), + attachments=attachments, + references=references, + ) + + +def _normalize_chat_references(raw: Any) -> list[dict[str, Any]]: + if not isinstance(raw, list): + return [] + references: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + continue + source_message_id = str(item.get("source_message_id") or "").strip() + selected_text = str(item.get("selected_text") or "").strip() + kind = str(item.get("kind") or "message").strip() or "message" + if not source_message_id and not selected_text: + continue + references.append( + { + "kind": kind, + "source_message_id": source_message_id, + "selected_text": selected_text, + } + ) + return references + + +def _references_to_prompt_text(references: list[dict[str, Any]]) -> str: + blocks: list[str] = [] + for item in references: + source_message_id = str(item.get("source_message_id") or "").strip() + selected_text = str(item.get("selected_text") or "").strip() + header = ( + f"> 引用 message:{source_message_id}" if source_message_id else "> 引用" + ) + if selected_text: + quote_lines = "\n".join(f"> {line}" for line in selected_text.splitlines()) + blocks.append(f"{header}\n{quote_lines}") + else: + blocks.append(header) + return "\n\n".join(blocks).strip() + + +async def _normalize_chat_attachment_ids(raw: Any) -> list[dict[str, str]]: + if not isinstance(raw, list): + return [] + attachments: list[dict[str, str]] = [] + seen: set[str] = set() + for raw_id in raw: + attachment_id = _valid_chat_attachment_id(raw_id) + if not attachment_id or attachment_id in seen: + continue + metadata = await _load_chat_attachment_metadata(attachment_id) + if metadata is None: + raise ChatAttachmentNotFoundError(attachment_id) + seen.add(attachment_id) + ref: dict[str, str] = { + "uid": attachment_id, + "kind": str(metadata.get("kind") or "file"), + "media_type": str(metadata.get("media_type") or "application/octet-stream"), + "display_name": str(metadata.get("name") or "attachment"), + "source_kind": "runtime_webchat_attachment", + "source_ref": str(metadata.get("download_url") or ""), + } + preview_url = str(metadata.get("preview_url") or "").strip() + if preview_url: + ref["render_source"] = preview_url + attachments.append(ref) + return attachments + + +async def _last_visible_user_message_matches( + job_manager: ChatJobManager, + conversation_id: str, + text: str, +) -> bool: + page = await job_manager.conversation_store.get_history_page( + conversation_id, + limit=20, + before=None, + ) + expected = str(text or "").strip() + if not expected: + return False + for record in reversed(page.records): + if not isinstance(record, dict): + continue + if _is_webchat_display_only_record(record): + continue + content = str(record.get("message", "") or "").strip() + if not content: + continue + display_name = str(record.get("display_name", "") or "").strip().lower() + return display_name != "bot" and content == expected + return False + + +async def _resolve_conversation_id( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + *, + raw_conversation_id: str = "", + create_default: bool = True, +) -> str: + await job_manager.conversation_store.ensure_ready(ctx.history_manager) + conversation_id = str(raw_conversation_id or "").strip() + if conversation_id: + conversation = await job_manager.conversation_store.get_conversation( + conversation_id + ) + if conversation is None: + raise KeyError(conversation_id) + return str(conversation["id"]) + if not create_default: + return "" + conversation = await job_manager.conversation_store.get_conversation( + _DEFAULT_CONVERSATION_ID + ) + if conversation is None: + conversation = ( + await job_manager.conversation_store.ensure_default_conversation() + ) + return str(conversation["id"]) + + +async def _history_record_to_item( + item: dict[str, Any], + *, + attachment_registry: Any | None = None, + scope_key: str | None = None, +) -> dict[str, Any] | None: + content = str(item.get("message", "")).strip() + webchat = item.get("webchat") + webchat_events = _webchat_history_events(webchat) + attachments = await _history_attachments( + item.get("attachments"), + attachment_registry=attachment_registry, + scope_key=scope_key, + ) + if not content and not webchat_events and not attachments: + return None + display_name = str(item.get("display_name", "")).strip().lower() + role = "bot" if display_name == "bot" else "user" + mapped: dict[str, Any] = { + "message_id": str(item.get("message_id") or "").strip(), + "role": role, + "content": content, + "timestamp": str(item.get("timestamp", "") or "").strip(), + } + if attachments: + mapped["attachments"] = attachments + references = _history_references(item.get("references")) + if references: + mapped["references"] = references + if isinstance(webchat, dict) and webchat_events: + webchat_calls = _webchat_history_calls(webchat) + webchat_timeline = _webchat_history_timeline(webchat) + mapped["webchat"] = { + "display_only": bool(webchat.get("display_only")), + "job_id": str(webchat.get("job_id", "") or "").strip(), + "mode": str(webchat.get("mode", "") or "").strip(), + "status": str(webchat.get("status", "") or "").strip(), + "created_at": webchat.get("created_at"), + "finished_at": webchat.get("finished_at"), + "duration_ms": webchat.get("duration_ms"), + "events": webchat_events, + "calls": webchat_calls, + "timeline": webchat_timeline, + } + return mapped + -_VIRTUAL_USER_NAME = "system" -_CHAT_SSE_KEEPALIVE_SECONDS = 10.0 +def _history_references(raw: Any) -> list[dict[str, Any]]: + if not isinstance(raw, list): + return [] + references: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + continue + kind = str(item.get("kind") or "message").strip() or "message" + source_message_id = str(item.get("source_message_id") or "").strip() + selected_text = str(item.get("selected_text") or "").strip() + if not source_message_id and not selected_text: + continue + references.append( + { + "kind": kind, + "source_message_id": source_message_id, + "selected_text": selected_text, + } + ) + return references + + +async def _history_attachments( + raw: Any, + *, + attachment_registry: Any | None = None, + scope_key: str | None = None, +) -> list[dict[str, str]]: + if not isinstance(raw, list): + return [] + attachments: list[dict[str, str]] = [] + if attachment_registry is not None: + load = getattr(attachment_registry, "load", None) + if callable(load): + with suppress(Exception): + await load() + for item in raw: + if not isinstance(item, dict): + continue + uid = str(item.get("uid", "") or "").strip() + if not uid: + continue + display_name = str(item.get("display_name", "") or "") + raw_media_type = str( + item.get("media_type") or item.get("kind") or "file" + ).strip() + kind = str(item.get("kind") or raw_media_type or "file").strip() + media_type = _normalize_chat_media_type(raw_media_type, display_name) + ref: dict[str, str] = { + "uid": uid, + "kind": kind or "file", + "media_type": media_type, + "display_name": display_name, + } + for key in ("source_kind", "source_ref", "semantic_kind", "description"): + value = str(item.get(key, "") or "").strip() + if value: + ref[key] = value + resolved = await _resolve_history_attachment( + uid, + attachment_registry=attachment_registry, + scope_key=scope_key, + ) + if resolved is not None: + ref.update(await _history_attachment_render_fields(resolved)) + # 补充 preview_url / download_url,供客户端渲染附件卡片 + ref["download_url"] = f"/api/v1/chat/attachments/{uid}" + if media_type.startswith("image/"): + ref["preview_url"] = f"/api/v1/chat/attachments/{uid}/preview" + attachments.append(ref) + return attachments + + +async def _resolve_history_attachment( + uid: str, + *, + attachment_registry: Any | None, + scope_key: str | None, +) -> Any | None: + if attachment_registry is None: + return None + try: + resolve_async = getattr(attachment_registry, "resolve_async", None) + if callable(resolve_async): + return await resolve_async(uid, scope_key) + resolve = getattr(attachment_registry, "resolve", None) + if callable(resolve): + return resolve(uid, scope_key) + except Exception as exc: + logger.debug( + "[RuntimeAPI] resolve history attachment failed uid=%s err=%s", uid, exc + ) + return None + + +async def _history_attachment_render_fields(record: Any) -> dict[str, str]: + fields: dict[str, str] = {} + source_ref = str(getattr(record, "source_ref", "") or "").strip() + if source_ref: + fields["source_ref"] = source_ref + local_path = str(getattr(record, "local_path", "") or "").strip() + media_type = str(getattr(record, "media_type", "") or "").strip().lower() + if media_type == "image": + if local_path: + try: + path = Path(local_path) + if await async_io.is_file(path): + fields["render_source"] = path.resolve().as_uri() + except OSError: + pass + if "render_source" not in fields and source_ref: + fields["render_source"] = source_ref + if source_ref.isalnum(): + fields["file_id"] = source_ref + return fields async def run_webui_chat( @@ -44,11 +2368,44 @@ async def run_webui_chat( *, text: str, send_output: Callable[[int, str], Awaitable[None]], + webchat_event_callback: Callable[[str, dict[str, Any]], Awaitable[None]] + | None = None, + conversation_store: WebChatConversationStore | None = None, + conversation_id: str | None = None, + input_attachments: list[dict[str, str]] | None = None, + input_references: list[dict[str, Any]] | None = None, + record_input_history: bool = True, ) -> str: """Execute a single WebUI chat turn (command dispatch or AI ask).""" + async def emit_stage(stage: str, detail: Any | None = None) -> None: + if webchat_event_callback is None: + return + await webchat_event_callback( + "stage", + {"stage": stage, **({"detail": detail} if detail is not None else {})}, + ) + cfg = ctx.config_getter() permission_sender_id = int(cfg.superadmin_qq) + resolved_conversation_id = ( + str(conversation_id or _DEFAULT_CONVERSATION_ID).strip() + or _DEFAULT_CONVERSATION_ID + ) + store = conversation_store or WebChatConversationStore() + await store.ensure_ready(ctx.history_manager) + logger.info( + "[RuntimeAPI][WebChat] 开始处理输入: conversation_id=%s text_len=%s", + resolved_conversation_id, + len(text), + ) + if conversation_id: + existing_conversation = await store.get_conversation(resolved_conversation_id) + if existing_conversation is None: + raise KeyError(resolved_conversation_id) + elif resolved_conversation_id == _DEFAULT_CONVERSATION_ID: + await store.ensure_default_conversation() + history_adapter = store.adapter(resolved_conversation_id) webui_scope_key = build_attachment_scope( user_id=_VIRTUAL_USER_ID, request_type="private", @@ -63,16 +2420,37 @@ async def run_webui_chat( get_forward_messages=ctx.onebot.get_forward_msg, ) normalized_text = registered_input.normalized_text or text - await ctx.history_manager.add_private_message( - user_id=_VIRTUAL_USER_ID, - text_content=normalized_text, - display_name=_VIRTUAL_USER_NAME, - user_name=_VIRTUAL_USER_NAME, - attachments=registered_input.attachments, + all_input_attachments = [ + *registered_input.attachments, + *list(input_attachments or []), + ] + normalized_references = list(input_references or []) + logger.info( + "[RuntimeAPI][WebChat] 输入附件注册完成: conversation_id=%s normalized_len=%s attachments=%s", + resolved_conversation_id, + len(normalized_text), + len(all_input_attachments), ) + if record_input_history: + await emit_stage("recording_history") + await store.append_message( + resolved_conversation_id, + role="user", + text_content=normalized_text, + display_name=_VIRTUAL_USER_NAME, + user_name=_VIRTUAL_USER_NAME, + attachments=all_input_attachments, + references=normalized_references or None, + ) command = ctx.command_dispatcher.parse_command(normalized_text) if command: + logger.info( + "[RuntimeAPI][WebChat] 分发私聊命令: conversation_id=%s command=%s", + resolved_conversation_id, + getattr(command, "name", ""), + ) + await emit_stage("running_command") await ctx.command_dispatcher.dispatch_private( user_id=_VIRTUAL_USER_ID, sender_id=permission_sender_id, @@ -80,22 +2458,30 @@ async def run_webui_chat( send_private_callback=send_output, is_webui_session=True, ) + await emit_stage("command_done") + logger.info( + "[RuntimeAPI][WebChat] 私聊命令完成: conversation_id=%s", + resolved_conversation_id, + ) return "command" current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") attachment_xml = ( - f"\n{attachment_refs_to_xml(registered_input.attachments)}" - if registered_input.attachments + f"\n{attachment_refs_to_xml(all_input_attachments)}" + if all_input_attachments else "" ) - full_question = f""" - {escape_xml_text(normalized_text)}{attachment_xml} - + message_xml = format_webchat_message_xml( + normalized_text, attachment_xml, current_time + ) + full_question = f"""{message_xml} 【WebUI 会话】 这是一条来自 WebUI 控制台的会话请求。 会话身份:虚拟用户 system(42)。 权限等级:superadmin(你可按最高管理权限处理)。 +WebUI 支持完整 Markdown 渲染和简单安全 HTML。复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放进 fenced code block;完整 HTML 页面请优先使用 ```html 代码框,方便 WebUI 的运行按钮预览。 +需要输出代码时,优先在当前聊天消息中直接给出,不要为了普通代码片段调用文件生成或文件发送工具;只有用户明确要求文件交付、内容长到不适合聊天展示,或确需附件工作流时才使用文件。所有代码都必须使用 fenced code block,并始终标明语言或类型,例如 ```python、```javascript、```html、```bash、```text;不确定语言时使用 ```text。 请正常进行私聊对话;如果需要结束会话,调用 end 工具。""" virtual_sender = _WebUIVirtualSender( _VIRTUAL_USER_ID, send_output, onebot=ctx.onebot @@ -104,16 +2490,17 @@ async def run_webui_chat( async def _get_recent_cb( chat_id: str, msg_type: str, start: int, end: int ) -> list[dict[str, Any]]: - return await get_recent_messages_prefer_local( + recent_messages = await get_recent_messages_prefer_local( chat_id=chat_id, msg_type=msg_type, start=start, end=end, onebot_client=ctx.onebot, - history_manager=ctx.history_manager, + history_manager=history_adapter, bot_qq=cfg.bot_qq, attachment_registry=getattr(ctx.ai, "attachment_registry", None), ) + return _filter_webchat_display_only_records(recent_messages) async with RequestContext( request_type="private", @@ -124,7 +2511,7 @@ async def _get_recent_cb( memory_storage = ctx.ai.memory_storage # noqa: F841 runtime_config = ctx.ai.runtime_config # noqa: F841 sender = virtual_sender # noqa: F841 - history_manager = ctx.history_manager # noqa: F841 + history_manager = history_adapter # noqa: F841 onebot_client = ctx.onebot # noqa: F841 scheduler = ctx.scheduler # noqa: F841 @@ -147,6 +2534,12 @@ def send_message_callback( rctx.set_resource("webui_session", True) rctx.set_resource("webui_permission", "superadmin") + await emit_stage("asking_ai") + logger.info( + "[RuntimeAPI][WebChat] 调用 AI: conversation_id=%s prompt_len=%s", + resolved_conversation_id, + len(full_question), + ) result = await ctx.ai.ask( full_question, send_message_callback=send_message_callback, @@ -164,6 +2557,8 @@ def send_message_callback( "sender_name": _VIRTUAL_USER_NAME, "webui_session": True, "webui_permission": "superadmin", + "webchat_conversation_id": resolved_conversation_id, + "webchat_event_callback": webchat_event_callback, }, ) @@ -171,55 +2566,363 @@ def send_message_callback( if final_reply: await send_output(_VIRTUAL_USER_ID, final_reply) + logger.info( + "[RuntimeAPI][WebChat] AI 调用结束: conversation_id=%s final_reply_len=%s", + resolved_conversation_id, + len(final_reply), + ) return "chat" -async def chat_history_handler( - ctx: RuntimeAPIContext, request: web.Request +async def chat_attachment_capabilities_handler( + ctx: RuntimeAPIContext, + request: web.Request, ) -> Response: - """Return recent WebUI chat history.""" + _ = request + return web.json_response( + { + "max_upload_size_bytes": _chat_attachment_max_upload_size_bytes(ctx), + "multipart_field": _CHAT_ATTACHMENT_UPLOAD_FIELD, + } + ) - limit_raw = str(request.query.get("limit", "200") or "200").strip() + +async def chat_attachment_upload_handler( + ctx: RuntimeAPIContext, + request: web.Request, +) -> Response: + max_size = _chat_attachment_max_upload_size_bytes(ctx) try: - limit = int(limit_raw) - except ValueError: - limit = 200 - limit = max(1, min(limit, 500)) + reader = await request.multipart() + except Exception: + return _json_error("multipart request required", status=400) - getter = getattr(ctx.history_manager, "get_recent_private", None) - if not callable(getter): - return _json_error("History manager not ready", status=503) + field_any: Any | None = None + try: + while True: + field = await reader.next() + if field is None: + break + current_field: Any = field + if getattr(current_field, "name", "") == _CHAT_ATTACHMENT_UPLOAD_FIELD: + field_any = current_field + break + except Exception: + return _json_error("multipart request required", status=400) - records = getter(_VIRTUAL_USER_ID, limit) - items: list[dict[str, Any]] = [] - for item in records: - if not isinstance(item, dict): - continue - content = str(item.get("message", "")).strip() - if not content: - continue - display_name = str(item.get("display_name", "")).strip().lower() - role = "bot" if display_name == "bot" else "user" - items.append( - { - "role": role, - "content": content, - "timestamp": str(item.get("timestamp", "") or "").strip(), - } + if field_any is None: + return _json_error("file field is required", status=400) + + display_name = _sanitize_chat_attachment_name( + str(getattr(field_any, "filename", "") or "attachment") + ) + media_type = mimetypes.guess_type(display_name)[0] or "application/octet-stream" + attachment_id = uuid4().hex + ensure_dir(_CHAT_ATTACHMENT_BLOB_DIR) + ensure_dir(_CHAT_ATTACHMENT_META_DIR) + blob_path = _chat_attachment_blob_path(attachment_id) + temp_path = blob_path.with_name(f".{attachment_id}.uploading") + total_size = 0 + digest = hashlib.sha256() + try: + async with aiofiles.open(temp_path, "wb") as file_handle: + while True: + chunk = await field_any.read_chunk(size=_CHAT_ATTACHMENT_CHUNK_SIZE) + if not chunk: + break + total_size += len(chunk) + if total_size > max_size: + with suppress(OSError): + await asyncio.to_thread(temp_path.unlink) + return web.json_response( + {"error": "file too large", "max_upload_size_bytes": max_size}, + status=413, + ) + digest.update(chunk) + await file_handle.write(chunk) + await asyncio.to_thread(os.replace, temp_path, blob_path) + except Exception: + with suppress(OSError): + await asyncio.to_thread(temp_path.unlink) + return _json_error("multipart request required", status=400) + + metadata = _chat_attachment_response_metadata( + { + "id": attachment_id, + "name": display_name, + "size": total_size, + "media_type": media_type, + "kind": "image" if media_type.startswith("image/") else "file", + "sha256": digest.hexdigest(), + "created_at": datetime.now().isoformat(timespec="seconds"), + } + ) + await async_io.write_json( + _chat_attachment_meta_path(attachment_id), metadata, use_lock=True + ) + return web.json_response( + {"attachment": metadata}, + status=201, + ) + + +async def chat_attachment_download_handler( + ctx: RuntimeAPIContext, + request: web.Request, +) -> web.StreamResponse: + metadata = await _load_chat_attachment_from_request(request, ctx=ctx) + if metadata is None: + return _json_error("Attachment not found", status=404) + blob_path = Path( + str( + metadata.get("_blob_path") + or _chat_attachment_blob_path(str(metadata["id"])) + ) + ) + if not await async_io.is_file(blob_path): + return _json_error("Attachment not found", status=404) + headers = { + "Content-Disposition": _content_disposition_attachment( + str(metadata.get("name") or "attachment") + ) + } + return web.FileResponse( + path=blob_path, + headers=headers, + chunk_size=_CHAT_ATTACHMENT_CHUNK_SIZE, + ) + + +async def chat_attachment_preview_handler( + ctx: RuntimeAPIContext, + request: web.Request, +) -> web.StreamResponse: + metadata = await _load_chat_attachment_from_request(request, ctx=ctx) + if metadata is None: + return _json_error("Attachment not found", status=404) + media_type = _normalize_chat_media_type( + metadata.get("media_type"), str(metadata.get("name") or "") + ) + if not media_type.startswith("image/"): + return _json_error("Attachment preview is not available", status=415) + blob_path = Path( + str( + metadata.get("_blob_path") + or _chat_attachment_blob_path(str(metadata["id"])) + ) + ) + if not await async_io.is_file(blob_path): + return _json_error("Attachment not found", status=404) + return web.FileResponse( + path=blob_path, + headers={"Content-Type": media_type}, + chunk_size=_CHAT_ATTACHMENT_CHUNK_SIZE, + ) + + +async def chat_conversations_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + _ = request + await job_manager.conversation_store.ensure_ready(ctx.history_manager) + conversations = await job_manager.conversation_store.list_conversations() + active_jobs = await job_manager.get_active_jobs() + active_job = active_jobs[-1] if active_jobs else None + active_snapshot = ( + await job_manager.snapshot(active_job) if active_job is not None else None + ) + running_conversation_ids = {job.conversation_id for job in active_jobs} + for item in conversations: + conversation_id = str(item.get("id") or "") + if conversation_id: + await job_manager.maybe_schedule_title_generation(conversation_id) + item["is_running"] = conversation_id in running_conversation_ids + logger.info( + "[RuntimeAPI][WebChat] 查询会话列表: count=%s active_job=%s", + len(conversations), + active_job.job_id if active_job is not None else "", + ) + return web.json_response( + { + "conversations": conversations, + "active_job": active_snapshot, + "default_conversation_id": _DEFAULT_CONVERSATION_ID, + "virtual_user_id": _VIRTUAL_USER_ID, + } + ) + + +async def chat_conversation_create_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + await job_manager.conversation_store.ensure_ready(ctx.history_manager) + try: + body = await request.json() + except Exception: + body = {} + title = str(body.get("title", "") or "").strip() + conversation = await job_manager.conversation_store.create_conversation( + title=title or None, + ) + logger.info( + "[RuntimeAPI][WebChat] API 新建会话: conversation_id=%s title_len=%s", + conversation.get("id", ""), + len(str(conversation.get("title", "") or "")), + ) + return web.json_response({"conversation": conversation}, status=201) + + +async def chat_conversation_update_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + await job_manager.conversation_store.ensure_ready(ctx.history_manager) + conversation_id = str(request.match_info.get("conversation_id", "") or "").strip() + try: + body = await request.json() + except Exception: + return _json_error("Invalid JSON", status=400) + title = str(body.get("title", "") or "").strip() + try: + conversation = await job_manager.conversation_store.rename_conversation( + conversation_id, + title, + ) + except KeyError: + return _json_error("Conversation not found", status=404) + except ValueError as exc: + return _json_error(str(exc), status=400) + logger.info( + "[RuntimeAPI][WebChat] API 重命名会话: conversation_id=%s title_len=%s", + conversation_id, + len(title), + ) + return web.json_response({"conversation": conversation}) + + +async def chat_conversation_delete_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + await job_manager.conversation_store.ensure_ready(ctx.history_manager) + conversation_id = str(request.match_info.get("conversation_id", "") or "").strip() + if await job_manager.has_running_job(conversation_id): + return _json_error("Chat job is still running", status=409) + existed = await job_manager.conversation_store.delete_conversation(conversation_id) + if not existed: + return _json_error("Conversation not found", status=404) + logger.info( + "[RuntimeAPI][WebChat] API 删除会话: conversation_id=%s", + conversation_id, + ) + return web.json_response({"success": True, "conversation_id": conversation_id}) + + +async def chat_history_handler( + ctx: RuntimeAPIContext, job_manager: ChatJobManager, request: web.Request +) -> Response: + """Return recent WebUI chat history.""" + + limit = _parse_limit(request, default=50, maximum=500) + before = _parse_before(request) + try: + conversation_id = await _resolve_conversation_id( + ctx, + job_manager, + raw_conversation_id=_query_conversation_id(request), + ) + page = await job_manager.conversation_store.get_history_page( + conversation_id, + limit=limit, + before=before, ) + except KeyError: + return _json_error("Conversation not found", status=404) + items: list[dict[str, Any]] = [] + for record in page.records: + if isinstance(record, dict): + mapped = await _history_record_to_item( + record, + attachment_registry=getattr(ctx.ai, "attachment_registry", None), + scope_key=build_attachment_scope( + user_id=_VIRTUAL_USER_ID, + request_type="private", + webui_session=True, + ), + ) + if mapped is not None: + items.append(mapped) + await job_manager.maybe_schedule_title_generation(conversation_id) + logger.info( + "[RuntimeAPI][WebChat] 查询历史: conversation_id=%s returned=%s total=%s has_more=%s before=%s", + conversation_id, + len(items), + page.total, + page.has_more, + before, + ) return web.json_response( { + "conversation_id": conversation_id, "virtual_user_id": _VIRTUAL_USER_ID, "permission": "superadmin", "count": len(items), "items": items, + "limit": limit, + "before": before, + "has_more": page.has_more, + "next_before": page.next_before, + "total": page.total, + } + ) + + +async def chat_history_clear_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + """Clear WebUI virtual private chat history only.""" + + try: + conversation_id = await _resolve_conversation_id( + ctx, + job_manager, + raw_conversation_id=_query_conversation_id(request), + ) + cleared = await job_manager.clear_history_when_idle(conversation_id) + except KeyError: + return _json_error("Conversation not found", status=404) + except RuntimeError: + return _json_error("History manager not ready", status=503) + if cleared is None: + return _json_error("Chat job is still running", status=409) + logger.info( + "[RuntimeAPI][WebChat] API 清空历史: conversation_id=%s cleared=%s", + conversation_id, + cleared, + ) + return web.json_response( + { + "success": True, + "conversation_id": conversation_id, + "virtual_user_id": _VIRTUAL_USER_ID, + "cleared": cleared, } ) async def chat_handler( - ctx: RuntimeAPIContext, request: web.Request + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, ) -> web.StreamResponse: """Handle a WebUI chat request (non-streaming or SSE streaming).""" @@ -231,47 +2934,67 @@ async def chat_handler( text = str(body.get("message", "") or "").strip() if not text: return _json_error("message is required", status=400) + try: + conversation_id = await _resolve_conversation_id( + ctx, + job_manager, + raw_conversation_id=_body_conversation_id(body), + ) + except KeyError: + return _json_error("Conversation not found", status=404) stream = _to_bool(body.get("stream")) - outputs: list[str] = [] - webui_scope_key = build_attachment_scope( - user_id=_VIRTUAL_USER_ID, - request_type="private", - webui_session=True, + logger.info( + "[RuntimeAPI][WebChat] 收到聊天请求: conversation_id=%s stream=%s text_len=%s", + conversation_id, + stream, + len(text), ) - - async def _capture_private_message(user_id: int, message: str) -> None: - _ = user_id - content = str(message or "").strip() - if not content: - return - rendered = await render_message_with_pic_placeholders( - content, - registry=ctx.ai.attachment_registry, - scope_key=webui_scope_key, - strict=False, - ) - if not rendered.delivery_text.strip(): - return - outputs.append(rendered.delivery_text) - await ctx.history_manager.add_private_message( - user_id=_VIRTUAL_USER_ID, - text_content=rendered.history_text, - display_name="Bot", - user_name="Bot", - attachments=rendered.attachments, - ) - if not stream: try: - mode = await run_webui_chat( - ctx, text=text, send_output=_capture_private_message - ) - except Exception as exc: - logger.exception("[RuntimeAPI] chat failed: %s", exc) + job = await job_manager.create_job(text, conversation_id) + except KeyError: + return _json_error("Conversation not found", status=404) + except RuntimeError: + return _json_error("Chat job is still running", status=409) + try: + await job.done.wait() + except asyncio.CancelledError: + await job_manager.cancel_job(job.job_id) + raise + snapshot = await job_manager.snapshot(job) + if job.status == "cancelled": + return _json_error("Chat cancelled", status=409) + if job.status == "error": + logger.error("[RuntimeAPI] chat failed: %s", job.error) return _json_error("Chat failed", status=502) - return web.json_response(_build_chat_response_payload(mode, outputs)) + outputs = [ + str(item) for item in snapshot.get("messages", []) if str(item).strip() + ] + mode = str(snapshot.get("mode") or job.mode or "chat") + payload = _build_chat_response_payload(mode, outputs) + payload["conversation_id"] = conversation_id + payload["job_id"] = job.job_id + payload["duration_ms"] = job.duration_ms + logger.info( + "[RuntimeAPI][WebChat] 非流式聊天完成: conversation_id=%s mode=%s outputs=%s", + conversation_id, + mode, + len(outputs), + ) + return web.json_response(payload) + try: + job = await job_manager.create_job(text, conversation_id) + except KeyError: + return _json_error("Conversation not found", status=404) + except RuntimeError: + return _json_error("Chat job is still running", status=409) + logger.info( + "[RuntimeAPI][WebChat] SSE 聊天 job 已创建: job_id=%s conversation_id=%s", + job.job_id, + conversation_id, + ) response = web.StreamResponse( status=200, reason="OK", @@ -282,78 +3005,266 @@ async def _capture_private_message(user_id: int, message: str) -> None: }, ) await response.prepare(request) + after = 0 + try: + while True: + if request.transport is None or request.transport.is_closing(): + break + events = await job_manager.wait_for_events_after( + job, + after, + timeout=min(_CHAT_STAGE_REFRESH_SECONDS, _CHAT_SSE_KEEPALIVE_SECONDS), + ) + if not events: + ( + events, + _snapshot, + live_events, + ) = await job_manager.events_after_with_snapshot(job, after) + for item in events: + await _write_sse_event(response, item) + after = item.seq + for live_event in live_events: + await _write_sse_event(response, live_event) + after = max(after, live_event.seq) + if events or live_events: + if job.done.is_set(): + break + continue + await response.write(b": keep-alive\n\n") + if job.done.is_set(): + break + continue + for item in events: + await _write_sse_event(response, item) + after = item.seq + if job.done.is_set() and after >= job.next_seq - 1: + break + except asyncio.CancelledError: + raise + except (ConnectionResetError, RuntimeError): + pass + except Exception as exc: + logger.exception("[RuntimeAPI] chat stream failed: %s", exc) + with suppress(Exception): + await response.write(_sse_event("error", {"error": str(exc)})) + finally: + with suppress(Exception): + await response.write_eof() - message_queue: asyncio.Queue[str] = asyncio.Queue() + return response - async def _capture_private_message_stream(user_id: int, message: str) -> None: - output_count = len(outputs) - await _capture_private_message(user_id, message) - if len(outputs) <= output_count: - return - content = outputs[-1].strip() - if content: - await message_queue.put(content) - task = asyncio.create_task( - run_webui_chat(ctx, text=text, send_output=_capture_private_message_stream) - ) - mode = "chat" - client_disconnected = False +async def chat_job_create_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: try: - await response.write( - _sse_event( - "meta", - { - "virtual_user_id": _VIRTUAL_USER_ID, - "permission": "superadmin", - }, + body = await request.json() + except Exception: + return _json_error("Invalid JSON", status=400) + try: + conversation_id = await _resolve_conversation_id( + ctx, + job_manager, + raw_conversation_id=_body_conversation_id(body), + ) + message = await _parse_chat_job_message(body, conversation_id=conversation_id) + if not message.text and not message.attachments: + return _json_error("message is required", status=400) + reuse_previous_user_message = _to_bool(body.get("reuse_previous_user_message")) + if reuse_previous_user_message and ( + message.attachments or message.references or not message.text + ): + return _json_error( + "reuse_previous_user_message only supports text-only messages", + status=400, + ) + if reuse_previous_user_message and not await _last_visible_user_message_matches( + job_manager, + conversation_id, + message.text, + ): + return _json_error( + "reuse_previous_user_message requires a matching last user message", + status=400, ) + job = await job_manager.create_job( + message.text, + conversation_id, + user_history_attachments=message.attachments, + user_history_references=message.references, + pre_record_user_history=_is_structured_chat_message(body.get("message")) + and not reuse_previous_user_message, + reuse_previous_user_history=reuse_previous_user_message, + ) + except KeyError: + return _json_error("Conversation not found", status=404) + except ChatAttachmentNotFoundError: + return _json_error("Attachment not found", status=404) + except ValueError as exc: + return _json_error(str(exc), status=400) + except RuntimeError: + return _json_error("Chat job is still running", status=409) + logger.info( + "[RuntimeAPI][WebChat] API 创建后台 job: job_id=%s conversation_id=%s text_len=%s", + job.job_id, + conversation_id, + len(message.text), + ) + return web.json_response(await job_manager.snapshot(job), status=202) + + +async def chat_job_active_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + _ = ctx + raw_conversation_id = _query_conversation_id(request) + jobs = await job_manager.get_active_jobs(raw_conversation_id or None) + job = jobs[-1] if jobs else None + snapshot = await job_manager.snapshot(job) if job is not None else None + snapshots = [await job_manager.snapshot(item) for item in jobs] + logger.debug( + "[RuntimeAPI][WebChat] 查询 active job: conversation_id=%s job_id=%s", + raw_conversation_id, + job.job_id if job is not None else "", + ) + return web.json_response({"job": snapshot, "jobs": snapshots}) + + +async def chat_job_detail_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + _ = ctx + job_id = str(request.match_info.get("job_id", "") or "").strip() + job = await job_manager.get_job(job_id) + if job is None: + return _json_error("Job not found", status=404) + return web.json_response(await job_manager.snapshot(job)) + + +async def chat_job_cancel_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> Response: + _ = ctx, request + job_id = str(request.match_info.get("job_id", "") or "").strip() + job = await job_manager.cancel_job(job_id) + if job is None: + return _json_error("Job not found", status=404) + logger.info( + "[RuntimeAPI][WebChat] API 取消 job: job_id=%s conversation_id=%s status=%s", + job.job_id, + job.conversation_id, + job.status, + ) + return web.json_response(await job_manager.snapshot(job)) + + +async def chat_job_events_handler( + ctx: RuntimeAPIContext, + job_manager: ChatJobManager, + request: web.Request, +) -> web.StreamResponse: + _ = ctx + job_id = str(request.match_info.get("job_id", "") or "").strip() + job = await job_manager.get_job(job_id) + if job is None: + return _json_error("Job not found", status=404) + requested_conversation_id = _query_conversation_id(request) + if requested_conversation_id and requested_conversation_id != job.conversation_id: + return _json_error("Job not found", status=404) + after = _parse_after(request) + accept_header = str(request.headers.get("Accept", "") or "").strip().lower() + wants_sse = "text/event-stream" in accept_header + wants_json = not wants_sse or ( + str(request.query.get("format", "") or "").strip().lower() == "json" + or "application/json" in accept_header + ) + if wants_json: + events, snapshot, live_events = await job_manager.events_after_with_snapshot( + job, after + ) + logger.debug( + "[RuntimeAPI][WebChat] 查询 job 事件: job_id=%s conversation_id=%s after=%s events=%s live_events=%s status=%s", + job.job_id, + job.conversation_id, + after, + len(events), + len(live_events), + job.status, + ) + return web.json_response( + { + "job": snapshot, + "after": after, + "last_seq": job.next_seq - 1, + "events": [ + { + "seq": event.seq, + "event": event.event, + "payload": dict(event.payload), + } + for event in [*events, *live_events] + ], + } ) + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + await response.prepare(request) + try: while True: if request.transport is None or request.transport.is_closing(): - client_disconnected = True break - if task.done() and message_queue.empty(): - break - try: - message = await asyncio.wait_for( - message_queue.get(), - timeout=_CHAT_SSE_KEEPALIVE_SECONDS, - ) - await response.write(_sse_event("message", {"content": message})) - except asyncio.TimeoutError: + events = await job_manager.wait_for_events_after( + job, + after, + timeout=min(_CHAT_STAGE_REFRESH_SECONDS, _CHAT_SSE_KEEPALIVE_SECONDS), + ) + if not events: + ( + events, + _snapshot, + live_events, + ) = await job_manager.events_after_with_snapshot(job, after) + for item in events: + await _write_sse_event(response, item) + after = item.seq + for live_event in live_events: + await _write_sse_event(response, live_event) + after = max(after, live_event.seq) + if events or live_events: + if job.done.is_set(): + break + continue await response.write(b": keep-alive\n\n") - - if client_disconnected: - task.cancel() - with suppress(asyncio.CancelledError): - await task - return response - - mode = await task - await response.write( - _sse_event("done", _build_chat_response_payload(mode, outputs)) - ) + if job.done.is_set(): + break + continue + for item in events: + await _write_sse_event(response, item) + after = item.seq + if job.done.is_set() and after >= job.next_seq - 1: + break except asyncio.CancelledError: - task.cancel() - with suppress(asyncio.CancelledError): - await task raise except (ConnectionResetError, RuntimeError): - task.cancel() - with suppress(asyncio.CancelledError): - await task - except Exception as exc: - logger.exception("[RuntimeAPI] chat stream failed: %s", exc) - if not task.done(): - task.cancel() - with suppress(asyncio.CancelledError): - await task - with suppress(Exception): - await response.write(_sse_event("error", {"error": str(exc)})) + pass finally: with suppress(Exception): await response.write_eof() - return response diff --git a/src/Undefined/api/routes/commands.py b/src/Undefined/api/routes/commands.py new file mode 100644 index 00000000..aebb2f6e --- /dev/null +++ b/src/Undefined/api/routes/commands.py @@ -0,0 +1,393 @@ +"""Slash command metadata route handlers for the Runtime API.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, cast + +from aiohttp import web +from aiohttp.web_response import Response + +from Undefined.api._context import RuntimeAPIContext +from Undefined.api._helpers import _VIRTUAL_USER_ID, _json_error, _to_bool +from Undefined.services.commands.context import CommandContext +from Undefined.services.commands.registry import CommandMeta, SubcommandMeta + +logger = logging.getLogger(__name__) + +_DEFAULT_COMMAND_SCOPE = "webui" +_VALID_COMMAND_SCOPES = frozenset({"webui", "private", "group"}) + + +@dataclass(frozen=True) +class _CommandRequestContext: + command_context: CommandContext + api_scope: str + execution_scope: str + sender_id: int + user_id: int | None + group_id: int + + +def _coerce_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _config_is_superadmin(config: Any, sender_id: int) -> bool: + checker = getattr(config, "is_superadmin", None) + if callable(checker): + return bool(checker(sender_id)) + return sender_id == _coerce_int(getattr(config, "superadmin_qq", 0), 0) + + +def _config_is_admin(config: Any, sender_id: int) -> bool: + checker = getattr(config, "is_admin", None) + if callable(checker): + return bool(checker(sender_id)) + admin_qqs = getattr(config, "admin_qqs", []) or [] + return sender_id in {_coerce_int(item, 0) for item in admin_qqs} + + +def _check_permission(config: Any, permission: str, sender_id: int) -> bool: + normalized = str(permission or "public").strip().lower() + if normalized == "superadmin": + return _config_is_superadmin(config, sender_id) + if normalized == "admin": + return _config_is_admin(config, sender_id) or _config_is_superadmin( + config, sender_id + ) + return True + + +def _permission_label(permission: str) -> str: + normalized = str(permission or "public").strip().lower() + if normalized == "superadmin": + return "superadmin" + if normalized == "admin": + return "admin" + return "public" + + +def _availability_reason( + *, + scope: str, + allow_in_private: bool, + permission: str, + permission_allowed: bool, + policy_visible: bool, +) -> str | None: + if not policy_visible: + return "policy_hidden" + if scope == "private" and not allow_in_private: + return "private_not_allowed" + if not permission_allowed: + return f"requires_{_permission_label(permission)}" + return None + + +def _build_command_request_context( + ctx: RuntimeAPIContext, request: web.Request +) -> _CommandRequestContext: + cfg = ctx.config_getter() + raw_scope = str(request.query.get("scope", _DEFAULT_COMMAND_SCOPE) or "").lower() + api_scope = ( + raw_scope if raw_scope in _VALID_COMMAND_SCOPES else _DEFAULT_COMMAND_SCOPE + ) + + if api_scope == "webui": + execution_scope = "private" + sender_id = _coerce_int(getattr(cfg, "superadmin_qq", 0), 0) + user_id: int | None = _VIRTUAL_USER_ID + group_id = 0 + is_webui_session = True + elif api_scope == "private": + execution_scope = "private" + sender_id = _coerce_int( + request.query.get("sender_id"), + _coerce_int(getattr(cfg, "superadmin_qq", 0), 0), + ) + user_id = _coerce_int(request.query.get("user_id"), sender_id) + group_id = 0 + is_webui_session = False + else: + execution_scope = "group" + sender_id = _coerce_int( + request.query.get("sender_id"), + _coerce_int(getattr(cfg, "superadmin_qq", 0), 0), + ) + user_id = None + group_id = _coerce_int(request.query.get("group_id"), 0) + is_webui_session = False + + dispatcher = ctx.command_dispatcher + command_registry = getattr(dispatcher, "command_registry", None) + if command_registry is None: + raise RuntimeError("command registry is unavailable") + command_context = CommandContext( + group_id=group_id, + sender_id=sender_id, + config=cfg, + sender=cast(Any, getattr(dispatcher, "sender", ctx.sender)), + ai=getattr(dispatcher, "ai", ctx.ai), + faq_storage=cast(Any, getattr(dispatcher, "faq_storage", None)), + onebot=cast(Any, getattr(dispatcher, "onebot", ctx.onebot)), + security=cast(Any, getattr(dispatcher, "security", None)), + queue_manager=getattr(dispatcher, "queue_manager", ctx.queue_manager), + rate_limiter=getattr(dispatcher, "rate_limiter", None), + dispatcher=dispatcher, + registry=command_registry, + scope=execution_scope, + user_id=user_id, + is_webui_session=is_webui_session, + cognitive_service=getattr(ctx, "cognitive_service", None), + history_manager=ctx.history_manager, + ) + return _CommandRequestContext( + command_context=command_context, + api_scope=api_scope, + execution_scope=execution_scope, + sender_id=sender_id, + user_id=user_id, + group_id=group_id, + ) + + +def _subcommand_usage(command: CommandMeta, subcommand: SubcommandMeta) -> str: + args = str(subcommand.args or "").strip() + return f"/{command.name} {subcommand.name}{f' {args}' if args else ''}" + + +def _serialize_subcommand( + command: CommandMeta, + subcommand: SubcommandMeta, + *, + request_context: _CommandRequestContext, + policy_visible: bool, +) -> dict[str, Any]: + scope = request_context.execution_scope + permission_allowed = _check_permission( + request_context.command_context.config, + subcommand.permission, + request_context.sender_id, + ) + unavailable_reason = _availability_reason( + scope=scope, + allow_in_private=subcommand.allow_in_private, + permission=subcommand.permission, + permission_allowed=permission_allowed, + policy_visible=policy_visible, + ) + return { + "name": subcommand.name, + "trigger": f"/{command.name} {subcommand.name}", + "description": subcommand.description, + "args": subcommand.args, + "usage": _subcommand_usage(command, subcommand), + "permission": subcommand.permission, + "allow_in_private": subcommand.allow_in_private, + "available": unavailable_reason is None, + "unavailable_reason": unavailable_reason, + } + + +def _serialize_inference(command: CommandMeta) -> dict[str, Any] | None: + inference = command.inference + if inference is None: + return None + return { + "default": inference.default, + "fallback": inference.fallback, + "rules": [ + {"pattern": rule.pattern.pattern, "subcommand": rule.subcommand} + for rule in inference.rules + ], + } + + +def _serialize_command( + command: CommandMeta, + *, + request_context: _CommandRequestContext, + include_unavailable: bool, +) -> dict[str, Any]: + registry = request_context.command_context.registry + policy_visible = True + if registry is not None: + policy_visible = bool( + registry.is_visible(command, request_context.command_context) + ) + + permission_allowed = _check_permission( + request_context.command_context.config, + command.permission, + request_context.sender_id, + ) + unavailable_reason = _availability_reason( + scope=request_context.execution_scope, + allow_in_private=command.allow_in_private, + permission=command.permission, + permission_allowed=permission_allowed, + policy_visible=policy_visible, + ) + subcommands = [ + _serialize_subcommand( + command, + subcommand, + request_context=request_context, + policy_visible=policy_visible, + ) + for subcommand in sorted( + command.subcommands.values(), key=lambda item: item.name + ) + ] + if not include_unavailable: + subcommands = [item for item in subcommands if bool(item.get("available"))] + return { + "name": command.name, + "trigger": f"/{command.name}", + "description": command.description, + "usage": command.usage, + "example": command.example, + "permission": command.permission, + "allow_in_private": command.allow_in_private, + "show_in_help": command.show_in_help, + "order": command.order, + "aliases": list(command.aliases), + "alias_triggers": [f"/{alias}" for alias in command.aliases], + "subcommands": subcommands, + "inference": _serialize_inference(command), + "available": unavailable_reason is None, + "unavailable_reason": unavailable_reason, + } + + +def _matches_query(command: dict[str, Any], query: str) -> bool: + if not query: + return True + haystacks = [ + command.get("name"), + command.get("description"), + command.get("usage"), + *(command.get("aliases") or []), + ] + for subcommand in command.get("subcommands") or []: + haystacks.extend( + [ + subcommand.get("name"), + subcommand.get("description"), + subcommand.get("args"), + subcommand.get("usage"), + ] + ) + return any(query in str(item or "").lower() for item in haystacks) + + +def _build_commands_payload( + ctx: RuntimeAPIContext, + request: web.Request, + *, + command_name: str | None = None, +) -> dict[str, Any] | None: + dispatcher = ctx.command_dispatcher + registry = getattr(dispatcher, "command_registry", None) + if registry is None: + return { + "scope": _DEFAULT_COMMAND_SCOPE, + "commands": [], + "count": 0, + "total": 0, + } + + include_hidden = _to_bool(request.query.get("include_hidden")) + include_unavailable = _to_bool(request.query.get("include_unavailable")) + request_context = _build_command_request_context(ctx, request) + + if command_name is not None: + command = registry.resolve(command_name) + if command is None: + return None + commands = [command] + else: + commands = registry.list_commands(include_hidden=include_hidden) + + serialized: list[dict[str, Any]] = [] + for command in commands: + if not include_hidden and not command.show_in_help: + continue + item = _serialize_command( + command, + request_context=request_context, + include_unavailable=include_unavailable, + ) + has_available_subcommand = any( + bool(subcommand.get("available")) + for subcommand in item.get("subcommands") or [] + ) + if ( + not include_unavailable + and not item["available"] + and not has_available_subcommand + ): + continue + serialized.append(item) + + query = str(request.query.get("q", "") or "").strip().lower() + if query: + serialized = [item for item in serialized if _matches_query(item, query)] + + total_aliases = sum(len(item.get("aliases") or []) for item in serialized) + total_subcommands = sum(len(item.get("subcommands") or []) for item in serialized) + payload = { + "scope": request_context.api_scope, + "execution_scope": request_context.execution_scope, + "sender_id": request_context.sender_id, + "user_id": request_context.user_id, + "group_id": request_context.group_id, + "commands": serialized, + "count": len(serialized), + "total": len(serialized), + "aliases": total_aliases, + "subcommands": total_subcommands, + } + if command_name is not None: + if not serialized: + return None + payload["command"] = serialized[0] + payload["requested_name"] = command_name + return payload + + +async def commands_list_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + payload = _build_commands_payload(ctx, request) + if payload is None: + return _json_error("Missing or invalid commands payload", status=400) + logger.info( + "[RuntimeAPI][Commands] 列出命令: scope=%s count=%s", + payload.get("scope"), + payload.get("count"), + ) + return web.json_response(payload) + + +async def command_detail_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + command_name = str(request.match_info.get("command_name", "") or "").strip().lower() + if not command_name: + return _json_error("command_name is required", status=400) + payload = _build_commands_payload(ctx, request, command_name=command_name) + if payload is None: + return _json_error("Command not found", status=404) + logger.info( + "[RuntimeAPI][Commands] 命令详情: requested=%s canonical=%s scope=%s", + command_name, + payload["command"].get("name"), + payload.get("scope"), + ) + return web.json_response(payload) diff --git a/src/Undefined/api/routes/schedules.py b/src/Undefined/api/routes/schedules.py new file mode 100644 index 00000000..e4cd4a1e --- /dev/null +++ b/src/Undefined/api/routes/schedules.py @@ -0,0 +1,530 @@ +"""Scheduled task route handlers for the Runtime API.""" + +from __future__ import annotations + +import re +import uuid +from copy import deepcopy +from typing import Any + +from aiohttp import web +from aiohttp.web_response import Response +from apscheduler.triggers.cron import CronTrigger + +from Undefined.api._context import RuntimeAPIContext +from Undefined.api._helpers import _json_error +from Undefined.utils.scheduler import SELF_CALL_TOOL_NAME + +_TASK_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$") +_LEGACY_TASK_ID_MAX_LENGTH = 256 +_MAX_TEXT_LENGTH = 16_000 +_MAX_TOOLS = 20 +_TARGET_TYPES = frozenset({"group", "private"}) +_EXECUTION_MODES = frozenset({"serial", "parallel"}) +_TASK_MODES = frozenset({"single", "multi", "self_instruction"}) + + +class SchedulePayloadError(ValueError): + """Raised when a schedule API payload is invalid.""" + + +def _scheduler_unavailable() -> Response: + return _json_error("Scheduler unavailable", status=503) + + +def _clean_text(value: Any, *, field: str, max_length: int = _MAX_TEXT_LENGTH) -> str: + text = str(value or "").strip() + if len(text) > max_length: + raise SchedulePayloadError(f"{field} is too long") + return text + + +def _optional_text( + body: dict[str, Any], + field: str, + *, + max_length: int = _MAX_TEXT_LENGTH, +) -> str | None: + if field not in body: + return None + return _clean_text(body.get(field), field=field, max_length=max_length) + + +def _parse_task_id(value: Any) -> str: + task_id = _clean_text(value, field="task_id", max_length=96) + if not task_id or _TASK_ID_RE.fullmatch(task_id) is None: + raise SchedulePayloadError("task_id contains unsupported characters") + return task_id + + +def _parse_existing_task_id(value: Any) -> str: + task_id = _clean_text( + value, + field="task_id", + max_length=_LEGACY_TASK_ID_MAX_LENGTH, + ) + if not task_id: + raise SchedulePayloadError("task_id is required") + return task_id + + +def _parse_json_object( + value: Any, *, field: str, default: dict[str, Any] +) -> dict[str, Any]: + if value is None: + return dict(default) + if not isinstance(value, dict): + raise SchedulePayloadError(f"{field} must be a JSON object") + return deepcopy(value) + + +def _parse_optional_positive_int( + value: Any, + *, + field: str, + allow_null: bool = True, +) -> int | None: + if value is None or value == "": + if allow_null: + return None + raise SchedulePayloadError(f"{field} is required") + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise SchedulePayloadError(f"{field} must be a positive integer") from exc + if parsed < 1: + raise SchedulePayloadError(f"{field} must be a positive integer") + return parsed + + +def _parse_target_type(value: Any) -> str: + target_type = _clean_text(value or "group", field="target_type", max_length=16) + if target_type not in _TARGET_TYPES: + raise SchedulePayloadError("target_type must be 'group' or 'private'") + return target_type + + +def _parse_execution_mode(value: Any) -> str: + execution_mode = _clean_text( + value or "serial", field="execution_mode", max_length=16 + ) + if execution_mode not in _EXECUTION_MODES: + raise SchedulePayloadError("execution_mode must be 'serial' or 'parallel'") + return execution_mode + + +def _parse_cron_expression(body: dict[str, Any], *, required: bool) -> str | None: + raw = body.get("cron_expression", body.get("cron")) + cron_expression = _clean_text(raw, field="cron_expression", max_length=128) + if not cron_expression: + if required: + raise SchedulePayloadError("cron_expression is required") + return None + try: + CronTrigger.from_crontab(cron_expression) + except Exception as exc: + raise SchedulePayloadError("cron_expression is invalid") from exc + return cron_expression + + +def _parse_tools(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list) or not value: + raise SchedulePayloadError("tools must be a non-empty array") + if len(value) > _MAX_TOOLS: + raise SchedulePayloadError(f"tools can contain at most {_MAX_TOOLS} items") + + tools: list[dict[str, Any]] = [] + for index, item in enumerate(value): + if not isinstance(item, dict): + raise SchedulePayloadError(f"tools[{index}] must be a JSON object") + tool_name = _clean_text( + item.get("tool_name"), + field=f"tools[{index}].tool_name", + max_length=160, + ) + if not tool_name: + raise SchedulePayloadError(f"tools[{index}].tool_name is required") + tool_args = _parse_json_object( + item.get("tool_args", {}), + field=f"tools[{index}].tool_args", + default={}, + ) + tools.append({"tool_name": tool_name, "tool_args": tool_args}) + return tools + + +def _resolve_mode(body: dict[str, Any], *, required: bool) -> str | None: + raw_mode = body.get("mode") + mode = _clean_text(raw_mode, field="mode", max_length=32) + aliases = { + "self": "self_instruction", + "self_instruction": "self_instruction", + "single": "single", + "tool": "single", + "multi": "multi", + "tools": "multi", + } + if mode: + resolved = aliases.get(mode) + if resolved is None or resolved not in _TASK_MODES: + raise SchedulePayloadError( + "mode must be 'single', 'multi', or 'self_instruction'" + ) + return resolved + + flags = [ + ("single", body.get("tool_name") is not None), + ("multi", body.get("tools") is not None), + ("self_instruction", body.get("self_instruction") is not None), + ] + present = [name for name, enabled in flags if enabled] + if len(present) > 1: + raise SchedulePayloadError( + "tool_name, tools, and self_instruction are mutually exclusive" + ) + if present: + return present[0] + if required: + raise SchedulePayloadError("mode or task content is required") + return None + + +def _normalize_schedule_payload( + body: dict[str, Any], + *, + partial: bool, +) -> tuple[dict[str, Any], set[str]]: + if not isinstance(body, dict): + raise SchedulePayloadError("Request body must be a JSON object") + + normalized: dict[str, Any] = {} + provided: set[str] = set() + + cron_expression = _parse_cron_expression(body, required=not partial) + if cron_expression is not None: + normalized["cron_expression"] = cron_expression + provided.add("cron_expression") + + task_name = _optional_text(body, "task_name", max_length=128) + if task_name is not None: + normalized["task_name"] = task_name + provided.add("task_name") + + if "target_type" in body: + normalized["target_type"] = _parse_target_type(body.get("target_type")) + provided.add("target_type") + elif not partial: + normalized["target_type"] = "group" + provided.add("target_type") + + if "target_id" in body: + normalized["target_id"] = _parse_optional_positive_int( + body.get("target_id"), + field="target_id", + ) + provided.add("target_id") + + if "max_executions" in body: + normalized["max_executions"] = _parse_optional_positive_int( + body.get("max_executions"), + field="max_executions", + ) + provided.add("max_executions") + + mode = _resolve_mode(body, required=not partial) + if mode is not None: + mode_fields = { + "tool_name": "single", + "tools": "multi", + "self_instruction": "self_instruction", + } + conflicts = [ + field + for field, field_mode in mode_fields.items() + if field in body and field_mode != mode + ] + if conflicts: + raise SchedulePayloadError( + "mode conflicts with fields: " + ", ".join(sorted(conflicts)) + ) + normalized["mode"] = mode + provided.add("mode") + if mode == "self_instruction": + instruction = _clean_text( + body.get("self_instruction"), field="self_instruction" + ) + if not instruction: + raise SchedulePayloadError("self_instruction is required") + normalized["tool_name"] = SELF_CALL_TOOL_NAME + normalized["tool_args"] = {"prompt": instruction} + normalized["self_instruction"] = instruction + normalized["tools"] = None + normalized["execution_mode"] = "serial" + elif mode == "single": + tool_name = _clean_text( + body.get("tool_name"), field="tool_name", max_length=160 + ) + if not tool_name: + raise SchedulePayloadError("tool_name is required") + normalized["tool_name"] = tool_name + normalized["tool_args"] = _parse_json_object( + body.get("tool_args", {}), + field="tool_args", + default={}, + ) + normalized["tools"] = None + if "execution_mode" in body: + normalized["execution_mode"] = _parse_execution_mode( + body.get("execution_mode") + ) + provided.add("execution_mode") + else: + tools = _parse_tools(body.get("tools")) + normalized["tools"] = tools + normalized["tool_name"] = tools[0]["tool_name"] + normalized["tool_args"] = tools[0]["tool_args"] + normalized["execution_mode"] = _parse_execution_mode( + body.get("execution_mode") + ) + normalized["self_instruction"] = None + elif "execution_mode" in body: + normalized["execution_mode"] = _parse_execution_mode(body.get("execution_mode")) + provided.add("execution_mode") + + if mode is None and "tool_args" in body: + normalized["tool_args"] = _parse_json_object( + body.get("tool_args", {}), + field="tool_args", + default={}, + ) + provided.add("tool_args") + + return normalized, provided + + +def _next_run_time_iso(ctx: RuntimeAPIContext, task_id: str) -> str | None: + scheduler = ctx.scheduler + apscheduler = getattr(scheduler, "scheduler", None) + get_job = getattr(apscheduler, "get_job", None) + if not callable(get_job): + return None + job = get_job(task_id) + next_run_time = getattr(job, "next_run_time", None) if job is not None else None + if next_run_time is None: + return None + return str(next_run_time.isoformat()) + + +def _schedule_task_mode(task: dict[str, Any]) -> str: + tools = task.get("tools") + if isinstance(tools, list) and tools: + if len(tools) == 1 and tools[0].get("tool_name") == SELF_CALL_TOOL_NAME: + return "self_instruction" + return "multi" + if task.get("self_instruction") or task.get("tool_name") == SELF_CALL_TOOL_NAME: + return "self_instruction" + return "single" + + +def serialize_schedule_task( + ctx: RuntimeAPIContext, + task_id: str, + task_info: dict[str, Any], +) -> dict[str, Any]: + task = deepcopy(task_info) + task.setdefault("task_id", task_id) + task["mode"] = _schedule_task_mode(task) + task["next_run_time"] = _next_run_time_iso(ctx, task_id) + tool_args = task.get("tool_args") + tools = task.get("tools") + if ( + not task.get("self_instruction") + and isinstance(tools, list) + and len(tools) == 1 + and isinstance(tools[0], dict) + and tools[0].get("tool_name") == SELF_CALL_TOOL_NAME + and isinstance(tools[0].get("tool_args"), dict) + ): + prompt = str(tools[0]["tool_args"].get("prompt", "")).strip() + if prompt: + task["self_instruction"] = prompt + if ( + task.get("tool_name") == SELF_CALL_TOOL_NAME + and not task.get("self_instruction") + and isinstance(tool_args, dict) + ): + prompt = str(tool_args.get("prompt", "")).strip() + if prompt: + task["self_instruction"] = prompt + return task + + +def build_schedules_summary(ctx: RuntimeAPIContext) -> dict[str, Any]: + scheduler = ctx.scheduler + if scheduler is None: + return {"available": False, "count": 0, "running": False} + list_tasks = getattr(scheduler, "list_tasks", None) + if not callable(list_tasks): + return {"available": False, "count": 0, "running": False} + tasks = list_tasks() + return { + "available": True, + "count": len(tasks), + "running": bool( + getattr(getattr(scheduler, "scheduler", None), "running", False) + ), + } + + +async def schedules_list_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + _ = request + scheduler = ctx.scheduler + if scheduler is None: + return _scheduler_unavailable() + tasks = scheduler.list_tasks() + items = [ + serialize_schedule_task(ctx, task_id, task_info) + for task_id, task_info in sorted(tasks.items()) + if isinstance(task_info, dict) + ] + return web.json_response({"count": len(items), "items": items}) + + +async def schedule_detail_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + scheduler = ctx.scheduler + if scheduler is None: + return _scheduler_unavailable() + try: + task_id = _parse_existing_task_id(request.match_info.get("task_id", "")) + except SchedulePayloadError as exc: + return _json_error(str(exc), status=400) + task_info = scheduler.list_tasks().get(task_id) + if not isinstance(task_info, dict): + return _json_error("Schedule task not found", status=404) + return web.json_response({"task": serialize_schedule_task(ctx, task_id, task_info)}) + + +async def schedules_create_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + scheduler = ctx.scheduler + if scheduler is None: + return _scheduler_unavailable() + try: + body = await request.json() + normalized, _provided = _normalize_schedule_payload(body, partial=False) + raw_task_id = body.get("task_id") if isinstance(body, dict) else None + if ( + isinstance(body, dict) + and "task_id" in body + and not _clean_text( + raw_task_id, + field="task_id", + max_length=96, + ) + ): + raise SchedulePayloadError("task_id is required") + task_id = ( + _parse_task_id(raw_task_id) + if raw_task_id + else f"task_{uuid.uuid4().hex[:12]}" + ) + except SchedulePayloadError as exc: + return _json_error(str(exc), status=400) + except Exception: + return _json_error("Invalid JSON", status=400) + + if task_id in scheduler.list_tasks(): + return _json_error("Schedule task already exists", status=409) + + success = await scheduler.add_task( + task_id=task_id, + tool_name=str(normalized["tool_name"]), + tool_args=normalized["tool_args"], + cron_expression=str(normalized["cron_expression"]), + target_id=normalized.get("target_id"), + target_type=str(normalized.get("target_type") or "group"), + task_name=normalized.get("task_name"), + max_executions=normalized.get("max_executions"), + tools=normalized.get("tools"), + execution_mode=str(normalized.get("execution_mode") or "serial"), + self_instruction=normalized.get("self_instruction"), + ) + if not success: + return _json_error("Failed to create schedule task", status=400) + task_info = scheduler.list_tasks().get(task_id, {}) + return web.json_response( + {"ok": True, "task": serialize_schedule_task(ctx, task_id, task_info)}, + status=201, + ) + + +async def schedule_update_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + scheduler = ctx.scheduler + if scheduler is None: + return _scheduler_unavailable() + try: + task_id = _parse_existing_task_id(request.match_info.get("task_id", "")) + body = await request.json() + normalized, provided = _normalize_schedule_payload(body, partial=True) + except SchedulePayloadError as exc: + return _json_error(str(exc), status=400) + except Exception: + return _json_error("Invalid JSON", status=400) + + if task_id not in scheduler.list_tasks(): + return _json_error("Schedule task not found", status=404) + + if not normalized and not provided: + return _json_error("No schedule fields provided", status=400) + + kwargs: dict[str, Any] = { + "task_id": task_id, + "cron_expression": normalized.get("cron_expression"), + "tool_name": normalized.get("tool_name"), + "tool_args": normalized.get("tool_args"), + "task_name": normalized.get("task_name") if "task_name" in provided else None, + "tools": normalized.get("tools") if "mode" in provided else None, + "execution_mode": normalized.get("execution_mode"), + "self_instruction": normalized.get("self_instruction"), + } + if "target_id" in provided: + kwargs["target_id"] = normalized.get("target_id") + kwargs["target_id_provided"] = True + if "target_type" in provided: + kwargs["target_type"] = normalized.get("target_type") + if "max_executions" in provided: + kwargs["max_executions"] = normalized.get("max_executions") + kwargs["max_executions_provided"] = True + + success = await scheduler.update_task(**kwargs) + if not success: + return _json_error("Failed to update schedule task", status=400) + task_info = scheduler.list_tasks().get(task_id, {}) + return web.json_response( + {"ok": True, "task": serialize_schedule_task(ctx, task_id, task_info)} + ) + + +async def schedule_delete_handler( + ctx: RuntimeAPIContext, request: web.Request +) -> Response: + scheduler = ctx.scheduler + if scheduler is None: + return _scheduler_unavailable() + try: + task_id = _parse_existing_task_id(request.match_info.get("task_id", "")) + except SchedulePayloadError as exc: + return _json_error(str(exc), status=400) + if task_id not in scheduler.list_tasks(): + return _json_error("Schedule task not found", status=404) + success = await scheduler.remove_task(task_id) + if not success: + return _json_error("Failed to delete schedule task", status=400) + return web.json_response({"ok": True, "task_id": task_id}) diff --git a/src/Undefined/api/routes/system.py b/src/Undefined/api/routes/system.py index 5dd0b879..250f64ee 100644 --- a/src/Undefined/api/routes/system.py +++ b/src/Undefined/api/routes/system.py @@ -29,6 +29,7 @@ _probe_ws_endpoint, _skipped_probe, ) +from Undefined.api.routes.schedules import build_schedules_summary logger = logging.getLogger(__name__) @@ -265,6 +266,7 @@ async def internal_probe_handler( "enabled": bool(ctx.cognitive_service and ctx.cognitive_service.enabled), "queue": cognitive_queue_snapshot, }, + "scheduler": build_schedules_summary(ctx), "api": { "enabled": bool(cfg.api.enabled), "host": cfg.api.host, diff --git a/src/Undefined/api/webchat_store.py b/src/Undefined/api/webchat_store.py new file mode 100644 index 00000000..366313ca --- /dev/null +++ b/src/Undefined/api/webchat_store.py @@ -0,0 +1,842 @@ +"""Persistent WebChat conversation storage.""" + +from __future__ import annotations + +import asyncio +import copy +import hashlib +import inspect +import logging +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, TypeVar +from uuid import uuid4 + +from Undefined.utils import io +from Undefined.utils.paths import ( + HISTORY_DIR, + WEBCHAT_CONVERSATIONS_DIR, + WEBCHAT_MIGRATION_MARKER_FILE, + ensure_dir, +) +from Undefined.utils.xml import escape_xml_attr, escape_xml_text + +logger = logging.getLogger(__name__) + +WEBCHAT_VIRTUAL_USER_ID: int = 42 +WEBCHAT_VIRTUAL_USER_NAME: str = "system" +DEFAULT_WEBCHAT_CONVERSATION_ID: str = "legacy-system-42" +_DEFAULT_TITLE: str = "新对话" +_TEMP_TITLE_CHARS: int = 18 +_MIGRATION_VERSION: int = 1 +_TITLE_STATUS_GENERATED: str = "generated" +_TITLE_STATUS_MANUAL: str = "manual" +_TITLE_STATUS_PENDING: str = "pending" +_TITLE_STATUS_TEMPORARY: str = "temporary" +_TITLE_STATUS_FAILED: str = "failed" +_JsonT = TypeVar("_JsonT") + + +@dataclass(frozen=True) +class WebChatHistoryPage: + records: list[dict[str, Any]] + has_more: bool + next_before: int | None + total: int + + +class WebChatHistoryAdapter: + """Expose one WebChat conversation through MessageHistoryManager-like APIs.""" + + def __init__(self, store: WebChatConversationStore, conversation_id: str) -> None: + self._store = store + self._conversation_id = conversation_id + + def get_recent( + self, + chat_id: str, + msg_type: str, + start: int, + end: int, + ) -> list[dict[str, Any]]: + if msg_type != "private" or str(chat_id) != str(WEBCHAT_VIRTUAL_USER_ID): + return [] + return self._store.get_recent_sync(self._conversation_id, start, end) + + async def add_private_message( + self, + user_id: int, + text_content: str, + display_name: str = "", + user_name: str = "", + message_id: int | None = None, + attachments: list[dict[str, str]] | None = None, + webchat: dict[str, Any] | None = None, + ) -> None: + _ = message_id + if int(user_id) != WEBCHAT_VIRTUAL_USER_ID: + return + role = "bot" if str(display_name or "").strip().lower() == "bot" else "user" + await self._store.append_message( + self._conversation_id, + role=role, + text_content=text_content, + display_name=display_name or user_name or str(user_id), + user_name=user_name or display_name or str(user_id), + attachments=attachments, + webchat=webchat, + ) + + async def flush_pending_saves(self) -> None: + return None + + +class WebChatConversationStore: + """Store WebChat conversations as one JSON file per conversation.""" + + def __init__(self) -> None: + self._global_lock = asyncio.Lock() + self._migration_lock = asyncio.Lock() + self._locks: dict[str, asyncio.Lock] = {} + self._cache: dict[str, dict[str, Any]] = {} + self._loaded = False + self._title_tasks: dict[str, asyncio.Task[None]] = {} + ensure_dir(WEBCHAT_CONVERSATIONS_DIR) + + def adapter(self, conversation_id: str) -> WebChatHistoryAdapter: + return WebChatHistoryAdapter(self, conversation_id) + + async def stop(self) -> None: + tasks = [task for task in self._title_tasks.values() if not task.done()] + self._title_tasks.clear() + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + async def ensure_ready(self, legacy_history_manager: Any | None = None) -> None: + async with self._global_lock: + if not self._loaded: + await self._load_conversations_locked() + self._loaded = True + await self._migrate_legacy_once(legacy_history_manager) + + async def ensure_default_conversation(self) -> dict[str, Any]: + existing = await self.get_conversation(DEFAULT_WEBCHAT_CONVERSATION_ID) + if existing is not None: + return existing + return await self.create_conversation( + conversation_id=DEFAULT_WEBCHAT_CONVERSATION_ID, + title=_DEFAULT_TITLE, + title_source="system", + ) + + async def list_conversations(self) -> list[dict[str, Any]]: + await self.ensure_ready() + async with self._global_lock: + items = [self._conversation_summary(conv) for conv in self._cache.values()] + return sorted( + items, + key=lambda item: str( + item.get("updated_at") or item.get("created_at") or "" + ), + reverse=True, + ) + + async def get_conversation(self, conversation_id: str) -> dict[str, Any] | None: + await self._ensure_loaded_only() + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._cache.get(conv_id) + return _copy_json(conv) if conv is not None else None + + async def create_conversation( + self, + *, + conversation_id: str | None = None, + title: str | None = None, + title_source: str = "temporary", + ) -> dict[str, Any]: + await self._ensure_loaded_only() + conv_id = _normalize_conversation_id(conversation_id or uuid4().hex) + now = _now_iso() + conv: dict[str, Any] = { + "id": conv_id, + "title": _sanitize_title(title or _DEFAULT_TITLE) or _DEFAULT_TITLE, + "title_source": str(title_source or "temporary"), + "title_status": _TITLE_STATUS_TEMPORARY, + "created_at": now, + "updated_at": now, + "virtual_user_id": WEBCHAT_VIRTUAL_USER_ID, + "virtual_user_name": WEBCHAT_VIRTUAL_USER_NAME, + "messages": [], + } + async with self._get_lock(conv_id): + existing = self._cache.get(conv_id) + if existing is not None: + return _copy_json(existing) + self._cache[conv_id] = conv + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 创建会话: conversation_id=%s title_source=%s", + conv_id, + conv["title_source"], + ) + return _copy_json(conv) + + async def rename_conversation( + self, conversation_id: str, title: str + ) -> dict[str, Any]: + conv_id = _normalize_conversation_id(conversation_id) + clean_title = _sanitize_title(title) + if not clean_title: + raise ValueError("title is required") + async with self._get_lock(conv_id): + conv = self._require_conversation_locked(conv_id) + conv["title"] = clean_title + conv["title_source"] = "manual" + conv["title_status"] = _TITLE_STATUS_MANUAL + conv["updated_at"] = _now_iso() + conv["title_updated_at"] = conv["updated_at"] + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 重命名会话: conversation_id=%s title_len=%s", + conv_id, + len(clean_title), + ) + return _copy_json(conv) + + async def delete_conversation(self, conversation_id: str) -> bool: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + existed = conv_id in self._cache or self._path_for(conv_id).exists() + self._cache.pop(conv_id, None) + path = self._path_for(conv_id) + if path.exists(): + await asyncio.to_thread(path.unlink) + task = self._title_tasks.pop(conv_id, None) + if task is not None: + task.cancel() + logger.info( + "[WebChat] 删除会话: conversation_id=%s existed=%s", + conv_id, + existed, + ) + return existed + + async def clear_conversation(self, conversation_id: str) -> int: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._require_conversation_locked(conv_id) + previous = len(_messages(conv)) + conv["messages"] = [] + conv["updated_at"] = _now_iso() + conv["title"] = _DEFAULT_TITLE + conv["title_source"] = "temporary" + conv["title_status"] = _TITLE_STATUS_TEMPORARY + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 清空会话: conversation_id=%s previous_messages=%s", + conv_id, + previous, + ) + return previous + + async def append_message( + self, + conversation_id: str, + *, + role: str, + text_content: str, + display_name: str, + user_name: str, + attachments: list[dict[str, str]] | None = None, + references: list[dict[str, Any]] | None = None, + webchat: dict[str, Any] | None = None, + ) -> dict[str, Any]: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._require_conversation_locked(conv_id) + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + normalized_role = "bot" if role == "bot" else "user" + record: dict[str, Any] = { + "message_id": f"msg_{uuid4().hex}", + "type": "private", + "chat_id": str(WEBCHAT_VIRTUAL_USER_ID), + "chat_name": WEBCHAT_VIRTUAL_USER_NAME, + "user_id": str(WEBCHAT_VIRTUAL_USER_ID), + "display_name": display_name or user_name or WEBCHAT_VIRTUAL_USER_NAME, + "timestamp": timestamp, + "message": str(text_content or ""), + } + if normalized_role == "bot": + record["display_name"] = "Bot" + record["chat_name"] = "Bot" + if attachments: + record["attachments"] = attachments + if references: + record["references"] = references + if isinstance(webchat, dict): + record["webchat"] = webchat + _messages(conv).append(record) + conv["updated_at"] = _now_iso() + if normalized_role == "user": + self._maybe_apply_temporary_title_locked(conv, record["message"]) + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 追加消息: conversation_id=%s role=%s text_len=%s attachments=%s webchat_events=%s total_messages=%s", + conv_id, + normalized_role, + len(record["message"]), + len(attachments or []), + len(webchat.get("events", []) if isinstance(webchat, dict) else []), + len(_messages(conv)), + ) + return _copy_json(record) + + async def get_history_page( + self, + conversation_id: str, + *, + limit: int, + before: int | None, + ) -> WebChatHistoryPage: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._require_conversation_locked(conv_id) + history = _messages(conv) + total = len(history) + if total == 0 or limit <= 0: + return WebChatHistoryPage([], False, None, total) + end = total if before is None else max(0, min(before, total)) + start = max(0, end - limit) + items = _copy_json(history[start:end]) + has_more = start > 0 + next_before = start if has_more else None + return WebChatHistoryPage(items, has_more, next_before, total) + + def get_recent_sync( + self, + conversation_id: str, + start: int, + end: int, + ) -> list[dict[str, Any]]: + conv_id = _normalize_conversation_id(conversation_id) + conv = self._cache.get(conv_id) + if conv is None: + return [] + history = _messages(conv) + total = len(history) + if total == 0: + return [] + actual_start = max(0, total - end) + actual_end = min(total, total - start) + if actual_start >= actual_end: + return [] + return _copy_json(history[actual_start:actual_end]) + + async def first_question_answer( + self, conversation_id: str + ) -> tuple[str, str] | None: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._cache.get(conv_id) + if conv is None: + return None + question = "" + answer = "" + for record in _messages(conv): + role = _record_role(record) + text = str(record.get("message", "") or "").strip() + if not text: + continue + if role == "user" and not question: + question = text + continue + if question and role == "bot": + answer = text + break + if question and answer: + return question, answer + return None + + async def mark_title_pending(self, conversation_id: str) -> bool: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._cache.get(conv_id) + if conv is None: + return False + if str(conv.get("title_status") or "") in { + _TITLE_STATUS_MANUAL, + _TITLE_STATUS_GENERATED, + }: + return False + first_pair = _first_question_answer_from_conv(conv) + if first_pair is None: + return False + conv["title_status"] = _TITLE_STATUS_PENDING + conv["title_basis_hash"] = _title_basis_hash(*first_pair) + conv["title_requested_at"] = _now_iso() + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 标题生成排队: conversation_id=%s question_len=%s answer_len=%s", + conv_id, + len(first_pair[0]), + len(first_pair[1]), + ) + return True + + async def apply_generated_title( + self, + conversation_id: str, + *, + title: str, + basis_hash: str, + ) -> bool: + conv_id = _normalize_conversation_id(conversation_id) + clean_title = _sanitize_title(title) + if not clean_title: + return False + async with self._get_lock(conv_id): + conv = self._cache.get(conv_id) + if conv is None: + return False + if str(conv.get("title_status") or "") == _TITLE_STATUS_MANUAL: + return False + first_pair = _first_question_answer_from_conv(conv) + if first_pair is None or _title_basis_hash(*first_pair) != basis_hash: + return False + conv["title"] = clean_title + conv["title_source"] = "model" + conv["title_status"] = _TITLE_STATUS_GENERATED + conv["title_updated_at"] = _now_iso() + conv["updated_at"] = conv["title_updated_at"] + await self._save_conversation_locked(conv) + logger.info( + "[WebChat] 应用生成标题: conversation_id=%s title_len=%s", + conv_id, + len(clean_title), + ) + return True + + async def mark_title_failed(self, conversation_id: str, basis_hash: str) -> None: + conv_id = _normalize_conversation_id(conversation_id) + async with self._get_lock(conv_id): + conv = self._cache.get(conv_id) + if conv is None: + return + if str(conv.get("title_status") or "") == _TITLE_STATUS_MANUAL: + return + if str(conv.get("title_basis_hash") or "") != basis_hash: + return + conv["title_status"] = _TITLE_STATUS_FAILED + conv["title_failed_at"] = _now_iso() + await self._save_conversation_locked(conv) + logger.info("[WebChat] 标题生成失败: conversation_id=%s", conv_id) + + def register_title_task( + self, conversation_id: str, task: asyncio.Task[None] + ) -> bool: + conv_id = _normalize_conversation_id(conversation_id) + previous = self._title_tasks.get(conv_id) + if previous is not None and not previous.done(): + task.cancel() + return False + self._title_tasks[conv_id] = task + + def _cleanup(done_task: asyncio.Task[None]) -> None: + if self._title_tasks.get(conv_id) is done_task: + self._title_tasks.pop(conv_id, None) + + task.add_done_callback(_cleanup) + return True + + def title_task_running(self, conversation_id: str) -> bool: + task = self._title_tasks.get(_normalize_conversation_id(conversation_id)) + return task is not None and not task.done() + + async def _ensure_loaded_only(self) -> None: + async with self._global_lock: + if not self._loaded: + await self._load_conversations_locked() + self._loaded = True + + async def _load_conversations_locked(self) -> None: + ensure_dir(WEBCHAT_CONVERSATIONS_DIR) + self._cache.clear() + paths = await asyncio.to_thread( + lambda: sorted(WEBCHAT_CONVERSATIONS_DIR.glob("*.json")) + ) + for path in paths: + raw = await io.read_json(path, use_lock=True) + if not isinstance(raw, dict): + continue + conv = _normalize_conversation(raw, path.stem) + self._cache[str(conv["id"])] = conv + logger.info( + "[WebChat] 会话存储加载完成: count=%s dir=%s", + len(self._cache), + WEBCHAT_CONVERSATIONS_DIR, + ) + + async def _migrate_legacy_once(self, legacy_history_manager: Any | None) -> None: + if WEBCHAT_MIGRATION_MARKER_FILE.exists(): + return + async with self._migration_lock: + if WEBCHAT_MIGRATION_MARKER_FILE.exists(): + return + legacy_path = HISTORY_DIR / f"private_{WEBCHAT_VIRTUAL_USER_ID}.json" + legacy_records = _legacy_records_from_manager(legacy_history_manager) + if not legacy_records: + raw = await io.read_json(legacy_path, use_lock=True) + legacy_records = raw if isinstance(raw, list) else [] + migrated_count = 0 + if legacy_records: + conv = await self.create_conversation( + conversation_id=DEFAULT_WEBCHAT_CONVERSATION_ID, + title=_DEFAULT_TITLE, + title_source="migration", + ) + conv_id = str(conv["id"]) + async with self._get_lock(conv_id): + cached = self._require_conversation_locked(conv_id) + cached["messages"] = [ + _normalize_history_record( + item, + conversation_id=conv_id, + index=index, + ) + for index, item in enumerate(legacy_records) + if isinstance(item, dict) + ] + migrated_count = len(cached["messages"]) + first_question = _first_question_from_conv(cached) + if first_question: + cached["title"] = _temporary_title(first_question) + cached["title_source"] = "temporary" + cached["title_status"] = _TITLE_STATUS_TEMPORARY + cached["legacy_source"] = str(legacy_path) + cached["migrated_at"] = _now_iso() + cached["updated_at"] = cached["migrated_at"] + await self._save_conversation_locked(cached) + await io.write_json( + WEBCHAT_MIGRATION_MARKER_FILE, + { + "version": _MIGRATION_VERSION, + "migrated_at": _now_iso(), + "source": str(legacy_path), + "count": migrated_count, + }, + use_lock=True, + ) + logger.info( + "[WebChat] 旧历史迁移完成: migrated_count=%s marker=%s", + migrated_count, + WEBCHAT_MIGRATION_MARKER_FILE, + ) + + def _get_lock(self, conversation_id: str) -> asyncio.Lock: + lock = self._locks.get(conversation_id) + if lock is None: + lock = asyncio.Lock() + self._locks[conversation_id] = lock + return lock + + def _path_for(self, conversation_id: str) -> Path: + return WEBCHAT_CONVERSATIONS_DIR / f"{conversation_id}.json" + + def _require_conversation_locked(self, conversation_id: str) -> dict[str, Any]: + conv = self._cache.get(conversation_id) + if conv is None: + raise KeyError(conversation_id) + return conv + + async def _save_conversation_locked(self, conv: dict[str, Any]) -> None: + normalized = _normalize_conversation(conv, str(conv.get("id") or uuid4().hex)) + self._cache[str(normalized["id"])] = normalized + await io.write_json( + self._path_for(str(normalized["id"])), normalized, use_lock=True + ) + + def _conversation_summary(self, conv: dict[str, Any]) -> dict[str, Any]: + messages = _messages(conv) + return { + "id": str(conv.get("id") or ""), + "title": str(conv.get("title") or _DEFAULT_TITLE), + "title_source": str(conv.get("title_source") or ""), + "title_status": str(conv.get("title_status") or ""), + "created_at": str(conv.get("created_at") or ""), + "updated_at": str(conv.get("updated_at") or ""), + "virtual_user_id": WEBCHAT_VIRTUAL_USER_ID, + "message_count": len(messages), + } + + def _maybe_apply_temporary_title_locked( + self, conv: dict[str, Any], first_message: str + ) -> None: + status = str(conv.get("title_status") or "") + if status in { + _TITLE_STATUS_MANUAL, + _TITLE_STATUS_GENERATED, + _TITLE_STATUS_PENDING, + }: + return + title = _temporary_title(first_message) + if not title: + return + conv["title"] = title + conv["title_source"] = "temporary" + conv["title_status"] = _TITLE_STATUS_TEMPORARY + conv["title_updated_at"] = _now_iso() + + +def _normalize_conversation_id(value: str) -> str: + text = str(value or "").strip() + if not text: + return uuid4().hex + allowed = "".join(ch for ch in text if ch.isalnum() or ch in {"-", "_"}) + return allowed[:80] or uuid4().hex + + +def _now_iso() -> str: + return datetime.now().isoformat(timespec="seconds") + + +def _copy_json(value: _JsonT) -> _JsonT: + return copy.deepcopy(value) + + +def _messages(conv: dict[str, Any]) -> list[dict[str, Any]]: + messages = conv.get("messages") + if not isinstance(messages, list): + messages = [] + conv["messages"] = messages + return messages + + +def _normalize_conversation(raw: dict[str, Any], fallback_id: str) -> dict[str, Any]: + conv = dict(raw) + conv["id"] = _normalize_conversation_id(str(conv.get("id") or fallback_id)) + conv["title"] = ( + _sanitize_title(conv.get("title") or _DEFAULT_TITLE) or _DEFAULT_TITLE + ) + conv["title_source"] = str(conv.get("title_source") or "temporary") + conv["title_status"] = str(conv.get("title_status") or _TITLE_STATUS_TEMPORARY) + conv["created_at"] = str(conv.get("created_at") or _now_iso()) + conv["updated_at"] = str(conv.get("updated_at") or conv["created_at"]) + conv["virtual_user_id"] = WEBCHAT_VIRTUAL_USER_ID + conv["virtual_user_name"] = WEBCHAT_VIRTUAL_USER_NAME + conversation_id = str(conv["id"]) + conv["messages"] = [ + _normalize_history_record(item, conversation_id=conversation_id, index=index) + for index, item in enumerate(conv.get("messages", [])) + if isinstance(item, dict) + ] + return conv + + +def _normalize_history_record( + record: dict[str, Any], + *, + conversation_id: str, + index: int, +) -> dict[str, Any]: + item = dict(record) + item["message_id"] = _normalize_message_id( + item.get("message_id"), + conversation_id=conversation_id, + index=index, + record=item, + ) + item["type"] = "private" + item["chat_id"] = str(WEBCHAT_VIRTUAL_USER_ID) + item["user_id"] = str(WEBCHAT_VIRTUAL_USER_ID) + item["chat_name"] = str(item.get("chat_name") or WEBCHAT_VIRTUAL_USER_NAME) + item["display_name"] = str(item.get("display_name") or WEBCHAT_VIRTUAL_USER_NAME) + item["timestamp"] = str(item.get("timestamp") or "") + item["message"] = str(item.get("message", item.get("content", "")) or "") + attachments = item.get("attachments") + item["attachments"] = attachments if isinstance(attachments, list) else [] + references = item.get("references") + if isinstance(references, list): + item["references"] = [ref for ref in references if isinstance(ref, dict)] + else: + item.pop("references", None) + return item + + +def _normalize_message_id( + raw: Any, + *, + conversation_id: str, + index: int, + record: dict[str, Any], +) -> str: + text = str(raw or "").strip() + if text: + allowed = "".join(ch for ch in text if ch.isalnum() or ch in {"-", "_"}) + if allowed: + return allowed[:96] + role = _record_role(record) + basis = "\n".join( + [ + conversation_id, + str(index), + role, + str(record.get("timestamp") or ""), + str(record.get("message", record.get("content", "")) or ""), + ] + ) + digest = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:32] + return f"msg_legacy_{digest}" + + +def _legacy_records_from_manager(history_manager: Any | None) -> list[dict[str, Any]]: + if history_manager is None: + return [] + recent_getter = getattr(history_manager, "get_recent_private", None) + if callable(recent_getter): + try: + records = recent_getter(WEBCHAT_VIRTUAL_USER_ID, 1000000) + if isinstance(records, list): + return [item for item in records if isinstance(item, dict)] + except Exception: + return [] + return [] + + +def _record_role(record: dict[str, Any]) -> str: + display_name = str(record.get("display_name", "") or "").strip().lower() + return "bot" if display_name == "bot" else "user" + + +def _first_question_from_conv(conv: dict[str, Any]) -> str: + for record in _messages(conv): + if _record_role(record) != "user": + continue + text = str(record.get("message", "") or "").strip() + if text: + return text + return "" + + +def _first_question_answer_from_conv(conv: dict[str, Any]) -> tuple[str, str] | None: + question = "" + for record in _messages(conv): + text = str(record.get("message", "") or "").strip() + if not text: + continue + role = _record_role(record) + if role == "user" and not question: + question = text + continue + if question and role == "bot": + return question, text + return None + + +def _temporary_title(text: str) -> str: + normalized = " ".join(str(text or "").strip().split()) + if not normalized: + return _DEFAULT_TITLE + return normalized[:_TEMP_TITLE_CHARS] + + +def _sanitize_title(value: Any) -> str: + text = " ".join(str(value or "").strip().split()) + text = text.strip(" \t\r\n\"'`“”‘’") + return text[:40] + + +def _title_basis_hash(question: str, answer: str) -> str: + basis = f"{question}\n\n{answer}" + return hashlib.sha256(basis.encode("utf-8")).hexdigest() + + +def webchat_title_basis_hash(question: str, answer: str) -> str: + return _title_basis_hash(question, answer) + + +def build_webchat_title_prompt(question: str, answer: str) -> list[dict[str, str]]: + content = ( + "请为一段 WebChat 对话生成一个简短标题。\n" + "要求:只返回标题文本;不要引号、编号或前缀;中文不超过 18 个字,英文不超过 6 个词;" + "标题应概括用户首问和 AI 首答的实际主题。\n\n" + f"{escape_xml_text(question)}\n" + f"{escape_xml_text(answer)}" + ) + return [{"role": "user", "content": content}] + + +def _resolve_webchat_title_chat_model(ai: Any) -> Any | None: + chat_config = getattr(ai, "chat_config", None) + if chat_config is None: + runtime_config = getattr(ai, "runtime_config", None) + chat_config = getattr(runtime_config, "chat_model", None) + if chat_config is None: + return None + selector = getattr(ai, "model_selector", None) + select_chat_config = getattr(selector, "select_chat_config", None) + if callable(select_chat_config): + runtime_config = getattr(ai, "runtime_config", None) + global_enabled = bool( + getattr(runtime_config, "model_pool_enabled", True) + if runtime_config is not None + else True + ) + return select_chat_config( + chat_config, + group_id=0, + user_id=WEBCHAT_VIRTUAL_USER_ID, + global_enabled=global_enabled, + ) + return chat_config + + +async def generate_webchat_title(ai: Any, question: str, answer: str) -> str: + messages = build_webchat_title_prompt(question, answer) + model_config = _resolve_webchat_title_chat_model(ai) + logger.info( + "[WebChat] 生成会话标题: model=%s question_len=%s answer_len=%s", + getattr(model_config, "model_name", ""), + len(question), + len(answer), + ) + submit = getattr(ai, "submit_background_llm_call", None) + if callable(submit) and model_config is not None: + result = await submit( + model_config=model_config, + messages=messages, + tools=None, + call_type="webchat_title", + ) + from Undefined.ai.parsing import extract_choices_content + + return _sanitize_title(extract_choices_content(result)) + request_model = getattr(ai, "request_model", None) + if callable(request_model) and model_config is not None: + result = await request_model( + model_config=model_config, + messages=messages, + tools=None, + call_type="webchat_title", + ) + from Undefined.ai.parsing import extract_choices_content + + return _sanitize_title(extract_choices_content(result)) + generate_title = getattr(ai, "generate_title", None) + if callable(generate_title): + logger.info("[WebChat] 会话标题生成回退到 generate_title") + maybe = generate_title(f"用户首问:{question}\nAI首答:{answer}") + result_text = await maybe if inspect.isawaitable(maybe) else maybe + return _sanitize_title(result_text) + return "" + + +def format_webchat_message_xml( + content: str, attachment_xml: str, current_time: str +) -> str: + return f""" + {escape_xml_text(content)}{attachment_xml} + """ diff --git a/src/Undefined/attachments/__init__.py b/src/Undefined/attachments/__init__.py index ad4d1a56..4f978ccf 100644 --- a/src/Undefined/attachments/__init__.py +++ b/src/Undefined/attachments/__init__.py @@ -17,8 +17,10 @@ render_message_with_pic_placeholders, ) from Undefined.attachments.segments import ( + attachment_ref_to_tag, append_attachment_text, attachment_refs_to_text, + attachment_refs_to_tags, attachment_refs_to_xml, build_attachment_scope, register_message_attachments, @@ -31,8 +33,10 @@ "AttachmentRenderError", "RegisteredMessageAttachments", "RenderedRichMessage", + "attachment_ref_to_tag", "append_attachment_text", "attachment_refs_to_text", + "attachment_refs_to_tags", "attachment_refs_to_xml", "build_attachment_scope", "dispatch_pending_file_sends", diff --git a/src/Undefined/attachments/segments.py b/src/Undefined/attachments/segments.py index d78b458f..6c064cdd 100644 --- a/src/Undefined/attachments/segments.py +++ b/src/Undefined/attachments/segments.py @@ -120,6 +120,24 @@ def attachment_refs_to_text(attachments: Sequence[Mapping[str, str]]) -> str: return " ".join(parts) +def attachment_ref_to_tag(attachment: Mapping[str, str]) -> str: + """将单个附件引用序列化为 AI 可复用的统一内联标签。""" + uid = str(attachment.get("uid", "") or "").strip() + if not uid: + return "" + return f'' + + +def attachment_refs_to_tags( + attachments: Sequence[Mapping[str, str]], + *, + separator: str = " ", +) -> str: + """将附件引用列表转为 ```` 内联标签串。""" + tags = [tag for item in attachments if (tag := attachment_ref_to_tag(item))] + return separator.join(tags) + + def attachment_refs_to_xml( attachments: Sequence[Mapping[str, str]], *, @@ -163,10 +181,15 @@ def attachment_refs_to_xml( def append_attachment_text( base_text: str, attachments: Sequence[Mapping[str, str]] ) -> str: - """在基础文本后追加附件占位摘要行。""" - attachment_text = attachment_refs_to_text(attachments) - if not attachment_text: + """在基础文本后追加统一附件标签行。""" + missing_tags = [ + tag + for item in attachments + if (tag := attachment_ref_to_tag(item)) and tag not in base_text + ] + if not missing_tags: return base_text + attachment_text = " ".join(missing_tags) if not base_text.strip(): return attachment_text return f"{base_text}\n附件: {attachment_text}" @@ -232,15 +255,9 @@ def segment_text( forward_id = str(data.get("id") or data.get("resid") or "").strip() return f"[合并转发: {forward_id}]" if forward_id else "[合并转发]" if ref is not None: - label = _MEDIA_LABELS.get( - str(ref.get("media_type") or ref.get("kind") or type_).strip(), "附件" - ) - uid = str(ref.get("uid", "") or "").strip() - name = str(ref.get("display_name", "") or "").strip() - if uid and name: - return f"[{label} uid={uid} name={name}]" - if uid: - return f"[{label} uid={uid}]" + tag = attachment_ref_to_tag(ref) + if tag: + return tag label = _MEDIA_LABELS.get(type_, "附件") raw = str(data.get("file") or data.get("url") or data.get("id") or "").strip() return f"[{label}: {raw}]" if raw else f"[{label}]" @@ -369,11 +386,8 @@ async def _collect_from_segments( try: if type_ == "image": raw_source = str(data.get("file") or data.get("url") or "").strip() - display_name = display_name_from_source( - raw_source, - f"image_{index + 1}.png", - ) if raw_source.startswith("base64://"): + display_name = f"image_{index + 1}.png" payload = raw_source[len("base64://") :].strip() content = base64.b64decode(payload) record = await registry.register_bytes( @@ -390,6 +404,7 @@ async def _collect_from_segments( ) ref = record.prompt_ref() elif is_data_url(raw_source): + display_name = f"image_{index + 1}.png" record = await registry.register_data_url( scope_key, raw_source, @@ -404,6 +419,10 @@ async def _collect_from_segments( ) ref = record.prompt_ref() else: + display_name = display_name_from_source( + raw_source, + f"image_{index + 1}.png", + ) resolved_source = raw_source if raw_source and resolve_image_url is not None: try: diff --git a/src/Undefined/cognitive/chroma_scheduler.py b/src/Undefined/cognitive/chroma_scheduler.py new file mode 100644 index 00000000..b67ff00f --- /dev/null +++ b/src/Undefined/cognitive/chroma_scheduler.py @@ -0,0 +1,296 @@ +"""ChromaDB operation scheduler for cognitive vector stores.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, TypeVar + +from Undefined.context import RequestContext + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + +CHROMA_PRIORITY_FOREGROUND_CRITICAL = "foreground_critical" +CHROMA_PRIORITY_FOREGROUND = "foreground" +CHROMA_PRIORITY_MAINTENANCE = "maintenance" +CHROMA_PRIORITY_BACKGROUND = "background" + +CHROMA_PRIORITY_DISPLAY_NAMES = { + CHROMA_PRIORITY_FOREGROUND_CRITICAL: "前台关键", + CHROMA_PRIORITY_FOREGROUND: "前台", + CHROMA_PRIORITY_MAINTENANCE: "维护", + CHROMA_PRIORITY_BACKGROUND: "后台", +} + +_PRIORITY_ORDER = ( + CHROMA_PRIORITY_FOREGROUND_CRITICAL, + CHROMA_PRIORITY_FOREGROUND, + CHROMA_PRIORITY_MAINTENANCE, + CHROMA_PRIORITY_BACKGROUND, +) +_FOREGROUND_PRIORITIES = ( + CHROMA_PRIORITY_FOREGROUND_CRITICAL, + CHROMA_PRIORITY_FOREGROUND, +) +_BACKGROUND_PRIORITIES = ( + CHROMA_PRIORITY_MAINTENANCE, + CHROMA_PRIORITY_BACKGROUND, +) + + +def normalize_chroma_priority(value: str | None, default: str) -> str: + """Normalize external priority values to a known scheduler lane.""" + raw = str(value or "").strip() + if raw in CHROMA_PRIORITY_DISPLAY_NAMES: + return raw + return default + + +@dataclass +class ChromaOperationReceipt: + """Execution timing for one Chroma operation.""" + + priority: str + operation: str + collection: str + request_id: str + queue_wait_seconds: float + exec_seconds: float + pending_before: int + + +@dataclass +class _ChromaOperation: + priority: str + operation: str + collection: str + request_id: str + callback: Callable[[], Any] + created_at: float + pending_before: int + future: asyncio.Future[tuple[Any, ChromaOperationReceipt]] + + +@dataclass +class ChromaSchedulerSnapshot: + running: bool + stopped: bool + foreground_burst: int + active: bool + pending: dict[str, int] = field(default_factory=dict) + + +class ChromaOperationScheduler: + """Single-worker priority scheduler for Chroma collection operations.""" + + def __init__(self, *, foreground_burst: int = 8) -> None: + self._foreground_burst = max(1, int(foreground_burst)) + self._queues: dict[str, deque[_ChromaOperation]] = { + priority: deque() for priority in _PRIORITY_ORDER + } + self._condition = asyncio.Condition() + self._worker: asyncio.Task[None] | None = None + self._stopped = False + self._foreground_since_background = 0 + self._active_operation: _ChromaOperation | None = None + + @property + def foreground_burst(self) -> int: + return self._foreground_burst + + def snapshot(self) -> ChromaSchedulerSnapshot: + return ChromaSchedulerSnapshot( + running=self._worker is not None and not self._worker.done(), + stopped=self._stopped, + foreground_burst=self._foreground_burst, + active=self._active_operation is not None, + pending={priority: len(queue) for priority, queue in self._queues.items()}, + ) + + async def run( + self, + *, + priority: str, + operation: str, + collection: str, + callback: Callable[[], T], + ) -> tuple[T, ChromaOperationReceipt]: + normalized_priority = normalize_chroma_priority( + priority, + CHROMA_PRIORITY_FOREGROUND, + ) + loop = asyncio.get_running_loop() + future: asyncio.Future[tuple[Any, ChromaOperationReceipt]] = ( + loop.create_future() + ) + request_id = self._current_request_id() + created_at = time.perf_counter() + async with self._condition: + if self._stopped: + raise RuntimeError("Chroma operation scheduler has stopped") + self._ensure_worker_locked() + pending_before = self._pending_count_locked() + job = _ChromaOperation( + priority=normalized_priority, + operation=operation, + collection=collection, + request_id=request_id, + callback=callback, + created_at=created_at, + pending_before=pending_before, + future=future, + ) + self._queues[normalized_priority].append(job) + self._condition.notify() + + try: + result, receipt = await future + except asyncio.CancelledError: + if not future.done(): + future.cancel() + raise + return result, receipt + + async def stop(self) -> None: + worker: asyncio.Task[None] | None + async with self._condition: + self._stopped = True + self._cancel_pending_locked() + self._condition.notify_all() + worker = self._worker + if worker is not None: + await worker + self._worker = None + + def _ensure_worker_locked(self) -> None: + if self._worker is None or self._worker.done(): + self._worker = asyncio.create_task(self._worker_loop()) + + async def _worker_loop(self) -> None: + while True: + async with self._condition: + while True: + if self._stopped and self._pending_count_locked() == 0: + return + job = self._pop_next_locked() + if job is not None: + self._active_operation = job + break + await self._condition.wait() + + try: + await self._execute(job) + finally: + async with self._condition: + self._active_operation = None + self._condition.notify_all() + + async def _execute(self, job: _ChromaOperation) -> None: + if job.future.cancelled(): + return + wait_seconds = time.perf_counter() - job.created_at + exec_started = time.perf_counter() + logger.info( + "[认知向量库] Chroma 操作开始: priority=%s operation=%s collection=%s request_id=%s wait=%.3fs pending_before=%s", + job.priority, + job.operation, + job.collection, + job.request_id, + wait_seconds, + job.pending_before, + ) + try: + result = await asyncio.to_thread(job.callback) + except Exception as exc: + exec_seconds = time.perf_counter() - exec_started + if not job.future.done(): + job.future.set_exception(exc) + logger.warning( + "[认知向量库] Chroma 操作失败: priority=%s operation=%s collection=%s request_id=%s wait=%.3fs exec=%.3fs err=%s", + job.priority, + job.operation, + job.collection, + job.request_id, + wait_seconds, + exec_seconds, + exc, + ) + return + + exec_seconds = time.perf_counter() - exec_started + receipt = ChromaOperationReceipt( + priority=job.priority, + operation=job.operation, + collection=job.collection, + request_id=job.request_id, + queue_wait_seconds=wait_seconds, + exec_seconds=exec_seconds, + pending_before=job.pending_before, + ) + if not job.future.done(): + job.future.set_result((result, receipt)) + logger.info( + "[认知向量库] Chroma 操作完成: priority=%s operation=%s collection=%s request_id=%s wait=%.3fs exec=%.3fs", + job.priority, + job.operation, + job.collection, + job.request_id, + wait_seconds, + exec_seconds, + ) + + def _pop_next_locked(self) -> _ChromaOperation | None: + if ( + self._foreground_since_background >= self._foreground_burst + and self._has_pending_locked(_BACKGROUND_PRIORITIES) + ): + job = self._pop_first_locked(_BACKGROUND_PRIORITIES) + if job is not None: + self._foreground_since_background = 0 + return job + + job = self._pop_first_locked(_FOREGROUND_PRIORITIES) + if job is not None: + self._foreground_since_background += 1 + return job + + job = self._pop_first_locked(_BACKGROUND_PRIORITIES) + if job is not None: + self._foreground_since_background = 0 + return job + return None + + def _pop_first_locked(self, priorities: tuple[str, ...]) -> _ChromaOperation | None: + for priority in priorities: + queue = self._queues[priority] + while queue: + job = queue.popleft() + if not job.future.cancelled(): + return job + return None + + def _has_pending_locked(self, priorities: tuple[str, ...]) -> bool: + return any(self._queues[priority] for priority in priorities) + + def _pending_count_locked(self) -> int: + return sum(len(queue) for queue in self._queues.values()) + + def _cancel_pending_locked(self) -> None: + for queue in self._queues.values(): + while queue: + job = queue.popleft() + if not job.future.done(): + job.future.cancel() + + @staticmethod + def _current_request_id() -> str: + ctx = RequestContext.current() + if ctx is None: + return "" + return str(getattr(ctx, "request_id", "") or "") diff --git a/src/Undefined/cognitive/historian/tools.py b/src/Undefined/cognitive/historian/tools.py index 83cc3018..b9eae327 100644 --- a/src/Undefined/cognitive/historian/tools.py +++ b/src/Undefined/cognitive/historian/tools.py @@ -67,8 +67,7 @@ "tags": { "type": "array", "items": {"type": "string"}, - "maxItems": 10, - "description": "身份级标签(角色/核心领域),最多 10 个,不写话题", + "description": "身份级标签(角色/核心领域),不写话题", }, "summary": {"type": "string", "description": "侧写正文(Markdown)"}, }, diff --git a/src/Undefined/cognitive/historian/worker.py b/src/Undefined/cognitive/historian/worker.py index 30ecf43c..6e91a3d2 100644 --- a/src/Undefined/cognitive/historian/worker.py +++ b/src/Undefined/cognitive/historian/worker.py @@ -9,6 +9,11 @@ from typing import Any, Callable from Undefined.ai.transports.openai_transport import RESPONSES_OUTPUT_ITEMS_KEY +from Undefined.cognitive.chroma_scheduler import ( + CHROMA_PRIORITY_BACKGROUND, + CHROMA_PRIORITY_MAINTENANCE, +) +from Undefined.cognitive.vector_store_compat import call_vector_store_method from Undefined.utils.tool_calls import extract_required_tool_call_arguments from Undefined.cognitive.historian.helpers import ( @@ -212,7 +217,13 @@ async def _process_job(self, job_id: str, job: dict[str, Any]) -> None: **base_metadata, "has_observations": True, } - await self._vector_store.upsert_event(event_id, canonical, meta) + await call_vector_store_method( + self._vector_store.upsert_event, + event_id, + canonical, + meta, + priority=CHROMA_PRIORITY_BACKGROUND, + ) canonicals.append(canonical) logger.info( "[史官] 任务 %s 事件入库完成(%s/%s): len=%s", @@ -516,10 +527,12 @@ async def _write_profile( profile_metadata["group_name"] = effective_name profile_metadata["group_id"] = entity_id - await self._vector_store.upsert_profile( + await call_vector_store_method( + self._vector_store.upsert_profile, f"{entity_type}:{entity_id}", profile_doc, profile_metadata, + priority=CHROMA_PRIORITY_BACKGROUND, ) logger.info( "[史官] 任务 %s 侧写向量入库完成: profile_id=%s perspective=%s", @@ -560,15 +573,19 @@ async def _query_user_history_events_for_profile_merge( query_embedding_value = query_embedding if query_embedding_value is None: query_embedding_value = await self._prepare_query_embedding(query_text) - sender_query = self._vector_store.query_events( + sender_query = call_vector_store_method( + self._vector_store.query_events, query_text, + priority=CHROMA_PRIORITY_MAINTENANCE, top_k=safe_top_k, where={"sender_id": entity_id}, apply_mmr=True, query_embedding=query_embedding_value, ) - user_query = self._vector_store.query_events( + user_query = call_vector_store_method( + self._vector_store.query_events, query_text, + priority=CHROMA_PRIORITY_MAINTENANCE, top_k=safe_top_k, where={"user_id": entity_id}, apply_mmr=True, @@ -631,8 +648,10 @@ async def _merge_profile_target( ) query_embedding = await self._prepare_query_embedding(observations_text) if entity_type == "group": - historical_events = await self._vector_store.query_events( + historical_events = await call_vector_store_method( + self._vector_store.query_events, observations_text, + priority=CHROMA_PRIORITY_MAINTENANCE, top_k=8, where={"group_id": entity_id}, apply_mmr=True, @@ -851,9 +870,7 @@ async def _merge_profile_target( raw_tags = tc_args.get("tags", []) up_tags: list[str] = [] if isinstance(raw_tags, list): - up_tags = [str(t).strip() for t in raw_tags if str(t).strip()][ - :10 - ] + up_tags = [str(t).strip() for t in raw_tags if str(t).strip()] llm_name = str(tc_args.get("name", "")).strip() is_target = up_et == entity_type and up_eid == entity_id diff --git a/src/Undefined/cognitive/service/service.py b/src/Undefined/cognitive/service/service.py index c7a69e9d..d4cb0e0e 100644 --- a/src/Undefined/cognitive/service/service.py +++ b/src/Undefined/cognitive/service/service.py @@ -9,6 +9,10 @@ from typing import TYPE_CHECKING, Any, Callable, cast from Undefined.context import RequestContext +from Undefined.cognitive.chroma_scheduler import ( + CHROMA_PRIORITY_FOREGROUND, + CHROMA_PRIORITY_FOREGROUND_CRITICAL, +) from Undefined.utils.coerce import safe_float from Undefined.cognitive.service.helpers import ( _build_profile_vector_payload, @@ -23,6 +27,7 @@ _resolve_auto_request_type, _serialize_profile_markdown, ) +from Undefined.cognitive.vector_store_compat import call_vector_store_method if TYPE_CHECKING: from Undefined.knowledge.runtime import RetrievalRuntime @@ -47,6 +52,11 @@ def __init__( self._reranker = reranker self._retrieval_runtime = retrieval_runtime + async def stop(self) -> None: + stop = getattr(self._vector_store, "stop", None) + if callable(stop): + await stop() + def _base_reranker(self) -> Any: if self._retrieval_runtime is not None: return self._retrieval_runtime.ensure_reranker() @@ -138,10 +148,12 @@ async def sync_profile_display_name( tags=_normalize_profile_tags(frontmatter.get("tags")), summary=summary, ) - await self._vector_store.upsert_profile( + await call_vector_store_method( + self._vector_store.upsert_profile, f"{normalized_entity_type}:{normalized_entity_id}", profile_doc, profile_metadata, + priority=CHROMA_PRIORITY_FOREGROUND, ) logger.info( "[认知服务] 已刷新侧写展示名: entity_type=%s entity_id=%s old=%s new=%s", @@ -223,10 +235,96 @@ def _merge_weighted_events( ) return [item[6] for item in scored_items[:safe_top_k]] + @staticmethod + def _merge_event_candidates( + scoped_results: list[tuple[list[dict[str, Any]], float]], + *, + current_group_id: str = "", + current_group_boost: float = 1.0, + ) -> list[dict[str, Any]]: + candidate_count = sum(len(events) for events, _ in scoped_results) + if candidate_count <= 0: + return [] + return CognitiveService._merge_weighted_events( + scoped_results, + top_k=candidate_count, + current_group_id=current_group_id, + current_group_boost=current_group_boost, + ) + + @staticmethod + async def _rerank_events( + *, + reranker: Any, + query: str, + events: list[dict[str, Any]], + top_k: int, + ) -> list[dict[str, Any]]: + safe_top_k = max(1, int(top_k)) + if not reranker or not events: + return events[:safe_top_k] + + rerank_started = time.perf_counter() + try: + reranked = await reranker.rerank( + query, + [str(event.get("document", "")) for event in events], + top_n=safe_top_k, + ) + except Exception as exc: + logger.warning( + "[认知服务] 自动检索最终重排失败,回退融合排序: candidates=%s top_k=%s err=%s", + len(events), + safe_top_k, + exc, + ) + return events[:safe_top_k] + + ranked_events: list[dict[str, Any]] = [] + for item in reranked[:safe_top_k]: + index = int(safe_float(item.get("index"), default=-1)) + if index < 0 or index >= len(events): + continue + event = dict(events[index]) + event["rerank_score"] = safe_float(item.get("relevance_score"), default=0.0) + ranked_events.append(event) + + if not ranked_events: + logger.warning( + "[认知服务] 自动检索最终重排结果为空,回退融合排序: candidates=%s top_k=%s", + len(events), + safe_top_k, + ) + return events[:safe_top_k] + + logger.info( + "[认知服务] 自动检索最终重排完成: candidates=%s final=%s query_len=%s duration=%.3fs", + len(events), + len(ranked_events), + len(query or ""), + time.perf_counter() - rerank_started, + ) + return ranked_events + + @staticmethod + def _normalize_recall_queries( + query: str, recall_queries: list[str] | None + ) -> list[str]: + normalized: list[str] = [] + for raw_query in recall_queries or []: + text = str(raw_query or "").strip() + if text: + normalized.append(text) + if normalized: + return normalized + fallback = str(query or "").strip() + return [fallback] if fallback else [] + async def _query_events_for_auto_context( self, *, query: str, + recall_queries: list[str] | None = None, request_type: str, group_id: str, user_id: str, @@ -251,9 +349,20 @@ async def _query_events_for_auto_context( ) if current_private_boost <= 0: current_private_boost = 1.25 - query_embedding = await self._prepare_query_embedding(query) + normalized_recall_queries = self._normalize_recall_queries( + query, recall_queries + ) + if not normalized_recall_queries: + return [] + configured_reranker = self._current_reranker() + final_reranker = ( + configured_reranker if len(normalized_recall_queries) > 1 else None + ) + query_level_reranker = ( + None if len(normalized_recall_queries) > 1 else configured_reranker + ) common_kwargs: dict[str, Any] = { - "reranker": self._current_reranker(), + "reranker": query_level_reranker, "candidate_multiplier": config.rerank_candidate_multiplier, "time_decay_enabled": bool(getattr(config, "time_decay_enabled", True)), "time_decay_half_life_days": float( @@ -265,85 +374,125 @@ async def _query_events_for_auto_context( ), "apply_mmr": True, } - if query_embedding is not None: - common_kwargs["query_embedding"] = query_embedding uid_values = self._uid_candidates(user_id, sender_id) if request_type == "group": - group_events: list[dict[str, Any]] = await self._vector_store.query_events( - query, - top_k=scoped_top_k, - where={"request_type": "group"}, - **common_kwargs, - ) + scoped_results: list[tuple[list[dict[str, Any]], float]] = [] + for recall_query in normalized_recall_queries: + query_embedding = await self._prepare_query_embedding(recall_query) + query_kwargs = dict(common_kwargs) + if query_embedding is not None: + query_kwargs["query_embedding"] = query_embedding + group_events = await call_vector_store_method( + self._vector_store.query_events, + recall_query, + priority=CHROMA_PRIORITY_FOREGROUND, + top_k=scoped_top_k, + where={"request_type": "group"}, + **query_kwargs, + ) + scoped_results.append((group_events, 1.0)) merge_started = time.perf_counter() - merged = self._merge_weighted_events( - [(group_events, 1.0)], - top_k=safe_top_k, + candidates = self._merge_event_candidates( + scoped_results, current_group_id=group_id, current_group_boost=current_group_boost, ) + merged = await self._rerank_events( + reranker=final_reranker, + query=query, + events=candidates, + top_k=safe_top_k, + ) merge_duration = time.perf_counter() - merge_started logger.info( - "[认知服务] 自动检索(群聊): group_candidates=%s merged=%s top_k=%s scope_multiplier=%s current_group_boost=%.2f merge=%.3fs", - len(group_events), + "[认知服务] 自动检索(群聊): recall_queries=%s group_candidates=%s merged=%s top_k=%s scope_multiplier=%s current_group_boost=%.2f final_rerank=%s merge=%.3fs", + len(normalized_recall_queries), + len(candidates), len(merged), safe_top_k, scope_candidate_multiplier, current_group_boost, + bool(final_reranker), merge_duration, ) return merged if request_type == "private": - group_task = self._vector_store.query_events( - query, - top_k=scoped_top_k, - where={"request_type": "group"}, - **common_kwargs, - ) + scoped_results = [] + private_where: dict[str, Any] | None = None if uid_values: uid_clauses = [{"user_id": value} for value in uid_values] + [ {"sender_id": value} for value in uid_values ] - private_where: dict[str, Any] = { + private_where = { "$and": [ {"request_type": "private"}, {"$or": uid_clauses}, ] } - private_task = self._vector_store.query_events( - query, + + for recall_query in normalized_recall_queries: + query_embedding = await self._prepare_query_embedding(recall_query) + query_kwargs = dict(common_kwargs) + if query_embedding is not None: + query_kwargs["query_embedding"] = query_embedding + group_task = call_vector_store_method( + self._vector_store.query_events, + recall_query, + priority=CHROMA_PRIORITY_FOREGROUND, top_k=scoped_top_k, - where=private_where, - **common_kwargs, + where={"request_type": "group"}, + **query_kwargs, ) - group_events_raw, private_events_raw = await asyncio.gather( - group_task, private_task - ) - group_events = cast(list[dict[str, Any]], group_events_raw) - private_events = cast(list[dict[str, Any]], private_events_raw) - else: - group_events = cast(list[dict[str, Any]], await group_task) - private_events = [] + if private_where is not None: + private_task = call_vector_store_method( + self._vector_store.query_events, + recall_query, + priority=CHROMA_PRIORITY_FOREGROUND, + top_k=scoped_top_k, + where=private_where, + **query_kwargs, + ) + group_events_raw, private_events_raw = await asyncio.gather( + group_task, private_task + ) + group_events = cast(list[dict[str, Any]], group_events_raw) + private_events = cast(list[dict[str, Any]], private_events_raw) + else: + group_events = cast(list[dict[str, Any]], await group_task) + private_events = [] + scoped_results.append((group_events, 1.0)) + scoped_results.append((private_events, current_private_boost)) merge_started = time.perf_counter() - merged = self._merge_weighted_events( - [ - (group_events, 1.0), - (private_events, current_private_boost), - ], + candidates = self._merge_event_candidates(scoped_results) + merged = await self._rerank_events( + reranker=final_reranker, + query=query, + events=candidates, top_k=safe_top_k, ) merge_duration = time.perf_counter() - merge_started + group_candidate_count = sum( + len(events) for events, weight in scoped_results if weight == 1.0 + ) + private_candidate_count = sum( + len(events) + for events, weight in scoped_results + if weight == current_private_boost + ) logger.info( - "[认知服务] 自动检索(私聊): group_candidates=%s private_candidates=%s merged=%s top_k=%s scope_multiplier=%s private_boost=%.2f uid_candidates=%s merge=%.3fs", - len(group_events), - len(private_events), + "[认知服务] 自动检索(私聊): recall_queries=%s group_candidates=%s private_candidates=%s candidates=%s merged=%s top_k=%s scope_multiplier=%s private_boost=%.2f uid_candidates=%s final_rerank=%s merge=%.3fs", + len(normalized_recall_queries), + group_candidate_count, + private_candidate_count, + len(candidates), len(merged), safe_top_k, scope_candidate_multiplier, current_private_boost, uid_values, + bool(final_reranker), merge_duration, ) return merged @@ -356,20 +505,39 @@ async def _query_events_for_auto_context( "$or": [{"user_id": value} for value in uid_values] + [{"sender_id": value} for value in uid_values] } - events: list[dict[str, Any]] = await self._vector_store.query_events( - query, + scoped_results = [] + for recall_query in normalized_recall_queries: + query_embedding = await self._prepare_query_embedding(recall_query) + query_kwargs = dict(common_kwargs) + if query_embedding is not None: + query_kwargs["query_embedding"] = query_embedding + events = await call_vector_store_method( + self._vector_store.query_events, + recall_query, + priority=CHROMA_PRIORITY_FOREGROUND, + top_k=scoped_top_k, + where=where, + **query_kwargs, + ) + scoped_results.append((events, 1.0)) + candidates = self._merge_event_candidates(scoped_results) + merged = await self._rerank_events( + reranker=final_reranker, + query=query, + events=candidates, top_k=safe_top_k, - where=where, - **common_kwargs, ) logger.info( - "[认知服务] 自动检索(兜底): mode=%s where=%s count=%s top_k=%s", + "[认知服务] 自动检索(兜底): mode=%s recall_queries=%s where=%s candidates=%s merged=%s top_k=%s final_rerank=%s", request_type or "unknown", + len(normalized_recall_queries), where or {}, - len(events), + len(candidates), + len(merged), safe_top_k, + bool(final_reranker), ) - return events + return merged async def enqueue_job( self, @@ -528,6 +696,7 @@ async def build_context( sender_name: str | None = None, group_name: str | None = None, request_type: str | None = None, + recall_queries: list[str] | None = None, ) -> str: config = self._config_getter() safe_group_id = str(group_id or "").strip() @@ -578,6 +747,7 @@ async def build_context( try: events = await self._query_events_for_auto_context( query=query, + recall_queries=recall_queries, request_type=safe_request_type, group_id=safe_group_id, user_id=safe_user_id, @@ -678,8 +848,10 @@ async def search_events(self, query: str, **kwargs: Any) -> list[dict[str, Any]] time_from_epoch, time_to_epoch, ) - results: list[dict[str, Any]] = await self._vector_store.query_events( + results = await call_vector_store_method( + self._vector_store.query_events, query, + priority=CHROMA_PRIORITY_FOREGROUND_CRITICAL, top_k=top_k, where=where or None, reranker=self._current_reranker(), @@ -696,7 +868,7 @@ async def search_events(self, query: str, **kwargs: Any) -> list[dict[str, Any]] query_embedding=await self._prepare_query_embedding(query), ) logger.info("[认知服务] 搜索事件完成: count=%s", len(results)) - return results + return cast(list[dict[str, Any]], results) async def get_profile(self, entity_type: str, entity_id: str) -> str | None: logger.info( @@ -739,8 +911,10 @@ async def search_profiles(self, query: str, **kwargs: Any) -> list[dict[str, Any top_k, where or {}, ) - results: list[dict[str, Any]] = await self._vector_store.query_profiles( + results = await call_vector_store_method( + self._vector_store.query_profiles, query, + priority=CHROMA_PRIORITY_FOREGROUND_CRITICAL, top_k=top_k, where=where, reranker=self._current_reranker(), @@ -748,4 +922,4 @@ async def search_profiles(self, query: str, **kwargs: Any) -> list[dict[str, Any query_embedding=await self._prepare_query_embedding(query), ) logger.info("[认知服务] 搜索侧写完成: count=%s", len(results)) - return results + return cast(list[dict[str, Any]], results) diff --git a/src/Undefined/cognitive/vector_store.py b/src/Undefined/cognitive/vector_store.py index 076b8a9f..a15a90e8 100644 --- a/src/Undefined/cognitive/vector_store.py +++ b/src/Undefined/cognitive/vector_store.py @@ -11,13 +11,20 @@ from typing import Any import chromadb - -from Undefined.utils.coerce import safe_float from chromadb.errors import InternalError as ChromaInternalError import numpy as np from numba import njit from numpy.typing import NDArray +from Undefined.cognitive.chroma_scheduler import ( + CHROMA_PRIORITY_BACKGROUND, + CHROMA_PRIORITY_FOREGROUND, + ChromaOperationReceipt, + ChromaOperationScheduler, + normalize_chroma_priority, +) +from Undefined.utils.coerce import safe_float + logger = logging.getLogger(__name__) _QUERY_EMBEDDING_CACHE_TTL_SECONDS = 60.0 @@ -173,7 +180,13 @@ def _mmr_select( class CognitiveVectorStore: - def __init__(self, path: str | Path, embedder: Any) -> None: + def __init__( + self, + path: str | Path, + embedder: Any, + *, + scheduler_foreground_burst: int = 8, + ) -> None: client = chromadb.PersistentClient(path=str(path)) self._client = client self._events = client.get_or_create_collection( @@ -183,19 +196,21 @@ def __init__(self, path: str | Path, embedder: Any) -> None: "cognitive_profiles", metadata={"hnsw:space": "cosine"} ) self._embedder = embedder - self._events_lock = asyncio.Lock() - self._profiles_lock = asyncio.Lock() + self._chroma_scheduler = ChromaOperationScheduler( + foreground_burst=scheduler_foreground_burst + ) self._query_embedding_cache: OrderedDict[ tuple[str, str, str, str], tuple[float, list[float]] ] = OrderedDict() self._query_embedding_cache_lock = asyncio.Lock() logger.info( - "[认知向量库] 初始化完成: path=%s events=%s profiles=%s query_cache_ttl=%ss query_cache_size=%s", + "[认知向量库] 初始化完成: path=%s events=%s profiles=%s query_cache_ttl=%ss query_cache_size=%s scheduler_foreground_burst=%s", str(path), getattr(self._events, "name", "cognitive_events"), getattr(self._profiles, "name", "cognitive_profiles"), _QUERY_EMBEDDING_CACHE_TTL_SECONDS, _QUERY_EMBEDDING_CACHE_MAX_SIZE, + self._chroma_scheduler.foreground_burst, ) async def _embed(self, text: str) -> list[float]: @@ -290,33 +305,62 @@ def _is_transient_query_error(exc: Exception) -> bool: text = str(exc).lower() return "error finding id" in text or "error executing plan" in text - def _collection_lock(self, col: Any) -> asyncio.Lock: - if col is self._events: - return self._events_lock - return self._profiles_lock + async def stop(self) -> None: + await self._chroma_scheduler.stop() + + async def _run_chroma_operation( + self, + *, + priority: str, + operation: str, + collection: str, + callback: Any, + ) -> tuple[Any, ChromaOperationReceipt]: + return await self._chroma_scheduler.run( + priority=priority, + operation=operation, + collection=collection, + callback=callback, + ) async def upsert_event( - self, event_id: str, document: str, metadata: dict[str, Any] + self, + event_id: str, + document: str, + metadata: dict[str, Any], + *, + priority: str = CHROMA_PRIORITY_BACKGROUND, ) -> None: safe_metadata = _sanitize_metadata(metadata) + safe_priority = normalize_chroma_priority(priority, CHROMA_PRIORITY_BACKGROUND) logger.info( - "[认知向量库] 写入事件: event_id=%s doc_len=%s metadata_keys=%s", + "[认知向量库] 写入事件: event_id=%s doc_len=%s metadata_keys=%s priority=%s", event_id, len(document or ""), sorted(safe_metadata.keys()), + safe_priority, ) emb = await self._embed(document) col = self._events - async with self._events_lock: - await asyncio.to_thread( - lambda: col.upsert( - ids=[event_id], - documents=[document], - embeddings=[emb], # type: ignore[arg-type] - metadatas=[safe_metadata], - ) - ) - logger.info("[认知向量库] 事件写入完成: event_id=%s", event_id) + col_name = getattr(col, "name", "cognitive_events") + _, receipt = await self._run_chroma_operation( + priority=safe_priority, + operation="upsert_event", + collection=col_name, + callback=lambda: col.upsert( + ids=[event_id], + documents=[document], + embeddings=[emb], # type: ignore[arg-type] + metadatas=[safe_metadata], + ), + ) + logger.info( + "[认知向量库] 事件写入完成: event_id=%s priority=%s chroma_wait=%.3fs chroma_exec=%.3fs", + event_id, + safe_priority, + receipt.queue_wait_seconds, + receipt.exec_seconds, + ) async def query_events( self, @@ -331,9 +375,11 @@ async def query_events( time_decay_min_similarity: float = 0.35, apply_mmr: bool = False, query_embedding: list[float] | None = None, + priority: str = CHROMA_PRIORITY_FOREGROUND, ) -> list[dict[str, Any]]: + safe_priority = normalize_chroma_priority(priority, CHROMA_PRIORITY_FOREGROUND) logger.info( - "[认知向量库] 查询事件: query_len=%s top_k=%s where=%s reranker=%s multiplier=%s decay_enabled=%s half_life_days=%s boost=%s min_sim=%s mmr=%s", + "[认知向量库] 查询事件: query_len=%s top_k=%s where=%s reranker=%s multiplier=%s decay_enabled=%s half_life_days=%s boost=%s min_sim=%s mmr=%s priority=%s", len(query_text or ""), top_k, where or {}, @@ -344,6 +390,7 @@ async def query_events( time_decay_boost, time_decay_min_similarity, apply_mmr, + safe_priority, ) return await self._query( self._events, @@ -358,30 +405,47 @@ async def query_events( time_decay_min_similarity=time_decay_min_similarity, apply_mmr=apply_mmr, query_embedding=query_embedding, + priority=safe_priority, ) async def upsert_profile( - self, profile_id: str, document: str, metadata: dict[str, Any] + self, + profile_id: str, + document: str, + metadata: dict[str, Any], + *, + priority: str = CHROMA_PRIORITY_BACKGROUND, ) -> None: safe_metadata = _sanitize_metadata(metadata) + safe_priority = normalize_chroma_priority(priority, CHROMA_PRIORITY_BACKGROUND) logger.info( - "[认知向量库] 写入侧写向量: profile_id=%s doc_len=%s metadata_keys=%s", + "[认知向量库] 写入侧写向量: profile_id=%s doc_len=%s metadata_keys=%s priority=%s", profile_id, len(document or ""), sorted(safe_metadata.keys()), + safe_priority, ) emb = await self._embed(document) col = self._profiles - async with self._profiles_lock: - await asyncio.to_thread( - lambda: col.upsert( - ids=[profile_id], - documents=[document], - embeddings=[emb], # type: ignore[arg-type] - metadatas=[safe_metadata], - ) - ) - logger.info("[认知向量库] 侧写向量写入完成: profile_id=%s", profile_id) + col_name = getattr(col, "name", "cognitive_profiles") + _, receipt = await self._run_chroma_operation( + priority=safe_priority, + operation="upsert_profile", + collection=col_name, + callback=lambda: col.upsert( + ids=[profile_id], + documents=[document], + embeddings=[emb], # type: ignore[arg-type] + metadatas=[safe_metadata], + ), + ) + logger.info( + "[认知向量库] 侧写向量写入完成: profile_id=%s priority=%s chroma_wait=%.3fs chroma_exec=%.3fs", + profile_id, + safe_priority, + receipt.queue_wait_seconds, + receipt.exec_seconds, + ) async def query_profiles( self, @@ -391,14 +455,17 @@ async def query_profiles( reranker: Any = None, candidate_multiplier: int = 3, query_embedding: list[float] | None = None, + priority: str = CHROMA_PRIORITY_FOREGROUND, ) -> list[dict[str, Any]]: + safe_priority = normalize_chroma_priority(priority, CHROMA_PRIORITY_FOREGROUND) logger.info( - "[认知向量库] 查询侧写: query_len=%s top_k=%s where=%s reranker=%s multiplier=%s", + "[认知向量库] 查询侧写: query_len=%s top_k=%s where=%s reranker=%s multiplier=%s priority=%s", len(query_text or ""), top_k, where or {}, bool(reranker), candidate_multiplier, + safe_priority, ) return await self._query( self._profiles, @@ -408,6 +475,7 @@ async def query_profiles( reranker, candidate_multiplier, query_embedding=query_embedding, + priority=safe_priority, ) async def _query( @@ -425,18 +493,21 @@ async def _query( time_decay_min_similarity: float = 0.35, apply_mmr: bool = False, query_embedding: list[float] | None = None, + priority: str = CHROMA_PRIORITY_FOREGROUND, ) -> list[dict[str, Any]]: col_name = getattr(col, "name", "unknown") + safe_priority = normalize_chroma_priority(priority, CHROMA_PRIORITY_FOREGROUND) safe_top_k = _safe_positive_int(top_k, default=1, maximum=500) safe_multiplier = _safe_positive_int(candidate_multiplier, default=1) total_started = time.perf_counter() logger.debug( - "[认知向量库] 开始查询 collection=%s top_k=%s where=%s decay=%s mmr=%s", + "[认知向量库] 开始查询 collection=%s top_k=%s where=%s decay=%s mmr=%s priority=%s", col_name, safe_top_k, where or {}, apply_time_decay, apply_mmr, + safe_priority, ) embed_started = time.perf_counter() emb, embedding_source = await self._resolve_query_embedding( @@ -469,14 +540,21 @@ async def _query( def _q() -> Any: return col.query(**kwargs) - query_lock = self._collection_lock(col) - chroma_started = time.perf_counter() + chroma_wait_duration = 0.0 + chroma_exec_duration = 0.0 last_exc: Exception | None = None raw: dict[str, Any] | None = None for attempt in range(1, _CHROMA_TRANSIENT_QUERY_RETRIES + 1): try: - async with query_lock: - raw = await asyncio.to_thread(_q) + raw_result, receipt = await self._run_chroma_operation( + priority=safe_priority, + operation="query", + collection=col_name, + callback=_q, + ) + raw = raw_result + chroma_wait_duration += receipt.queue_wait_seconds + chroma_exec_duration += receipt.exec_seconds last_exc = None break except Exception as exc: @@ -499,7 +577,6 @@ def _q() -> Any: if last_exc is not None: raise last_exc raise RuntimeError(f"query returned no result for collection={col_name}") - chroma_duration = time.perf_counter() - chroma_started docs: list[str] = (raw.get("documents") or [[]])[0] metas: list[dict[str, Any]] = (raw.get("metadatas") or [[]])[0] dists: list[float] = (raw.get("distances") or [[]])[0] @@ -606,14 +683,16 @@ def _q() -> Any: len(final), ) logger.info( - "[认知向量库] 查询阶段耗时: collection=%s embed=%.3fs chroma_query=%.3fs rerank=%.3fs post_rank=%.3fs total=%.3fs embedding_source=%s", + "[认知向量库] 查询阶段耗时: collection=%s embed=%.3fs chroma_wait=%.3fs chroma_exec=%.3fs rerank=%.3fs post_rank=%.3fs total=%.3fs embedding_source=%s priority=%s", col_name, embed_duration, - chroma_duration, + chroma_wait_duration, + chroma_exec_duration, rerank_duration, post_rank_duration, total_duration, embedding_source, + safe_priority, ) return final diff --git a/src/Undefined/cognitive/vector_store_compat.py b/src/Undefined/cognitive/vector_store_compat.py new file mode 100644 index 00000000..2e18364b --- /dev/null +++ b/src/Undefined/cognitive/vector_store_compat.py @@ -0,0 +1,38 @@ +"""Compatibility helpers for cognitive vector store calls.""" + +from __future__ import annotations + +import inspect +from typing import Any + + +def _accepts_keyword(method: Any, keyword: str) -> bool: + try: + signature = inspect.signature(method) + except (TypeError, ValueError): + return True + for parameter in signature.parameters.values(): + if parameter.kind is inspect.Parameter.VAR_KEYWORD: + return True + if parameter.name == keyword and parameter.kind in { + inspect.Parameter.KEYWORD_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + }: + return True + return False + + +async def call_vector_store_method( + method: Any, + *args: Any, + priority: str, + **kwargs: Any, +) -> Any: + """Call a vector-store method with priority when the method supports it.""" + call_kwargs = dict(kwargs) + if _accepts_keyword(method, "priority"): + call_kwargs["priority"] = priority + result = method(*args, **call_kwargs) + if inspect.isawaitable(result): + return await result + return result diff --git a/src/Undefined/config/config_class.py b/src/Undefined/config/config_class.py index b29a786e..aa2e2d6a 100644 --- a/src/Undefined/config/config_class.py +++ b/src/Undefined/config/config_class.py @@ -129,6 +129,7 @@ class Config: webui_url: str webui_port: int webui_password: str + webui_autostart_bot: bool api: APIConfig # Code Delivery Agent code_delivery_enabled: bool @@ -188,6 +189,7 @@ class Config: # GitHub 仓库自动提取 github_auto_extract_enabled: bool github_request_timeout_seconds: float + github_request_retries: int github_auto_extract_group_ids: list[int] github_auto_extract_private_ids: list[int] github_auto_extract_max_items: int diff --git a/src/Undefined/config/domain_parsers.py b/src/Undefined/config/domain_parsers.py index c0de5fa0..9d8b35e5 100644 --- a/src/Undefined/config/domain_parsers.py +++ b/src/Undefined/config/domain_parsers.py @@ -46,6 +46,17 @@ def _parse_cognitive_config(data: dict[str, Any]) -> CognitiveConfig: vs.get("path") if isinstance(vs, dict) else None, "data/cognitive/chromadb", ), + vector_store_scheduler_foreground_burst=max( + 1, + _coerce_int( + ( + vs.get("scheduler_foreground_burst") + if isinstance(vs, dict) + else None + ), + 8, + ), + ), queue_path=_coerce_str( que.get("path") if isinstance(que, dict) else None, "data/cognitive/queues", diff --git a/src/Undefined/config/load_sections/domains.py b/src/Undefined/config/load_sections/domains.py index 6ada6453..ab4d8075 100644 --- a/src/Undefined/config/load_sections/domains.py +++ b/src/Undefined/config/load_sections/domains.py @@ -46,6 +46,7 @@ def load_domains( "webui_url": webui_settings.url, "webui_port": webui_settings.port, "webui_password": webui_settings.password, + "webui_autostart_bot": webui_settings.autostart_bot, "api": api_config, "cognitive": cognitive, "memes": memes, diff --git a/src/Undefined/config/load_sections/integrations.py b/src/Undefined/config/load_sections/integrations.py index ebbafdf7..9c50e718 100644 --- a/src/Undefined/config/load_sections/integrations.py +++ b/src/Undefined/config/load_sections/integrations.py @@ -19,6 +19,13 @@ logger = logging.getLogger(__name__) +_DEFAULT_GITHUB_REQUEST_TIMEOUT_SECONDS: float = 10.0 +_DEFAULT_GITHUB_REQUEST_RETRIES: int = 2 +_MAX_GITHUB_REQUEST_TIMEOUT_SECONDS: float = 60.0 +_MAX_GITHUB_REQUEST_RETRIES: int = 5 +_DEFAULT_GITHUB_AUTO_EXTRACT_MAX_ITEMS: int = 3 +_MAX_GITHUB_AUTO_EXTRACT_MAX_ITEMS: int = 10 + def load_integrations( data: dict[str, Any], *, config_path: Optional[Path] = None @@ -109,12 +116,21 @@ def load_integrations( _get_value(data, ("github", "auto_extract_enabled"), None), False ) github_request_timeout_seconds = _coerce_float( - _get_value(data, ("github", "request_timeout_seconds"), None), 10.0 + _get_value(data, ("github", "request_timeout_seconds"), None), + _DEFAULT_GITHUB_REQUEST_TIMEOUT_SECONDS, ) if github_request_timeout_seconds <= 0: - github_request_timeout_seconds = 10.0 - if github_request_timeout_seconds > 60.0: - github_request_timeout_seconds = 60.0 + github_request_timeout_seconds = _DEFAULT_GITHUB_REQUEST_TIMEOUT_SECONDS + if github_request_timeout_seconds > _MAX_GITHUB_REQUEST_TIMEOUT_SECONDS: + github_request_timeout_seconds = _MAX_GITHUB_REQUEST_TIMEOUT_SECONDS + github_request_retries = _coerce_int( + _get_value(data, ("github", "request_retries"), None), + _DEFAULT_GITHUB_REQUEST_RETRIES, + ) + if github_request_retries < 0: + github_request_retries = 0 + if github_request_retries > _MAX_GITHUB_REQUEST_RETRIES: + github_request_retries = _MAX_GITHUB_REQUEST_RETRIES github_auto_extract_group_ids = _coerce_int_list( _get_value(data, ("github", "auto_extract_group_ids"), None) ) @@ -122,12 +138,13 @@ def load_integrations( _get_value(data, ("github", "auto_extract_private_ids"), None) ) github_auto_extract_max_items = _coerce_int( - _get_value(data, ("github", "auto_extract_max_items"), None), 3 + _get_value(data, ("github", "auto_extract_max_items"), None), + _DEFAULT_GITHUB_AUTO_EXTRACT_MAX_ITEMS, ) if github_auto_extract_max_items <= 0: - github_auto_extract_max_items = 3 - if github_auto_extract_max_items > 10: - github_auto_extract_max_items = 10 + github_auto_extract_max_items = _DEFAULT_GITHUB_AUTO_EXTRACT_MAX_ITEMS + if github_auto_extract_max_items > _MAX_GITHUB_AUTO_EXTRACT_MAX_ITEMS: + github_auto_extract_max_items = _MAX_GITHUB_AUTO_EXTRACT_MAX_ITEMS # Code Delivery Agent 配置 code_delivery_enabled = _coerce_bool( @@ -251,6 +268,7 @@ def load_integrations( "arxiv_summary_preview_chars": arxiv_summary_preview_chars, "github_auto_extract_enabled": github_auto_extract_enabled, "github_request_timeout_seconds": github_request_timeout_seconds, + "github_request_retries": github_request_retries, "github_auto_extract_group_ids": github_auto_extract_group_ids, "github_auto_extract_private_ids": github_auto_extract_private_ids, "github_auto_extract_max_items": github_auto_extract_max_items, diff --git a/src/Undefined/config/models.py b/src/Undefined/config/models.py index f0011f1c..e97494a7 100644 --- a/src/Undefined/config/models.py +++ b/src/Undefined/config/models.py @@ -304,6 +304,7 @@ class CognitiveConfig: # 史官改写时 bot 自身的称呼(仅影响认知记忆事件文本,不影响主提示词) bot_name: str = "Undefined" vector_store_path: str = "data/cognitive/chromadb" + vector_store_scheduler_foreground_burst: int = 8 queue_path: str = "data/cognitive/queues" profiles_path: str = "data/cognitive/profiles" auto_top_k: int = 3 diff --git a/src/Undefined/config/webui_settings.py b/src/Undefined/config/webui_settings.py index 45a5392b..b5879e6c 100644 --- a/src/Undefined/config/webui_settings.py +++ b/src/Undefined/config/webui_settings.py @@ -6,11 +6,12 @@ from pathlib import Path from typing import Optional -from .coercers import _coerce_int, _coerce_str, _normalize_str, _get_value +from .coercers import _coerce_bool, _coerce_int, _coerce_str, _normalize_str, _get_value DEFAULT_WEBUI_URL = "127.0.0.1" DEFAULT_WEBUI_PORT = 8787 DEFAULT_WEBUI_PASSWORD = "changeme" +DEFAULT_WEBUI_AUTOSTART_BOT = False @dataclass @@ -18,6 +19,7 @@ class WebUISettings: url: str port: int password: str + autostart_bot: bool using_default_password: bool config_exists: bool @@ -37,11 +39,13 @@ def load_webui_settings(config_path: Optional[Path] = None) -> WebUISettings: url_value = _get_value(data, ("webui", "url"), None) port_value = _get_value(data, ("webui", "port"), None) password_value = _get_value(data, ("webui", "password"), None) + autostart_bot_value = _get_value(data, ("webui", "autostart_bot"), None) url = _coerce_str(url_value, DEFAULT_WEBUI_URL) port = _coerce_int(port_value, DEFAULT_WEBUI_PORT) if port <= 0 or port > 65535: port = DEFAULT_WEBUI_PORT + autostart_bot = _coerce_bool(autostart_bot_value, DEFAULT_WEBUI_AUTOSTART_BOT) password_normalized = _normalize_str(password_value) if not password_normalized: @@ -49,6 +53,7 @@ def load_webui_settings(config_path: Optional[Path] = None) -> WebUISettings: url=url, port=port, password=DEFAULT_WEBUI_PASSWORD, + autostart_bot=autostart_bot, using_default_password=True, config_exists=config_exists, ) @@ -56,6 +61,7 @@ def load_webui_settings(config_path: Optional[Path] = None) -> WebUISettings: url=url, port=port, password=password_normalized, + autostart_bot=autostart_bot, using_default_password=False, config_exists=config_exists, ) diff --git a/src/Undefined/github/client.py b/src/Undefined/github/client.py index 574c5ef5..0eb367ab 100644 --- a/src/Undefined/github/client.py +++ b/src/Undefined/github/client.py @@ -15,6 +15,8 @@ "User-Agent": "Undefined-bot/3.x (https://github.com/69gg/Undefined)", "X-GitHub-Api-Version": "2022-11-28", } +DEFAULT_REQUEST_TIMEOUT_SECONDS: float = 10.0 +DEFAULT_REQUEST_RETRIES: int = 2 def _as_str(value: object) -> str: @@ -81,6 +83,7 @@ async def _fetch_contributor_count( repo_id: str, *, request_timeout: float, + request_retries: int, context: dict[str, object] | None, ) -> int | None: response = await request_with_retry( @@ -88,10 +91,10 @@ async def _fetch_contributor_count( f"{_API_BASE_URL}/repos/{repo_id}/contributors", params={"per_page": 1}, headers=_HEADERS, - default_timeout=request_timeout, + timeout=request_timeout, follow_redirects=True, context=context, - retries=0, + retries=request_retries, ) return _parse_contributor_count(response.headers.get("link", ""), response.json()) @@ -112,7 +115,7 @@ def _parse_repo_info( stars=_as_int(payload.get("stargazers_count")), forks=_as_int(payload.get("forks_count")), open_issues=_as_int(payload.get("open_issues_count")), - watchers=_as_int(payload.get("watchers_count")), + watchers=_as_optional_int(payload.get("subscribers_count")), subscribers=_as_optional_int(payload.get("subscribers_count")), contributors=contributor_count, language=_as_str(payload.get("language")), @@ -130,7 +133,8 @@ def _parse_repo_info( async def get_public_repo_info( repo_id: str, *, - request_timeout: float = 10.0, + request_timeout: float = DEFAULT_REQUEST_TIMEOUT_SECONDS, + request_retries: int = DEFAULT_REQUEST_RETRIES, context: dict[str, object] | None = None, ) -> GitHubRepoInfo: """获取 public GitHub 仓库信息。""" @@ -142,10 +146,10 @@ async def get_public_repo_info( "GET", f"{_API_BASE_URL}/repos/{normalized}", headers=_HEADERS, - default_timeout=request_timeout, + timeout=request_timeout, follow_redirects=True, context=context, - retries=0, + retries=request_retries, ) payload = response.json() if not isinstance(payload, dict): @@ -158,6 +162,7 @@ async def get_public_repo_info( contributor_count = await _fetch_contributor_count( normalized, request_timeout=request_timeout, + request_retries=request_retries, context=context, ) except Exception: diff --git a/src/Undefined/github/models.py b/src/Undefined/github/models.py index 4724c3be..50607bf7 100644 --- a/src/Undefined/github/models.py +++ b/src/Undefined/github/models.py @@ -19,7 +19,7 @@ class GitHubRepoInfo: stars: int forks: int open_issues: int - watchers: int + watchers: int | None subscribers: int | None contributors: int | None language: str diff --git a/src/Undefined/github/sender.py b/src/Undefined/github/sender.py index df443a4e..690a432e 100644 --- a/src/Undefined/github/sender.py +++ b/src/Undefined/github/sender.py @@ -8,7 +8,11 @@ from typing import TYPE_CHECKING, Literal import uuid -from Undefined.github.client import get_public_repo_info +from Undefined.github.client import ( + DEFAULT_REQUEST_RETRIES, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + get_public_repo_info, +) from Undefined.github.models import GitHubRepoInfo from Undefined.render import render_html_to_image from Undefined.skills.http_config import get_request_proxy @@ -318,13 +322,15 @@ async def send_github_repo_card( sender: "MessageSender", target_type: Literal["group", "private"], target_id: int, - request_timeout: float = 10.0, + request_timeout: float = DEFAULT_REQUEST_TIMEOUT_SECONDS, + request_retries: int = DEFAULT_REQUEST_RETRIES, context: dict[str, object] | None = None, ) -> str: """获取 public 仓库信息并发送图片卡片。""" info = await get_public_repo_info( repo_id, request_timeout=request_timeout, + request_retries=request_retries, context=context, ) output_dir = ensure_dir(RENDER_CACHE_DIR / "github") diff --git a/src/Undefined/handlers/auto_extract.py b/src/Undefined/handlers/auto_extract.py index 8375f8cb..ef8a5f99 100644 --- a/src/Undefined/handlers/auto_extract.py +++ b/src/Undefined/handlers/auto_extract.py @@ -183,13 +183,24 @@ async def _handle_github_extract( target_type: str, ) -> None: """处理 GitHub 仓库自动提取和发送。""" + from Undefined.github.client import ( + DEFAULT_REQUEST_RETRIES, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + ) from Undefined.github.sender import send_github_repo_card max_items = max( 1, int(getattr(self.config, "github_auto_extract_max_items", 3)) ) request_timeout = float( - getattr(self.config, "github_request_timeout_seconds", 10.0) + getattr( + self.config, + "github_request_timeout_seconds", + DEFAULT_REQUEST_TIMEOUT_SECONDS, + ) + ) + request_retries = int( + getattr(self.config, "github_request_retries", DEFAULT_REQUEST_RETRIES) ) for repo_id in repo_ids[:max_items]: @@ -200,6 +211,7 @@ async def _handle_github_extract( target_type=target_type, # type: ignore[arg-type] target_id=target_id, request_timeout=request_timeout, + request_retries=request_retries, context={ "request_id": ( f"github_auto_extract:{target_type}:{target_id}:{repo_id}" @@ -214,10 +226,11 @@ async def _handle_github_extract( result, ) except Exception as exc: - logger.info( - "[GitHub] 自动提取跳过 %s → %s:%s: %s", + logger.exception( + "[GitHub] 自动提取跳过 %s → %s:%s: exc_type=%s exc=%r", repo_id, target_type, target_id, + type(exc).__name__, exc, ) diff --git a/src/Undefined/handlers/message_flow.py b/src/Undefined/handlers/message_flow.py index b9b2a5a5..ad9bc175 100644 --- a/src/Undefined/handlers/message_flow.py +++ b/src/Undefined/handlers/message_flow.py @@ -18,6 +18,7 @@ build_attachment_scope, register_message_attachments, ) +from Undefined.attachments.models import RegisteredMessageAttachments from Undefined.ai import AIClient from Undefined.config import Config from Undefined.faq import FAQStorage @@ -57,6 +58,18 @@ def _is_private_model_pool_control_text(text: str) -> bool: return bool(ModelPoolService.is_private_control_text(text)) +def _coerce_registered_attachments(value: Any) -> RegisteredMessageAttachments: + """兼容测试替身:将旧的附件列表返回值转为注册结果对象。""" + if isinstance(value, RegisteredMessageAttachments): + return value + if isinstance(value, list): + return RegisteredMessageAttachments( + attachments=[item for item in value if isinstance(item, dict)], + normalized_text="", + ) + return RegisteredMessageAttachments(attachments=[], normalized_text="") + + class MessageHandler(PokeMixin, RepeatMixin, AutoExtractMixin): """消息处理器。 @@ -130,7 +143,9 @@ def __init__( self._pipelines_initialized = False self._pipelines_init_lock = asyncio.Lock() - self._repeat_counter: dict[int, list[tuple[str, int]]] = {} + self._repeat_counter: dict[ + int, list[tuple[str, int, tuple[tuple[str, str], ...]]] + ] = {} self._repeat_locks: dict[int, asyncio.Lock] = {} self._repeat_cooldown: dict[int, dict[str, float]] = {} @@ -235,20 +250,20 @@ async def _collect_message_attachments( group_id: int | None = None, user_id: int | None = None, request_type: str, - ) -> list[dict[str, str]]: + ) -> RegisteredMessageAttachments: scope_key = build_attachment_scope( group_id=group_id, user_id=user_id, request_type=request_type, ) if not scope_key: - return [] + return RegisteredMessageAttachments(attachments=[], normalized_text="") ai_client = getattr(self, "ai", None) attachment_registry = ( getattr(ai_client, "attachment_registry", None) if ai_client else None ) if attachment_registry is None: - return [] + return RegisteredMessageAttachments(attachments=[], normalized_text="") onebot = getattr(self, "onebot", None) resolve_image_url = getattr(onebot, "get_image", None) if onebot else None result = await register_message_attachments( @@ -263,7 +278,10 @@ async def _collect_message_attachments( attachments = result.attachments # 命中表情库时为 AI 上下文补充 [表情包] 描述 attachments = await self._annotate_meme_descriptions(attachments, scope_key) - return attachments + return RegisteredMessageAttachments( + attachments=attachments, + normalized_text=result.normalized_text, + ) def _schedule_meme_ingest( self, @@ -432,7 +450,7 @@ async def _handle_private_message(self, event: dict[str, Any]) -> None: logger.warning("获取用户昵称失败: %s", exc) text = handlers_module.extract_text(private_message_content, self.config.bot_qq) - private_attachments, parsed_content_raw = await asyncio.gather( + private_registered_raw, parsed_content_raw = await asyncio.gather( self._collect_message_attachments( private_message_content, user_id=private_sender_id, @@ -445,6 +463,8 @@ async def _handle_private_message(self, event: dict[str, Any]) -> None: self.onebot.get_forward_msg, ), ) + private_registered = _coerce_registered_attachments(private_registered_raw) + private_attachments = private_registered.attachments safe_text = redact_string(text) logger.info( "[私聊消息] 发送者=%s 昵称=%s 内容=%s", @@ -459,7 +479,10 @@ async def _handle_private_message(self, event: dict[str, Any]) -> None: sender_name=resolved_private_name, ) - parsed_content = append_attachment_text(parsed_content_raw, private_attachments) + parsed_content_base = private_registered.normalized_text or parsed_content_raw + parsed_content = append_attachment_text( + parsed_content_base, private_attachments + ) safe_parsed = redact_string(parsed_content) logger.debug( "[历史记录] 保存私聊: user=%s content=%s...", @@ -530,7 +553,7 @@ async def _handle_private_message(self, event: dict[str, Any]) -> None: await self.ai_coordinator.handle_private_reply( private_sender_id, - text, + parsed_content_base, private_message_content, attachments=private_attachments, sender_name=user_name, @@ -578,7 +601,7 @@ async def _fetch_group_name() -> str: logger.warning(f"获取群聊名失败: {e}") return "" - group_attachments, group_name, parsed_content_raw = await asyncio.gather( + group_registered_raw, group_name, parsed_content_raw = await asyncio.gather( self._collect_message_attachments( message_content, group_id=group_id, @@ -592,6 +615,8 @@ async def _fetch_group_name() -> str: self.onebot.get_forward_msg, ), ) + group_registered = _coerce_registered_attachments(group_registered_raw) + group_attachments = group_registered.attachments resolved_group_sender_name = (sender_card or sender_nickname or "").strip() self._schedule_profile_display_name_refresh( @@ -602,7 +627,8 @@ async def _fetch_group_name() -> str: group_name=str(group_name or "").strip(), ) - parsed_content = append_attachment_text(parsed_content_raw, group_attachments) + parsed_content_base = group_registered.normalized_text or parsed_content_raw + parsed_content = append_attachment_text(parsed_content_base, group_attachments) safe_parsed = redact_string(parsed_content) logger.debug( f"[历史记录] 保存群聊: group={group_id}, sender={sender_id}, content={safe_parsed[:50]}..." @@ -623,7 +649,7 @@ async def _fetch_group_name() -> str: # 机器人发言计入复读计数,防止 bot 复读自身 if sender_id == self.config.bot_qq: - await self._append_bot_repeat_counter(group_id, text) + await self._append_bot_repeat_counter(group_id, parsed_content_base) return self._schedule_meme_ingest( @@ -680,7 +706,12 @@ async def _fetch_group_name() -> str: return # 复读命中则跳过管线与 AI 自动回复 - if await self._maybe_trigger_repeat(group_id, sender_id, text): + if await self._maybe_trigger_repeat( + group_id, + sender_id, + parsed_content_base, + attachments=group_attachments, + ): return await self._run_pipelines( @@ -694,7 +725,7 @@ async def _fetch_group_name() -> str: await self.ai_coordinator.handle_auto_reply( group_id, sender_id, - normalized_text, + normalized_text if not group_attachments else parsed_content_base, message_content, attachments=group_attachments, sender_name=display_name, diff --git a/src/Undefined/handlers/repeat.py b/src/Undefined/handlers/repeat.py index 5307e792..706aa606 100644 --- a/src/Undefined/handlers/repeat.py +++ b/src/Undefined/handlers/repeat.py @@ -14,6 +14,11 @@ from Undefined.config import Config from Undefined.utils.sender import MessageSender +from Undefined.attachments import ( + build_attachment_scope, + dispatch_pending_file_sends, + render_message_with_attachments, +) from Undefined.utils.logging import redact_string logger = logging.getLogger(__name__) @@ -27,7 +32,7 @@ class RepeatMixin: if TYPE_CHECKING: config: Config sender: MessageSender - _repeat_counter: dict[int, list[tuple[str, int]]] + _repeat_counter: dict[int, list[tuple[str, int, tuple[tuple[str, str], ...]]]] _repeat_locks: dict[int, asyncio.Lock] _repeat_cooldown: dict[int, dict[str, float]] @@ -77,25 +82,66 @@ async def _append_bot_repeat_counter(self, group_id: int, text: str) -> None: return async with self._get_repeat_lock(group_id): counter = self._repeat_counter.setdefault(group_id, []) - counter.append((text, self.config.bot_qq)) + counter.append((text, self.config.bot_qq, ())) n = self.config.repeat_threshold if len(counter) > n: self._repeat_counter[group_id] = counter[-n:] + @staticmethod + def _freeze_repeat_attachments( + attachments: list[dict[str, str]] | None, + ) -> tuple[tuple[str, str], ...]: + """构建稳定、可比较且可恢复的附件引用快照。""" + if not attachments: + return () + frozen_items: list[tuple[str, str]] = [] + seen_uids: set[str] = set() + for item in attachments: + uid = str(item.get("uid", "") or "").strip() + if not uid or uid in seen_uids: + continue + seen_uids.add(uid) + for key, value in sorted(item.items()): + text = str(value or "").strip() + if text: + frozen_items.append((f"{uid}\x00{key}", text)) + return tuple(frozen_items) + + @staticmethod + def _unfreeze_repeat_attachments( + frozen: tuple[tuple[str, str], ...], + ) -> list[dict[str, str]]: + restored: dict[str, dict[str, str]] = {} + order: list[str] = [] + for compound_key, value in frozen: + uid, separator, key = compound_key.partition("\x00") + if not separator or not uid or not key: + continue + if uid not in restored: + restored[uid] = {"uid": uid} + order.append(uid) + restored[uid][key] = value + return [restored[uid] for uid in order] + async def _maybe_trigger_repeat( self, group_id: int, sender_id: int, text: str, + *, + attachments: list[dict[str, str]] | None = None, ) -> bool: """尝试触发群聊复读;若已发送复读消息则返回 True。""" if not self.config.repeat_enabled or not text: return False n = self.config.repeat_threshold + frozen_attachments = self._freeze_repeat_attachments(attachments) + reply_text = "" + reply_attachments: list[dict[str, str]] = [] async with self._get_repeat_lock(group_id): counter = self._repeat_counter.setdefault(group_id, []) - counter.append((text, sender_id)) + counter.append((text, sender_id, frozen_attachments)) if len(counter) > n: self._repeat_counter[group_id] = counter[-n:] counter = self._repeat_counter[group_id] @@ -104,8 +150,8 @@ async def _maybe_trigger_repeat( return False last_n = counter[-n:] - texts = [t for t, _ in last_n] - senders = [s for _, s in last_n] + texts = [t for t, _s, _a in last_n] + senders = [s for _t, s, _a in last_n] # 连续 n 条文本相同且来自 n 个不同发送者,且 bot 未参与 if not ( len(set(texts)) == 1 @@ -133,14 +179,61 @@ async def _maybe_trigger_repeat( self._repeat_counter[group_id] = [] self._record_repeat_cooldown(group_id, texts[0]) - logger.info( - "[复读] 触发复读: group=%s text=%s", - group_id, - redact_string(reply_text)[:50], + reply_attachments = self._unfreeze_repeat_attachments(last_n[-1][2]) + + logger.info( + "[复读] 触发复读: group=%s text=%s", + group_id, + redact_string(reply_text)[:50], + ) + delivery_text = reply_text + history_text = reply_text + history_attachments = reply_attachments + rendered = None + if reply_attachments: + registry = getattr(self.sender, "attachment_registry", None) + if registry is None: + logger.warning( + "[复读] 附件注册表不可用,跳过附件复读发送: group=%s text=%s", + group_id, + redact_string(reply_text)[:50], + ) + return False + scope_key = build_attachment_scope( + group_id=group_id, + request_type="group", ) - await self.sender.send_group_message( - group_id, - reply_text, - history_prefix=REPEAT_REPLY_HISTORY_PREFIX, + try: + rendered = await render_message_with_attachments( + reply_text, + registry=registry, + scope_key=scope_key, + strict=False, + ) + delivery_text = rendered.delivery_text + history_text = rendered.history_text + history_attachments = list(rendered.attachments) or reply_attachments + except Exception: + logger.warning( + "[复读] 图片/附件渲染失败,跳过本次复读发送: group=%s text=%s", + group_id, + redact_string(reply_text)[:50], + exc_info=True, + ) + return False + await self.sender.send_group_message( + group_id, + delivery_text, + history_prefix=REPEAT_REPLY_HISTORY_PREFIX, + history_message=history_text, + attachments=history_attachments, + ) + if rendered is not None: + await dispatch_pending_file_sends( + rendered, + sender=self.sender, + target_type="group", + target_id=group_id, + registry=getattr(self.sender, "attachment_registry", None), ) - return True + return True diff --git a/src/Undefined/main.py b/src/Undefined/main.py index df95c086..8dd9ead7 100644 --- a/src/Undefined/main.py +++ b/src/Undefined/main.py @@ -313,6 +313,7 @@ async def main() -> None: vector_store = CognitiveVectorStore( str(_cog_chroma), retrieval_runtime, + scheduler_foreground_burst=config.cognitive.vector_store_scheduler_foreground_burst, ) job_queue = JobQueue(str(_cog_queues)) profile_storage = ProfileStorage( diff --git a/src/Undefined/services/ai_coordinator.py b/src/Undefined/services/ai_coordinator.py index c61b6017..dc4fec71 100644 --- a/src/Undefined/services/ai_coordinator.py +++ b/src/Undefined/services/ai_coordinator.py @@ -22,13 +22,17 @@ MessageBatcher, make_scope, ) +from Undefined.services.coordinator.message_ids import collect_message_ids from Undefined.utils.history import MessageHistoryManager from Undefined.utils.sender import MessageSender from Undefined.utils.scheduler import TaskScheduler from Undefined.services.security import SecurityService from Undefined.utils.recent_messages import get_recent_messages_prefer_local from Undefined.utils.resources import read_text_resource -from Undefined.utils.xml import escape_xml_attr, escape_xml_text +from Undefined.utils.xml import ( + escape_xml_attr, + escape_xml_text_preserving_attachment_tags, +) logger = logging.getLogger(__name__) @@ -50,6 +54,8 @@ 3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】 4. 其他技术问题 → 【酌情回复,直接按用户提到的对象回答,不要引入无关的项目名/工具名作背景】 5. 先判断当前输入批次(无连续消息说明时就是最后一条消息)是不是在对你说: + - 先看 sender_id、@/reply、前后文对话对象和当前群聊环境;不要先入为主把"你"、"AI"、"bot"、"机器人"当作在叫 Undefined + - 泛称或讨论其他 AI/bot/机器人时不算叫你;无法确认指向 Undefined 时默认不回复 - 如果明显是在和别人说话 → 【不要回复】 - 如果你不能确定是不是在和你说话 → 【默认不回复】 - 只有明确在和你说,或多人公开讨论且对话明显开放时,才进入下一步 @@ -302,6 +308,11 @@ async def _execute_auto_reply(self, request: dict[str, Any]) -> None: group_name = str(request.get("group_name") or "未知群聊") full_question = request["full_question"] trigger_message_id = request.get("trigger_message_id") + message_ids = [ + str(item).strip() + for item in request.get("message_ids", []) + if str(item).strip() + ] # 用于向 batcher 注册 inflight 任务(仅当本请求源自合并桶时生效) batcher_scope: str | None = make_scope(group_id=group_id) if group_id else None @@ -368,6 +379,8 @@ async def send_like_cb(uid: int, times: int = 1) -> None: ctx.set_resource(key, value) if trigger_message_id is not None: ctx.set_resource("trigger_message_id", trigger_message_id) + if message_ids: + ctx.set_resource("message_ids", list(message_ids)) if request.get("_queue_lane"): ctx.set_resource("queue_lane", request.get("_queue_lane")) logger.debug( @@ -410,6 +423,12 @@ async def send_like_cb(uid: int, times: int = 1) -> None: "is_at_bot": bool(request.get("is_at_bot", False)), "sender_name": sender_name, "group_name": group_name, + "message_ids": list(message_ids), + "batched_count": int(request.get("batched_count", 1) or 1), + "current_input_is_batched": int( + request.get("batched_count", 1) or 1 + ) + > 1, }, ) finally: @@ -438,6 +457,11 @@ async def _execute_private_reply(self, request: dict[str, Any]) -> None: sender_name = str(request.get("sender_name") or "未知用户") full_question = request["full_question"] trigger_message_id = request.get("trigger_message_id") + message_ids = [ + str(item).strip() + for item in request.get("message_ids", []) + if str(item).strip() + ] batcher_scope: str | None = make_scope(user_id=user_id) async with RequestContext( @@ -498,6 +522,8 @@ async def send_private_cb( ctx.set_resource(key, value) if trigger_message_id is not None: ctx.set_resource("trigger_message_id", trigger_message_id) + if message_ids: + ctx.set_resource("message_ids", list(message_ids)) if request.get("_queue_lane"): ctx.set_resource("queue_lane", request.get("_queue_lane")) logger.debug( @@ -536,6 +562,12 @@ async def send_private_cb( "is_private_chat": True, "sender_name": sender_name, "selected_model_name": request.get("selected_model_name"), + "message_ids": list(message_ids), + "batched_count": int(request.get("batched_count", 1) or 1), + "current_input_is_batched": int( + request.get("batched_count", 1) or 1 + ) + > 1, }, ) finally: @@ -801,7 +833,10 @@ def _format_group_message_segment(self, item: BufferedMessage) -> str: safe_role = escape_xml_attr(item.sender_role or "member") safe_title = escape_xml_attr(item.sender_title or "") safe_time = escape_xml_attr(time_str) - safe_text = escape_xml_text(item.text) + safe_text = escape_xml_text_preserving_attachment_tags( + item.text, + item.attachments, + ) message_id_attr = "" if item.trigger_message_id is not None: message_id_attr = ( @@ -831,7 +866,10 @@ def _format_private_message_segment(self, item: BufferedMessage) -> str: safe_name = escape_xml_attr(item.sender_name or "未知用户") safe_uid = escape_xml_attr(item.sender_id) safe_time = escape_xml_attr(time_str) - safe_text = escape_xml_text(item.text) + safe_text = escape_xml_text_preserving_attachment_tags( + item.text, + item.attachments, + ) message_id_attr = "" if item.trigger_message_id is not None: message_id_attr = ( @@ -895,6 +933,10 @@ def _build_grouped_prompt(self, items: list[BufferedMessage]) -> str: body += _GROUP_STRATEGY_FOOTER if not is_private else _PRIVATE_STRATEGY_FOOTER return body + @staticmethod + def _collect_message_ids(items: list[BufferedMessage]) -> list[str]: + return collect_message_ids(items) + async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: """根据一组 BufferedMessage 决定优先级、构造 prompt 并入队。 @@ -905,6 +947,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: first = items[0] last = items[-1] full_question = self._build_grouped_prompt(items) + message_ids = self._collect_message_ids(items) any_poke = any(it.is_poke for it in items) any_at_bot = any(it.is_at_bot for it in items) @@ -917,6 +960,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: "text": last.text, "full_question": full_question, "trigger_message_id": last.trigger_message_id, + "message_ids": message_ids, "batched_count": len(items), } if first.batch_token is not None: @@ -954,6 +998,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: "full_question": full_question, "is_at_bot": any_at_bot, "trigger_message_id": last.trigger_message_id, + "message_ids": message_ids, "batched_count": len(items), } if first.batch_token is not None: @@ -1010,7 +1055,7 @@ def _build_prompt( safe_role = escape_xml_attr(role) safe_title = escape_xml_attr(title) safe_time = escape_xml_attr(time_str) - safe_text = escape_xml_text(text) + safe_text = escape_xml_text_preserving_attachment_tags(text, attachments) message_id_attr = "" if message_id is not None: message_id_attr = f' message_id="{escape_xml_attr(message_id)}"' @@ -1021,32 +1066,7 @@ def _build_prompt( return f"""{prefix} {safe_text}{attachment_xml} - - 【回复策略 - 更克制,纯表情包才前置检索】 - 1. 如果用户 @ 了你或拍了拍你 → 【必须回复】 - 2. 如果消息中明确提到了你(根据上下文判断用户是否在叫你或维持对话流) → 【必须回复】 - 3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】 - 4. 其他技术问题 → 【酌情回复,直接按用户提到的对象回答,不要引入无关的项目名/工具名作背景】 - 5. 先判断当前输入批次(无连续消息说明时就是最后一条消息)是不是在对你说: - - 如果明显是在和别人说话 → 【不要回复】 - - 如果你不能确定是不是在和你说话 → 【默认不回复】 - - 只有明确在和你说,或多人公开讨论且对话明显开放时,才进入下一步 - 6. 群聊里的主动参与只保留给公开、开放的技术或项目讨论: - - 只在多人公开讨论代码、AI、开发工具、项目进展、技术 bug 等,且不是别人之间定向交流时,才可以【极低频参与】 - - 默认更倾向不参与;不要长篇大论,一两句点到为止;如果别人已经在深入讨论且不需要你,保持沉默 - - 轻松互动、玩梗、吐槽本身不构成参与许可;只有在你已经决定要回复,且本轮明确是纯表情包/纯反应图时,才优先考虑表情包表达 - 7. 对于已经决定要回复的场景(包括被@、被拍一拍、轻量答疑,以及少量符合条件的主动参与): - - 只有明确纯表情包回复才先检索表情包,再用 memes.send_meme_by_uid 单独发一条图片消息 - - 其他需要文字承接、解释、答疑、推进任务、确认操作或表达具体态度的场景,第一轮必须优先把必要文字回复做好并调用 send_message - - 如果确实还想补表情包,把 memes.search_memes 和 memes.send_meme_by_uid 放到文字发送后的后续响应轮次,不要阻塞首条文字回复 - - 不要发送任何敷衍消息(如'懒得掺和'、'哦'等);不想回复就直接调用 end - - 严肃、任务型、高信息密度场景少发表情包,避免打断信息传递 - - 绝不要刷屏、绝不要每条都回 - 8. 对于本来就会回复的场景(私聊、被拍一拍、被@、轻量答疑): - - 如果表情包能自然增强语气、缓和语气或让表达更像真人,也只能作为后续可选补充 - - 但不要为了发表情包而牺牲信息传递;信息密度优先时仍以文字为主 - - 简单说:像个极度安静的群友。主动插话只留给公开、开放的技术或项目讨论;明显对别人说或拿不准时就闭嘴。已经决定要回复时,除非明确是纯表情包回复,否则先把文字回复做好,表情包最后再搜。""" +{_GROUP_STRATEGY_FOOTER}""" async def _send_image(self, tid: int, mtype: str, path: str) -> None: """发送图片或语音消息到群聊或私聊""" diff --git a/src/Undefined/services/coordinator/batching.py b/src/Undefined/services/coordinator/batching.py index d3da2c63..0b5a1196 100644 --- a/src/Undefined/services/coordinator/batching.py +++ b/src/Undefined/services/coordinator/batching.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any from Undefined.services.coordinator.group import _GROUP_STRATEGY_FOOTER +from Undefined.services.coordinator.message_ids import collect_message_ids from Undefined.services.coordinator.private import _PRIVATE_STRATEGY_FOOTER from Undefined.services.message_batcher import BufferedMessage @@ -95,6 +96,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: first = items[0] last = items[-1] full_question = self._build_grouped_prompt(items) + message_ids = collect_message_ids(items) any_poke = any(it.is_poke for it in items) any_at_bot = any(it.is_at_bot for it in items) @@ -107,6 +109,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: "text": last.text, "full_question": full_question, "trigger_message_id": last.trigger_message_id, + "message_ids": message_ids, "batched_count": len(items), } if first.batch_token is not None: @@ -145,6 +148,7 @@ async def _dispatch_grouped_request(self, items: list[BufferedMessage]) -> None: "full_question": full_question, "is_at_bot": any_at_bot, "trigger_message_id": last.trigger_message_id, + "message_ids": message_ids, "batched_count": len(items), } if first.batch_token is not None: diff --git a/src/Undefined/services/coordinator/group.py b/src/Undefined/services/coordinator/group.py index 2a63d9a3..20ca134c 100644 --- a/src/Undefined/services/coordinator/group.py +++ b/src/Undefined/services/coordinator/group.py @@ -15,7 +15,10 @@ from Undefined.render import render_html_to_image, render_markdown_to_html from Undefined.services.message_batcher import BufferedMessage, make_scope from Undefined.utils.recent_messages import get_recent_messages_prefer_local -from Undefined.utils.xml import escape_xml_attr, escape_xml_text +from Undefined.utils.xml import ( + escape_xml_attr, + escape_xml_text_preserving_attachment_tags, +) if TYPE_CHECKING: from Undefined.config import Config @@ -36,6 +39,8 @@ 3. 如果问题明确涉及某个项目/代码/部署细节(用户明确点名或上下文明确指向) → 【酌情回复,必要时先查证再回答】 4. 其他技术问题 → 【酌情回复,直接按用户提到的对象回答,不要引入无关的项目名/工具名作背景】 5. 先判断当前输入批次(无连续消息说明时就是最后一条消息)是不是在对你说: + - 先看 sender_id、@/reply、前后文对话对象和当前群聊环境;不要先入为主把"你"、"AI"、"bot"、"机器人"当作在叫 Undefined + - 泛称或讨论其他 AI/bot/机器人时不算叫你;无法确认指向 Undefined 时默认不回复 - 如果明显是在和别人说话 → 【不要回复】 - 如果你不能确定是不是在和你说话 → 【默认不回复】 - 只有明确在和你说,或多人公开讨论且对话明显开放时,才进入下一步 @@ -165,6 +170,11 @@ async def _execute_auto_reply(self, request: dict[str, Any]) -> None: group_name = str(request.get("group_name") or "未知群聊") full_question = request["full_question"] trigger_message_id = request.get("trigger_message_id") + message_ids = [ + str(item).strip() + for item in request.get("message_ids", []) + if str(item).strip() + ] # 用于向 batcher 注册 inflight 任务(仅当本请求源自合并桶时生效) batcher_scope: str | None = make_scope(group_id=group_id) if group_id else None @@ -231,6 +241,8 @@ async def send_like_cb(uid: int, times: int = 1) -> None: ctx.set_resource(key, value) if trigger_message_id is not None: ctx.set_resource("trigger_message_id", trigger_message_id) + if message_ids: + ctx.set_resource("message_ids", list(message_ids)) if request.get("_queue_lane"): ctx.set_resource("queue_lane", request.get("_queue_lane")) logger.debug( @@ -273,6 +285,12 @@ async def send_like_cb(uid: int, times: int = 1) -> None: "is_at_bot": bool(request.get("is_at_bot", False)), "sender_name": sender_name, "group_name": group_name, + "message_ids": list(message_ids), + "batched_count": int(request.get("batched_count", 1) or 1), + "current_input_is_batched": int( + request.get("batched_count", 1) or 1 + ) + > 1, }, ) finally: @@ -343,7 +361,10 @@ def _format_group_message_segment(self, item: BufferedMessage) -> str: safe_role = escape_xml_attr(item.sender_role or "member") safe_title = escape_xml_attr(item.sender_title or "") safe_time = escape_xml_attr(time_str) - safe_text = escape_xml_text(item.text) + safe_text = escape_xml_text_preserving_attachment_tags( + item.text, + item.attachments, + ) message_id_attr = "" if item.trigger_message_id is not None: message_id_attr = ( @@ -393,7 +414,7 @@ def _build_prompt( safe_role = escape_xml_attr(role) safe_title = escape_xml_attr(title) safe_time = escape_xml_attr(time_str) - safe_text = escape_xml_text(text) + safe_text = escape_xml_text_preserving_attachment_tags(text, attachments) message_id_attr = "" if message_id is not None: message_id_attr = f' message_id="{escape_xml_attr(message_id)}"' diff --git a/src/Undefined/services/coordinator/message_ids.py b/src/Undefined/services/coordinator/message_ids.py new file mode 100644 index 00000000..480b2635 --- /dev/null +++ b/src/Undefined/services/coordinator/message_ids.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from Undefined.services.message_batcher import BufferedMessage + + +def collect_message_ids(items: list[BufferedMessage]) -> list[str]: + """Collect all known message IDs from a grouped request.""" + message_ids: list[str] = [] + seen: set[str] = set() + for item in items: + if item.trigger_message_id is None: + continue + message_id = str(item.trigger_message_id).strip() + if not message_id or message_id in seen: + continue + seen.add(message_id) + message_ids.append(message_id) + return message_ids diff --git a/src/Undefined/services/coordinator/private.py b/src/Undefined/services/coordinator/private.py index e3a6ec74..b5ecbe48 100644 --- a/src/Undefined/services/coordinator/private.py +++ b/src/Undefined/services/coordinator/private.py @@ -20,7 +20,10 @@ from Undefined.render import render_html_to_image, render_markdown_to_html from Undefined.services.message_batcher import BufferedMessage, make_scope from Undefined.utils.recent_messages import get_recent_messages_prefer_local -from Undefined.utils.xml import escape_xml_attr, escape_xml_text +from Undefined.utils.xml import ( + escape_xml_attr, + escape_xml_text_preserving_attachment_tags, +) if TYPE_CHECKING: from Undefined.config import Config @@ -118,6 +121,11 @@ async def _execute_private_reply(self, request: dict[str, Any]) -> None: sender_name = str(request.get("sender_name") or "未知用户") full_question = request["full_question"] trigger_message_id = request.get("trigger_message_id") + message_ids = [ + str(item).strip() + for item in request.get("message_ids", []) + if str(item).strip() + ] batcher_scope: str | None = make_scope(user_id=user_id) async with RequestContext( @@ -178,6 +186,8 @@ async def send_private_cb( ctx.set_resource(key, value) if trigger_message_id is not None: ctx.set_resource("trigger_message_id", trigger_message_id) + if message_ids: + ctx.set_resource("message_ids", list(message_ids)) if request.get("_queue_lane"): ctx.set_resource("queue_lane", request.get("_queue_lane")) logger.debug( @@ -216,6 +226,12 @@ async def send_private_cb( "is_private_chat": True, "sender_name": sender_name, "selected_model_name": request.get("selected_model_name"), + "message_ids": list(message_ids), + "batched_count": int(request.get("batched_count", 1) or 1), + "current_input_is_batched": int( + request.get("batched_count", 1) or 1 + ) + > 1, }, ) finally: @@ -265,7 +281,10 @@ def _format_private_message_segment(self, item: BufferedMessage) -> str: safe_name = escape_xml_attr(item.sender_name or "未知用户") safe_uid = escape_xml_attr(item.sender_id) safe_time = escape_xml_attr(time_str) - safe_text = escape_xml_text(item.text) + safe_text = escape_xml_text_preserving_attachment_tags( + item.text, + item.attachments, + ) message_id_attr = "" if item.trigger_message_id is not None: message_id_attr = ( diff --git a/src/Undefined/skills/agents/README.md b/src/Undefined/skills/agents/README.md index 20da6b9e..f9acf715 100644 --- a/src/Undefined/skills/agents/README.md +++ b/src/Undefined/skills/agents/README.md @@ -263,6 +263,7 @@ mv skills/tools/my_tool skills/agents/my_agent/tools/ - **功能**:网页搜索和网页内容获取 - **适用场景**:获取互联网最新信息、搜索新闻、爬取网页内容 - **子工具**:`grok_search`, `web_search`, `crawl_webpage` +- **grok_search 参数**:优先使用 `search_request`,用自然语言完整叙述搜索要求,不要只传关键词。 ### file_analysis_agent(文件分析助手) - **功能**:分析代码、PDF、Docx、Xlsx 等多种格式文件 @@ -270,9 +271,15 @@ mv skills/tools/my_tool skills/agents/my_agent/tools/ - **子工具**:`read_file`, `analyze_code`, `analyze_pdf`, `analyze_docx`, `analyze_xlsx` ### naga_code_analysis_agent(NagaAgent 代码分析助手) -- **功能**:专门用于分析 NagaAgent 框架及当前项目的源码 -- **适用场景**:深入分析 NagaAgent 架构、项目代码审查 -- **子工具**:`read_file`, `search_code`, `analyze_structure` +- **功能**:专门用于分析 NagaAgent 框架源码 +- **适用场景**:深入分析 NagaAgent 架构、模块实现、代码线索 +- **子工具**:`read_file`, `list_directory`, `glob`, `search_file_content`, `read_naga_intro` + +### undefined_self_code_agent(Undefined 自身代码查阅助手) +- **功能**:只读查阅 Undefined 当前仓库的源码、测试、文档、资源、脚本、配置示例和 App 实现 +- **适用场景**:解释 Undefined 自身实现、定位模块、核对配置示例、查看测试覆盖 +- **访问范围**:`src/`, `scripts/`, `tests/`, `res/`, `docs/`, `apps/`, `README.md`, `CHANGELOG.md`, `ARCHITECTURE.md`, `config.toml.example` +- **子工具**:`read_file`, `list_directory`, `glob`, `search_file_content` ### info_agent(信息查询助手) - **功能**:查询天气、热搜、历史、WHOIS、B 站信息、arXiv 检索等 diff --git a/src/Undefined/skills/agents/agent_tool_registry.py b/src/Undefined/skills/agents/agent_tool_registry.py index a7c2cbf9..36775211 100644 --- a/src/Undefined/skills/agents/agent_tool_registry.py +++ b/src/Undefined/skills/agents/agent_tool_registry.py @@ -417,6 +417,22 @@ async def handler(args: dict[str, Any], context: dict[str, Any]) -> str: # 构造被调用方上下文,避免复用调用方身份与历史。 callee_context = context.copy() callee_context["agent_name"] = target_agent_name + parent_call_id = str(context.get("webchat_parent_call_id") or "") + if parent_call_id: + callee_context["webchat_parent_call_id"] = parent_call_id + call_parent_id = str(context.get("webchat_call_parent_id") or "") + if call_parent_id: + callee_context["webchat_call_parent_id"] = call_parent_id + try: + callee_context["webchat_depth"] = max( + 0, int(context.get("webchat_depth", 0) or 0) + ) + except (TypeError, ValueError): + callee_context["webchat_depth"] = 0 + agent_path = context.get("webchat_agent_path") + callee_context["webchat_agent_path"] = ( + list(agent_path) if isinstance(agent_path, list) else [] + ) agent_histories = context.get("agent_histories") if not isinstance(agent_histories, dict): diff --git a/src/Undefined/skills/agents/arxiv_analysis_agent/handler.py b/src/Undefined/skills/agents/arxiv_analysis_agent/handler.py index 7b6d6052..024a9d7f 100644 --- a/src/Undefined/skills/agents/arxiv_analysis_agent/handler.py +++ b/src/Undefined/skills/agents/arxiv_analysis_agent/handler.py @@ -5,7 +5,10 @@ from typing import Any from Undefined.arxiv.parser import normalize_arxiv_id -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -43,6 +46,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=15, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/code_delivery_agent/handler.py b/src/Undefined/skills/agents/code_delivery_agent/handler.py index 9627be45..d5062e9f 100644 --- a/src/Undefined/skills/agents/code_delivery_agent/handler.py +++ b/src/Undefined/skills/agents/code_delivery_agent/handler.py @@ -99,7 +99,10 @@ async def _run_agent_with_retry( agent_dir: Path, ) -> str: """执行 agent。""" - from Undefined.skills.agents.runner import run_agent_with_tools + from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, + ) return await run_agent_with_tools( agent_name="code_delivery_agent", @@ -110,7 +113,7 @@ async def _run_agent_with_retry( context=context, agent_dir=agent_dir, logger=logger, - max_iterations=50, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/entertainment_agent/handler.py b/src/Undefined/skills/agents/entertainment_agent/handler.py index 3408af04..e9f0f14f 100644 --- a/src/Undefined/skills/agents/entertainment_agent/handler.py +++ b/src/Undefined/skills/agents/entertainment_agent/handler.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -21,6 +24,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=20, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/file_analysis_agent/handler.py b/src/Undefined/skills/agents/file_analysis_agent/handler.py index 7db9cbe0..0b1ddb33 100644 --- a/src/Undefined/skills/agents/file_analysis_agent/handler.py +++ b/src/Undefined/skills/agents/file_analysis_agent/handler.py @@ -5,7 +5,10 @@ from typing import Any from Undefined.attachments import scope_from_context -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -55,6 +58,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=30, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/file_analysis_agent/tools/download_file/handler.py b/src/Undefined/skills/agents/file_analysis_agent/tools/download_file/handler.py index f12b6654..acafe3d0 100644 --- a/src/Undefined/skills/agents/file_analysis_agent/tools/download_file/handler.py +++ b/src/Undefined/skills/agents/file_analysis_agent/tools/download_file/handler.py @@ -1,11 +1,11 @@ +import asyncio +import logging import uuid from pathlib import Path -from typing import Any, Dict -import logging -import httpx -import aiofiles +from typing import Any, Callable, Dict, Protocol, cast -from Undefined.attachments import scope_from_context +import aiofiles +import httpx logger = logging.getLogger(__name__) @@ -23,6 +23,79 @@ } DEFAULT_SIZE_LIMIT = 100 * 1024 * 1024 +_MAX_PATH_SOURCE_LENGTH = 4096 + + +class WriteBytesFn(Protocol): + async def __call__( + self, file_path: str | Path, content: bytes, use_lock: bool = True + ) -> None: ... + + +def _safe_download_filename( + *, + preferred_name: str, + fallback_name: str = "", + fallback_prefix: str, + task_uuid: str, +) -> str: + name = str(preferred_name or "").strip() + suffix = _safe_suffix(name) or _safe_suffix(str(fallback_name or "").strip()) + if suffix: + return f"{fallback_prefix}_{task_uuid}{suffix}" + return f"{fallback_prefix}_{task_uuid}" + + +def _safe_suffix(name: str) -> str: + if not name or len(name) > 255: + return "" + basename = name.replace("\\", "/").rsplit("/", 1)[-1].split("?", 1)[0] + basename = basename.split("#", 1)[0] + suffixes = Path(basename).suffixes[-2:] + suffix = "".join(suffixes).lower() + if len(suffix) > 16: + suffix = Path(suffix).suffix.lower() + if not suffix or len(suffix) > 16: + return "" + if any(ch not in ".abcdefghijklmnopqrstuvwxyz0123456789_-" for ch in suffix): + return "" + return suffix + + +def _download_prefix(record: Any | None = None) -> str: + if record is None: + return "file" + kind = str(getattr(record, "media_type", "") or getattr(record, "kind", "")) + return "image" if kind.strip().lower() == "image" else "file" + + +def _is_http_url(value: str) -> bool: + return value.startswith("http://") or value.startswith("https://") + + +def _is_file_uri(value: str) -> bool: + return value.startswith("file://") + + +def _can_treat_as_local_path(value: str) -> bool: + if not value or len(value) > _MAX_PATH_SOURCE_LENGTH: + return False + lowered = value.lower() + if lowered.startswith(("base64://", "data:")): + return False + if "://" in value and not _is_file_uri(value): + return False + return True + + +async def _copy_file_to_temp( + source: Path, + target: Path, + write_bytes_fn: WriteBytesFn, +) -> None: + async with aiofiles.open(source, "rb") as src: + content = await src.read() + await write_bytes_fn(target, content, use_lock=False) async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: @@ -42,37 +115,77 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: return "错误:文件源不能为空" task_uuid: str = uuid.uuid4().hex[:16] - from Undefined.utils.paths import DOWNLOAD_CACHE_DIR, ensure_dir - temp_dir: Path = ensure_dir(DOWNLOAD_CACHE_DIR / task_uuid) + download_cache_dir_raw = context.get("download_cache_dir") + ensure_dir_fn = context.get("ensure_dir_fn") + if download_cache_dir_raw is None or not callable(ensure_dir_fn): + return "错误:download_file 缺少下载缓存目录上下文依赖" + write_bytes_fn = context.get("write_bytes_fn") + if not callable(write_bytes_fn): + return "错误:download_file 缺少原子文件写入上下文依赖" + write_bytes = cast(WriteBytesFn, write_bytes_fn) + + download_cache_dir = Path(download_cache_dir_raw) + temp_dir: Path = cast(Callable[[Path], Path], ensure_dir_fn)( + download_cache_dir / task_uuid + ) attachment_registry = context.get("attachment_registry") - scope_key = scope_from_context(context) + scope_key = str(context.get("scope_key") or "").strip() or None + if scope_key is None: + get_scope_from_context = context.get("get_scope_from_context") + if callable(get_scope_from_context): + scope_key_raw = get_scope_from_context(context) + scope_key = str(scope_key_raw or "").strip() or None if attachment_registry and scope_key: try: - record = attachment_registry.resolve(file_source, scope_key) + load = getattr(attachment_registry, "load", None) + if load is not None: + await load() + resolve_async = getattr(attachment_registry, "resolve_async", None) + if resolve_async is not None: + record = await resolve_async(file_source, scope_key) + else: + record = attachment_registry.resolve(file_source, scope_key) except Exception: + logger.exception("附件 UID 解析失败: %s", file_source) record = None if record is not None: return await _download_from_attachment_record( record, + registry=attachment_registry, temp_dir=temp_dir, max_size_mb=max_size_mb, task_uuid=task_uuid, + write_bytes_fn=write_bytes, ) - is_url: bool = file_source.startswith("http://") or file_source.startswith( - "https://" - ) + is_url: bool = _is_http_url(file_source) if is_url: - return await _download_from_url(file_source, temp_dir, max_size_mb, task_uuid) + return await _download_from_url( + file_source, + temp_dir, + max_size_mb, + task_uuid, + write_bytes, + ) else: - return await _download_from_file_id(file_source, temp_dir, context, task_uuid) + return await _download_from_file_id( + file_source, + temp_dir, + context, + task_uuid, + write_bytes, + ) async def _download_from_url( - url: str, temp_dir: Path, max_size_mb: float, task_uuid: str + url: str, + temp_dir: Path, + max_size_mb: float, + task_uuid: str, + write_bytes_fn: WriteBytesFn, ) -> str: """从 Web URL 进行下载,包含大小预检""" max_size_bytes: int = int(max_size_mb * 1024 * 1024) @@ -96,12 +209,13 @@ async def _download_from_url( response = await client.get(url, timeout=120.0) response.raise_for_status() - filename = _extract_filename_from_url(url) - if not filename or "." not in filename: - filename = f"downloaded_{task_uuid}" - + filename = _safe_download_filename( + preferred_name=_extract_filename_from_url(url), + fallback_prefix="file", + task_uuid=task_uuid, + ) file_path = temp_dir / filename - file_path.write_bytes(response.content) + await write_bytes_fn(file_path, response.content, use_lock=False) logger.info(f"文件已保存到: {file_path}") return str(file_path) @@ -116,7 +230,11 @@ async def _download_from_url( async def _download_from_file_id( - file_id: str, temp_dir: Path, context: Dict[str, Any], task_uuid: str + file_id: str, + temp_dir: Path, + context: Dict[str, Any], + task_uuid: str, + write_bytes_fn: WriteBytesFn, ) -> str: """从 OneBot file_id 进行下载或解析""" get_image_url_callback = context.get("get_image_url_callback") @@ -132,7 +250,7 @@ async def _download_from_file_id( logger.info(f"获取到 URL: {url}") # 检查是否为 HTTP/HTTPS URL - is_http_url = url.startswith("http://") or url.startswith("https://") + is_http_url = _is_http_url(url) if is_http_url: # 使用 httpx 下载远程文件 @@ -140,15 +258,21 @@ async def _download_from_file_id( response = await client.get(url, timeout=120.0) response.raise_for_status() - filename = f"file_{file_id}" + filename = _safe_download_filename( + preferred_name=file_id, + fallback_prefix="file", + task_uuid=task_uuid, + ) file_path = temp_dir / filename - file_path.write_bytes(response.content) + await write_bytes_fn(file_path, response.content, use_lock=False) logger.info(f"文件已保存到: {file_path}") return str(file_path) else: + if not _can_treat_as_local_path(url): + return "错误:解析结果不是可访问的本地文件路径或 HTTP URL" # 处理本地文件路径 - local_path = Path(url) + local_path = Path(url[7:] if _is_file_uri(url) else url) if not local_path.exists(): return f"错误:本地文件不存在: {url}" @@ -156,9 +280,13 @@ async def _download_from_file_id( async with aiofiles.open(local_path, "rb") as f: content = await f.read() - filename = local_path.name + filename = _safe_download_filename( + preferred_name=local_path.name, + fallback_prefix="file", + task_uuid=task_uuid, + ) file_path = temp_dir / filename - file_path.write_bytes(content) + await write_bytes_fn(file_path, content, use_lock=False) logger.info(f"本地文件已复制到: {file_path}") return str(file_path) @@ -178,44 +306,71 @@ def _extract_filename_from_url(url: str) -> str: async def _download_from_attachment_record( record: Any, *, + registry: Any, temp_dir: Path, max_size_mb: float, task_uuid: str, + write_bytes_fn: WriteBytesFn, ) -> str: max_size_bytes: int = int(max_size_mb * 1024 * 1024) - local_path_raw = getattr(record, "local_path", None) - if local_path_raw: - local_path = Path(str(local_path_raw)) - if local_path.is_file(): - size = local_path.stat().st_size - if size > max_size_bytes: - return ( - f"错误:文件大小 ({size / 1024 / 1024:.2f}MB) " - f"超过限制 ({max_size_mb}MB)" + try: + ensure_local_file = getattr(registry, "ensure_local_file", None) + if ensure_local_file is not None: + record = await ensure_local_file(record) + + source_ref = str(getattr(record, "source_ref", "") or "").strip() + local_path_raw = str(getattr(record, "local_path", "") or "").strip() + if not _can_treat_as_local_path(local_path_raw): + if _is_http_url(source_ref): + return await _download_from_url( + source_ref, + temp_dir, + max_size_mb, + task_uuid, + write_bytes_fn, + ) + return f"错误:无法从附件 UID {getattr(record, 'uid', '')} 解析到可下载文件" + + local_path = Path( + local_path_raw[7:] if _is_file_uri(local_path_raw) else local_path_raw + ) + if not await asyncio.to_thread(local_path.is_file): + if _is_http_url(source_ref): + return await _download_from_url( + source_ref, + temp_dir, + max_size_mb, + task_uuid, + write_bytes_fn, ) - display_name = str(getattr(record, "display_name", "") or "").strip() - filename = display_name or local_path.name or f"downloaded_{task_uuid}" - target = temp_dir / filename - async with aiofiles.open(local_path, "rb") as src: - content = await src.read() - target.write_bytes(content) - logger.info("附件 UID 已复制到: %s", target) - return str(target) - - source_ref = str(getattr(record, "source_ref", "") or "").strip() - if source_ref.startswith("http://") or source_ref.startswith("https://"): - return await _download_from_url(source_ref, temp_dir, max_size_mb, task_uuid) - - if source_ref: - candidate = Path(source_ref) - if candidate.exists() and candidate.is_file(): - display_name = str(getattr(record, "display_name", "") or "").strip() - filename = display_name or candidate.name or f"downloaded_{task_uuid}" - target = temp_dir / filename - async with aiofiles.open(candidate, "rb") as src: - content = await src.read() - target.write_bytes(content) - logger.info("附件 UID 源文件已复制到: %s", target) - return str(target) - - return f"错误:无法从附件 UID {getattr(record, 'uid', '')} 解析到可下载文件" + return f"错误:附件 UID 本地文件不存在:{getattr(record, 'uid', '')}" + + size = await asyncio.to_thread(lambda: local_path.stat().st_size) + if size > max_size_bytes: + return ( + f"错误:文件大小 ({size / 1024 / 1024:.2f}MB) " + f"超过限制 ({max_size_mb}MB)" + ) + + display_name = str(getattr(record, "display_name", "") or "").strip() + filename = _safe_download_filename( + preferred_name=display_name, + fallback_name=local_path.name, + fallback_prefix=_download_prefix(record), + task_uuid=task_uuid, + ) + target = temp_dir / filename + await _copy_file_to_temp( + local_path, + target, + write_bytes_fn, + ) + logger.info("附件 UID 已通过注册表复制到: %s", target) + return str(target) + except OSError as exc: + logger.warning( + "附件 UID 本地化复制失败 uid=%s err=%s", + getattr(record, "uid", ""), + exc, + ) + return "错误:附件文件读取失败" diff --git a/src/Undefined/skills/agents/info_agent/handler.py b/src/Undefined/skills/agents/info_agent/handler.py index 910971e6..6332597e 100644 --- a/src/Undefined/skills/agents/info_agent/handler.py +++ b/src/Undefined/skills/agents/info_agent/handler.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -21,6 +24,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=20, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/naga_code_analysis_agent/config.json b/src/Undefined/skills/agents/naga_code_analysis_agent/config.json index 7257c74b..8aac0960 100644 --- a/src/Undefined/skills/agents/naga_code_analysis_agent/config.json +++ b/src/Undefined/skills/agents/naga_code_analysis_agent/config.json @@ -2,7 +2,7 @@ "type": "function", "function": { "name": "naga_code_analysis_agent", - "description": "NagaAgent代码分析助手,提供针对NagaAgent项目的文件读取、目录浏览、内容搜索、文件匹配等功能,用于理解和分析代码库。", + "description": "NagaAgent代码分析助手,提供针对NagaAgent项目的文件读取、目录浏览、内容搜索、文件匹配等功能;仅用于 NagaAgent 项目,不负责 Undefined 自身源码、用户上传文件或代码交付任务。", "parameters": { "type": "object", "properties": { diff --git a/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py b/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py index e9dca11f..eb4b4fd0 100644 --- a/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py +++ b/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -21,6 +24,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=20, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/naga_code_analysis_agent/intro.md b/src/Undefined/skills/agents/naga_code_analysis_agent/intro.md index 8cd726f7..2ff82011 100644 --- a/src/Undefined/skills/agents/naga_code_analysis_agent/intro.md +++ b/src/Undefined/skills/agents/naga_code_analysis_agent/intro.md @@ -11,7 +11,9 @@ 补充:本 Agent 的目录遍历/内容搜索工具为纯 Python 实现,可在 Windows/macOS/Linux 上使用(不依赖 `find`/`grep` 等外部命令)。 ## 边界 -- **仅限 NagaAgent 项目**,通用代码分析请用 `file_analysis_agent` +- **仅限 NagaAgent 项目**,不回答 Undefined 自身源码问题 +- 用户上传/外部文件解析请用 `file_analysis_agent` +- 代码编写、修改、执行验证和打包交付请用 `code_delivery_agent` - 不进行外部联网搜索 ## 输入偏好 diff --git a/src/Undefined/skills/agents/naga_code_analysis_agent/prompt.md b/src/Undefined/skills/agents/naga_code_analysis_agent/prompt.md index 48360e4e..bcf2daf9 100644 --- a/src/Undefined/skills/agents/naga_code_analysis_agent/prompt.md +++ b/src/Undefined/skills/agents/naga_code_analysis_agent/prompt.md @@ -1,11 +1,12 @@ 你是 NagaAgent 项目代码分析助手,目标是帮助用户理解该项目内部实现。 工作原则: -- 先判断是否是 NagaAgent 相关问题,非相关则建议使用 file_analysis_agent。 +- 先判断是否是 NagaAgent 项目内部实现、源码、配置、部署、构建或技术排错问题;非 NagaAgent 技术问题要说明越界并返回给主 AI 重新路由。 - 分析第一步:调用read_naga_intro工具 - 优先阅读项目说明/文档,再深入到具体文件。 - 用工具获取证据后再下结论,避免臆测。 - 工具为纯 Python 实现,跨平台可用;注意遵守 base_path 限制,避免越权读取。 +- 不回答 Undefined 自身源码问题、用户上传/外部文件解析问题,也不承担代码编写、修改、执行验证或打包交付任务。 表达风格: - 简洁、结构化,先给结论再给依据。 diff --git a/src/Undefined/skills/agents/naga_code_analysis_agent/tools/read_naga_intro/handler.py b/src/Undefined/skills/agents/naga_code_analysis_agent/tools/read_naga_intro/handler.py index 54cfa4b6..736b3518 100644 --- a/src/Undefined/skills/agents/naga_code_analysis_agent/tools/read_naga_intro/handler.py +++ b/src/Undefined/skills/agents/naga_code_analysis_agent/tools/read_naga_intro/handler.py @@ -3,29 +3,40 @@ # NagaAgent 项目介绍内容(直接嵌入以保证稳定性) NAGA_INTRO_CONTENT = """ ## 项目概览 -- 后端:Python 3.11 + FastAPI + LiteLLM + Pydantic。 -- 前端:Vue 3 + TypeScript + Vite + Electron + PrimeVue + UnoCSS。 -- 统一入口:`main.py`(并行拉起 API/Agent/TTS 等服务)。 -- 配置中心:`system/config.py` + `config.json`(支持 JSON5 注释解析)。 -- 默认端口:API 8000、Agent 8001、MCP 8003(保留)、TTS 5048。 +- README 标识版本 5.1.0。 +- 后端:Python 3.11 + FastAPI + OpenAI/Anthropic/LiteLLM 兼容调用 + Pydantic。 +- 前端:Vue 3 + TypeScript + Vite + Electron,入口在 `frontend/`。 +- 统一入口:`main.py`,负责启动后台任务、API Server、MCP Server、Agent Server、TTS 等服务。 +- 配置中心:`system/config.py` + `config.json`/`config.json.example`,支持运行时配置同步与热更新。 +- 默认端口:API 8000、Agent 8001、MCP 8003、TTS 5048、ASR 5060。 +- 核心能力:流式工具调用、GRAG 知识图谱记忆、MCP 服务、Anthropic-style skills、OpenClaw 电脑操作、DogTag 心跳/屏幕主动感知、旅行探索、游戏攻略。 ## 快速定位 - 服务并行启动逻辑:`main.py` -- API 路由(如 `/chat`、`/chat/stream`):`apiserver/api_server.py` +- API 应用入口与共享状态:`apiserver/api_server.py` +- API 路由(如 `/chat`、`/chat/stream`、配置、会话、工具、论坛、扩展):`apiserver/routes/` - 模型调用/参数拼装:`apiserver/llm_service.py` -- 流式工具调用提取:`apiserver/streaming_tool_extractor.py` +- Agentic 工具调用循环:`apiserver/agentic_tool_loop.py` +- 流式文本处理与 TTS 推送:`apiserver/streaming_tool_extractor.py` - 会话与消息管理:`apiserver/message_manager.py` -- Agent 调度/OpenClaw 执行:`agentserver/agent_server.py` -- 任务调度与任务记忆:`agentserver/task_scheduler.py` +- 上下文压缩:`apiserver/context_compressor.py` +- NagaCAS 登录与认证:`apiserver/naga_auth.py`、`apiserver/routes/auth.py` +- 运行时控制(如语音暂停):`apiserver/naga_control.py` +- Agent 调度服务:`agentserver/agent_server.py` +- DogTag 心跳/屏幕主动感知:`agentserver/dogtag/` - OpenClaw 连接与运行时:`agentserver/openclaw/` +- MCP 管理与服务注册:`mcpserver/mcp_manager.py`、`mcpserver/mcp_registry.py` +- 内置 MCP agents:`mcpserver/agent_*` - 全局配置结构与端口:`system/config.py` - 配置热更新接口:`system/config_manager.py` -- 系统提示词:`system/prompts/conversation_style_prompt.txt`、`system/prompts/conversation_analyzer_prompt.txt` +- 角色包与提示词:`system/character_bundle.py`、`system/prompts/` - 语音输出服务:`voice/output/start_voice_service.py`、`voice/output/server.py` - 实时语音输入链路:`voice/input/voice_realtime/` - 前端页面路由与主界面:`frontend/src/views/`、`frontend/src/App.vue` - 前端 API 封装:`frontend/src/api/` - Electron 主进程与后端拉起:`frontend/electron/main.ts`、`frontend/electron/modules/backend.ts` +- 技能定义:`skills/*/SKILL.md` +- 游戏攻略/画面理解:`guide_engine/` - 长期记忆(GRAG/图谱):`summer_memory/` ## 目录与文件说明 @@ -33,28 +44,31 @@ | 路径 | 作用 | 常改文件 | |---|---|---| | `main.py` | 项目总入口,负责并行启动服务、端口检查、代理初始化 | `main.py` | -| `apiserver/` | 对话 API 核心(路由、LLM 调用、流式输出、工具调用循环) | `api_server.py`、`llm_service.py`、`streaming_tool_extractor.py`、`message_manager.py` | -| `agentserver/` | 任务调度与电脑控制执行服务(OpenClaw) | `agent_server.py`、`task_scheduler.py`、`openclaw/*.py` | +| `apiserver/` | 对话 API 核心(路由、LLM 调用、流式输出、工具调用循环、认证、论坛代理) | `api_server.py`、`routes/*.py`、`llm_service.py`、`agentic_tool_loop.py` | +| `agentserver/` | Agent 调度、DogTag 心跳/屏幕感知、OpenClaw 集成 | `agent_server.py`、`dogtag/*.py`、`openclaw/*.py` | +| `mcpserver/` | MCP 服务管理、内置 MCP agents、统一工具调用路由 | `mcp_manager.py`、`mcp_registry.py`、`agent_*` | | `system/` | 配置系统、提示词、环境检测、日志初始化 | `config.py`、`config_manager.py`、`system_checker.py`、`prompts/*.txt` | | `voice/` | 语音输入输出能力(TTS/Realtime) | `output/start_voice_service.py`、`output/server.py`、`input/unified_voice_manager.py` | | `summer_memory/` | 记忆系统与图谱检索(五元组、RAG、任务记忆) | `memory_manager.py`、`quintuple_extractor.py`、`quintuple_rag_query.py` | | `frontend/` | Vue3 + Electron 前端 | `src/views/*.vue`、`src/api/*.ts`、`electron/main.ts` | +| `guide_engine/` | 游戏攻略、截图识别、RAG/图谱查询与提示词管理 | `guide_service.py`、`query_router.py`、`screenshot_provider.py` | | `skills/` | 内置技能定义(SKILL.md) | `*/SKILL.md` | | `scripts/` | 构建/自动化脚本 | `build-win.py` | | `logs/` | 日志与运行期输出目录 | `logs/*.log` | ## 根目录关键文件(排查优先看) - `config.json`:运行配置(若不存在会尝试由 `config.json.example` 生成)。 -- `pyproject.toml`:Python 依赖与版本约束(`>=3.11,<3.12`)。 +- `pyproject.toml`:项目版本、Python 依赖与版本约束(`>=3.11,<3.12`)。 - `uv.lock`:`uv` 锁定依赖版本。 - `requirements.txt`:传统 pip 安装依赖清单。 - `build.md`:完整打包说明。 -- `naga-backend.spec`:PyInstaller 打包配置。 +- `build.py` / `naga-backend.spec`:跨平台构建与 PyInstaller 打包配置。 - `start.bat`、`setup_venv.bat`:Windows 启动/环境脚本。 +- `proactive_vision_config.json`:屏幕主动感知默认/运行配置。 ## 当前目录状态提示 -- `game/`、`mcpserver/`、`mqtt_tool/`、`nagaagent_core/`、`models/`、`ui/` 目前主要是历史目录/缓存占位(源码文件基本不在这些目录中)。 -- 现阶段开发优先从 `main.py`、`apiserver/`、`agentserver/`、`system/`、`voice/`、`frontend/`、`summer_memory/` 入手。 +- 现阶段开发优先从 `main.py`、`apiserver/`、`agentserver/`、`mcpserver/`、`system/`、`voice/`、`frontend/`、`guide_engine/`、`summer_memory/`、`skills/` 入手。 +- `characters/` 存放角色资源,`vendor/openclaw/` 是 OpenClaw vendor 源码/运行时相关内容。 ## 环境准备 ```bash @@ -67,18 +81,18 @@ pip install -r requirements.txt ``` - Python 版本必须满足:`>=3.11,<3.12`。 +- `api.api_format` 支持 `openai` 与 `anthropic`,默认示例为 DeepSeek OpenAI-compatible API。 - 默认优先使用 `uv run ...` 运行命令。 -## 启动命令 -```bash -cd frontend/ -npm run dev -# 前端 Electron 主进程会调用 backend 模块拉起根目录 main.py -``` +## 启动相关 +- 服务统一入口在 `main.py`。 +- 前端 Electron 主进程会通过 `frontend/electron/modules/backend.ts` 拉起后端。 +- API 与 Agent Server 可从 `apiserver/`、`agentserver/` 下的入口文件继续追踪。 ## 打包相关 -- Windows 一键构建:`python scripts/build-win.py`。 -- 详细流程见:`build.md`。 +- 跨平台构建入口文件:`build.py`。 +- Windows 构建脚本位于 `scripts/`。 +- 详细流程见:`build.md`、`docs/build-windows.md`。 """ diff --git a/src/Undefined/skills/agents/runner/__init__.py b/src/Undefined/skills/agents/runner/__init__.py index 09676932..5ac28cf6 100644 --- a/src/Undefined/skills/agents/runner/__init__.py +++ b/src/Undefined/skills/agents/runner/__init__.py @@ -2,10 +2,14 @@ # 对外 re-export,兼容 `from Undefined.skills.agents.runner import run_agent_with_tools` from Undefined.skills.agents.runner.context import load_prompt_text -from Undefined.skills.agents.runner.loop import run_agent_with_tools +from Undefined.skills.agents.runner.loop import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) from Undefined.skills.agents.runner.tools import _filter_tools_for_runtime_config __all__ = [ + "DEFAULT_AGENT_MAX_ITERATIONS", "load_prompt_text", "run_agent_with_tools", "_filter_tools_for_runtime_config", diff --git a/src/Undefined/skills/agents/runner/loop.py b/src/Undefined/skills/agents/runner/loop.py index bc215e8b..6de77a70 100644 --- a/src/Undefined/skills/agents/runner/loop.py +++ b/src/Undefined/skills/agents/runner/loop.py @@ -8,6 +8,40 @@ from Undefined.ai.transports.openai_transport import RESPONSES_OUTPUT_ITEMS_KEY from Undefined.skills.agents.runner.context import prepare_agent_run from Undefined.skills.agents.runner.tools import execute_assistant_tool_calls +from Undefined.skills.agents.runner.webchat_utils import ( + webchat_agent_path, + webchat_depth, +) + + +DEFAULT_AGENT_MAX_ITERATIONS = 1000 + + +async def _emit_webchat_agent_stage( + context: dict[str, Any], + agent_name: str, + stage: str, + detail: Any | None = None, +) -> None: + callback = context.get("webchat_event_callback") + if not callable(callback): + return + call_id = str(context.get("webchat_parent_call_id") or "").strip() + if not call_id: + return + parent_call_id = str(context.get("webchat_call_parent_id") or "").strip() + payload: dict[str, Any] = { + "webchat_call_id": call_id, + "parent_webchat_call_id": parent_call_id, + "agent_name": agent_name, + "name": agent_name, + "stage": stage, + "depth": webchat_depth(context.get("webchat_depth")), + "agent_path": webchat_agent_path(context.get("webchat_agent_path")), + } + if detail is not None: + payload["detail"] = detail + await callback("agent_stage", payload) # Agent 主循环:LLM 决策 → 工具执行 → 结果回填 @@ -21,7 +55,7 @@ async def run_agent_with_tools( context: dict[str, Any], agent_dir: Path, logger: logging.Logger, - max_iterations: int = 20, + max_iterations: int = DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix: str = "错误", ) -> str: """执行通用 Agent 循环。 @@ -50,6 +84,7 @@ async def run_agent_with_tools( if isinstance(prepared, str): return prepared + await _emit_webchat_agent_stage(context, agent_name, "context_ready") messages = prepared.messages transport_state: dict[str, Any] | None = None pre_tool_failure_count = 0 @@ -62,6 +97,12 @@ async def run_agent_with_tools( message_checkpoint_len = len(messages) transport_state_checkpoint = transport_state try: + await _emit_webchat_agent_stage( + context, + agent_name, + "waiting_model", + f"iteration={iteration} model={prepared.agent_config.model_name}", + ) # 通过队列提交 LLM 请求(含 tools 与 transport 多轮状态) result = await prepared.ai_client.submit_queued_llm_call( model_config=prepared.agent_config, @@ -118,8 +159,12 @@ async def run_agent_with_tools( # 无工具调用即视为最终回复 if not tool_calls: + await _emit_webchat_agent_stage(context, agent_name, "done") return content + await _emit_webchat_agent_stage( + context, agent_name, "preparing_tools", len(tool_calls) + ) # 将 assistant 消息(含 tool_calls)追加到对话历史 assistant_message: dict[str, Any] = { "role": "assistant", @@ -138,6 +183,21 @@ async def run_agent_with_tools( messages.append(assistant_message) # 并发执行 tool_calls,结果以 role=tool 消息回填 + tool_names = [ + str( + (tool_call.get("function") or {}).get("name") + if isinstance(tool_call, dict) + and isinstance(tool_call.get("function"), dict) + else "" + ) + for tool_call in tool_calls + ] + await _emit_webchat_agent_stage( + context, + agent_name, + "waiting_tools", + ", ".join(name for name in tool_names if name), + ) tool_execution_started = await execute_assistant_tool_calls( agent_name=agent_name, tool_calls=tool_calls, @@ -169,6 +229,9 @@ async def run_agent_with_tools( iteration, exc, ) + await _emit_webchat_agent_stage( + context, agent_name, "retrying_model", str(exc) + ) continue logger.exception( "[Agent:%s] 执行失败,已静默抑制: lane=%s iteration=%s error=%s", @@ -179,4 +242,5 @@ async def run_agent_with_tools( ) return "" + await _emit_webchat_agent_stage(context, agent_name, "done") return "达到最大迭代次数" diff --git a/src/Undefined/skills/agents/runner/tools.py b/src/Undefined/skills/agents/runner/tools.py index 31d79177..e7a01b0f 100644 --- a/src/Undefined/skills/agents/runner/tools.py +++ b/src/Undefined/skills/agents/runner/tools.py @@ -8,9 +8,29 @@ from Undefined.ai.tooling import END_CO_CALL_REJECT_CONTENT from Undefined.skills.anthropic_skills import AnthropicSkillRegistry from Undefined.skills.agents.agent_tool_registry import AgentToolRegistry +from Undefined.skills.agents.runner.webchat_utils import ( + webchat_agent_path, + webchat_depth, +) from Undefined.utils.tool_calls import parse_tool_arguments +def _webchat_call_id(parent_call_id: str, call_id: str, fallback: str) -> str: + local_id = str(call_id or fallback or "tool").strip() or "tool" + return f"{parent_call_id}/{local_id}" if parent_call_id else local_id + + +async def _emit_webchat_tool_event( + context: dict[str, Any], + event: str, + payload: dict[str, Any], +) -> None: + callback = context.get("webchat_event_callback") + if not callable(callback): + return + await callback(event, payload) + + # 按运行时配置过滤不可用工具 schema def _filter_tools_for_runtime_config( agent_name: str, @@ -49,12 +69,20 @@ async def execute_assistant_tool_calls( ) -> bool: """并发执行 assistant 的 tool_calls,回填 tool 消息。返回是否已开始工具执行。""" - tool_tasks: list[asyncio.Future[Any]] = [] + tool_tasks: list[asyncio.Task[Any]] = [] tool_call_ids: list[str] = [] tool_api_names: list[str] = [] + tool_internal_names: list[str] = [] end_tool_call: dict[str, Any] | None = None end_tool_args: dict[str, Any] = {} + end_webchat_event_base: dict[str, Any] = {} tool_execution_started = False + parent_call_id = str(context.get("webchat_parent_call_id") or "").strip() + depth = webchat_depth(context.get("webchat_depth")) + agent_path = webchat_agent_path(context.get("webchat_agent_path")) + callable_agent_names: set[str] = getattr( + tool_registry, "_callable_agent_tool_names", set() + ) for tool_call in tool_calls: call_id = str(tool_call.get("id", "")) @@ -79,6 +107,30 @@ async def execute_assistant_tool_calls( if not isinstance(function_args, dict): function_args = {} + is_nested_agent = internal_function_name in callable_agent_names + webchat_call_id = _webchat_call_id( + parent_call_id, + call_id, + internal_function_name, + ) + webchat_event_base: dict[str, Any] = { + "webchat_call_id": webchat_call_id, + "parent_webchat_call_id": parent_call_id, + "depth": depth, + "agent_path": agent_path, + } + await _emit_webchat_tool_event( + context, + "tool_start", + { + "tool_call_id": call_id, + "name": internal_function_name, + "api_name": api_function_name, + "arguments": function_args, + "is_agent": is_nested_agent, + **webchat_event_base, + }, + ) # end 工具延后处理:若与其他工具同批调用则返回拒绝 if internal_function_name == "end": @@ -90,36 +142,96 @@ async def execute_assistant_tool_calls( ) end_tool_call = tool_call end_tool_args = function_args + end_webchat_event_base = webchat_event_base continue tool_call_ids.append(call_id) tool_api_names.append(api_function_name) + tool_internal_names.append(internal_function_name) + tool_context = context.copy() + if is_nested_agent: + tool_context["webchat_parent_call_id"] = webchat_call_id + tool_context["webchat_call_parent_id"] = parent_call_id + tool_context["webchat_depth"] = depth + 1 + tool_context["webchat_agent_path"] = [ + *agent_path, + internal_function_name, + ] skill_delimiter = ( agent_skill_registry.dot_delimiter if agent_skill_registry else "-_-" ) # Anthropic Skill 走独立 registry,其余走 AgentToolRegistry is_agent_skill = internal_function_name.startswith(f"skills{skill_delimiter}") - if is_agent_skill and agent_skill_registry: - tool_tasks.append( - asyncio.ensure_future( - agent_skill_registry.execute_skill_tool( - internal_function_name, - function_args, + + async def _execute_tool_with_webchat_event( + *, + call_id: str, + api_name: str, + internal_name: str, + args: dict[str, Any], + context: dict[str, Any], + webchat_event_base: dict[str, Any], + is_nested_agent: bool, + is_agent_skill: bool, + ) -> Any: + try: + if is_agent_skill and agent_skill_registry: + result = await agent_skill_registry.execute_skill_tool( + internal_name, + args, context, ) - ) - ) - else: - tool_tasks.append( - asyncio.ensure_future( - tool_registry.execute_tool( - internal_function_name, - function_args, + else: + result = await tool_registry.execute_tool( + internal_name, + args, context, ) + except Exception as exc: + await _emit_webchat_tool_event( + context, + "tool_end", + { + "tool_call_id": call_id, + "name": internal_name, + "api_name": api_name, + "ok": False, + "result": f"{tool_error_prefix}: {exc}", + "is_agent": is_nested_agent, + **webchat_event_base, + }, + ) + raise + await _emit_webchat_tool_event( + context, + "tool_end", + { + "tool_call_id": call_id, + "name": internal_name, + "api_name": api_name, + "ok": True, + "result": str(result), + "is_agent": is_nested_agent, + **webchat_event_base, + }, + ) + return result + + tool_tasks.append( + asyncio.create_task( + _execute_tool_with_webchat_event( + call_id=call_id, + api_name=api_function_name, + internal_name=internal_function_name, + args=function_args, + context=tool_context, + webchat_event_base=webchat_event_base, + is_nested_agent=is_nested_agent, + is_agent_skill=is_agent_skill, ) ) + ) if tool_tasks: tool_execution_started = True @@ -152,6 +264,19 @@ async def execute_assistant_tool_calls( end_call_id = str(end_tool_call.get("id", "")) end_api_name = end_tool_call.get("function", {}).get("name", "end") if tool_tasks: + await _emit_webchat_tool_event( + context, + "tool_end", + { + "tool_call_id": end_call_id, + "name": "end", + "api_name": end_api_name, + "ok": False, + "result": END_CO_CALL_REJECT_CONTENT, + "is_agent": False, + **end_webchat_event_base, + }, + ) messages.append( { "role": "tool", @@ -166,7 +291,27 @@ async def execute_assistant_tool_calls( ) else: tool_execution_started = True - end_result = await tool_registry.execute_tool("end", end_tool_args, context) + try: + end_result = await tool_registry.execute_tool( + "end", end_tool_args, context + ) + end_ok = True + except Exception as exc: + end_result = f"{tool_error_prefix}: {exc}" + end_ok = False + await _emit_webchat_tool_event( + context, + "tool_end", + { + "tool_call_id": end_call_id, + "name": "end", + "api_name": end_api_name, + "ok": end_ok, + "result": str(end_result), + "is_agent": False, + **end_webchat_event_base, + }, + ) messages.append( { "role": "tool", diff --git a/src/Undefined/skills/agents/runner/webchat_utils.py b/src/Undefined/skills/agents/runner/webchat_utils.py new file mode 100644 index 00000000..9395e934 --- /dev/null +++ b/src/Undefined/skills/agents/runner/webchat_utils.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Any + + +def webchat_agent_path(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if str(item).strip()] + + +def webchat_depth(value: Any) -> int: + try: + return max(0, int(value)) + except (TypeError, ValueError): + return 0 diff --git a/src/Undefined/skills/agents/summary_agent/handler.py b/src/Undefined/skills/agents/summary_agent/handler.py index f64aa4fc..94224657 100644 --- a/src/Undefined/skills/agents/summary_agent/handler.py +++ b/src/Undefined/skills/agents/summary_agent/handler.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -69,6 +72,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=Path(__file__).parent, logger=logger, - max_iterations=10, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="错误", ) diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/README.md b/src/Undefined/skills/agents/undefined_self_code_agent/README.md new file mode 100644 index 00000000..a9dad1ee --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/README.md @@ -0,0 +1,18 @@ +# undefined_self_code_agent 智能体 + +面向 Undefined 当前仓库的只读代码查阅助手,提供受限文件读取、目录浏览、glob 匹配和内容检索能力。 + +目录结构: +- `config.json`:智能体定义 +- `intro.md`:给主 AI 看的能力说明 +- `prompt.md`:智能体系统提示词 +- `tools/`:只读代码查阅工具集合 + +访问范围: +- 目录:`src/`、`scripts/`、`tests/`、`res/`、`docs/`、`apps/` +- 根文件:`README.md`、`CHANGELOG.md`、`ARCHITECTURE.md`、`config.toml.example` + +运行机制: +- 由 `AgentRegistry` 自动发现并注册 +- 子工具统一复用 `tools/_shared.py` 的路径白名单与文本读取逻辑 +- 不提供写入、命令执行或联网能力 diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/__init__.py b/src/Undefined/skills/agents/undefined_self_code_agent/__init__.py new file mode 100644 index 00000000..d5f7abfb --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/__init__.py @@ -0,0 +1 @@ +"""Undefined self code inspection agent.""" diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/config.json b/src/Undefined/skills/agents/undefined_self_code_agent/config.json new file mode 100644 index 00000000..91ebe6e4 --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/config.json @@ -0,0 +1,17 @@ +{ + "type": "function", + "function": { + "name": "undefined_self_code_agent", + "description": "Undefined 自身代码查阅助手,用于只读查询当前 Undefined 仓库的源码、测试、文档、资源、脚本与 App 实现细节;不包含 code/NagaAgent 子模块,不写代码、不运行命令。", + "parameters": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "需要查阅 Undefined 自身代码或文档的具体问题" + } + }, + "required": ["prompt"] + } + } +} diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/handler.py b/src/Undefined/skills/agents/undefined_self_code_agent/handler.py new file mode 100644 index 00000000..d5577855 --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/handler.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) + +logger = logging.getLogger(__name__) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """执行 undefined_self_code_agent。""" + + user_prompt = str(args.get("prompt", "")).strip() + return await run_agent_with_tools( + agent_name="undefined_self_code_agent", + user_content=user_prompt, + empty_user_content_message="请提供要查阅的 Undefined 代码问题", + default_prompt="你是 Undefined 项目的只读代码查阅助手。", + context=context, + agent_dir=Path(__file__).parent, + logger=logger, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, + tool_error_prefix="错误", + ) diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/intro.md b/src/Undefined/skills/agents/undefined_self_code_agent/intro.md new file mode 100644 index 00000000..fcf67497 --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/intro.md @@ -0,0 +1,22 @@ +# Undefined 自身代码查阅助手 + +## 定位 +只用于回答 **Undefined 项目自身** 的源码、测试、文档、资源、脚本、配置示例和 App 实现细节问题。 + +## 擅长 +- 查阅 `src/`、`scripts/`、`tests/`、`res/`、`docs/`、`apps/` 下的文件 +- 查阅根目录 `README.md`、`CHANGELOG.md`、`ARCHITECTURE.md`、`config.toml.example` +- 浏览目录、按 glob 查找文件、按关键词或正则搜索代码内容 +- 基于实时读取到的文件内容解释当前实现 + +## 边界 +- 只读查阅,不修改文件、不运行命令、不联网搜索 +- 不读取未列入白名单的路径,例如 `.env`、`data/`、`logs/`、`code/`、`pyproject.toml` +- `code/NagaAgent/` 是 NagaAgent 子模块,不属于 Undefined 自身代码查阅范围 +- NagaAgent 相关技术问题仍交给 `naga_code_analysis_agent` +- 用户上传文件或外部文件解析仍交给 `file_analysis_agent` +- 代码编写、修改和交付仍交给 `code_delivery_agent` + +## 输入偏好 +- 明确的模块、文件、报错、配置项、测试名或功能点 +- 若问题较宽泛,会先通过目录、glob 或内容搜索缩小范围 diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/prompt.md b/src/Undefined/skills/agents/undefined_self_code_agent/prompt.md new file mode 100644 index 00000000..2417b18b --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/prompt.md @@ -0,0 +1,20 @@ +你是 Undefined 项目的只读代码查阅助手,目标是帮助用户理解当前 Undefined 仓库内部实现。 + +工作原则: +- 先判断问题是否与 Undefined 自身源码、测试、文档、资源、脚本、配置示例或 App 实现有关。 +- 如果是宽泛问题,先用 `list_directory`、`glob` 或 `search_file_content` 定位相关文件,再深入具体内容。 +- 用工具获取证据后再下结论,避免凭记忆或猜测回答。 +- 路径只能使用仓库相对路径;不要要求读取绝对路径。 +- 只允许查阅 `src/`、`scripts/`、`tests/`、`res/`、`docs/`、`apps/`,以及根目录 `README.md`、`CHANGELOG.md`、`ARCHITECTURE.md`、`config.toml.example`。 +- 禁止尝试读取 `.env`、`data/`、`logs/`、`.git/`、`code/`、根目录其它文件或任何越界路径。 +- 你只能查阅和解释,不修改代码、不运行命令、不联网搜索。 +- `code/NagaAgent/` 是 NagaAgent 子模块,永远不属于 Undefined 自身代码查阅范围;NagaAgent 相关技术问题不由你处理,应建议使用 `naga_code_analysis_agent`。 +- 用户上传文件或外部文件解析不由你处理,应建议使用 `file_analysis_agent`。 +- 代码编写、修改、验证和打包不由你处理,应建议使用 `code_delivery_agent`。 + +表达风格: +- 简洁、结构化,先给结论再给依据。 +- 引用文件路径时使用仓库相对路径。 +- 如果依据不足,说明还需要查阅哪个文件或让用户缩小范围。 + +如果问题涉及“当前时间/今日”等,且工具可用,先调用 `get_current_time` 校准时间。 diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/tools/__init__.py b/src/Undefined/skills/agents/undefined_self_code_agent/tools/__init__.py new file mode 100644 index 00000000..3acb0e63 --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/tools/__init__.py @@ -0,0 +1 @@ +"""Tools for Undefined self code inspection agent.""" diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/tools/_shared.py b/src/Undefined/skills/agents/undefined_self_code_agent/tools/_shared.py new file mode 100644 index 00000000..1f71af39 --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/tools/_shared.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import fnmatch +import re +from collections.abc import AsyncIterator +from dataclasses import dataclass +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any + +from Undefined.utils import io as async_io + + +ALLOWED_DIRECTORIES: tuple[str, ...] = ( + "src", + "scripts", + "tests", + "res", + "docs", + "apps", +) +ALLOWED_ROOT_FILES: tuple[str, ...] = ( + "README.md", + "CHANGELOG.md", + "ARCHITECTURE.md", + "config.toml.example", +) +PROJECT_MARKERS: tuple[str, ...] = ( + "pyproject.toml", + "src/Undefined", + "config.toml.example", +) +EXCLUDED_DIR_NAMES: frozenset[str] = frozenset( + { + ".git", + ".hg", + ".svn", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".cache", + ".venv", + "__pycache__", + "node_modules", + "target", + "dist", + "build", + ".vite", + "coverage", + } +) +MAX_TEXT_BYTES = 1_500_000 +DEFAULT_MAX_CHARS = 60_000 +DEFAULT_LINE_LIMIT = 200 +DEFAULT_MAX_RESULTS = 100 +DEFAULT_MAX_MATCHES = 100 +MAX_LINE_LEN = 500 + + +@dataclass(frozen=True) +class ResolvedPath: + repo_root: Path + path: Path + rel_path: str + + +def allowed_roots_text() -> str: + """返回允许访问范围说明。""" + + dirs = ", ".join(f"{name}/" for name in ALLOWED_DIRECTORIES) + files = ", ".join(ALLOWED_ROOT_FILES) + return f"允许目录: {dirs}; 允许根文件: {files}" + + +def find_repo_root(context: dict[str, Any]) -> Path: + """解析 Undefined 仓库根目录。""" + + raw_root = context.get("repo_root") or context.get("project_root") + candidates: list[Path] = [] + if raw_root: + candidates.append(Path(raw_root)) + candidates.append(Path.cwd()) + candidates.extend(Path.cwd().parents) + current = Path(__file__).resolve() + candidates.extend(current.parents) + + seen: set[Path] = set() + for candidate in candidates: + root = candidate.resolve() + if root in seen: + continue + seen.add(root) + if all((root / marker).exists() for marker in PROJECT_MARKERS): + return root + + raise ValueError("无法定位 Undefined 仓库根目录") + + +def _normalize_rel_path(value: str | None) -> str: + rel = str(value or "").strip().replace("\\", "/") + while rel.startswith("./"): + rel = rel[2:] + return rel.rstrip("/") + + +def _is_excluded_by_parts(path: Path, repo_root: Path) -> bool: + try: + parts = path.relative_to(repo_root).parts + except ValueError: + return True + return any(part in EXCLUDED_DIR_NAMES or part.startswith(".") for part in parts) + + +def _is_allowed_relative(rel_path: str, *, allow_root: bool) -> bool: + if rel_path in {"", "."}: + return allow_root + if rel_path in ALLOWED_ROOT_FILES: + return True + first = rel_path.split("/", 1)[0] + return first in ALLOWED_DIRECTORIES + + +def is_allowed_path(path: Path, repo_root: Path, *, allow_root: bool = False) -> bool: + """判断路径是否位于允许访问范围内。""" + + try: + rel = path.resolve().relative_to(repo_root.resolve()).as_posix() + except ValueError: + return False + if _is_excluded_by_parts(path.resolve(), repo_root.resolve()): + return False + return _is_allowed_relative(rel, allow_root=allow_root) + + +def resolve_allowed_path( + path_value: str | None, + context: dict[str, Any], + *, + allow_root: bool = False, +) -> ResolvedPath: + """解析并校验仓库相对路径。""" + + repo_root = find_repo_root(context) + rel = _normalize_rel_path(path_value) + target = (repo_root / rel).resolve() if rel else repo_root.resolve() + + try: + rel_path = target.relative_to(repo_root).as_posix() + except ValueError as exc: + raise PermissionError(f"路径越界: {path_value}") from exc + + if rel_path == ".": + rel_path = "" + if not is_allowed_path(target, repo_root, allow_root=allow_root): + raise PermissionError(f"路径不在允许范围内: {path_value or '.'}") + + return ResolvedPath(repo_root=repo_root, path=target, rel_path=rel_path) + + +def resolve_search_root( + path_value: str | None, + context: dict[str, Any], +) -> ResolvedPath: + """解析搜索根路径,空路径表示整个允许范围。""" + + return resolve_allowed_path(path_value, context, allow_root=True) + + +def _iter_allowed_roots(repo_root: Path, root: Path | None = None) -> list[Path]: + """返回允许范围内的扫描根。""" + + base = (root or repo_root).resolve() + if base == repo_root.resolve(): + roots = [repo_root / name for name in ALLOWED_DIRECTORIES] + roots.extend(repo_root / name for name in ALLOWED_ROOT_FILES) + return roots + return [base] + + +async def path_exists(path: Path) -> bool: + """异步检查路径是否存在。""" + + return await async_io.exists(path) + + +async def path_is_file(path: Path) -> bool: + """异步检查路径是否为普通文件。""" + + return await async_io.is_file(path) + + +async def path_is_dir(path: Path) -> bool: + """异步检查路径是否为目录。""" + + return await async_io.is_dir(path) + + +async def iter_allowed_files( + repo_root: Path, + root: Path | None = None, +) -> AsyncIterator[Path]: + """异步遍历允许范围内的文件。""" + + for item in _iter_allowed_roots(repo_root, root): + if not await path_exists(item): + continue + if await path_is_file(item): + if is_allowed_path(item, repo_root): + yield item + continue + if not await path_is_dir(item) or not is_allowed_path(item, repo_root): + continue + async for path in async_io.iter_rglob_files(item): + if is_allowed_path(path, repo_root): + yield path + + +def _normalize_glob_pattern(pattern: str) -> str: + normalized = pattern.strip().replace("\\", "/") + if not normalized: + raise ValueError("glob 模式不能为空") + if ( + PurePosixPath(normalized).is_absolute() + or PureWindowsPath(pattern).is_absolute() + ): + raise ValueError("glob 模式不能是绝对路径") + if ".." in normalized.split("/"): + raise ValueError("glob 模式不能包含 ..") + return normalized + + +def _has_glob_magic(value: str) -> bool: + return any(char in value for char in "*?[") + + +def _root_file_matches(pattern: str, rel_path: str) -> bool: + if pattern.startswith("**/"): + return _root_file_matches(pattern[3:], rel_path) + return PurePosixPath(rel_path).match(pattern) or fnmatch.fnmatchcase( + rel_path, + pattern, + ) + + +def _iter_constrained_glob_roots( + repo_root: Path, + search_root: Path, + pattern: str, +) -> list[tuple[Path, str]]: + if search_root.resolve() != repo_root.resolve(): + return [(search_root.resolve(), pattern)] + + first, separator, remainder = pattern.partition("/") + if not separator: + return [] + if first == "**": + return [(repo_root / name, pattern) for name in ALLOWED_DIRECTORIES] + if first in ALLOWED_DIRECTORIES: + return [(repo_root / first, remainder or "*")] + if _has_glob_magic(first): + return [ + (repo_root / name, remainder or "*") + for name in ALLOWED_DIRECTORIES + if fnmatch.fnmatchcase(name, first) + ] + return [] + + +def collect_allowed_glob_matches( + repo_root: Path, + search_root: Path, + pattern: str, + max_results: int, +) -> list[str]: + """按 glob 收集允许范围内的文件路径。调用方需用 asyncio.to_thread 包装。""" + + pattern = _normalize_glob_pattern(pattern) + + matches: list[str] = [] + seen: set[str] = set() + + def add_match(candidate: Path) -> bool: + rel = format_relative(candidate, repo_root) + if rel in seen: + return False + seen.add(rel) + matches.append(rel) + return len(matches) >= max_results + + if search_root.resolve() == repo_root.resolve(): + for name in ALLOWED_ROOT_FILES: + candidate = repo_root / name + if not candidate.is_file() or not _root_file_matches(pattern, name): + continue + if add_match(candidate): + return sorted(matches) + elif search_root.is_file(): + rel = format_relative(search_root, repo_root) + if is_allowed_path(search_root, repo_root) and _root_file_matches(pattern, rel): + add_match(search_root) + return sorted(matches) + + for root, root_pattern in _iter_constrained_glob_roots( + repo_root, + search_root, + pattern, + ): + if not root.exists() or not root.is_dir(): + continue + for candidate in root.glob(root_pattern): + if not candidate.is_file(): + continue + if not is_allowed_path(candidate, repo_root): + continue + if add_match(candidate): + return sorted(matches) + return sorted(matches) + + +async def list_allowed_directory_entries( + repo_root: Path, + directory: Path, +) -> list[tuple[str, bool]]: + """异步列出允许目录项,返回 (relative_path, is_dir)。""" + + entries = sorted( + await async_io.list_directory_entries(directory), + key=lambda item: (not item[1], item[0].name.lower()), + ) + result: list[tuple[str, bool]] = [] + for entry, is_dir in entries: + if not is_allowed_path(entry, repo_root): + continue + result.append((format_relative(entry, repo_root), is_dir)) + return result + + +def is_probably_text(raw: bytes) -> bool: + """粗略判断字节内容是否适合作为文本读取。""" + + if b"\x00" in raw: + return False + if not raw: + return True + control = sum(1 for value in raw if value < 32 and value not in {9, 10, 12, 13}) + return control <= max(12, len(raw) // 20) + + +def decode_text(raw: bytes) -> str: + """按常见源码/文档编码解码文本。""" + + for encoding in ("utf-8-sig", "utf-8", "gb18030", "latin-1"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + return raw.decode("utf-8", errors="replace") + + +def read_text_file(path: Path) -> tuple[str, bool, int]: + """读取文本文件,返回内容、是否因大小截断、原始字节数。""" + + size = path.stat().st_size + with open(path, "rb") as file: + raw = file.read(MAX_TEXT_BYTES + 1) + truncated_bytes = len(raw) > MAX_TEXT_BYTES + if truncated_bytes: + raw = raw[:MAX_TEXT_BYTES] + if not is_probably_text(raw): + raise UnicodeError("文件看起来是二进制文件") + return decode_text(raw), truncated_bytes, size + + +def clamp_int(value: Any, default: int, minimum: int, maximum: int) -> int: + """将任意输入规范为整数范围。""" + + try: + number = int(value) + except (TypeError, ValueError): + number = default + return max(minimum, min(maximum, number)) + + +def format_relative(path: Path, repo_root: Path) -> str: + """格式化为仓库相对路径。""" + + return path.resolve().relative_to(repo_root.resolve()).as_posix() + + +def path_matches_include(path: Path, repo_root: Path, include: str) -> bool: + """判断文件是否匹配 include glob。""" + + if not include: + return True + rel = format_relative(path, repo_root) + return fnmatch.fnmatch(rel, include) or fnmatch.fnmatch(path.name, include) + + +def compile_pattern( + pattern: str, + *, + is_regex: bool, + case_sensitive: bool, +) -> re.Pattern[str]: + """编译搜索模式。""" + + flags = 0 if case_sensitive else re.IGNORECASE + source = pattern if is_regex else re.escape(pattern) + return re.compile(source, flags) + + +def trim_line(line: str, max_len: int = MAX_LINE_LEN) -> str: + """截断过长的单行输出。""" + + if len(line) <= max_len: + return line + return line[:max_len] + "..." diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/tools/glob/config.json b/src/Undefined/skills/agents/undefined_self_code_agent/tools/glob/config.json new file mode 100644 index 00000000..617f2caa --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/tools/glob/config.json @@ -0,0 +1,25 @@ +{ + "type": "function", + "function": { + "name": "glob", + "description": "在 Undefined 仓库允许范围内按 glob 查找文件。", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "glob 模式,例如 **/*.py、src/Undefined/**/*.py、docs/*.md" + }, + "base_path": { + "type": "string", + "description": "可选搜索根目录,仓库相对路径" + }, + "max_results": { + "type": "integer", + "description": "最大返回结果数,默认 100,范围 1-500" + } + }, + "required": ["pattern"] + } + } +} diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/tools/glob/handler.py b/src/Undefined/skills/agents/undefined_self_code_agent/tools/glob/handler.py new file mode 100644 index 00000000..33cae56d --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/tools/glob/handler.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from Undefined.skills.agents.undefined_self_code_agent.tools._shared import ( + DEFAULT_MAX_RESULTS, + allowed_roots_text, + clamp_int, + collect_allowed_glob_matches, + path_exists, + resolve_search_root, +) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """按 glob 模式查找允许范围内的文件。""" + + pattern = str(args.get("pattern") or "").strip() + if not pattern: + return "错误:pattern 不能为空" + base_path = str(args.get("base_path") or "").strip() + max_results = clamp_int(args.get("max_results"), DEFAULT_MAX_RESULTS, 1, 500) + + try: + resolved = resolve_search_root(base_path, context) + except PermissionError as exc: + return f"权限不足:{exc}。{allowed_roots_text()}" + except ValueError as exc: + return f"错误:{exc}" + + if not await path_exists(resolved.path): + return f"路径不存在: {base_path or '.'}" + + try: + matches = await asyncio.to_thread( + collect_allowed_glob_matches, + resolved.repo_root, + resolved.path, + pattern, + max_results, + ) + except ValueError as exc: + return f"glob 模式无效: {exc}" + + if not matches: + return f"未找到匹配的文件: {pattern}" + result = "\n".join(matches) + if len(matches) >= max_results: + result += f"\n\n... (结果已截断,共显示 {max_results} 条)" + return result diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/tools/list_directory/config.json b/src/Undefined/skills/agents/undefined_self_code_agent/tools/list_directory/config.json new file mode 100644 index 00000000..5864e96b --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/tools/list_directory/config.json @@ -0,0 +1,21 @@ +{ + "type": "function", + "function": { + "name": "list_directory", + "description": "列出 Undefined 仓库允许范围内的目录内容。空路径列出允许访问的顶层范围。", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "目录路径,仓库相对路径;为空时列出允许访问范围" + }, + "max_entries": { + "type": "integer", + "description": "最大返回条目数,默认 120,范围 1-500" + } + }, + "required": [] + } + } +} diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/tools/list_directory/handler.py b/src/Undefined/skills/agents/undefined_self_code_agent/tools/list_directory/handler.py new file mode 100644 index 00000000..9502d4cf --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/tools/list_directory/handler.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any + +from Undefined.skills.agents.undefined_self_code_agent.tools._shared import ( + ALLOWED_DIRECTORIES, + ALLOWED_ROOT_FILES, + allowed_roots_text, + clamp_int, + list_allowed_directory_entries, + path_exists, + path_is_dir, + resolve_allowed_path, +) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """列出允许范围内的目录内容。""" + + path_arg = str(args.get("path") or "").strip() + max_entries = clamp_int(args.get("max_entries"), 120, 1, 500) + + try: + resolved = resolve_allowed_path(path_arg, context, allow_root=True) + except PermissionError as exc: + return f"权限不足:{exc}。{allowed_roots_text()}" + except ValueError as exc: + return f"错误:{exc}" + + if not path_arg: + lines = ["允许访问范围:"] + lines.extend(f"📁 {name}/" for name in ALLOWED_DIRECTORIES) + lines.extend(f"📄 {name}" for name in ALLOWED_ROOT_FILES) + return "\n".join(lines) + + if not await path_exists(resolved.path): + return f"目录不存在: {path_arg}" + if not await path_is_dir(resolved.path): + return f"错误:{path_arg} 不是目录" + + entries = await list_allowed_directory_entries( + resolved.repo_root, + resolved.path, + ) + visible: list[str] = [] + for rel, is_dir in entries: + icon = "📁" if is_dir else "📄" + suffix = "/" if is_dir else "" + visible.append(f"{icon} {rel}{suffix}") + if len(visible) >= max_entries: + break + + if not visible: + return f"{resolved.rel_path or '.'}: 无可列出的允许内容" + + total_visible = len(entries) + if total_visible > len(visible): + visible.append(f"... 还有 {total_visible - len(visible)} 项") + return "\n".join(visible) diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/tools/read_file/config.json b/src/Undefined/skills/agents/undefined_self_code_agent/tools/read_file/config.json new file mode 100644 index 00000000..91d49f38 --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/tools/read_file/config.json @@ -0,0 +1,29 @@ +{ + "type": "function", + "function": { + "name": "read_file", + "description": "读取 Undefined 仓库允许范围内的文本文件。路径必须是仓库相对路径。", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "文件路径,例如 src/Undefined/ai/client/setup.py 或 README.md" + }, + "max_chars": { + "type": "integer", + "description": "最大返回字符数,默认 60000,范围 1000-200000" + }, + "offset": { + "type": "integer", + "description": "可选:起始行号,从 1 开始" + }, + "limit": { + "type": "integer", + "description": "可选:读取行数,默认 200,范围 1-2000" + } + }, + "required": ["file_path"] + } + } +} diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/tools/read_file/handler.py b/src/Undefined/skills/agents/undefined_self_code_agent/tools/read_file/handler.py new file mode 100644 index 00000000..e3e57403 --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/tools/read_file/handler.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from Undefined.skills.agents.undefined_self_code_agent.tools._shared import ( + DEFAULT_LINE_LIMIT, + DEFAULT_MAX_CHARS, + allowed_roots_text, + clamp_int, + path_exists, + path_is_file, + read_text_file, + resolve_allowed_path, +) + +logger = logging.getLogger(__name__) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """读取允许范围内的文本文件。""" + + file_path = str(args.get("file_path") or args.get("path") or "").strip() + if not file_path: + return "错误:file_path 不能为空" + + try: + resolved = resolve_allowed_path(file_path, context) + except PermissionError as exc: + return f"权限不足:{exc}。{allowed_roots_text()}" + except ValueError as exc: + return f"错误:{exc}" + + if not await path_exists(resolved.path): + return f"文件不存在: {file_path}" + if not await path_is_file(resolved.path): + return f"错误:{file_path} 不是文件" + + try: + content, truncated_bytes, size = await asyncio.to_thread( + read_text_file, resolved.path + ) + except UnicodeError: + return f"错误:{resolved.rel_path} 不是可读取的文本文件" + except OSError as exc: + logger.exception("读取文件失败: %s", resolved.rel_path) + return f"读取文件失败 {resolved.rel_path}: {exc}" + + offset_raw = args.get("offset") + limit_raw = args.get("limit") + line_window = offset_raw is not None or limit_raw is not None + header = f"=== {resolved.rel_path} ({size} bytes) ===" + + if line_window: + lines = content.splitlines() + total_lines = len(lines) + offset = clamp_int(offset_raw, 1, 1, max(total_lines, 1)) + limit = clamp_int(limit_raw, DEFAULT_LINE_LIMIT, 1, 2000) + start_idx = offset - 1 + selected = lines[start_idx : start_idx + limit] + end_line = start_idx + len(selected) + body = "\n".join(selected) + if total_lines == 0 or not selected: + range_header = f"{header}\n行 0-0/0(空文件)" + else: + range_header = f"{header}\n行 {offset}-{end_line}/{total_lines}" + if truncated_bytes: + range_header += "\n提示:文件因大小限制只读取了前一部分字节" + return f"{range_header}\n{body}" + + max_chars = clamp_int(args.get("max_chars"), DEFAULT_MAX_CHARS, 1000, 200000) + total_chars = len(content) + truncated_chars = total_chars > max_chars + if truncated_chars: + content = content[:max_chars] + + notes: list[str] = [] + if truncated_bytes: + notes.append("文件因大小限制只读取了前一部分字节") + if truncated_chars: + notes.append(f"内容共 {total_chars} 字符,已截断到前 {max_chars} 字符") + note_text = "\n".join(f"提示:{note}" for note in notes) + if note_text: + return f"{header}\n{note_text}\n{content}" + return f"{header}\n{content}" diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/tools/search_file_content/config.json b/src/Undefined/skills/agents/undefined_self_code_agent/tools/search_file_content/config.json new file mode 100644 index 00000000..2c07ed33 --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/tools/search_file_content/config.json @@ -0,0 +1,37 @@ +{ + "type": "function", + "function": { + "name": "search_file_content", + "description": "在 Undefined 仓库允许范围内搜索文本内容,支持普通字符串或正则。", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "要搜索的字符串或正则表达式" + }, + "path": { + "type": "string", + "description": "可选搜索路径,仓库相对路径;为空时搜索全部允许范围" + }, + "include": { + "type": "string", + "description": "可选文件 glob 过滤,例如 *.py、src/Undefined/**/*.py" + }, + "is_regex": { + "type": "boolean", + "description": "pattern 是否按正则表达式处理,默认 false" + }, + "case_sensitive": { + "type": "boolean", + "description": "是否大小写敏感,默认 true" + }, + "max_matches": { + "type": "integer", + "description": "最大匹配行数,默认 100,范围 1-500" + } + }, + "required": ["pattern"] + } + } +} diff --git a/src/Undefined/skills/agents/undefined_self_code_agent/tools/search_file_content/handler.py b/src/Undefined/skills/agents/undefined_self_code_agent/tools/search_file_content/handler.py new file mode 100644 index 00000000..b91628d3 --- /dev/null +++ b/src/Undefined/skills/agents/undefined_self_code_agent/tools/search_file_content/handler.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import asyncio +import logging +import re +from typing import Any + +from Undefined.skills.agents.undefined_self_code_agent.tools._shared import ( + DEFAULT_MAX_MATCHES, + allowed_roots_text, + clamp_int, + compile_pattern, + format_relative, + iter_allowed_files, + path_matches_include, + path_exists, + read_text_file, + resolve_search_root, + trim_line, +) + +logger = logging.getLogger(__name__) + + +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: + """在允许范围内搜索文本内容。""" + + pattern = str(args.get("pattern") or "").strip() + if not pattern: + return "错误:pattern 不能为空" + + path_arg = str(args.get("path") or "").strip() + include = str(args.get("include") or "").strip() + is_regex = bool(args.get("is_regex", False)) + case_sensitive = bool(args.get("case_sensitive", True)) + max_matches = clamp_int(args.get("max_matches"), DEFAULT_MAX_MATCHES, 1, 500) + + try: + resolved = resolve_search_root(path_arg, context) + except PermissionError as exc: + return f"权限不足:{exc}。{allowed_roots_text()}" + except ValueError as exc: + return f"错误:{exc}" + + if not await path_exists(resolved.path): + return f"路径不存在: {path_arg or '.'}" + + try: + compiled = compile_pattern( + pattern, + is_regex=is_regex, + case_sensitive=case_sensitive, + ) + except re.error as exc: + return f"正则表达式错误: {exc}" + + matches: list[str] = [] + try: + async for file_path in iter_allowed_files(resolved.repo_root, resolved.path): + if not path_matches_include(file_path, resolved.repo_root, include): + continue + try: + text, _truncated, _size = await asyncio.to_thread( + read_text_file, + file_path, + ) + except (OSError, UnicodeError): + continue + rel = format_relative(file_path, resolved.repo_root) + for line_number, line in enumerate(text.splitlines(), start=1): + if compiled.search(line): + matches.append(f"{rel}:{line_number}:{trim_line(line.rstrip())}") + if len(matches) >= max_matches: + break + if len(matches) >= max_matches: + break + except Exception as exc: + logger.exception("搜索失败: %s", pattern) + return f"搜索失败: {exc}" + + if not matches: + return f"未找到匹配: {pattern}" + + result = "\n".join(matches) + if len(matches) >= max_matches: + result += f"\n\n... (结果已截断,共显示 {max_matches} 条匹配)" + return result diff --git a/src/Undefined/skills/agents/web_agent/README.md b/src/Undefined/skills/agents/web_agent/README.md index 9a985c21..d8c04c82 100644 --- a/src/Undefined/skills/agents/web_agent/README.md +++ b/src/Undefined/skills/agents/web_agent/README.md @@ -2,7 +2,7 @@ 用于网络搜索与网页抓取,支持结合 MCP 的浏览器能力。 默认子工具包括: -- `grok_search`:优先级最高的联网搜索工具(需显式启用) +- `grok_search`:优先级最高的联网搜索工具(需显式启用),调用时使用 `search_request` 自然语言完整叙述搜索要求;工具会向 Grok 模型注入当前服务端时间、必须先搜索、交叉检索、禁止编造和必须给来源的约束 - `web_search`:基于 SearXNG 的后备搜索工具 - `crawl_webpage`:读取网页正文 diff --git a/src/Undefined/skills/agents/web_agent/handler.py b/src/Undefined/skills/agents/web_agent/handler.py index 3595c77e..f4e0358d 100644 --- a/src/Undefined/skills/agents/web_agent/handler.py +++ b/src/Undefined/skills/agents/web_agent/handler.py @@ -4,7 +4,10 @@ from pathlib import Path from typing import Any -from Undefined.skills.agents.runner import run_agent_with_tools +from Undefined.skills.agents.runner import ( + DEFAULT_AGENT_MAX_ITERATIONS, + run_agent_with_tools, +) logger = logging.getLogger(__name__) @@ -22,6 +25,6 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: context=context, agent_dir=agent_dir, logger=logger, - max_iterations=20, + max_iterations=DEFAULT_AGENT_MAX_ITERATIONS, tool_error_prefix="Error", ) diff --git a/src/Undefined/skills/agents/web_agent/prompt.md b/src/Undefined/skills/agents/web_agent/prompt.md index 3944ebf5..54f18db8 100644 --- a/src/Undefined/skills/agents/web_agent/prompt.md +++ b/src/Undefined/skills/agents/web_agent/prompt.md @@ -2,7 +2,7 @@ 工作原则: - 先判断是“搜索”还是“读取 URL”,必要时追问范围或关键词。 -- 若 `grok_search` 可用,优先调用它;提问时要使用详细自然语言,尽量写清对象、时间范围、限定条件和想要的结果。 +- 若 `grok_search` 可用,优先调用它;调用时使用 `search_request`,用完整自然语言详细说明搜索内容和回答要求,不要只给关键词,也不要主动把范围限定到用户未要求的时间、地区、站点或排除项。若用户明确给出这些约束,再一并写入。 - 只有在 `grok_search` 不可用或明显不适合时,才改用 `web_search`。 - 优先给出权威来源或一手材料的要点。 - 结果要点化,避免堆砌原文。 diff --git a/src/Undefined/skills/agents/web_agent/tools/grok_search/config.json b/src/Undefined/skills/agents/web_agent/tools/grok_search/config.json index 8a23bb8e..129b4937 100644 --- a/src/Undefined/skills/agents/web_agent/tools/grok_search/config.json +++ b/src/Undefined/skills/agents/web_agent/tools/grok_search/config.json @@ -2,16 +2,16 @@ "type": "function", "function": { "name": "grok_search", - "description": "最优先使用的联网搜索工具,适用于获取最新信息、开放式互联网检索和高质量综合答案。提问时请使用详细自然语言,尽量带上时间范围、对象、限定条件和想要的结果。", + "description": "最优先使用的联网搜索工具,适用于获取最新信息、开放式互联网检索和高质量综合答案。调用时必须使用 search_request,用自然语言详细说明要搜索的内容和回答要求;不要只给关键词,也不要主动把范围写死到用户未要求的限制里。若用户明确给出时间、地区、站点、排除项、输出格式或比较维度等约束,再一并写入。", "parameters": { "type": "object", "properties": { - "query": { + "search_request": { "type": "string", - "description": "请用详细自然语言完整描述搜索问题,而不是只给几个关键词。" + "description": "用自然语言详细说明搜索内容和回答要求。把用户真正想查的主题、背景、重点、需要核实或比较的问题说清楚;不要只写关键词,也不要主动添加用户未要求的硬性范围。若用户给了时间、地区、来源、排除项或格式等要求,再一并写入。" } }, - "required": ["query"] + "required": ["search_request"] } } } diff --git a/src/Undefined/skills/agents/web_agent/tools/grok_search/handler.py b/src/Undefined/skills/agents/web_agent/tools/grok_search/handler.py index 8533f3e6..0aa81cc5 100644 --- a/src/Undefined/skills/agents/web_agent/tools/grok_search/handler.py +++ b/src/Undefined/skills/agents/web_agent/tools/grok_search/handler.py @@ -1,15 +1,59 @@ from __future__ import annotations +from datetime import datetime +import json import logging from typing import Any logger = logging.getLogger(__name__) +def _extract_grok_content(result: Any) -> str: + payload = result + if isinstance(result, str): + text = result.strip() + if not text: + return result + try: + payload = json.loads(text) + except json.JSONDecodeError: + return result + if not isinstance(payload, dict): + return str(result) + + choices = payload.get("choices") + if not isinstance(choices, list) or not choices: + return str(result) + first = choices[0] + if not isinstance(first, dict): + return str(result) + message = first.get("message") + if not isinstance(message, dict): + return str(result) + content = message.get("content") + if isinstance(content, str): + return content + return str(result) + + +def _build_grok_search_system_prompt(now: datetime | None = None) -> str: + current_time = (now or datetime.now().astimezone()).isoformat(timespec="seconds") + return "\n".join( + [ + "你是联网搜索执行器,必须严格遵守以下规则:", + f"- 当前基准时间是 {current_time},判断“今天”“最新”“最近”等相对时间时必须以这个时间为准,不要以模型内部时间为准。", + "- 必须先调用搜索能力获取外部信息,再组织回答;不要只依赖已有知识。", + "- 总是调用多个搜索工具或多组搜索查询,从不同角度全方位、深度检索以满足用户要求。", + "- 不可胡编乱造;无法确认的信息要明确说明不确定或未找到。", + "- 最终回答必须给出来源,优先包含标题、发布时间或访问时间、URL。", + ] + ) + + async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: - query = str(args.get("query") or "").strip() - if not query: - return "请提供详细的自然语言搜索问题。" + search_request = str(args.get("search_request") or "").strip() + if not search_request: + return "请用 search_request 提供完整的自然语言搜索要求。" runtime_config = context.get("runtime_config") if runtime_config is None: @@ -39,7 +83,10 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: if ai_client is None: return "Grok 搜索功能不可用(缺少 AI client)" - messages = [{"role": "user", "content": query}] + messages = [ + {"role": "system", "content": _build_grok_search_system_prompt()}, + {"role": "user", "content": search_request}, + ] try: result = await ai_client.submit_queued_llm_call( @@ -52,4 +99,4 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: logger.exception("[grok_search] 搜索失败: %s", exc) return "Grok 搜索失败,请稍后重试" - return str(result) + return _extract_grok_content(result) diff --git a/src/Undefined/skills/commands/changelog/config.json b/src/Undefined/skills/commands/changelog/config.json index 9477d560..51957445 100644 --- a/src/Undefined/skills/commands/changelog/config.json +++ b/src/Undefined/skills/commands/changelog/config.json @@ -12,5 +12,25 @@ "show_in_help": true, "order": 15, "allow_in_private": true, - "aliases": ["cl"] + "aliases": ["cl"], + "subcommands": { + "list": { + "description": "按新到旧列出版本号与标题", + "args": "[数量]" + }, + "show": { + "description": "展示指定版本的完整变更详情", + "args": "<版本号>" + }, + "latest": { + "description": "展示最新版本的完整变更详情" + } + }, + "inference": { + "default": "list", + "rules": [ + { "pattern": "^\\d+$", "subcommand": "list" }, + { "pattern": "^[vV]?\\d+(?:\\.\\d+){1,3}(?:[-+][A-Za-z0-9._-]+)?$", "subcommand": "show" } + ] + } } diff --git a/src/Undefined/skills/tools/end/README.md b/src/Undefined/skills/tools/end/README.md index 1a85d70a..239ea2d1 100644 --- a/src/Undefined/skills/tools/end/README.md +++ b/src/Undefined/skills/tools/end/README.md @@ -4,7 +4,8 @@ 关键信息: - `memo`(可选):本轮便签纸,留给短期记忆看的简短备注(纯流水账动作写这里,如调用工具、决定不回复等)。若当前输入批次包含多条消息,应概括整批处理结果。 -- `observations`(可选):字符串数组,本轮值得长期留存的观察(严格一条一个要点,可多条)——包括用户/群聊事实和有价值的自身行为(帮谁解决了什么问题);纯流水账写 memo 而非此处。若当前输入批次包含 MessageBatcher 合并的多条消息,必须覆盖整批消息内容,不能只记录最后一条。 +- `observations`(可选):字符串数组,本轮有价值的新观察(严格一条一个要点,可多条)——只允许来自当前输入批次直接出现的新事实,或本轮回复行为产生的有价值事实(帮谁解决了什么问题)。不要求与 bot 相关,也不要求长期稳定;当前批次中有价值即可记录。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为新事实来源。纯流水账写 memo 而非此处。若当前输入批次包含 MessageBatcher 合并的多条消息,必须覆盖整批消息内容,不能只记录最后一条。 +- 专名拼写:涉及本项目或 bot 主名时必须写作 `Undefined`。工具会在入队认知记忆前把已知错拼 `Unfined`、`Undefind`、`undefind` 规范为 `Undefined`,避免污染长期观察。 - `force`(可选):`true` 时可跳过"本轮未发送消息"的结束检查;同时在认知史官绝对化正则闸门失败时允许强制入库 - 两者都可为空;为空时仅结束会话,不写认知队列 diff --git a/src/Undefined/skills/tools/end/config.json b/src/Undefined/skills/tools/end/config.json index bb782b8f..703b2e91 100644 --- a/src/Undefined/skills/tools/end/config.json +++ b/src/Undefined/skills/tools/end/config.json @@ -2,18 +2,18 @@ "type": "function", "function": { "name": "end", - "description": "结束当前对话。memo 是本轮便签纸(短句);observations 从当前输入批次提取本轮值得长期留存的观察——包括用户/群聊事实和有回忆价值的自身行为(帮谁解决了什么),纯流水账动作写 memo。若当前输入批次包含 MessageBatcher 合并的多条消息,记忆记录必须覆盖整批,不要只看最后一条。", + "description": "结束当前对话。memo 是本轮便签纸(短句);observations 只能从当前输入批次提取本轮有价值的新观察,不要求与 bot 相关,也不要求长期稳定。可记录当前批次直接出现的用户/群聊/第三方事实,以及本轮回复行为产生的有价值事实(帮谁解决了什么)。历史消息、认知记忆、侧写和最近消息参考只能用于消歧,不能作为 observations 的新事实来源。纯流水账动作写 memo。若当前输入批次包含 MessageBatcher 合并的多条消息,记忆记录必须覆盖整批,不要只看最后一条。项目名/主名必须逐字写作 Undefined,禁止写成 Unfined、Undefind、undefind 或其它变体。", "parameters": { "type": "object", "properties": { "memo": { "type": "string", - "description": "本轮行动备忘(可空,建议短句)。若当前输入批次包含多条消息,memo 应概括整批处理结果。" + "description": "本轮行动备忘(可空,建议短句)。若当前输入批次包含多条消息,memo 应概括整批处理结果。涉及本项目或你自己时必须写作 Undefined。" }, "observations": { "type": "array", "items": {"type": "string"}, - "description": "从当前输入批次提取认知观察列表;存在【连续消息说明】或多段当前 时,必须覆盖整批消息内容,不能只记录最后一条。记录用户/群聊事实(偏好、计划、状态、关系、观点、人物事实、群聊事实)以及有回忆价值的自身行为(帮谁解决了什么问题、给了什么建议)。每条一个要点,宁多勿漏。纯流水账动作(调了什么工具、决定不回复)写 memo 而非此处。格式:具体、绝对化,写明谁/何时/何地。" + "description": "从当前输入批次提取认知观察列表;只记录当前批次直接出现的新事实,或本轮回复行为产生的有价值事实。不要求与 bot 相关,也不要求长期稳定;当前批次中有价值即可记录。历史消息、认知记忆、侧写和最近消息参考只能用于实体/时间/地点消歧,禁止从其中摘取新事实写入 observations。存在【连续消息说明】或多段当前 时,必须覆盖整批消息内容,不能只记录最后一条。记录用户/群聊/第三方事实(偏好、计划、状态、关系、观点、人物事实、群聊事实)以及有价值的自身行为(帮谁解决了什么问题、给了什么建议)。每条一个要点;纯流水账动作(调了什么工具、决定不回复)写 memo 而非此处。格式:具体、绝对化,写明谁/何时/何地。涉及本项目或你自己时必须逐字写作 Undefined,禁止写成 Unfined、Undefind、undefind 或其它变体。" }, "perspective": { "type": "string", diff --git a/src/Undefined/skills/tools/end/handler.py b/src/Undefined/skills/tools/end/handler.py index 84e3a643..e15b8cfb 100644 --- a/src/Undefined/skills/tools/end/handler.py +++ b/src/Undefined/skills/tools/end/handler.py @@ -7,6 +7,7 @@ import re from Undefined.context import RequestContext +from Undefined.ai.prompts.current_input import drop_current_input_batch_if_duplicated from Undefined.utils.coerce import coerce_truthy, is_truthy, safe_int, was_message_sent from Undefined.utils.xml import format_message_xml @@ -33,6 +34,8 @@ _MAX_HISTORIAN_LINES = 50 _MIN_HISTORIAN_LINE_LEN = 16 _MAX_HISTORIAN_LINE_LEN = 1000 +_CANONICAL_PROJECT_NAME = "Undefined" +_PROJECT_NAME_MISSPELLINGS = ("Unfined", "Undefind", "undefind") def _parse_force_flag(value: Any) -> tuple[bool, bool]: @@ -54,6 +57,13 @@ def _clip_text(value: Any, max_len: int) -> str: return text[: max_len - 3].rstrip() + "..." +def _normalize_project_name_spelling(text: str) -> str: + normalized = str(text or "") + for misspelling in _PROJECT_NAME_MISSPELLINGS: + normalized = normalized.replace(misspelling, _CANONICAL_PROJECT_NAME) + return normalized + + def _clamp_int(value: int, min_value: int, max_value: int) -> int: if value < min_value: return min_value @@ -151,7 +161,7 @@ def _extract_current_input_batch_from_question(question: str, *, max_len: int) - def _build_historian_recent_messages( - context: Dict[str, Any], *, recent_k: int + context: Dict[str, Any], *, recent_k: int, current_question: str ) -> list[str]: """Build XML-formatted recent messages for historian context. @@ -188,6 +198,15 @@ def _build_historian_recent_messages( if not isinstance(recent, list): return [] + recent, dropped = drop_current_input_batch_if_duplicated( + recent, + current_question, + ) + if dropped: + logger.info( + "[end工具] 史官最近消息剔除当前输入批次重复消息: count=%s", + dropped, + ) lines: list[str] = [] for msg in recent: @@ -212,7 +231,11 @@ def _inject_historian_reference_context(context: Dict[str, Any]) -> None: current_question, max_source_len ) - recent_lines = _build_historian_recent_messages(context, recent_k=recent_k) + recent_lines = _build_historian_recent_messages( + context, + recent_k=recent_k, + current_question=current_question, + ) if recent_lines: context["historian_recent_messages"] = recent_lines @@ -243,13 +266,23 @@ def _build_location(context: Dict[str, Any]) -> EndSummaryLocation | None: async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: memo_raw = args.get("memo", "") - memo = memo_raw.strip() if isinstance(memo_raw, str) else "" + memo = ( + _normalize_project_name_spelling(memo_raw.strip()) + if isinstance(memo_raw, str) + else "" + ) observations_raw = args.get("observations", []) if isinstance(observations_raw, str): - observations = [observations_raw.strip()] if observations_raw.strip() else [] + observations = ( + [_normalize_project_name_spelling(observations_raw.strip())] + if observations_raw.strip() + else [] + ) elif isinstance(observations_raw, list): observations = [ - str(item).strip() for item in observations_raw if str(item).strip() + _normalize_project_name_spelling(str(item).strip()) + for item in observations_raw + if str(item).strip() ] else: observations = [] diff --git a/src/Undefined/skills/toolsets/messages/context_utils.py b/src/Undefined/skills/toolsets/messages/context_utils.py new file mode 100644 index 00000000..34b49e31 --- /dev/null +++ b/src/Undefined/skills/toolsets/messages/context_utils.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def mark_message_sent(context: dict[str, Any]) -> None: + marker = context.get("mark_message_sent_this_turn") + if not callable(marker): + logger.warning("缺少 mark_message_sent_this_turn 上下文依赖") + return + marker(context) diff --git a/src/Undefined/skills/toolsets/messages/react_message_emoji/handler.py b/src/Undefined/skills/toolsets/messages/react_message_emoji/handler.py index 9157a41f..5d1bef0a 100644 --- a/src/Undefined/skills/toolsets/messages/react_message_emoji/handler.py +++ b/src/Undefined/skills/toolsets/messages/react_message_emoji/handler.py @@ -7,6 +7,7 @@ from Undefined.context import RequestContext from Undefined.utils.qq_emoji import resolve_emoji_id_by_alias, search_emoji_aliases +from Undefined.skills.toolsets.messages.context_utils import mark_message_sent logger = logging.getLogger(__name__) @@ -156,10 +157,7 @@ def _resolve_onebot_client(context: Dict[str, Any]) -> Any | None: def _mark_action_sent(context: Dict[str, Any]) -> None: - context["message_sent_this_turn"] = True - ctx = RequestContext.current() - if ctx is not None: - ctx.set_resource("message_sent_this_turn", True) + mark_message_sent(context) def _get_seen_ops(context: Dict[str, Any]) -> set[str]: diff --git a/src/Undefined/skills/toolsets/messages/send_message/handler.py b/src/Undefined/skills/toolsets/messages/send_message/handler.py index 709c2c92..507faefa 100644 --- a/src/Undefined/skills/toolsets/messages/send_message/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_message/handler.py @@ -8,6 +8,7 @@ ) from Undefined.utils.message_targets import TargetType, parse_positive_int from Undefined.utils.message_targets import resolve_message_target +from Undefined.skills.toolsets.messages.context_utils import mark_message_sent logger = logging.getLogger(__name__) @@ -168,7 +169,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: message, **send_kwargs, ) - context["message_sent_this_turn"] = True + mark_message_sent(context) await dispatch_pending_file_sends( rendered, sender=sender, @@ -192,7 +193,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if send_message_callback and _is_current_group_target(context, target_id): try: await send_message_callback(message, reply_to=reply_to_id) - context["message_sent_this_turn"] = True + mark_message_sent(context) return "消息已发送" except Exception as e: logger.exception( @@ -215,7 +216,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: await send_private_message_callback( target_id, message, reply_to=reply_to_id ) - context["message_sent_this_turn"] = True + mark_message_sent(context) return "消息已发送" except Exception as e: logger.exception( @@ -229,7 +230,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if send_message_callback and _is_current_private_target(context, target_id): try: await send_message_callback(message, reply_to=reply_to_id) - context["message_sent_this_turn"] = True + mark_message_sent(context) return "消息已发送" except Exception as e: logger.exception( diff --git a/src/Undefined/skills/toolsets/messages/send_poke/handler.py b/src/Undefined/skills/toolsets/messages/send_poke/handler.py index e26590d9..9100c236 100644 --- a/src/Undefined/skills/toolsets/messages/send_poke/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_poke/handler.py @@ -5,6 +5,7 @@ from typing import Any, Dict, Literal, cast from Undefined.context import RequestContext +from Undefined.skills.toolsets.messages.context_utils import mark_message_sent logger = logging.getLogger(__name__) @@ -210,10 +211,7 @@ def _resolve_onebot_client(context: Dict[str, Any]) -> Any | None: def _mark_action_sent(context: Dict[str, Any]) -> None: - context["message_sent_this_turn"] = True - ctx = RequestContext.current() - if ctx is not None: - ctx.set_resource("message_sent_this_turn", True) + mark_message_sent(context) def _resolve_bot_qq(context: Dict[str, Any]) -> int: diff --git a/src/Undefined/skills/toolsets/messages/send_private_message/handler.py b/src/Undefined/skills/toolsets/messages/send_private_message/handler.py index 74a8bbed..e224f2f8 100644 --- a/src/Undefined/skills/toolsets/messages/send_private_message/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_private_message/handler.py @@ -6,6 +6,7 @@ render_message_with_pic_placeholders, scope_from_context, ) +from Undefined.skills.toolsets.messages.context_utils import mark_message_sent logger = logging.getLogger(__name__) @@ -115,7 +116,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: message, **send_kwargs, ) - context["message_sent_this_turn"] = True + mark_message_sent(context) await dispatch_pending_file_sends( rendered, sender=sender, @@ -136,7 +137,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: if send_private_message_callback: try: await send_private_message_callback(user_id, message, reply_to=reply_to_id) - context["message_sent_this_turn"] = True + mark_message_sent(context) return f"私聊消息已发送给用户 {user_id}" except Exception as e: logger.exception( diff --git a/src/Undefined/skills/toolsets/messages/send_text_file/handler.py b/src/Undefined/skills/toolsets/messages/send_text_file/handler.py index ee65cb40..7ba457a9 100644 --- a/src/Undefined/skills/toolsets/messages/send_text_file/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_text_file/handler.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Any, Dict, Literal, cast +from Undefined.utils.message_turn import mark_message_sent_this_turn + logger = logging.getLogger(__name__) TargetType = Literal["group", "private"] @@ -442,7 +444,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: file_size=file_size, ) - context["message_sent_this_turn"] = True + mark_message_sent_this_turn(context) logger.info( "[发送文本文件] 成功: request_id=%s target_type=%s target_id=%s file=%s size=%sB", request_id, diff --git a/src/Undefined/skills/toolsets/messages/send_url_file/handler.py b/src/Undefined/skills/toolsets/messages/send_url_file/handler.py index c57db73d..e05ef29d 100644 --- a/src/Undefined/skills/toolsets/messages/send_url_file/handler.py +++ b/src/Undefined/skills/toolsets/messages/send_url_file/handler.py @@ -16,6 +16,7 @@ parse_content_length, probe_remote_file, ) +from Undefined.utils.message_turn import mark_message_sent_this_turn logger = logging.getLogger(__name__) @@ -495,7 +496,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: file_size=downloaded_size, ) - context["message_sent_this_turn"] = True + mark_message_sent_this_turn(context) logger.info( "[URL文件发送] 成功: request_id=%s target_type=%s target_id=%s file=%s size=%sB url=%s", request_id, diff --git a/src/Undefined/utils/history.py b/src/Undefined/utils/history.py index 76cebae2..7ee33882 100644 --- a/src/Undefined/utils/history.py +++ b/src/Undefined/utils/history.py @@ -428,6 +428,7 @@ async def add_private_message( user_name: str = "", message_id: int | None = None, attachments: list[dict[str, str]] | None = None, + webchat: dict[str, Any] | None = None, ) -> None: """异步保存私聊消息到历史记录""" await self._ensure_initialized() @@ -456,6 +457,8 @@ async def add_private_message( record["message_id"] = message_id if attachments: record["attachments"] = attachments + if isinstance(webchat, dict): + record["webchat"] = webchat self._private_message_history[user_id_str].append(record) @@ -514,6 +517,46 @@ def get_recent_private(self, user_id: int, count: int) -> list[dict[str, Any]]: return [] return self._private_message_history[user_id_str][-count:] if count > 0 else [] + def get_private_page( + self, + user_id: int, + *, + limit: int, + before: int | None = None, + ) -> tuple[list[dict[str, Any]], bool, int | None, int]: + """按时间倒序游标分页读取私聊历史,返回结果保持正序。 + + ``before`` 是完整历史数组里的结束下标(exclusive)。不传时从最新 + 消息开始读取;返回的 ``next_before`` 可用于继续向更早历史翻页。 + """ + user_id_str = str(user_id) + history = self._private_message_history.get(user_id_str, []) + total = len(history) + if total == 0 or limit <= 0: + return [], False, None, total + + end = total if before is None else max(0, min(before, total)) + start = max(0, end - limit) + items = history[start:end] + has_more = start > 0 + next_before = start if has_more else None + return items, has_more, next_before, total + + async def clear_private_history(self, user_id: int) -> int: + """清空指定私聊会话的内存与落盘历史,返回清空前记录数。""" + await self._ensure_initialized() + + user_id_str = str(user_id) + path = self._get_private_history_path(user_id) + async with self._get_private_lock(user_id_str): + previous_count = len(self._private_message_history.get(user_id_str, [])) + self._private_message_history[user_id_str] = [] + self._queue_history_save([], path) + + # 等待空数组写入,避免正在运行的旧保存任务最终把旧历史恢复到文件。 + await self.flush_pending_saves() + return previous_count + async def modify_last_group_message( self, group_id: int, diff --git a/src/Undefined/utils/io.py b/src/Undefined/utils/io.py index 738a681a..e3a986fe 100644 --- a/src/Undefined/utils/io.py +++ b/src/Undefined/utils/io.py @@ -4,8 +4,10 @@ import json import logging import os +import shutil import tempfile import time +from collections.abc import AsyncIterator, Iterator from pathlib import Path from typing import Any, Optional @@ -174,6 +176,39 @@ async def exists(file_path: Path | str) -> bool: return await asyncio.to_thread(Path(file_path).exists) +async def is_file(file_path: Path | str) -> bool: + """异步检查路径是否为普通文件。""" + return await asyncio.to_thread(Path(file_path).is_file) + + +async def is_dir(file_path: Path | str) -> bool: + """异步检查路径是否为目录。""" + return await asyncio.to_thread(Path(file_path).is_dir) + + +def _list_directory_entries_sync(directory: Path) -> list[tuple[Path, bool]]: + return [(entry, entry.is_dir()) for entry in directory.iterdir()] + + +async def list_directory_entries(directory: Path | str) -> list[tuple[Path, bool]]: + """异步列出目录项及其目录标记。""" + return await asyncio.to_thread(_list_directory_entries_sync, Path(directory)) + + +def _next_rglob_file(iterator: Iterator[Path]) -> Path | None: + for path in iterator: + if path.is_file(): + return path + return None + + +async def iter_rglob_files(directory: Path | str) -> AsyncIterator[Path]: + """异步递归遍历目录下的普通文件。""" + iterator = Path(directory).rglob("*") + while path := await asyncio.to_thread(_next_rglob_file, iterator): + yield path + + async def delete_file(file_path: Path | str) -> bool: """异步删除指定文件 @@ -194,6 +229,19 @@ def sync_delete() -> bool: return await asyncio.to_thread(sync_delete) +async def delete_tree(dir_path: Path | str) -> bool: + """异步删除目录树,目录不存在则返回 False。""" + p = Path(dir_path) + + def sync_delete() -> bool: + if not p.exists(): + return False + shutil.rmtree(p) + return True + + return await asyncio.to_thread(sync_delete) + + def _write_text_sync(target: Path, content: str, use_lock: bool) -> None: target.parent.mkdir(parents=True, exist_ok=True) @@ -239,6 +287,33 @@ def _read_bytes_sync(target: Path, use_lock: bool) -> bytes: return target.read_bytes() +def _write_bytes_sync(target: Path, content: bytes, use_lock: bool) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + + def atomic_write() -> None: + tmp_path: Path | None = None + try: + fd, tmp_name = tempfile.mkstemp( + prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent) + ) + tmp_path = Path(tmp_name) + with os.fdopen(fd, "wb") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_name, target) + finally: + if tmp_path is not None and tmp_path.exists(): + tmp_path.unlink() + + if use_lock: + lock_path = target.with_name(f"{target.name}.lock") + with FileLock(lock_path, shared=False): + atomic_write() + else: + atomic_write() + + async def write_text( file_path: str | Path, content: str, use_lock: bool = True ) -> None: @@ -257,3 +332,11 @@ async def read_bytes(file_path: str | Path, use_lock: bool = False) -> bytes: """异步读取二进制文件""" target = Path(file_path) return await asyncio.to_thread(_read_bytes_sync, target, use_lock) + + +async def write_bytes( + file_path: str | Path, content: bytes, use_lock: bool = True +) -> None: + """原子写入二进制文件""" + target = Path(file_path) + await asyncio.to_thread(_write_bytes_sync, target, content, use_lock) diff --git a/src/Undefined/utils/message_turn.py b/src/Undefined/utils/message_turn.py new file mode 100644 index 00000000..b680722c --- /dev/null +++ b/src/Undefined/utils/message_turn.py @@ -0,0 +1,24 @@ +"""Helpers for tracking per-turn user-visible output.""" + +from __future__ import annotations + +from collections.abc import MutableMapping +from typing import Any + +from Undefined.context import RequestContext + + +def mark_message_sent_this_turn( + context: MutableMapping[str, Any] | None = None, +) -> None: + """Mark the current turn as having produced user-visible output. + + Tool runners may execute tools with copied context dictionaries. Writing + the flag to both the passed context and the active request context keeps + downstream tools such as ``end`` from missing a successful send. + """ + if context is not None: + context["message_sent_this_turn"] = True + request_context = RequestContext.current() + if request_context is not None: + request_context.set_resource("message_sent_this_turn", True) diff --git a/src/Undefined/utils/paths.py b/src/Undefined/utils/paths.py index 05254147..a077bed3 100644 --- a/src/Undefined/utils/paths.py +++ b/src/Undefined/utils/paths.py @@ -2,18 +2,22 @@ from pathlib import Path -PACKAGE_ROOT = Path(__file__).resolve().parent.parent - -DATA_DIR = Path("data") -CACHE_DIR = DATA_DIR / "cache" -RENDER_CACHE_DIR = CACHE_DIR / "render" -IMAGE_CACHE_DIR = CACHE_DIR / "images" -ATTACHMENT_CACHE_DIR = CACHE_DIR / "attachments" -DOWNLOAD_CACHE_DIR = CACHE_DIR / "downloads" -TEXT_FILE_CACHE_DIR = CACHE_DIR / "text_files" -URL_FILE_CACHE_DIR = CACHE_DIR / "url_files" -WEBUI_FILE_CACHE_DIR = CACHE_DIR / "webui_files" -ATTACHMENT_REGISTRY_FILE = DATA_DIR / "attachment_registry.json" +PACKAGE_ROOT: Path = Path(__file__).resolve().parent.parent + +DATA_DIR: Path = Path("data") +HISTORY_DIR: Path = DATA_DIR / "history" +CACHE_DIR: Path = DATA_DIR / "cache" +RENDER_CACHE_DIR: Path = CACHE_DIR / "render" +IMAGE_CACHE_DIR: Path = CACHE_DIR / "images" +ATTACHMENT_CACHE_DIR: Path = CACHE_DIR / "attachments" +DOWNLOAD_CACHE_DIR: Path = CACHE_DIR / "downloads" +TEXT_FILE_CACHE_DIR: Path = CACHE_DIR / "text_files" +URL_FILE_CACHE_DIR: Path = CACHE_DIR / "url_files" +WEBUI_FILE_CACHE_DIR: Path = CACHE_DIR / "webui_files" +ATTACHMENT_REGISTRY_FILE: Path = DATA_DIR / "attachment_registry.json" +WEBCHAT_DIR: Path = DATA_DIR / "webchat" +WEBCHAT_CONVERSATIONS_DIR: Path = WEBCHAT_DIR / "conversations" +WEBCHAT_MIGRATION_MARKER_FILE: Path = WEBCHAT_DIR / "legacy_private_42_migrated.json" def ensure_dir(path: Path) -> Path: @@ -23,24 +27,24 @@ def ensure_dir(path: Path) -> Path: # Cognitive Memory -COGNITIVE_DIR = DATA_DIR / "cognitive" -COGNITIVE_CHROMADB_DIR = COGNITIVE_DIR / "chromadb" -COGNITIVE_PROFILES_DIR = COGNITIVE_DIR / "profiles" -COGNITIVE_PROFILES_USERS_DIR = COGNITIVE_PROFILES_DIR / "users" -COGNITIVE_PROFILES_GROUPS_DIR = COGNITIVE_PROFILES_DIR / "groups" -COGNITIVE_PROFILES_HISTORY_DIR = COGNITIVE_PROFILES_DIR / "history" -COGNITIVE_QUEUES_DIR = COGNITIVE_DIR / "queues" -COGNITIVE_QUEUES_PENDING_DIR = COGNITIVE_QUEUES_DIR / "pending" -COGNITIVE_QUEUES_PROCESSING_DIR = COGNITIVE_QUEUES_DIR / "processing" -COGNITIVE_QUEUES_FAILED_DIR = COGNITIVE_QUEUES_DIR / "failed" +COGNITIVE_DIR: Path = DATA_DIR / "cognitive" +COGNITIVE_CHROMADB_DIR: Path = COGNITIVE_DIR / "chromadb" +COGNITIVE_PROFILES_DIR: Path = COGNITIVE_DIR / "profiles" +COGNITIVE_PROFILES_USERS_DIR: Path = COGNITIVE_PROFILES_DIR / "users" +COGNITIVE_PROFILES_GROUPS_DIR: Path = COGNITIVE_PROFILES_DIR / "groups" +COGNITIVE_PROFILES_HISTORY_DIR: Path = COGNITIVE_PROFILES_DIR / "history" +COGNITIVE_QUEUES_DIR: Path = COGNITIVE_DIR / "queues" +COGNITIVE_QUEUES_PENDING_DIR: Path = COGNITIVE_QUEUES_DIR / "pending" +COGNITIVE_QUEUES_PROCESSING_DIR: Path = COGNITIVE_QUEUES_DIR / "processing" +COGNITIVE_QUEUES_FAILED_DIR: Path = COGNITIVE_QUEUES_DIR / "failed" # Meme Library -MEMES_DIR = DATA_DIR / "memes" -MEMES_BLOBS_DIR = MEMES_DIR / "blobs" -MEMES_PREVIEWS_DIR = MEMES_DIR / "previews" -MEMES_DB_PATH = MEMES_DIR / "memes.sqlite3" -MEMES_CHROMADB_DIR = MEMES_DIR / "chromadb" -MEMES_QUEUES_DIR = MEMES_DIR / "queues" -MEMES_QUEUES_PENDING_DIR = MEMES_QUEUES_DIR / "pending" -MEMES_QUEUES_PROCESSING_DIR = MEMES_QUEUES_DIR / "processing" -MEMES_QUEUES_FAILED_DIR = MEMES_QUEUES_DIR / "failed" +MEMES_DIR: Path = DATA_DIR / "memes" +MEMES_BLOBS_DIR: Path = MEMES_DIR / "blobs" +MEMES_PREVIEWS_DIR: Path = MEMES_DIR / "previews" +MEMES_DB_PATH: Path = MEMES_DIR / "memes.sqlite3" +MEMES_CHROMADB_DIR: Path = MEMES_DIR / "chromadb" +MEMES_QUEUES_DIR: Path = MEMES_DIR / "queues" +MEMES_QUEUES_PENDING_DIR: Path = MEMES_QUEUES_DIR / "pending" +MEMES_QUEUES_PROCESSING_DIR: Path = MEMES_QUEUES_DIR / "processing" +MEMES_QUEUES_FAILED_DIR: Path = MEMES_QUEUES_DIR / "failed" diff --git a/src/Undefined/utils/recent_messages.py b/src/Undefined/utils/recent_messages.py index 10adca85..e288dbff 100644 --- a/src/Undefined/utils/recent_messages.py +++ b/src/Undefined/utils/recent_messages.py @@ -4,7 +4,7 @@ import logging import re -from typing import Any, cast +from typing import Any, Pattern, cast from Undefined.attachments import build_attachment_scope from Undefined.onebot import get_message_content, parse_message_time @@ -13,7 +13,11 @@ logger = logging.getLogger(__name__) -_HISTORY_IMAGE_UID_RE = re.compile(r"\[图片\s+uid=(?Ppic_[^\s\]]+)") +_HISTORY_IMAGE_UID_RE: Pattern[str] = re.compile( + r"(?:\[图片\s+uid=(?Ppic_[^\s\]]+)|" + r"[\"'])(?Ppic_[^\"']+)(?P=quote)\s*/?>)", + re.IGNORECASE, +) def _normalize_int(value: Any, default: int) -> int: @@ -218,7 +222,9 @@ async def _augment_local_messages_with_meme_attachments( text = str(message.get("message", "") or "") seen_uids: set[str] = set() for match in _HISTORY_IMAGE_UID_RE.finditer(text): - uid = str(match.group("uid") or "").strip() + uid = str( + match.group("bracket_uid") or match.group("tag_uid") or "" + ).strip() if not uid or uid in seen_uids: continue seen_uids.add(uid) diff --git a/src/Undefined/utils/scheduler.py b/src/Undefined/utils/scheduler.py index 9f61ad00..0fc7a76a 100644 --- a/src/Undefined/utils/scheduler.py +++ b/src/Undefined/utils/scheduler.py @@ -198,8 +198,12 @@ async def update_task( cron_expression: str | None = None, tool_name: str | None = None, tool_args: dict[str, Any] | None = None, + target_id: int | None = None, + target_id_provided: bool = False, + target_type: str | None = None, task_name: str | None = None, max_executions: int | None = None, + max_executions_provided: bool = False, tools: list[dict[str, Any]] | None = None, execution_mode: str | None = None, self_instruction: str | None = None, @@ -211,8 +215,12 @@ async def update_task( cron_expression: 新的 crontab 表达式 tool_name: 新的工具名称(单工具模式) tool_args: 新的工具参数(单工具模式) + target_id: 新的发送目标 ID + target_id_provided: 是否显式更新发送目标 ID(允许清空) + target_type: 新的发送目标类型 task_name: 新的任务名称 max_executions: 新的最大执行次数 + max_executions_provided: 是否显式更新最大执行次数(允许清空) tools: 新的多工具调用列表(多工具模式) execution_mode: 新的执行模式("serial" 或 "parallel") self_instruction: 新的面向未来自己的指令文本(可选) @@ -249,10 +257,16 @@ async def update_task( if prompt: task_info["self_instruction"] = prompt + if target_id is not None or target_id_provided or target_type is not None: + if target_id is not None or target_id_provided: + task_info["target_id"] = target_id + if target_type is not None: + task_info["target_type"] = target_type + if task_name is not None: task_info["task_name"] = task_name - if max_executions is not None: + if max_executions is not None or max_executions_provided: task_info["max_executions"] = max_executions if tools is not None: @@ -294,6 +308,18 @@ async def update_task( if old_context_id and old_context_id != new_context_id: await self._delete_context_snapshot(old_context_id) + job = self.scheduler.get_job(task_id) + if job is not None: + job.modify( + args=[ + task_id, + task_info.get("tool_name", ""), + task_info.get("tool_args", {}), + task_info.get("target_id"), + task_info.get("target_type", "group"), + ] + ) + # 持久化保存 await self.storage.save_all(self.tasks) diff --git a/src/Undefined/utils/xml.py b/src/Undefined/utils/xml.py index 16d24153..908cc34e 100644 --- a/src/Undefined/utils/xml.py +++ b/src/Undefined/utils/xml.py @@ -2,11 +2,19 @@ from __future__ import annotations +import html +import re from typing import Any, Callable, Sequence, Mapping from xml.sax.saxutils import escape +_INLINE_ATTACHMENT_TAG_RE = re.compile( + r"[\"'])(?P[^\"']+)(?P=quote)\s*/?>", + re.IGNORECASE, +) + + def escape_xml_text(value: str) -> str: return escape(value, {'"': """, "'": "'"}) @@ -16,6 +24,33 @@ def escape_xml_attr(value: object) -> str: return escape(text, {'"': """, "'": "'"}) +def escape_xml_text_preserving_attachment_tags( + value: str, + attachments: Sequence[Mapping[str, str]] | None = None, +) -> str: + """Escape XML text while preserving known ```` tags.""" + allowed_uids = { + str(item.get("uid", "") or "").strip() + for item in (attachments or []) + if isinstance(item, Mapping) and str(item.get("uid", "") or "").strip() + } + if not allowed_uids: + return escape_xml_text(value) + + text = str(value or "") + parts: list[str] = [] + last_index = 0 + for match in _INLINE_ATTACHMENT_TAG_RE.finditer(text): + uid = html.unescape(str(match.group("uid") or "").strip()) + if uid not in allowed_uids: + continue + parts.append(escape_xml_text(text[last_index : match.start()])) + parts.append(f'') + last_index = match.end() + parts.append(escape_xml_text(text[last_index:])) + return "".join(parts) + + def _message_location(msg_type: str, chat_name: str) -> str: """Derive the human-readable location label from message type.""" if msg_type == "group": @@ -55,7 +90,7 @@ def format_message_xml( safe_role = escape_xml_attr(role) safe_title = escape_xml_attr(title) safe_time = escape_xml_attr(timestamp) - safe_text = escape_xml_text(text) + safe_text = escape_xml_text_preserving_attachment_tags(text, attachments) safe_location = escape_xml_attr(_message_location(msg_type_val, chat_name)) msg_id_attr = "" diff --git a/src/Undefined/webui/app.py b/src/Undefined/webui/app.py index 7b7f61a4..3fe2bd5d 100644 --- a/src/Undefined/webui/app.py +++ b/src/Undefined/webui/app.py @@ -1,6 +1,7 @@ import asyncio import gzip as _gzip_mod import logging +import secrets from logging.handlers import RotatingFileHandler from pathlib import Path @@ -9,6 +10,7 @@ from Undefined.config import load_webui_settings, get_config_manager, get_config from Undefined.utils.cors import is_allowed_cors_origin, normalize_origin +from Undefined.utils import io as async_io from .core import BotProcessController, SessionStore from .routes import routes from .routes._shared import ( @@ -33,15 +35,20 @@ CSP_POLICY = ( "default-src 'self'; " - "script-src 'self'; " + "script-src 'self' 'nonce-{nonce}'; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "font-src 'self' https://fonts.gstatic.com data:; " - "img-src 'self' data:; " + "img-src 'self' data: blob:; " "connect-src 'self'; " "base-uri 'self'; " "frame-ancestors 'none'" ) + +def _build_csp_policy(nonce: str) -> str: + return CSP_POLICY.format(nonce=nonce) + + # ── gzip 压缩 ── _GZIP_CONTENT_TYPES = frozenset( @@ -180,14 +187,20 @@ async def security_headers_middleware( request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]], ) -> web.StreamResponse: + csp_nonce = secrets.token_urlsafe(16) + request["csp_nonce"] = csp_nonce try: response = await handler(request) except web.HTTPException as exc: response = exc - response.headers.setdefault("Content-Security-Policy", CSP_POLICY) + response.headers.setdefault("Content-Security-Policy", _build_csp_policy(csp_nonce)) response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("Referrer-Policy", "no-referrer") + # 静态资源(runtime.js / css 等)URL 无版本号:强制浏览器每次按 ETag 重新校验, + # 避免改动前端后用户因强缓存看不到更新(内容未变命中 ETag 仍返回 304,开销极小)。 + if request.path.startswith("/static"): + response.headers["Cache-Control"] = "no-cache" return response @@ -225,18 +238,30 @@ async def on_startup(app: web.Application) -> None: get_config_manager().start_hot_reload() logger.info("[WebUI] 后台任务已启动(热重载)") + bot = app[BOT_APP_KEY] + + # 1. 优先检查自动恢复标记(现有逻辑) # If we restarted WebUI after an update and the bot was previously running, # auto-start it again. try: marker = Path("data/cache/pending_bot_autostart") - if marker.exists(): - marker.unlink(missing_ok=True) - bot = app[BOT_APP_KEY] + if await async_io.exists(marker): + await async_io.delete_file(marker) await bot.start() logger.info("[WebUI] 检测到自动恢复标记,已尝试启动机器人进程") + return # 已启动,跳过后续检查 except Exception: logger.debug("[WebUI] 自动恢复机器人进程失败", exc_info=True) + # 2. 检查配置项(新增逻辑) + try: + settings = app[SETTINGS_APP_KEY] + if settings.autostart_bot: + await bot.start() + logger.info("[WebUI] 配置 autostart_bot=true,已自动启动机器人进程") + except Exception: + logger.debug("[WebUI] 自动启动机器人进程失败", exc_info=True) + async def on_shutdown(app: web.Application) -> None: bot = app[BOT_APP_KEY] diff --git a/src/Undefined/webui/routes/_index.py b/src/Undefined/webui/routes/_index.py index c48a2b5b..3dd254b1 100644 --- a/src/Undefined/webui/routes/_index.py +++ b/src/Undefined/webui/routes/_index.py @@ -1,4 +1,5 @@ import json +from typing import Any, cast from aiohttp import web from aiohttp.web_response import Response @@ -12,6 +13,13 @@ ) +def _request_mapping_value(request: web.Request, key: str) -> Any: + getter = getattr(request, "get", None) + if callable(getter): + return getter(key) + return cast(Any, request).__dict__.get(key) + + @routes.get("/") async def index_handler(request: web.Request) -> Response: settings = get_settings(request) @@ -67,6 +75,12 @@ async def index_handler(request: web.Request) -> Response: initial_state_json = json.dumps(initial_state).replace(" Path: log_path = Path("logs/bot.log") @@ -77,12 +80,21 @@ def _resolve_any_log_file(log_dir: Path, file_name: str | None) -> Path | None: return next((p for p in _list_all_log_files(log_dir) if p.name == file_name), None) +def _parse_log_lines(request: web.Request) -> int: + raw_value = str(request.query.get("lines", DEFAULT_LOG_TAIL_LINES)).strip() + try: + lines = int(raw_value) + except ValueError: + lines = DEFAULT_LOG_TAIL_LINES + return max(1, min(lines, MAX_LOG_TAIL_LINES)) + + @routes.get("/api/v1/management/logs") @routes.get("/api/logs") async def logs_handler(request: web.Request) -> Response: if not check_auth(request): return web.json_response({"error": "Unauthorized"}, status=401) - lines = max(1, min(int(request.query.get("lines", "200")), 2000)) + lines = _parse_log_lines(request) log_type = request.query.get("type", "bot") file_name = request.query.get("file") if log_type == "webui": @@ -145,7 +157,7 @@ async def logs_files_handler(request: web.Request) -> Response: async def logs_stream_handler(request: web.Request) -> web.StreamResponse: if not check_auth(request): return web.json_response({"error": "Unauthorized"}, status=401) - lines = max(1, min(int(request.query.get("lines", "200")), 2000)) + lines = _parse_log_lines(request) log_type = request.query.get("type", "bot") if log_type == "all": return web.json_response( diff --git a/src/Undefined/webui/routes/_runtime.py b/src/Undefined/webui/routes/_runtime.py index fa26c0d8..656819df 100644 --- a/src/Undefined/webui/routes/_runtime.py +++ b/src/Undefined/webui/routes/_runtime.py @@ -2,16 +2,18 @@ import asyncio import json +import uuid from collections.abc import Mapping -from urllib.parse import quote as _url_quote from pathlib import Path -from typing import Any +from typing import Any, cast +from urllib.parse import quote as _url_quote -from aiohttp import ClientSession, ClientTimeout, web +from aiohttp import ClientSession, ClientTimeout, FormData, web from aiohttp.web_response import Response from Undefined.ai.queue_budget import compute_queued_llm_timeout_seconds from Undefined.config import get_config +from Undefined.utils import io as async_io from Undefined.utils.paths import CACHE_DIR, WEBUI_FILE_CACHE_DIR from ._shared import check_auth, routes @@ -25,6 +27,7 @@ ".webp", ".bmp", } +_WEBUI_FILE_UPLOAD_NAME_MAX_LENGTH = 128 def _runtime_base_url() -> str: @@ -121,6 +124,35 @@ def _resolve_chat_image_path(raw_path: str) -> Path | None: return path +def _sanitize_upload_display_name(raw_name: str) -> str: + name = Path(str(raw_name or "").strip() or "attachment").name or "attachment" + if len(name) > _WEBUI_FILE_UPLOAD_NAME_MAX_LENGTH: + suffix = "".join(Path(name).suffixes[-2:]) or Path(name).suffix + suffix = suffix if len(suffix) <= 16 else "" + name = f"attachment{suffix}" + return name + + +def _random_upload_filename(display_name: str) -> str: + suffix = "".join(Path(display_name).suffixes[-2:]) or Path(display_name).suffix + suffix = suffix if len(suffix) <= 16 else "" + return f"file_{uuid.uuid4().hex[:16]}{suffix}" + + +def _chat_message_payload(body: Mapping[str, Any]) -> Any | None: + raw_message = body.get("message") + if isinstance(raw_message, str): + text = raw_message.strip() + return text or None + if isinstance(raw_message, Mapping): + text = str(raw_message.get("text") or "").strip() + attachment_ids = raw_message.get("attachment_ids") + has_attachments = isinstance(attachment_ids, list) and bool(attachment_ids) + if text or has_attachments: + return dict(raw_message) + return None + + async def _proxy_runtime( *, method: str, @@ -167,12 +199,126 @@ async def _proxy_runtime( ) +async def _proxy_runtime_binary( + *, + method: str, + path: str, + params: Mapping[str, str] | None = None, + timeout_seconds: float | None = 20.0, +) -> Response: + cfg = get_config(strict=False) + if not cfg.api.enabled: + return _runtime_disabled() + + url = f"{_runtime_base_url()}{path}" + timeout = ClientTimeout(total=timeout_seconds) + headers = {_AUTH_HEADER: str(cfg.api.auth_key or "")} + + try: + async with ClientSession(timeout=timeout) as session: + async with session.request( + method=method, + url=url, + params=params, + headers=headers, + ) as resp: + body = await resp.read() + response_headers: dict[str, str] = {} + disposition = resp.headers.get("Content-Disposition") + if disposition: + response_headers["Content-Disposition"] = disposition + return web.Response( + status=resp.status, + body=body, + headers=response_headers, + content_type=resp.content_type, + ) + except (OSError, asyncio.TimeoutError) as exc: + return web.json_response( + {"error": "Runtime API unreachable", "detail": str(exc)}, + status=502, + ) + + +async def _proxy_runtime_multipart_file( + request: web.Request, + *, + path: str, + timeout_seconds: float | None = 60.0, +) -> Response: + cfg = get_config(strict=False) + if not cfg.api.enabled: + return _runtime_disabled() + + try: + reader = await request.multipart() + field = await reader.next() + except Exception: + return web.json_response({"error": "Invalid multipart body"}, status=400) + + field_any = cast(Any, field) + if field is None or getattr(field_any, "name", None) != "file": + return web.json_response({"error": "file field is required"}, status=400) + + filename = _sanitize_upload_display_name( + str(getattr(field_any, "filename", "") or "attachment") + ) + content_type = str(getattr(field_any, "content_type", "") or "").strip() + if not content_type: + headers = getattr(field_any, "headers", {}) + content_type = str(headers.get("Content-Type", "") or "").strip() + if not content_type: + content_type = "application/octet-stream" + + body = bytearray() + while True: + chunk = await field_any.read_chunk() + if not chunk: + break + body.extend(chunk) + + url = f"{_runtime_base_url()}{path}" + timeout = ClientTimeout(total=timeout_seconds) + headers = {_AUTH_HEADER: str(cfg.api.auth_key or "")} + form = FormData() + form.add_field( + "file", + bytes(body), + filename=filename, + content_type=content_type, + ) + + try: + async with ClientSession(timeout=timeout) as session: + async with session.post(url, data=form, headers=headers) as resp: + text = await resp.text() + content_type_header = (resp.headers.get("Content-Type") or "").lower() + if "application/json" in content_type_header: + try: + data = json.loads(text) if text else {} + except json.JSONDecodeError: + data = {"raw": text} + return web.json_response(data, status=resp.status) + return web.Response( + status=resp.status, + text=text, + content_type=resp.content_type, + charset=resp.charset, + ) + except (OSError, asyncio.TimeoutError) as exc: + return web.json_response( + {"error": "Runtime API unreachable", "detail": str(exc)}, + status=502, + ) + + async def _proxy_runtime_stream( request: web.Request, *, method: str, path: str, payload: dict[str, Any] | None = None, + params: Mapping[str, str] | None = None, timeout_seconds: float | None = None, ) -> web.StreamResponse: cfg = get_config(strict=False) @@ -193,6 +339,7 @@ async def _proxy_runtime_stream( async with session.request( method=method, url=url, + params=params, json=payload, headers=headers, ) as upstream: @@ -342,6 +489,79 @@ async def runtime_memory_delete_handler(request: web.Request) -> Response: ) +@routes.get("/api/v1/management/runtime/schedules") +@routes.get("/api/runtime/schedules") +async def runtime_schedules_list_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="GET", + path="/api/v1/schedules", + timeout_seconds=20.0, + ) + + +@routes.post("/api/v1/management/runtime/schedules") +@routes.post("/api/runtime/schedules") +async def runtime_schedules_create_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + try: + payload = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + return web.json_response({"error": "Invalid JSON payload"}, status=400) + return await _proxy_runtime( + method="POST", + path="/api/v1/schedules", + payload=payload, + timeout_seconds=30.0, + ) + + +@routes.get("/api/v1/management/runtime/schedules/{task_id}") +@routes.get("/api/runtime/schedules/{task_id}") +async def runtime_schedule_detail_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + task_id = _url_quote(str(request.match_info.get("task_id", "")).strip(), safe="") + return await _proxy_runtime( + method="GET", + path=f"/api/v1/schedules/{task_id}", + timeout_seconds=20.0, + ) + + +@routes.patch("/api/v1/management/runtime/schedules/{task_id}") +@routes.patch("/api/runtime/schedules/{task_id}") +async def runtime_schedule_update_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + task_id = _url_quote(str(request.match_info.get("task_id", "")).strip(), safe="") + try: + payload = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError, ValueError): + return web.json_response({"error": "Invalid JSON payload"}, status=400) + return await _proxy_runtime( + method="PATCH", + path=f"/api/v1/schedules/{task_id}", + payload=payload, + timeout_seconds=30.0, + ) + + +@routes.delete("/api/v1/management/runtime/schedules/{task_id}") +@routes.delete("/api/runtime/schedules/{task_id}") +async def runtime_schedule_delete_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + task_id = _url_quote(str(request.match_info.get("task_id", "")).strip(), safe="") + return await _proxy_runtime( + method="DELETE", + path=f"/api/v1/schedules/{task_id}", + timeout_seconds=30.0, + ) + + @routes.get("/api/v1/management/runtime/cognitive/events") @routes.get("/api/runtime/cognitive/events") async def runtime_cognitive_events_handler(request: web.Request) -> Response: @@ -379,6 +599,33 @@ async def runtime_cognitive_profile_handler(request: web.Request) -> Response: ) +@routes.get("/api/v1/management/runtime/commands") +@routes.get("/api/runtime/commands") +async def runtime_commands_list_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="GET", + path="/api/v1/commands", + params=request.query, + ) + + +@routes.get("/api/v1/management/runtime/commands/{command_name}") +@routes.get("/api/runtime/commands/{command_name}") +async def runtime_command_detail_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + command_name = _url_quote( + str(request.match_info.get("command_name", "")).strip(), safe="" + ) + return await _proxy_runtime( + method="GET", + path=f"/api/v1/commands/{command_name}", + params=request.query, + ) + + @routes.post("/api/v1/management/runtime/chat") @routes.post("/api/runtime/chat") async def runtime_chat_handler(request: web.Request) -> web.StreamResponse: @@ -392,9 +639,12 @@ async def runtime_chat_handler(request: web.Request) -> web.StreamResponse: message = str(body.get("message", "") or "").strip() if not message: return web.json_response({"error": "message is required"}, status=400) + conversation_id = str(body.get("conversation_id", "") or "").strip() stream = _to_bool(body.get("stream")) payload: dict[str, Any] = {"message": message} + if conversation_id: + payload["conversation_id"] = conversation_id if stream: payload["stream"] = True return await _proxy_runtime_stream( @@ -413,6 +663,71 @@ async def runtime_chat_handler(request: web.Request) -> web.StreamResponse: ) +@routes.get("/api/v1/management/runtime/chat/conversations") +@routes.get("/api/runtime/chat/conversations") +async def runtime_chat_conversations_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="GET", + path="/api/v1/chat/conversations", + params=request.query, + ) + + +@routes.post("/api/v1/management/runtime/chat/conversations") +@routes.post("/api/runtime/chat/conversations") +async def runtime_chat_conversation_create_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + try: + body = await request.json() + except Exception: + body = {} + payload: dict[str, Any] = {} + title = str(body.get("title", "") or "").strip() + if title: + payload["title"] = title + return await _proxy_runtime( + method="POST", + path="/api/v1/chat/conversations", + payload=payload, + ) + + +@routes.patch("/api/v1/management/runtime/chat/conversations/{conversation_id}") +@routes.patch("/api/runtime/chat/conversations/{conversation_id}") +async def runtime_chat_conversation_update_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + conversation_id = _url_quote( + str(request.match_info.get("conversation_id", "")).strip(), safe="" + ) + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + return await _proxy_runtime( + method="PATCH", + path=f"/api/v1/chat/conversations/{conversation_id}", + payload={"title": str(body.get("title", "") or "").strip()}, + ) + + +@routes.delete("/api/v1/management/runtime/chat/conversations/{conversation_id}") +@routes.delete("/api/runtime/chat/conversations/{conversation_id}") +async def runtime_chat_conversation_delete_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + conversation_id = _url_quote( + str(request.match_info.get("conversation_id", "")).strip(), safe="" + ) + return await _proxy_runtime( + method="DELETE", + path=f"/api/v1/chat/conversations/{conversation_id}", + ) + + @routes.get("/api/v1/management/runtime/chat/history") @routes.get("/api/runtime/chat/history") async def runtime_chat_history_handler(request: web.Request) -> Response: @@ -425,6 +740,161 @@ async def runtime_chat_history_handler(request: web.Request) -> Response: ) +@routes.delete("/api/v1/management/runtime/chat/history") +@routes.delete("/api/runtime/chat/history") +async def runtime_chat_history_clear_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="DELETE", + path="/api/v1/chat/history", + params=request.query, + ) + + +@routes.get("/api/v1/management/runtime/chat/attachments/capabilities") +@routes.get("/api/runtime/chat/attachments/capabilities") +async def runtime_chat_attachment_capabilities_handler( + request: web.Request, +) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="GET", + path="/api/v1/chat/attachments/capabilities", + timeout_seconds=20.0, + ) + + +@routes.post("/api/v1/management/runtime/chat/attachments") +@routes.post("/api/runtime/chat/attachments") +async def runtime_chat_attachment_upload_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime_multipart_file( + request, + path="/api/v1/chat/attachments", + timeout_seconds=60.0, + ) + + +@routes.get("/api/v1/management/runtime/chat/attachments/{attachment_id}") +@routes.get("/api/runtime/chat/attachments/{attachment_id}") +async def runtime_chat_attachment_download_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + attachment_id = _url_quote( + str(request.match_info.get("attachment_id", "")).strip(), safe="" + ) + return await _proxy_runtime_binary( + method="GET", + path=f"/api/v1/chat/attachments/{attachment_id}", + timeout_seconds=60.0, + ) + + +@routes.get("/api/v1/management/runtime/chat/attachments/{attachment_id}/preview") +@routes.get("/api/runtime/chat/attachments/{attachment_id}/preview") +async def runtime_chat_attachment_preview_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + attachment_id = _url_quote( + str(request.match_info.get("attachment_id", "")).strip(), safe="" + ) + return await _proxy_runtime_binary( + method="GET", + path=f"/api/v1/chat/attachments/{attachment_id}/preview", + timeout_seconds=60.0, + ) + + +@routes.post("/api/v1/management/runtime/chat/jobs") +@routes.post("/api/runtime/chat/jobs") +async def runtime_chat_job_create_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + message = _chat_message_payload(body) + if message is None: + return web.json_response({"error": "message is required"}, status=400) + payload: dict[str, Any] = {"message": message} + conversation_id = str(body.get("conversation_id", "") or "").strip() + if conversation_id: + payload["conversation_id"] = conversation_id + if _to_bool(body.get("reuse_previous_user_message")): + payload["reuse_previous_user_message"] = True + return await _proxy_runtime( + method="POST", + path="/api/v1/chat/jobs", + payload=payload, + timeout_seconds=20.0, + ) + + +@routes.get("/api/v1/management/runtime/chat/jobs/active") +@routes.get("/api/runtime/chat/jobs/active") +async def runtime_chat_job_active_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + return await _proxy_runtime( + method="GET", + path="/api/v1/chat/jobs/active", + params=request.query, + ) + + +@routes.get("/api/v1/management/runtime/chat/jobs/{job_id}") +@routes.get("/api/runtime/chat/jobs/{job_id}") +async def runtime_chat_job_detail_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + job_id = _url_quote(str(request.match_info.get("job_id", "")).strip(), safe="") + return await _proxy_runtime(method="GET", path=f"/api/v1/chat/jobs/{job_id}") + + +@routes.get("/api/v1/management/runtime/chat/jobs/{job_id}/events") +@routes.get("/api/runtime/chat/jobs/{job_id}/events") +async def runtime_chat_job_events_handler(request: web.Request) -> web.StreamResponse: + if not check_auth(request): + return _unauthorized() + job_id = _url_quote(str(request.match_info.get("job_id", "")).strip(), safe="") + wants_json = ( + str(request.query.get("format", "") or "").strip().lower() == "json" + or "application/json" + in str(request.headers.get("Accept", "") or "").strip().lower() + ) + if wants_json: + return await _proxy_runtime( + method="GET", + path=f"/api/v1/chat/jobs/{job_id}/events", + params=request.query, + timeout_seconds=20.0, + ) + return await _proxy_runtime_stream( + request, + method="GET", + path=f"/api/v1/chat/jobs/{job_id}/events", + params=request.query, + timeout_seconds=_chat_proxy_timeout_seconds(), + ) + + +@routes.post("/api/v1/management/runtime/chat/jobs/{job_id}/cancel") +@routes.post("/api/runtime/chat/jobs/{job_id}/cancel") +async def runtime_chat_job_cancel_handler(request: web.Request) -> Response: + if not check_auth(request): + return _unauthorized() + job_id = _url_quote(str(request.match_info.get("job_id", "")).strip(), safe="") + return await _proxy_runtime( + method="POST", + path=f"/api/v1/chat/jobs/{job_id}/cancel", + timeout_seconds=20.0, + ) + + @routes.get("/api/v1/management/runtime/chat/image") @routes.get("/api/runtime/chat/image") async def runtime_chat_image_handler(request: web.Request) -> web.StreamResponse: @@ -480,6 +950,65 @@ async def runtime_chat_file_handler(request: web.Request) -> web.StreamResponse: ) +@routes.post("/api/v1/management/runtime/chat/files") +@routes.post("/api/runtime/chat/files") +async def runtime_chat_file_upload_handler(request: web.Request) -> Response: + """旧 WebUI 浏览器文件缓存;新客户端使用 Runtime attachment id。""" + if not check_auth(request): + return _unauthorized() + + try: + reader = await request.multipart() + field = await reader.next() + except Exception: + return web.json_response({"error": "Invalid multipart body"}, status=400) + + field_any = cast(Any, field) + if field is None or getattr(field_any, "name", None) != "file": + return web.json_response({"error": "file field is required"}, status=400) + + raw_name = _sanitize_upload_display_name( + str(getattr(field_any, "filename", "") or "attachment") + ) + file_id = uuid.uuid4().hex + dest_dir = (Path.cwd() / WEBUI_FILE_CACHE_DIR / file_id).resolve() + cache_root = (Path.cwd() / WEBUI_FILE_CACHE_DIR).resolve() + if cache_root not in dest_dir.parents and dest_dir != cache_root: + return web.json_response({"error": "Invalid file path"}, status=400) + dest = dest_dir / _random_upload_filename(raw_name) + cfg = get_config(strict=False) + max_size_mb = max(1, int(getattr(cfg, "messages_send_url_file_max_size_mb", 100))) + max_size_bytes = max_size_mb * 1024 * 1024 + + size = 0 + chunks = bytearray() + try: + while True: + chunk = await field_any.read_chunk() + if not chunk: + break + size += len(chunk) + if size > max_size_bytes: + await async_io.delete_tree(dest_dir) + return web.json_response( + {"error": "file too large", "max_size": max_size_bytes}, + status=413, + ) + chunks.extend(chunk) + await async_io.write_bytes(dest, bytes(chunks), use_lock=False) + except Exception: + await async_io.delete_tree(dest_dir) + raise + + return web.json_response( + { + "id": file_id, + "name": raw_name, + "size": size, + } + ) + + # ------------------------------------------------------------------ # Tool Invoke API proxy # ------------------------------------------------------------------ diff --git a/src/Undefined/webui/static/css/app.css b/src/Undefined/webui/static/css/app.css index 083ebb39..3f51ddc9 100644 --- a/src/Undefined/webui/static/css/app.css +++ b/src/Undefined/webui/static/css/app.css @@ -158,25 +158,329 @@ body.is-mobile-drawer-open { max-width: 1400px; width: 100%; } -.main-content.chat-layout { max-width: none; } -.main-content.chat-layout #tab-chat .runtime-card { max-width: 100%; } +.main-content.chat-layout { + display: flex; + flex-direction: column; + height: 100dvh; + min-height: 0; + max-width: none; + overflow: hidden; + padding-bottom: max(16px, env(safe-area-inset-bottom)); +} +.main-content.chat-layout #appContent { + flex: 1 1 auto; + grid-template-rows: auto minmax(0, 1fr); + height: auto; + min-height: 0; +} +.main-content.chat-layout #appContent > .mobile-shell { + min-height: 0; +} +.main-content.chat-layout #tab-chat.active { + display: grid; + grid-row: 2; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + min-height: 0; + overflow: hidden; +} +.main-content.chat-layout #tab-chat > .header { + margin-bottom: 10px; +} +.runtime-chat-header { + align-items: center; + gap: 16px; +} +.runtime-chat-title { + min-width: 0; +} +.runtime-chat-page-title { + display: flex; + align-items: baseline; + gap: 12px; + min-width: 0; + margin-bottom: 0; +} +.runtime-chat-title-meta { + min-width: 0; + color: var(--text-secondary); + font-family: var(--font-sans); + font-size: 13px; + font-weight: 400; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.runtime-chat-shell { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + min-height: 0; + padding-right: 42px; + overflow: hidden; +} +.runtime-chat-sidebar { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 40; + width: min(340px, calc(100% - 44px)); + min-height: 0; + overflow: visible; + transform: translateX(calc(100% - 36px)); + transition: + transform 0.24s cubic-bezier(0.2, 0.8, 0.2, 1), + filter 0.24s ease; + will-change: transform; +} +.runtime-chat-sidebar:hover, +.runtime-chat-sidebar:focus-within, +.runtime-chat-sidebar.is-open { + transform: translateX(0); +} +.runtime-chat-sidebar-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + padding: 14px 12px 14px 46px; + border: 1px solid var(--border-color); + border-right: 0; + border-radius: 10px 0 0 10px; + background: color-mix(in srgb, var(--bg-card) 94%, var(--bg-app)); + box-shadow: -18px 0 40px rgba(0, 0, 0, 0.18); + backdrop-filter: blur(12px); + overflow: hidden; +} +.runtime-chat-sidebar-head { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 30px; + margin-bottom: 8px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; +} +.runtime-chat-sidebar-tab { + position: absolute; + top: 18px; + left: 0; + z-index: 1; + display: grid; + place-items: center; + width: 36px; + min-height: 112px; + border: 1px solid var(--border-color); + border-left: 0; + border-radius: 0 8px 8px 0; + background: color-mix(in srgb, var(--bg-card) 88%, var(--accent-subtle)); + color: var(--accent-color); + box-shadow: 8px 0 18px rgba(0, 0, 0, 0.08); + cursor: pointer; + font: inherit; + padding: 0; +} +.runtime-chat-sidebar-tab::after { + content: ""; + position: absolute; + right: 7px; + bottom: 10px; + width: 7px; + height: 7px; + border-top: 1px solid currentColor; + border-left: 1px solid currentColor; + opacity: 0.72; + transform: rotate(-45deg); + transition: transform 0.24s ease; +} +.runtime-chat-sidebar:hover .runtime-chat-sidebar-tab::after, +.runtime-chat-sidebar:focus-within .runtime-chat-sidebar-tab::after, +.runtime-chat-sidebar.is-open .runtime-chat-sidebar-tab::after { + transform: rotate(135deg); +} +.runtime-chat-sidebar-tab:focus-visible { + outline: 2px solid rgba(217, 119, 87, 0.65); + outline-offset: 2px; +} +.runtime-chat-sidebar-tab-label { + writing-mode: vertical-rl; + text-orientation: mixed; + font-size: 12px; + font-weight: 700; + line-height: 1; + white-space: nowrap; +} +.runtime-chat-conversations { + display: flex; + flex-direction: column; + gap: 6px; + height: 100%; + overflow-y: auto; + padding: 0 4px 4px 0; + scrollbar-gutter: stable; +} +.runtime-chat-conversation { + display: grid; + grid-template-columns: minmax(0, 1fr) 28px 28px; + align-items: center; + gap: 2px; + border-radius: 8px; + color: var(--text-secondary); + border: 1px solid transparent; + transition: + background-color 0.18s ease, + border-color 0.18s ease, + color 0.18s ease; +} +.runtime-chat-conversation.active { + background: var(--bg-card); + color: var(--text-primary); + border-color: var(--border-color); +} +.runtime-chat-conversation:hover { + background: color-mix(in srgb, var(--bg-card) 72%, transparent); + color: var(--text-primary); +} +.runtime-chat-conversation.is-new { + animation: runtime-chat-conversation-created 1.2s ease-out; +} +.runtime-chat-conversation.running .runtime-chat-conversation-title::after { + content: ""; + display: inline-block; + width: 6px; + height: 6px; + margin-left: 6px; + border-radius: 50%; + background: var(--success); + vertical-align: middle; +} +.runtime-chat-conversation-main { + min-width: 0; + padding: 9px 8px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} +.runtime-chat-conversation-main:hover, +.runtime-chat-conversation-rename:hover, +.runtime-chat-conversation-delete:hover { + color: var(--text-primary); +} +.runtime-chat-conversation-title, +.runtime-chat-conversation-meta { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.runtime-chat-conversation-title { + font-size: 13px; + font-weight: 600; +} +.runtime-chat-conversation-meta { + margin-top: 2px; + color: var(--text-tertiary); + font-size: 11px; +} +.runtime-chat-conversation-rename, +.runtime-chat-conversation-delete { + width: 28px; + height: 28px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; +} +.runtime-chat-conversation-empty { + padding: 12px 8px; + color: var(--text-tertiary); + font-size: 13px; +} +.runtime-chat-conversation-head { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; + padding: 8px 18px; + border-bottom: 1px solid var(--border-color); +} +.runtime-chat-current-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + font-weight: 700; +} +.runtime-chat-current-meta { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary); + font-size: 12px; +} .main-content.chat-layout #tab-chat .chat-runtime-card { display: grid; - grid-template-rows: auto minmax(0, 1fr) auto; - height: clamp(520px, calc(100vh - 230px), 840px); + grid-template-rows: auto auto minmax(0, 1fr) auto; + height: auto; + min-height: 0; + max-width: 100%; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + overflow: hidden; } .main-content.chat-layout #tab-chat .runtime-chat-log { min-height: 0; max-height: none; + margin-bottom: 0; + padding: 18px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; } .main-content.chat-layout #tab-chat .runtime-chat-input { - height: 44px; - min-height: 44px; - max-height: 44px; + height: 54px; + min-height: 54px; + max-height: 120px; resize: none; - line-height: 22px; + line-height: 1.45; overflow-y: auto; } +.main-content.chat-layout #tab-chat .runtime-chat-input-row { + position: relative; + padding: 12px 18px 0; + background: var(--bg-app); +} +.main-content.chat-layout #tab-chat .runtime-chat-content { + font-size: 15.5px; +} + +@keyframes runtime-chat-conversation-created { + 0% { + background: color-mix(in srgb, var(--accent-subtle) 78%, var(--bg-card)); + border-color: rgba(217, 119, 87, 0.56); + } + 60% { + background: color-mix(in srgb, var(--accent-subtle) 38%, var(--bg-card)); + border-color: rgba(217, 119, 87, 0.34); + } + 100% { + background: transparent; + border-color: transparent; + } +} .header { margin-bottom: 32px; display: flex; justify-content: space-between; align-items: flex-end; } .toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } diff --git a/src/Undefined/webui/static/css/components.css b/src/Undefined/webui/static/css/components.css index aed5932b..1753e073 100644 --- a/src/Undefined/webui/static/css/components.css +++ b/src/Undefined/webui/static/css/components.css @@ -103,11 +103,21 @@ /* Toggle */ .toggle-wrapper { display: flex; align-items: center; gap: 8px; cursor: pointer; } -.toggle-input { display: none; } +.toggle-input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} .toggle-track { width: 40px; height: 22px; background: var(--border-color); border-radius: 99px; position: relative; transition: 0.2s; } .toggle-handle { width: 18px; height: 18px; background: white; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: 0.2s; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .toggle-input:checked + .toggle-track { background: var(--accent-color); } .toggle-input:checked + .toggle-track .toggle-handle { transform: translateX(18px); } +.toggle-input:focus-visible + .toggle-track { + outline: 2px solid color-mix(in srgb, var(--accent-color) 68%, transparent); + outline-offset: 3px; +} /* Utility */ .w-full { width: 100%; } @@ -145,6 +155,210 @@ .runtime-head { gap: 12px; flex-wrap: wrap; margin-bottom: 12px; } .runtime-inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .runtime-inline .form-control { min-width: 160px; } + +/* Schedules */ +.schedule-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 20px; +} +.schedule-summary-item { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + padding: 14px 16px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-card); + box-shadow: var(--shadow-sm); +} +.schedule-summary-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1.1px; + color: var(--text-tertiary); +} +.schedule-summary-value { + font-size: 24px; + line-height: 1; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} +.schedule-workspace { + display: grid; + grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); + align-items: start; + gap: 18px; +} +.schedule-pane { + min-width: 0; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-card); + box-shadow: var(--shadow-sm); +} +.schedule-list-pane { + padding: 14px; + position: sticky; + top: 88px; +} +.schedule-list-toolbar { + margin-bottom: 12px; +} +.schedule-list { + display: grid; + gap: 8px; + max-height: min(68vh, 760px); + overflow: auto; + padding-right: 4px; +} +.schedule-list::-webkit-scrollbar { + width: 10px; +} +.schedule-list::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--text-tertiary) 34%, transparent); + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; +} +.schedule-list-item { + display: grid; + gap: 10px; + width: 100%; + padding: 12px; + color: inherit; + text-align: left; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: var(--bg-app); + cursor: pointer; + transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease; +} +.schedule-list-item:hover { + border-color: color-mix(in srgb, var(--accent-color) 38%, var(--border-color)); + background: var(--bg-glow); +} +.schedule-list-item.is-selected { + border-color: var(--accent-color); + background: var(--accent-subtle); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-color) 22%, transparent); +} +.schedule-list-main { + display: grid; + gap: 4px; + min-width: 0; +} +.schedule-list-title { + overflow: hidden; + color: var(--text-primary); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} +.schedule-list-sub { + min-width: 0; + color: var(--text-secondary); + font-size: 12px; +} +.schedule-list-sub code { + display: inline-block; + max-width: 100%; + overflow: hidden; + font-family: var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} +.schedule-list-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + color: var(--text-tertiary); + font-size: 11px; +} +.schedule-editor { + padding: 20px; +} +.schedule-editor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 18px; +} +.schedule-editor-head .card-title { + margin-bottom: 4px; +} +.schedule-editor-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px 16px; +} +.schedule-mode-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 4px 0 18px; +} +.schedule-mode-option { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 0; + min-height: 40px; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-app); + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease; +} +.schedule-mode-option:hover { + border-color: color-mix(in srgb, var(--accent-color) 40%, var(--border-color)); + color: var(--text-primary); +} +.schedule-mode-option:has(input:checked) { + border-color: var(--accent-color); + background: var(--accent-subtle); + color: var(--accent-color); +} +.schedule-mode-option input { + accent-color: var(--accent-color); +} +.schedule-mode-fields { + display: grid; + gap: 14px; + padding-top: 16px; + border-top: 1px dashed var(--border-color); +} +.schedule-json-area { + min-height: 180px; + max-height: 420px; + overflow: auto; + tab-size: 2; +} +.schedule-editor-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 18px; + margin-top: 6px; + border-top: 1px solid var(--border-color); +} +.schedule-editor-footer .status-msg { + min-height: 20px; + margin-bottom: 0; + color: var(--text-tertiary); +} +.status-msg.success { color: var(--success); } +.status-msg.error { color: var(--error); } .runtime-query-stack { display: grid; gap: 8px; @@ -575,23 +789,51 @@ color: var(--text-secondary); font-family: var(--font-mono); } +.runtime-chat-header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} +.runtime-chat-auto-scroll-toggle { + min-height: 30px; + padding: 4px 8px 4px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-card) 80%, var(--bg-app)); + color: var(--text-secondary); + font-size: 12px; +} .runtime-chat-log { border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-app); + min-width: 0; min-height: 220px; max-height: 420px; + overflow-x: hidden; overflow-y: auto; padding: 12px; margin-bottom: 12px; display: grid; + align-content: start; gap: 10px; } +.runtime-chat-load-more { + min-height: 20px; + color: var(--text-tertiary); + font-size: 12px; + text-align: center; +} .runtime-chat-item { + min-width: 0; + max-width: 100%; border-radius: var(--radius-sm); border: 1px solid var(--border-color); - padding: 10px 12px; + padding: 12px 14px; background: var(--bg-card); + animation: runtime-chat-enter 0.18s ease-out; } .runtime-chat-item.user { border-color: rgba(217, 119, 87, 0.35); @@ -599,22 +841,143 @@ .runtime-chat-item.bot { border-color: var(--border-color); } +.runtime-chat-item.streaming .runtime-chat-content::after { + content: ""; + display: inline-block; + width: 7px; + height: 1.1em; + margin-left: 4px; + vertical-align: -0.15em; + background: var(--accent); + animation: runtime-chat-cursor 0.9s steps(2, start) infinite; +} .runtime-chat-role { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--text-tertiary); margin-bottom: 6px; + display: flex; + align-items: center; + gap: 8px; + min-height: 18px; + flex-wrap: wrap; +} +.runtime-chat-quote-btn { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 1px 7px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-tertiary); + font-size: 11px; + line-height: 1; + letter-spacing: 0; + text-transform: none; + cursor: pointer; + opacity: 0; + transform: translateY(-1px); + transition: + opacity 0.16s ease, + color 0.16s ease, + background 0.16s ease, + border-color 0.16s ease; +} +.runtime-chat-quote-btn[hidden] { + display: none; +} +.runtime-chat-item:hover .runtime-chat-quote-btn, +.runtime-chat-quote-btn:focus-visible { + opacity: 1; +} +.runtime-chat-quote-btn:hover, +.runtime-chat-quote-btn:focus-visible { + border-color: color-mix(in srgb, var(--accent) 28%, var(--border-color)); + background: color-mix(in srgb, var(--accent) 9%, transparent); + color: var(--accent-color); +} +.runtime-chat-quote-btn.is-visible { + opacity: 1; +} +.runtime-chat-cancel-btn { + border-color: color-mix(in srgb, var(--error) 22%, transparent); + color: var(--error); +} +.runtime-chat-cancel-btn:hover, +.runtime-chat-cancel-btn:focus-visible { + border-color: color-mix(in srgb, var(--error) 42%, var(--border-color)); + background: color-mix(in srgb, var(--error) 10%, transparent); + color: var(--error); +} +.runtime-chat-cancel-btn:disabled, +.runtime-chat-retry-btn:disabled { + cursor: default; + opacity: 0.55; +} +.runtime-chat-retry-btn { + border-color: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--accent-color); +} +.runtime-chat-stage { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: min(260px, 100%); + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + font-size: 11px; + line-height: 1.25; + letter-spacing: 0; + text-transform: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.runtime-chat-stage[hidden] { + display: none; +} +.runtime-chat-stage.is-final { + background: color-mix(in srgb, var(--success) 12%, transparent); + color: var(--success); +} +.runtime-chat-stage::before { + content: ""; + width: 5px; + height: 5px; + flex: 0 0 auto; + border-radius: 999px; + background: currentColor; + opacity: 0.85; } .runtime-chat-content { - font-size: 14px; + min-width: 0; + max-width: 100%; + font-size: 15px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; + overflow-wrap: anywhere; +} +.runtime-chat-timeline { + display: grid; + gap: 10px; + min-width: 0; + max-width: 100%; +} +.runtime-chat-item.tool-only .runtime-chat-content { + display: none; } .runtime-chat-content.markdown { white-space: normal; } +.runtime-chat-content.markdown > * { + min-width: 0; + max-width: 100%; +} .runtime-chat-content.markdown > *:first-child { margin-top: 0; } .runtime-chat-content.markdown > *:last-child { margin-bottom: 0; } .runtime-chat-content.markdown p { @@ -642,7 +1005,29 @@ border-left: 3px solid var(--border-color); color: var(--text-secondary); } +.runtime-quote-block { + min-width: 0; + max-width: 100%; + margin: 0.5em 0; + padding: 0.3em 0.9em; + border-left: 3px solid color-mix(in srgb, var(--accent) 60%, var(--border-color)); + background: color-mix(in srgb, var(--bg-app) 86%, var(--bg-card)); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.55; + overflow-wrap: anywhere; +} +.runtime-quote-block > *:first-child { + margin-top: 0; +} +.runtime-quote-block > *:last-child { + margin-bottom: 0; +} .runtime-chat-content.markdown table { + width: 100%; + max-width: 100%; + table-layout: fixed; border-collapse: collapse; margin: 0.5em 0; font-size: 13px; @@ -651,6 +1036,8 @@ .runtime-chat-content.markdown td { border: 1px solid var(--border-color); padding: 4px 8px; + overflow-wrap: anywhere; + word-break: break-word; } .runtime-chat-content.markdown th { background: var(--bg-app); @@ -660,47 +1047,263 @@ color: var(--accent); text-decoration: underline; } -.runtime-chat-content.markdown pre { +.runtime-code-block { + min-width: 0; + max-width: 100%; margin: 0.5em 0; - padding: 10px 12px; - border-radius: var(--radius-sm); - border: 1px solid var(--border-color); - background: var(--bg-app); - overflow-x: auto; -} -.runtime-chat-content.markdown pre code { - display: block; - padding: 0; - border: none; - background: none; - border-radius: 0; - font-size: 12.5px; - line-height: 1.5; - white-space: pre; -} -.runtime-chat-content code { - display: inline-block; - padding: 2px 6px; - border-radius: 6px; - border: 1px solid var(--border-color); - background: var(--bg-app); - font-family: var(--font-mono); - font-size: 12px; -} -.runtime-chat-image { - display: block; - max-width: min(720px, 100%); border-radius: var(--radius-sm); border: 1px solid var(--border-color); - margin: 6px 0; + background: color-mix(in srgb, var(--bg-app) 88%, var(--bg-deep)); + overflow: hidden; } -.runtime-chat-file-card { +.runtime-code-toolbar { + position: sticky; + top: 0; + z-index: 1; display: flex; align-items: center; + justify-content: space-between; gap: 10px; - padding: 10px 14px; - margin: 6px 0; - border: 1px solid var(--border-color); + min-height: 34px; + padding: 5px 8px 5px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + background: color-mix(in srgb, var(--bg-card) 58%, transparent); +} +.runtime-code-language { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; +} +.runtime-code-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.runtime-code-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + line-height: 1; + cursor: pointer; + transition: + background 0.16s ease, + border-color 0.16s ease, + color 0.16s ease, + transform 0.16s ease; +} +.runtime-code-action:hover, +.runtime-code-action:focus-visible { + border-color: color-mix(in srgb, var(--accent) 28%, var(--border-color)); + background: color-mix(in srgb, var(--accent) 10%, transparent); + color: var(--text-primary); +} +.runtime-code-action.primary { + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); +} +.runtime-code-action.primary:hover, +.runtime-code-action.primary:focus-visible { + background: var(--accent); + color: #fff; +} +.runtime-code-block.is-collapsed .runtime-code-body { + height: 9.2em; + overflow: auto; + position: relative; + scrollbar-gutter: stable; +} +.runtime-code-block.is-collapsed .runtime-code-body::after { + display: none; +} +.runtime-chat-content.markdown pre { + max-width: 100%; + margin: 0; + padding: 10px 12px; + border: 0; + border-radius: 0; + background: transparent; + overflow-x: hidden; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.runtime-chat-content.markdown pre code { + display: block; + min-width: 0; + max-width: 100%; + padding: 0; + border: none; + background: none; + border-radius: 0; + font-size: 12.5px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} +.runtime-chat-content.markdown pre code.hljs, +.runtime-chat-content.markdown code.hljs { + padding: 0; + background: transparent; + color: var(--text-primary); +} +.runtime-code-block .hljs-keyword, +.runtime-code-block .hljs-doctag, +.runtime-code-block .hljs-template-tag, +.runtime-code-block .hljs-type { + color: #9b3fb5; + font-weight: 600; +} +.runtime-code-block .hljs-string, +.runtime-code-block .hljs-regexp, +.runtime-code-block .hljs-meta .hljs-string { + color: #246f4f; +} +.runtime-code-block .hljs-comment, +.runtime-code-block .hljs-quote { + color: var(--text-tertiary); + font-style: italic; +} +.runtime-code-block .hljs-number, +.runtime-code-block .hljs-literal, +.runtime-code-block .hljs-variable, +.runtime-code-block .hljs-attribute, +.runtime-code-block .hljs-symbol { + color: #b45f2b; +} +.runtime-code-block .hljs-title, +.runtime-code-block .hljs-title.function_, +.runtime-code-block .hljs-title.class_, +.runtime-code-block .hljs-section { + color: #1f6fb2; + font-weight: 600; +} +.runtime-code-block .hljs-operator, +.runtime-code-block .hljs-punctuation, +.runtime-code-block .hljs-meta { + color: #8a6350; +} +.runtime-code-block .hljs-property, +.runtime-code-block .hljs-attr, +.runtime-code-block .hljs-selector-attr, +.runtime-code-block .hljs-selector-class, +.runtime-code-block .hljs-selector-id { + color: #b25577; +} +.runtime-code-block .hljs-name, +.runtime-code-block .hljs-selector-tag, +.runtime-code-block .hljs-built_in { + color: #22735a; +} +.runtime-code-block .hljs-addition { + color: #1d7f45; + background: color-mix(in srgb, #1d7f45 12%, transparent); +} +.runtime-code-block .hljs-deletion { + color: #b31d28; + background: color-mix(in srgb, #b31d28 10%, transparent); +} +[data-theme="dark"] .runtime-code-block .hljs-keyword, +[data-theme="dark"] .runtime-code-block .hljs-doctag, +[data-theme="dark"] .runtime-code-block .hljs-template-tag, +[data-theme="dark"] .runtime-code-block .hljs-type { + color: #c792ea; +} +[data-theme="dark"] .runtime-code-block .hljs-string, +[data-theme="dark"] .runtime-code-block .hljs-regexp, +[data-theme="dark"] .runtime-code-block .hljs-meta .hljs-string { + color: #89d39a; +} +[data-theme="dark"] .runtime-code-block .hljs-comment, +[data-theme="dark"] .runtime-code-block .hljs-quote { + color: #7f8a91; +} +[data-theme="dark"] .runtime-code-block .hljs-number, +[data-theme="dark"] .runtime-code-block .hljs-literal, +[data-theme="dark"] .runtime-code-block .hljs-variable, +[data-theme="dark"] .runtime-code-block .hljs-attribute, +[data-theme="dark"] .runtime-code-block .hljs-symbol { + color: #f2b86d; +} +[data-theme="dark"] .runtime-code-block .hljs-title, +[data-theme="dark"] .runtime-code-block .hljs-title.function_, +[data-theme="dark"] .runtime-code-block .hljs-title.class_, +[data-theme="dark"] .runtime-code-block .hljs-section { + color: #82b8ff; +} +[data-theme="dark"] .runtime-code-block .hljs-operator, +[data-theme="dark"] .runtime-code-block .hljs-punctuation, +[data-theme="dark"] .runtime-code-block .hljs-meta { + color: #c6a58d; +} +[data-theme="dark"] .runtime-code-block .hljs-property, +[data-theme="dark"] .runtime-code-block .hljs-attr, +[data-theme="dark"] .runtime-code-block .hljs-selector-attr, +[data-theme="dark"] .runtime-code-block .hljs-selector-class, +[data-theme="dark"] .runtime-code-block .hljs-selector-id { + color: #f08bad; +} +[data-theme="dark"] .runtime-code-block .hljs-name, +[data-theme="dark"] .runtime-code-block .hljs-selector-tag, +[data-theme="dark"] .runtime-code-block .hljs-built_in { + color: #7fd6b4; +} +[data-theme="dark"] .runtime-code-block .hljs-addition { + color: #89d39a; + background: color-mix(in srgb, #89d39a 13%, transparent); +} +[data-theme="dark"] .runtime-code-block .hljs-deletion { + color: #ff8d8d; + background: color-mix(in srgb, #ff8d8d 12%, transparent); +} +.runtime-chat-content code { + display: inline-block; + max-width: 100%; + padding: 2px 6px; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-app); + font-family: var(--font-mono); + font-size: 12px; + white-space: normal; + overflow-wrap: anywhere; +} +.runtime-chat-image { + display: block; + max-width: min(720px, 100%); + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + margin: 6px 0; + cursor: zoom-in; + transition: + border-color 0.16s ease, + box-shadow 0.16s ease, + transform 0.16s ease; +} +.runtime-chat-image:hover { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border-color)); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16); + transform: translateY(-1px); +} +.runtime-chat-file-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + margin: 6px 0; + border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-app); max-width: 360px; @@ -743,40 +1346,1124 @@ color: #fff; } .runtime-chat-input-row { + --chat-attachment-rail-width: 0px; + --chat-attachment-card-width: 132px; + --chat-attachment-gap: 8px; + display: flex; + align-items: center; + gap: 0; + min-width: 0; + position: relative; +} +.runtime-chat-command-palette { + position: absolute; + left: 0; + right: 0; + bottom: calc(100% + 10px); + z-index: 180; display: grid; - grid-template-columns: minmax(0, 1fr) auto; + gap: 4px; + max-height: min(360px, 46vh); + padding: 8px; + border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--border-color)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-card) 96%, var(--bg-app)); + box-shadow: 0 16px 42px rgba(15, 23, 42, 0.2); + overflow-y: auto; + opacity: 0; + transform: translateY(8px) scale(0.99); + transform-origin: bottom center; + pointer-events: none; + transition: + opacity 0.16s ease, + transform 0.18s ease; +} +.runtime-chat-command-palette.is-open { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; +} +.runtime-chat-command-palette[hidden] { + display: none; +} +.runtime-chat-command-head { + padding: 2px 6px 6px; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 650; +} +.runtime-chat-command-empty { + padding: 12px; + color: var(--text-tertiary); + font-size: 13px; +} +.runtime-chat-command-item { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(160px, 36%); align-items: center; + gap: 12px; + width: 100%; + min-height: 58px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; + transition: + border-color 0.14s ease, + background 0.14s ease, + transform 0.14s ease; +} +.runtime-chat-command-item:hover, +.runtime-chat-command-item.active { + border-color: color-mix(in srgb, var(--accent) 26%, var(--border-color)); + background: color-mix(in srgb, var(--accent) 8%, transparent); +} +.runtime-chat-command-item.active { + transform: translateX(2px); +} +.runtime-chat-command-main, +.runtime-chat-command-side { + display: grid; + min-width: 0; + gap: 3px; +} +.runtime-chat-command-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent-color); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 700; +} +.runtime-chat-command-desc { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + font-size: 12px; +} +.runtime-chat-command-side { + justify-items: end; + color: var(--text-tertiary); + font-size: 11px; +} +.runtime-chat-command-side code { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 2px 6px; + border-radius: calc(var(--radius-sm) - 1px); + background: color-mix(in srgb, var(--bg-app) 78%, transparent); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11px; +} +.runtime-chat-command-help { + display: grid; gap: 8px; + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border-color)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 6%, var(--bg-card)); + color: var(--text-primary); } -.runtime-chat-actions { +.runtime-chat-command-help-head { display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; + gap: 10px; + min-width: 0; +} +.runtime-chat-command-help-name { + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; - gap: 8px; + color: var(--accent-color); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 700; } -.runtime-chat-action-btn { - width: 44px; - height: 44px; - min-width: 44px; - padding: 0; - border-radius: 999px; +.runtime-chat-command-help-kicker { + flex: 0 0 auto; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 650; +} +.runtime-chat-command-help-desc { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} +.runtime-chat-command-help-grid { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 5px 10px; + color: var(--text-secondary); + font-size: 12px; +} +.runtime-chat-command-help-grid > span:nth-child(odd) { + color: var(--text-tertiary); +} +.runtime-chat-command-help-grid code { + max-width: 100%; + overflow-wrap: anywhere; + padding: 2px 6px; + border-radius: calc(var(--radius-sm) - 1px); + background: color-mix(in srgb, var(--bg-app) 78%, transparent); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 11px; +} +.runtime-chat-command-help-note { + color: var(--text-tertiary); + font-size: 11px; + line-height: 1.45; +} +.runtime-chat-references { + flex: 0 0 min(260px, 28%); + display: flex; + align-self: stretch; + gap: 6px; + min-width: 0; + max-width: min(260px, 28%); + height: 54px; + margin-right: var(--chat-attachment-gap); + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior: contain; + scrollbar-width: none; + opacity: 1; + transform: translateX(0); + transition: + flex-basis 0.2s ease, + max-width 0.2s ease, + margin-right 0.2s ease, + opacity 0.16s ease, + transform 0.18s ease; +} +.runtime-chat-references::-webkit-scrollbar { + display: none; +} +.runtime-chat-references[hidden] { + display: flex; + flex-basis: 0; + max-width: 0; + margin-right: 0; + opacity: 0; + pointer-events: none; + transform: translateX(8px); + visibility: hidden; +} +.runtime-chat-reference { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 24px; + align-items: center; + gap: 6px; + flex: 0 0 min(220px, 100%); + min-width: 160px; + padding: 5px 6px; + border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border-color)); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 7%, var(--bg-card)); + color: var(--text-primary); + overflow: hidden; + animation: runtime-chat-attachment-in 0.2s ease both; +} +.runtime-chat-reference-mark { display: inline-flex; align-items: center; justify-content: center; -} -.runtime-chat-action-icon { - font-size: 20px; + width: 24px; + height: 34px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent-color); + font-family: var(--font-serif); + font-size: 22px; line-height: 1; - font-weight: 700; } -.runtime-chat-action-btn-send .runtime-chat-action-icon { - transform: translateY(-1px); +.runtime-chat-reference-main { + display: grid; + gap: 1px; + min-width: 0; } -.runtime-chat-input { - min-height: 88px; - resize: vertical; - line-height: 1.5; +.runtime-chat-reference-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent-color); + font-size: 11px; + font-weight: 650; +} +.runtime-chat-reference-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + font-size: 11px; +} +.runtime-chat-reference-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + font-size: 15px; + font-weight: 700; + transition: + background 0.16s ease, + color 0.16s ease, + transform 0.16s ease; +} +.runtime-chat-reference-remove:hover, +.runtime-chat-reference-remove:focus-visible { + background: color-mix(in srgb, var(--error) 12%, transparent); + color: var(--error); + transform: scale(1.04); +} +.runtime-chat-input-row > .runtime-chat-input { + flex: 1 1 auto; + min-width: min(100%, 260px); + margin-right: var(--chat-attachment-gap); + transition: + flex 0.24s ease, + flex-basis 0.22s ease, + width 0.22s ease, + margin-right 0.22s ease; +} +.runtime-chat-attachments { + flex: 0 0 var(--chat-attachment-rail-width); + display: flex; + align-self: stretch; + flex-direction: row; + gap: 6px; + width: var(--chat-attachment-rail-width); + height: 54px; + min-width: 0; + max-width: var(--chat-attachment-rail-width); + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior: contain; + opacity: 1; + scrollbar-width: none; + transform: translateX(0); + transition: + flex-basis 0.22s ease, + width 0.22s ease, + max-width 0.22s ease, + margin-right 0.22s ease, + opacity 0.18s ease, + transform 0.22s ease; +} +.runtime-chat-attachments::-webkit-scrollbar { + display: none; +} +.runtime-chat-input-row.has-attachments .runtime-chat-attachments { + margin-right: var(--chat-attachment-gap); +} +.runtime-chat-attachments[hidden] { + display: flex; + flex-basis: 0; + width: 0; + max-width: 0; + margin-right: 0; + opacity: 0; + pointer-events: none; + transform: translateX(8px); + visibility: hidden; +} +.runtime-chat-attachment { + display: inline-grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + flex: 0 0 var(--chat-attachment-card-width); + min-width: 42px; + max-width: var(--chat-attachment-card-width); + position: relative; + min-height: 44px; + padding: 5px 7px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-card) 72%, var(--bg-app)); + color: var(--text-primary); + overflow: hidden; + animation: runtime-chat-attachment-in 0.2s ease both; + transition: + flex-basis 0.2s ease, + grid-template-columns 0.18s ease, + max-width 0.2s ease, + min-width 0.2s ease, + padding 0.18s ease, + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} +.runtime-chat-attachment:hover { + border-color: color-mix(in srgb, var(--accent) 32%, var(--border-color)); + background: color-mix(in srgb, var(--bg-card) 84%, var(--accent-subtle)); + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); +} +.runtime-chat-input-row.has-attachments .runtime-chat-attachment { + min-width: 0; +} +.runtime-chat-input-row.is-attachment-rail-full .runtime-chat-attachment { + grid-template-columns: 30px minmax(0, 1fr) auto; + padding-inline: 6px; +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachments { + gap: 4px; +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment { + min-width: 32px; + grid-template-columns: minmax(24px, 1fr); + justify-items: center; + gap: 0; + padding: 3px; +} +.runtime-chat-attachment-preview { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + overflow: hidden; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); + transition: + width 0.18s ease, + height 0.18s ease, + border-radius 0.18s ease; +} +.runtime-chat-attachment-thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.runtime-chat-attachment-preview.is-missing-thumb::before { + content: "IMG"; + font-size: 10px; + font-weight: 700; + letter-spacing: 0; +} +.runtime-chat-attachment-file { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + letter-spacing: 0; +} +.runtime-chat-attachment-main { + display: grid; + min-width: 0; + gap: 1px; + transition: + opacity 0.18s ease, + max-width 0.18s ease; +} +.runtime-chat-attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; +} +.runtime-chat-attachment-meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary); + font-size: 11px; +} +.runtime-chat-attachment-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + transition: + background 0.16s ease, + color 0.16s ease, + transform 0.16s ease; +} +.runtime-chat-attachment-remove:hover { + background: color-mix(in srgb, var(--error) 12%, transparent); + color: var(--error); + transform: scale(1.04); +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-preview { + width: 100%; + height: 38px; + border-radius: calc(var(--radius-sm) - 1px); +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-main { + width: 0; + max-width: 0; + opacity: 0; + overflow: hidden; +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-remove { + position: absolute; + top: 3px; + right: 3px; + width: 22px; + height: 22px; + border: 1px solid color-mix(in srgb, var(--bg-card) 88%, var(--border-color)); + font-size: 15px; + font-weight: 700; + background: color-mix(in srgb, var(--bg-card) 92%, transparent); + color: var(--text-primary); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.22); + opacity: 0.92; +} +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-remove:hover, +.runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-remove:focus-visible { + background: color-mix(in srgb, var(--error) 16%, var(--bg-card)); + color: var(--error); + opacity: 1; +} +@keyframes runtime-chat-attachment-in { + from { + opacity: 0; + transform: translateX(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} +.runtime-chat-actions { + display: flex; + align-items: center; + justify-content: flex-end; + white-space: nowrap; + gap: 8px; +} +.runtime-chat-action-btn { + width: 44px; + height: 44px; + min-width: 44px; + padding: 0; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.runtime-chat-action-icon { + font-size: 20px; + line-height: 1; + font-weight: 700; +} +.runtime-chat-action-btn-send .runtime-chat-action-icon { + transform: translateY(-1px); +} +.runtime-chat-input { + min-height: 88px; + resize: vertical; + line-height: 1.5; +} +.runtime-chat-selection-quote { + position: fixed; + z-index: 220; + transform: translateX(-50%); + min-height: 28px; + padding: 4px 10px; + border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border-color)); + border-radius: 999px; + background: var(--bg-card); + color: var(--accent-color); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.18); + font-size: 12px; + font-weight: 600; + cursor: pointer; + animation: runtime-chat-selection-quote-in 0.14s ease-out; +} +.runtime-chat-selection-quote[hidden] { + display: none; +} +.runtime-chat-tools { + display: grid; + gap: 8px; +} +.runtime-tool-block { + --tool-accent: var(--accent-color); + min-width: 0; + max-width: 100%; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-app) 92%, var(--bg-card)); + overflow: hidden; + position: relative; + transition: + border-color 0.18s ease, + background 0.18s ease, + transform 0.18s ease, + box-shadow 0.18s ease; +} +.runtime-tool-block::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--tool-accent); + opacity: 0.65; +} +.runtime-tool-block.is-agent { + --tool-accent: var(--accent-color); + background: color-mix(in srgb, var(--bg-card) 60%, var(--bg-app)); +} +.runtime-tool-block.is-tool { + --tool-accent: color-mix(in srgb, var(--success) 76%, var(--accent-color)); +} +.runtime-tool-block.running { + --tool-accent: color-mix(in srgb, var(--warning) 82%, var(--accent-color)); +} +.runtime-tool-block.done { + --tool-accent: var(--success); +} +.runtime-tool-block.error { + --tool-accent: var(--error); +} +.runtime-tool-block.cancelled { + --tool-accent: var(--warning); +} +.runtime-tool-block:hover { + border-color: color-mix(in srgb, var(--tool-accent) 38%, var(--border-color)); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); +} +.runtime-tool-block summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + cursor: pointer; + min-height: 32px; + padding: 3px 10px 3px 13px; + font-size: 12px; + line-height: 1.2; + color: var(--text-secondary); + list-style: none; +} +.runtime-tool-block summary::-webkit-details-marker { + display: none; +} +.runtime-tool-block summary::before { + content: ""; + width: 6px; + height: 6px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.18s ease; + opacity: 0.7; +} +.runtime-tool-block[open] summary::before { + transform: rotate(45deg); +} +.runtime-tool-block summary .runtime-tool-summary-main { + min-width: 0; + overflow: hidden; +} +.runtime-tool-block summary .runtime-tool-title { + display: inline-flex; + align-items: baseline; + gap: 7px; + min-width: 0; + max-width: 100%; + vertical-align: middle; +} +.runtime-tool-block summary .runtime-tool-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border: 0; + background: transparent; + padding: 0; + color: var(--text-primary); + font-size: 12px; + font-weight: 650; +} +.runtime-tool-block summary .runtime-tool-duration { + flex: 0 0 auto; + padding: 1px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--tool-accent) 10%, transparent); + color: color-mix(in srgb, var(--tool-accent) 84%, var(--text-primary)); + font-family: var(--font-mono); + font-size: 10.5px; + line-height: 1.4; + white-space: nowrap; +} +.runtime-tool-block summary .runtime-tool-duration[hidden] { + display: none; +} +.runtime-tool-block summary .runtime-tool-status { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-style: normal; + white-space: nowrap; + color: var(--text-tertiary); +} +.runtime-tool-block summary .runtime-tool-kind { + min-width: 44px; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; + white-space: nowrap; + color: var(--text-secondary); +} +.runtime-tool-block.webchat-private-send, +.runtime-tool-block.webchat-end { + background: color-mix(in srgb, var(--bg-app) 72%, var(--bg-card)); +} +.runtime-tool-block.webchat-private-send summary, +.runtime-tool-block.webchat-end summary { + min-height: 32px; + padding-block: 3px; +} +.runtime-tool-block.running summary .runtime-tool-status { + color: var(--warning); +} +.runtime-tool-block.done summary .runtime-tool-status { + color: var(--success); +} +.runtime-tool-block.error summary .runtime-tool-status { + color: var(--error); +} +.runtime-tool-block.cancelled summary .runtime-tool-status { + color: var(--warning); +} +.runtime-tool-preview { + min-width: 0; + max-width: 100%; + border-top: 1px solid var(--border-color); + padding: 8px 10px 10px; + animation: runtime-tool-reveal 0.18s ease-out; +} +.runtime-tool-preview + .runtime-tool-preview { + border-top-style: dashed; +} +.runtime-tool-preview-label { + margin-bottom: 6px; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; +} +.runtime-tool-preview-body { + min-width: 0; + max-width: 100%; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: min(34vh, 260px); + overflow: auto; +} +.runtime-tool-preview-body > *:first-child { + margin-top: 0; +} +.runtime-tool-preview-body > *:last-child { + margin-bottom: 0; +} +.runtime-tool-preview-body.is-structured { + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-card) 70%, var(--bg-app)); + white-space: normal; +} +.runtime-tool-structured-list { + display: grid; + gap: 6px; + min-width: 0; +} +.runtime-tool-structured-list .runtime-tool-structured-list { + margin-top: 5px; + padding-left: 10px; + border-left: 1px solid var(--border-color); +} +.runtime-tool-structured-row { + display: grid; + grid-template-columns: minmax(64px, min(34%, 180px)) minmax(0, 1fr); + align-items: start; + gap: 8px; + min-width: 0; +} +.runtime-tool-key { + min-width: 0; + color: var(--accent-color); + font-family: var(--font-mono); + font-size: 11px; + overflow-wrap: anywhere; +} +.runtime-tool-value { + min-width: 0; + color: var(--text-secondary); + overflow-wrap: anywhere; +} +.runtime-tool-value.string { + color: var(--text-primary); +} +.runtime-tool-value.number { + color: var(--warning); + font-family: var(--font-mono); +} +.runtime-tool-value.boolean { + color: var(--success); + font-family: var(--font-mono); +} +.runtime-tool-value.muted { + color: var(--text-tertiary); + font-family: var(--font-mono); +} +.runtime-tool-preview-body .runtime-chat-image { + max-width: min(420px, 100%); +} +.runtime-tool-preview-body .runtime-chat-file-card { + max-width: min(340px, 100%); +} +.runtime-chat-image-viewer { + position: fixed; + inset: 0; + z-index: 260; + display: grid; + place-items: center; + padding: 28px; + background: color-mix(in srgb, #020617 78%, transparent); + backdrop-filter: blur(7px); + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; +} +.runtime-chat-image-viewer.is-open { + opacity: 1; + pointer-events: auto; +} +.runtime-chat-image-viewer[hidden] { + display: none; +} +.runtime-chat-image-viewer-figure { + display: grid; + gap: 10px; + justify-items: center; + max-width: min(1120px, calc(100vw - 56px)); + max-height: calc(100dvh - 56px); + margin: 0; +} +.runtime-chat-image-viewer-image { + display: block; + max-width: min(1120px, calc(100vw - 56px)); + max-height: calc(100dvh - 104px); + object-fit: contain; + border-radius: var(--radius-sm); + background: #020617; + box-shadow: 0 24px 70px rgba(2, 6, 23, 0.46); + animation: runtime-chat-image-viewer-in 0.18s ease-out; +} +.runtime-chat-image-viewer-caption { + max-width: min(720px, calc(100vw - 56px)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: rgba(255, 255, 255, 0.78); + font-size: 12px; +} +.runtime-chat-image-viewer-close { + position: fixed; + top: max(16px, env(safe-area-inset-top)); + right: max(16px, env(safe-area-inset-right)); + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: var(--radius-sm); + background: rgba(15, 23, 42, 0.68); + color: #fff; + cursor: pointer; + font-size: 24px; + line-height: 1; + transition: + background 0.16s ease, + border-color 0.16s ease, + transform 0.16s ease; +} +.runtime-chat-image-viewer-close:hover, +.runtime-chat-image-viewer-close:focus-visible { + border-color: rgba(255, 255, 255, 0.4); + background: rgba(15, 23, 42, 0.9); + transform: translateY(-1px); +} +.runtime-tool-children { + display: grid; + gap: 8px; + margin-left: 10px; + padding: 8px 10px 10px 12px; + border-top: 1px dashed var(--border-color); + border-left: 1px solid color-mix(in srgb, var(--tool-accent) 30%, var(--border-color)); + background: color-mix(in srgb, var(--bg-app) 84%, var(--bg-card)); + animation: runtime-tool-reveal 0.18s ease-out; +} +.runtime-tool-children .runtime-tool-block { + background: var(--bg-card); +} +.runtime-tool-message { + min-width: 0; + max-width: 100%; + border: 1px solid color-mix(in srgb, var(--tool-accent) 18%, var(--border-color)); + border-left: 3px solid color-mix(in srgb, var(--tool-accent) 48%, var(--border-color)); + border-radius: var(--radius-sm); + padding: 8px 10px; + background: color-mix(in srgb, var(--bg-card) 72%, var(--bg-app)); + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.5; + animation: runtime-tool-reveal 0.18s ease-out; +} +.runtime-tool-message > *:first-child { + margin-top: 0; +} +.runtime-tool-message > *:last-child { + margin-bottom: 0; +} +.runtime-html-runner { + position: fixed; + z-index: 118; + width: min(760px, calc(100vw - 32px)); + height: 360px; + max-width: calc(100vw - 32px); + max-height: calc(100dvh - 32px - env(safe-area-inset-bottom)); + min-width: 360px; + min-height: 280px; + overflow: visible; + pointer-events: auto; +} +.runtime-html-runner[hidden] { + display: none; +} +.runtime-html-runner-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: 100%; + height: 100%; + min-height: 280px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + border-radius: var(--radius-md); + background: var(--bg-card); + box-shadow: 0 18px 56px rgba(15, 23, 42, 0.2); + position: relative; + animation: runtime-html-runner-in 0.2s ease-out; +} +.runtime-html-runner.is-resizing, +.runtime-html-runner.is-resizing * { + cursor: nwse-resize !important; + user-select: none; +} +.runtime-html-runner.is-resizing .runtime-html-runner-frame { + pointer-events: none; +} +.runtime-html-runner.is-dragging, +.runtime-html-runner.is-dragging * { + cursor: move !important; + user-select: none; +} +.runtime-html-runner.is-dragging .runtime-html-runner-frame { + pointer-events: none; +} +.runtime-html-runner-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + min-height: 44px; + padding: 8px 10px 8px 14px; + border-bottom: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-app) 64%, var(--bg-card)); + cursor: move; + touch-action: none; +} +.runtime-html-runner-actions, +.runtime-html-runner-actions * { + cursor: auto; +} +.runtime-html-runner-actions button { + cursor: pointer; +} +.runtime-html-runner-title { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--text-primary); + font-size: 13px; + font-weight: 650; +} +.runtime-html-runner-meta { + max-width: 28ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 999px; + padding: 2px 7px; + background: color-mix(in srgb, var(--accent) 11%, transparent); + color: var(--accent); + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 600; +} +.runtime-html-runner-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.runtime-html-runner-btn { + min-height: 28px; + padding: 4px 10px; + font-size: 12px; +} +.runtime-html-runner-btn.is-active { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border-color)); + background: var(--accent-subtle); + color: var(--accent-color); +} +.runtime-html-runner-frame { + width: 100%; + height: 100%; + min-height: 220px; + border: 0; + background: #fff; +} +.runtime-html-runner-resize { + position: absolute; + right: 5px; + bottom: 5px; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--border-color) 68%, transparent); + border-radius: 6px; + background: + linear-gradient(135deg, transparent 45%, color-mix(in srgb, var(--text-tertiary) 65%, transparent) 46%, transparent 52%), + linear-gradient(135deg, transparent 62%, color-mix(in srgb, var(--text-tertiary) 65%, transparent) 63%, transparent 70%), + color-mix(in srgb, var(--bg-card) 88%, transparent); + box-shadow: 0 3px 10px rgba(15, 23, 42, 0.18); + cursor: nwse-resize; + opacity: 0.86; +} +.runtime-html-runner-resize:hover, +.runtime-html-runner-resize:focus-visible { + border-color: color-mix(in srgb, var(--accent) 44%, var(--border-color)); + opacity: 1; +} +.runtime-html-runner.is-picking .runtime-html-runner-panel { + border-color: color-mix(in srgb, var(--accent) 54%, var(--border-color)); + box-shadow: + 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent), + 0 18px 56px rgba(15, 23, 42, 0.22); +} +@keyframes runtime-chat-enter { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes runtime-tool-reveal { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes runtime-html-runner-in { + from { + opacity: 0; + transform: translateY(10px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +@keyframes runtime-chat-image-viewer-in { + from { + opacity: 0; + transform: scale(0.985); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes runtime-chat-selection-quote-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} +@keyframes runtime-chat-cursor { + 0%, + 45% { + opacity: 1; + } + 46%, + 100% { + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + .runtime-chat-item, + .runtime-chat-item.streaming .runtime-chat-content::after, + .runtime-tool-preview, + .runtime-tool-children, + .runtime-tool-message, + .runtime-html-runner-panel, + .runtime-chat-selection-quote { + animation: none; + } + .runtime-tool-block, + .runtime-tool-block summary::before { + transition: none; + } } .sr-only { position: absolute; diff --git a/src/Undefined/webui/static/css/highlight-github.min.css b/src/Undefined/webui/static/css/highlight-github.min.css new file mode 100644 index 00000000..275239a7 --- /dev/null +++ b/src/Undefined/webui/static/css/highlight-github.min.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub + Description: Light theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-light + Current colors taken from GitHub's CSS +*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0} \ No newline at end of file diff --git a/src/Undefined/webui/static/css/responsive.css b/src/Undefined/webui/static/css/responsive.css index b6feeba1..eeeab83f 100644 --- a/src/Undefined/webui/static/css/responsive.css +++ b/src/Undefined/webui/static/css/responsive.css @@ -12,7 +12,10 @@ padding-right: 30px; } .search-group .form-control { min-width: 0; } - .main-content.chat-layout #tab-chat .chat-runtime-card { height: clamp(460px, calc(100vh - 220px), 760px); } + .main-content.chat-layout { + height: 100dvh; + padding-bottom: max(14px, env(safe-area-inset-bottom)); + } } @media (max-width: 768px) { @@ -54,6 +57,117 @@ min-height: 100vh; padding: 20px 16px 28px; } + .main-content.chat-layout { + height: 100dvh; + min-height: 0; + padding: 14px 16px max(12px, env(safe-area-inset-bottom)); + } + .main-content.chat-layout #tab-chat > .header { + margin-bottom: 8px; + } + .main-content.chat-layout #tab-chat .runtime-chat-header { + gap: 10px; + } + .runtime-chat-shell { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + padding-right: 0; + } + .runtime-chat-sidebar { + position: static; + width: auto; + transform: none; + transition: none; + padding: 0 0 8px; + border-right: 0; + border-bottom: 1px solid var(--border-color); + } + .runtime-chat-sidebar-panel { + display: none; + height: auto; + padding: 8px 0 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; + } + .runtime-chat-sidebar.is-open .runtime-chat-sidebar-panel { + display: block; + } + .runtime-chat-sidebar-head { + display: flex; + min-height: 0; + margin-bottom: 7px; + padding-inline: 2px; + } + .runtime-chat-sidebar-tab { + position: static; + display: flex; + place-items: initial; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-card); + box-shadow: none; + } + .runtime-chat-sidebar-tab::after { + position: static; + right: auto; + bottom: auto; + flex: 0 0 auto; + transform: rotate(-135deg); + } + .runtime-chat-sidebar.is-open .runtime-chat-sidebar-tab::after { + transform: rotate(45deg); + } + .runtime-chat-sidebar-tab-label { + writing-mode: horizontal-tb; + font-size: 13px; + } + .runtime-chat-sidebar:not(.is-open) { + padding-bottom: 8px; + } + .runtime-chat-conversations { + flex-direction: row; + height: auto; + overflow-x: auto; + overflow-y: hidden; + padding: 0 0 6px; + } + .runtime-chat-conversation { + flex: 0 0 min(220px, 72vw); + } + .runtime-chat-conversation-head { + padding: 7px 12px; + } + .runtime-chat-page-title { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + .runtime-chat-title-meta { + max-width: 100%; + font-size: 12px; + line-height: 1.35; + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .runtime-chat-header-actions { + width: 100%; + justify-content: space-between; + flex-wrap: nowrap; + } + .runtime-chat-auto-scroll-toggle { + flex: 1 1 auto; + min-width: 0; + } .header { margin-bottom: 24px; gap: 14px; @@ -74,6 +188,34 @@ margin-bottom: 20px; } .overview-grid, .runtime-grid { grid-template-columns: 1fr; gap: 18px; } + .schedule-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .schedule-workspace { + grid-template-columns: 1fr; + } + .schedule-list-pane { + position: static; + } + .schedule-list { + max-height: 360px; + } + .schedule-editor { + padding: 18px; + } + .schedule-editor-grid { + grid-template-columns: 1fr; + } + .schedule-mode-row { + grid-template-columns: 1fr; + } + .schedule-editor-footer { + flex-direction: column; + align-items: stretch; + } + .schedule-editor-footer .toolbar { + flex-direction: row; + } .form-grid { column-count: 1; column-width: auto; @@ -247,16 +389,137 @@ .runtime-chat-file-card { max-width: 100%; } .runtime-chat-content.markdown table { - display: block; - overflow-x: auto; - white-space: nowrap; + display: table; + overflow-x: visible; + white-space: normal; + } + .runtime-code-toolbar { + gap: 6px; + min-height: 32px; + padding: 4px 6px 4px 9px; + } + .runtime-code-actions { + gap: 4px; + } + .runtime-code-action { + min-height: 24px; + padding-inline: 7px; + font-size: 11px; } .runtime-chat-input-row { - grid-template-columns: 1fr; - align-items: stretch; + --chat-attachment-gap: 6px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "references references" + "attachments attachments" + "input actions"; + align-items: end; + column-gap: 7px; + row-gap: 0; + min-width: 0; + } + .runtime-chat-command-palette { + max-height: min(310px, 42vh); + padding: 6px; + } + .runtime-chat-command-item { + grid-template-columns: minmax(0, 1fr); + gap: 5px; + min-height: 64px; + padding: 9px; + } + .runtime-chat-command-side { + justify-items: start; + } + .runtime-chat-command-help { + padding: 9px; + } + .runtime-chat-command-help-head { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + .runtime-chat-command-help-grid { + grid-template-columns: minmax(0, 1fr); + gap: 3px; + } + .runtime-chat-command-help-grid > span:nth-child(even) { + margin-bottom: 5px; + } + .runtime-chat-image-viewer { + padding: 14px; + } + .runtime-chat-image-viewer-figure, + .runtime-chat-image-viewer-image, + .runtime-chat-image-viewer-caption { + max-width: calc(100vw - 28px); + } + .runtime-chat-image-viewer-image { + max-height: calc(100dvh - 86px); + } + .runtime-chat-image-viewer-close { + top: max(10px, env(safe-area-inset-top)); + right: max(10px, env(safe-area-inset-right)); + } + .runtime-chat-references { + grid-area: references; + align-self: stretch; + width: 100%; + max-width: 100%; + height: 46px; + margin-right: 0; + margin-bottom: 7px; + } + .runtime-chat-references[hidden] { display: none; } + .runtime-chat-reference { + flex-basis: min(220px, 78vw); + min-width: 132px; + grid-template-columns: 20px minmax(0, 1fr) 24px; + padding-inline: 5px; + } + .runtime-chat-reference-mark { + width: 20px; + height: 34px; + font-size: 19px; + } + .runtime-chat-input-row > .runtime-chat-input { + grid-area: input; + min-width: 0; + width: 100%; + margin-right: 0; + } + .runtime-chat-attachments { + grid-area: attachments; + align-self: stretch; + width: 100%; + max-width: 100%; + height: 48px; + margin-right: 0; + margin-bottom: 7px; + } + .runtime-chat-attachments[hidden] { display: none; } + .runtime-chat-input-row.has-attachments .runtime-chat-attachments { + margin-right: 0; + } + .runtime-chat-attachment { + flex-basis: min(var(--chat-attachment-card-width), 72vw); + min-width: 34px; + } + .runtime-chat-input-row.is-attachment-rail-full .runtime-chat-attachment { + min-width: 30px; + } + .runtime-chat-actions { + grid-area: actions; + align-self: end; + justify-content: flex-end; + } + .main-content.chat-layout #tab-chat .runtime-chat-log { + padding: 12px; + } + .main-content.chat-layout #tab-chat .runtime-chat-input-row { + padding: 10px 12px 0; } - .runtime-chat-actions { justify-content: flex-start; } - .main-content.chat-layout #tab-chat .chat-runtime-card { height: clamp(480px, calc(100vh - 180px), 720px); } .main-content.chat-layout #tab-chat .runtime-chat-input { height: auto; min-height: 88px; @@ -271,6 +534,65 @@ .main-content.chat-layout #tab-chat .runtime-chat-action-icon { font-size: 18px; } + .runtime-tool-block summary { + grid-template-columns: auto minmax(0, 1fr) minmax(46px, auto); + gap: 6px; + } + .runtime-tool-block summary .runtime-tool-title { + gap: 5px; + } + .runtime-tool-block summary .runtime-tool-duration { + padding-inline: 5px; + font-size: 10px; + } + .runtime-tool-block summary .runtime-tool-status { + justify-self: end; + max-width: 30vw; + } + .runtime-tool-block summary .runtime-tool-kind { + display: none; + } + .runtime-tool-structured-row { + grid-template-columns: 1fr; + gap: 3px; + } + .runtime-tool-children { + margin-left: 6px; + padding-left: 8px; + padding-right: 6px; + } + .runtime-html-runner { + width: calc(100vw - 24px); + min-width: 0; + min-height: 260px; + max-width: calc(100vw - 24px); + max-height: calc(100dvh - 24px - env(safe-area-inset-bottom)); + } + .runtime-html-runner-panel { + grid-template-rows: auto minmax(0, 1fr); + min-height: 260px; + border-radius: var(--radius-sm); + } + .runtime-html-runner-toolbar { + align-items: flex-start; + flex-wrap: wrap; + padding: 8px 9px 8px 11px; + } + .runtime-html-runner-title { + display: grid; + gap: 3px; + flex: 1 1 min(160px, 100%); + } + .runtime-html-runner-meta { + max-width: min(62vw, 260px); + } + .runtime-html-runner-actions { + margin-left: auto; + gap: 4px; + } + .runtime-html-runner-btn { + padding-inline: 8px; + } } @media (max-width: 480px) { @@ -293,6 +615,24 @@ padding-left: 16px; padding-right: 16px; } + .runtime-chat-header-actions { + gap: 6px; + } + .runtime-chat-auto-scroll-toggle { + font-size: 11px; + padding-inline: 8px; + } + .runtime-chat-auto-scroll-toggle .toggle-track { + width: 34px; + height: 20px; + } + .runtime-chat-auto-scroll-toggle .toggle-handle { + width: 16px; + height: 16px; + } + .runtime-chat-auto-scroll-toggle .toggle-input:checked + .toggle-track .toggle-handle { + transform: translateX(14px); + } .search-group { flex-direction: column; } @@ -305,6 +645,27 @@ .logs-toolbar .toolbar-group-primary { grid-template-columns: 1fr; } + .schedule-summary-grid { + grid-template-columns: 1fr; + } + .schedule-summary-item { + padding: 12px 14px; + } + .schedule-editor-head { + flex-direction: column; + align-items: flex-start; + } + .schedule-editor-footer .toolbar { + flex-direction: column; + align-items: stretch; + } + .schedule-editor-footer .toolbar .btn { + width: 100%; + } + .schedule-list-meta { + display: grid; + grid-template-columns: 1fr; + } .log-viewer { height: 52vh; } .probe-item { grid-template-columns: 1fr; gap: 6px; } .probe-item-value { justify-self: flex-start; } diff --git a/src/Undefined/webui/static/js/api.js b/src/Undefined/webui/static/js/api.js index ed528e10..ed8287e5 100644 --- a/src/Undefined/webui/static/js/api.js +++ b/src/Undefined/webui/static/js/api.js @@ -59,8 +59,15 @@ function shouldRetryCandidate(res) { async function requestOnce(path, options = {}) { const headers = { ...(options.headers || {}) }; + const body = options.body; + const isNativeBody = + (typeof FormData !== "undefined" && body instanceof FormData) || + (typeof Blob !== "undefined" && body instanceof Blob) || + (typeof URLSearchParams !== "undefined" && + body instanceof URLSearchParams); const needsJson = - options.body && + body && + !isNativeBody && !headers["Content-Type"] && ["POST", "PATCH", "PUT", "DELETE"].includes( String(options.method || "").toUpperCase(), diff --git a/src/Undefined/webui/static/js/i18n.js b/src/Undefined/webui/static/js/i18n.js index 7be72b9f..b0c9cce0 100644 --- a/src/Undefined/webui/static/js/i18n.js +++ b/src/Undefined/webui/static/js/i18n.js @@ -9,6 +9,7 @@ const I18N = { "landing.probes": "探针诊断", "landing.memory": "记忆检索", "landing.memes": "表情包库", + "landing.schedules": "定时任务", "landing.runtime": "运行接口", "landing.chat": "智能对话", "landing.about": "关于项目", @@ -28,6 +29,7 @@ const I18N = { "tabs.probes": "探针诊断", "tabs.memory": "记忆检索", "tabs.memes": "表情包库", + "tabs.schedules": "定时任务", "tabs.runtime": "运行接口", "tabs.chat": "智能对话", "tabs.about": "项目说明", @@ -240,6 +242,53 @@ const I18N = { "memes.reindex_queued": "已加入重建索引队列", "memes.select_prompt": "请选择一个表情包", "memes.confirm_delete": "确定删除这个表情包吗?", + "schedules.title": "定时任务", + "schedules.subtitle": "查看、创建和编辑运行中的调度任务。", + "schedules.refresh": "刷新", + "schedules.new": "新建任务", + "schedules.total": "总任务", + "schedules.self_count": "自我督办", + "schedules.multi_count": "多工具", + "schedules.limited_count": "有限次数", + "schedules.search_placeholder": "搜索任务...", + "schedules.editor_new": "新建任务", + "schedules.editor_edit": "编辑任务", + "schedules.task_id": "任务 ID", + "schedules.task_name": "任务名称", + "schedules.cron": "Crontab", + "schedules.max_executions": "最大执行次数", + "schedules.target_type": "目标类型", + "schedules.target_group": "群聊", + "schedules.target_private": "私聊", + "schedules.target_id": "目标 ID", + "schedules.mode_single": "单工具", + "schedules.mode_multi": "多工具", + "schedules.mode_self": "自我督办", + "schedules.tool_name": "工具名称", + "schedules.tool_args": "工具参数 JSON", + "schedules.execution_mode": "执行模式", + "schedules.execution_serial": "串行", + "schedules.execution_parallel": "并行", + "schedules.tools_json": "工具列表 JSON", + "schedules.self_instruction": "未来指令", + "schedules.reset": "重置", + "schedules.delete": "删除", + "schedules.save": "保存", + "schedules.untitled": "未命名任务", + "schedules.no_results": "未找到匹配任务。", + "schedules.empty": "暂无定时任务。", + "schedules.no_target": "未指定目标", + "schedules.next_run": "下次执行", + "schedules.positive_int_error": "{label} 必须是正整数", + "schedules.cron_required": "请填写 Crontab 表达式", + "schedules.self_required": "请填写未来指令", + "schedules.tools_required": "请填写至少一个工具", + "schedules.tool_required": "请填写工具名称", + "schedules.loaded": "已加载 {count} 个任务", + "schedules.saved": "定时任务已保存", + "schedules.save_failed": "保存失败", + "schedules.confirm_delete": "确定删除这个定时任务吗?", + "schedules.deleted": "定时任务已删除", "runtime.title": "Runtime 能力中心", "runtime.subtitle": "探针、记忆查询与侧写查看。", "runtime.refresh_all": "刷新全部", @@ -262,12 +311,105 @@ const I18N = { "runtime.profiles_placeholder": "输入侧写关键词...", "runtime.cognitive_profile_get": "按实体查看侧写", "runtime.fetch": "获取", - "runtime.chat_title": "AI Chat(虚拟私聊 system#42)", - "runtime.chat_hint": - "该会话由 WebUI 发起,权限为 superadmin;私聊里可直接使用 /命令。", "runtime.chat_placeholder": "输入消息,或直接 /help 这样的命令", + "runtime.chat_log_label": "聊天消息", + "runtime.chat_auto_scroll": "自动滚动到底部", + "runtime.chat_clear": "清空历史", + "runtime.chat_clear_confirm": + "确定清空当前 WebChat 对话的聊天历史吗?长期记忆和认知记忆不会受影响。", + "runtime.chat_cleared": "聊天历史已清空", + "runtime.chat_conversations": "对话", + "runtime.chat_new_conversation": "新建对话", + "runtime.chat_conversation_created": "已新建对话", + "runtime.chat_no_conversations": "暂无对话", + "runtime.chat_rename_conversation": "重命名对话", + "runtime.chat_delete_conversation": "删除对话", + "runtime.chat_delete_confirm": + "确定删除当前 WebChat 对话吗?该对话历史会被删除。", + "runtime.chat_message_count": "{count} 条消息", + "runtime.chat_title_pending": "标题生成中", + "runtime.chat_loading_more": "正在加载更早消息...", + "runtime.chat_streaming": "正在生成", + "runtime.chat_reconnecting": "正在恢复连接...", + "runtime.chat_cancelled": "已取消生成", + "runtime.chat_running": "已有对话正在运行,请稍候。", + "runtime.chat_command_hint": "选择命令,Enter/Tab 填入", + "runtime.chat_command_hint_subcommand": "选择子命令,Enter/Tab 填入", + "runtime.chat_command_loading": "正在加载可用命令...", + "runtime.chat_command_empty": "未找到匹配命令", + "runtime.chat_command_unknown_command": + "继续输入完整命令,或从候选命令中选择", + "runtime.chat_command_subcommand_empty": "未找到匹配子命令", + "runtime.chat_command_subcommands": "{count} 个子命令", + "runtime.command_help": "命令帮助", + "runtime.command_usage": "用法", + "runtime.command_example": "示例", + "runtime.command_aliases": "别名", + "runtime.command_no_subcommands_note": + "该命令没有子命令,可按上方用法补充参数后发送。", + "runtime.chat_stage_received": "已接收", + "runtime.chat_stage_processing": "处理中", + "runtime.chat_stage_recording_history": "记录历史", + "runtime.chat_stage_running_command": "执行命令", + "runtime.chat_stage_command_done": "命令完成", + "runtime.chat_stage_asking_ai": "进入 AI", + "runtime.chat_stage_building_context": "构建上下文", + "runtime.chat_stage_checking_long_term_memory": "查长期记忆", + "runtime.chat_stage_searching_cognitive_memory": "查认知记忆", + "runtime.chat_stage_loading_chat_history": "加载历史", + "runtime.chat_stage_context_ready": "上下文就绪", + "runtime.chat_stage_selecting_model": "选择模型", + "runtime.chat_stage_waiting_model": "等待模型", + "runtime.chat_stage_preparing_tools": "准备工具", + "runtime.chat_stage_waiting_tools": "等待工具", + "runtime.chat_stage_sending_message": "发送消息", + "runtime.chat_stage_retrying_model": "重试模型", + "runtime.chat_stage_finalizing": "收尾", + "runtime.chat_stage_done": "完成", + "runtime.tool": "工具", + "runtime.agent": "智能体", + "runtime.tool_input": "输入", + "runtime.tool_output": "输出", + "runtime.message": "消息", + "runtime.end": "结束", + "runtime.running": "运行中", + "runtime.done": "完成", + "runtime.sending": "发送中", + "runtime.sent": "已发送", + "runtime.ended": "已结束", + "runtime.error": "错误", + "runtime.cancelled": "已取消", "runtime.image": "图片", - "runtime.image_added": "已插入图片", + "runtime.attach_file": "附加文件", + "runtime.attachment_added": "已附加 1 个文件", + "runtime.attachments_added": "已附加 {count} 个文件", + "runtime.attachment_kind_image": "图片", + "runtime.attachment_kind_file": "文件", + "runtime.remove_attachment": "移除附件", + "runtime.quote": "引用", + "runtime.quote_selection": "引用所选", + "runtime.reference_added": "已加入引用", + "runtime.remove_reference": "移除引用", + "runtime.reference_message": "引用 AI", + "runtime.reference_selection": "引用所选内容", + "runtime.reference_html": "引用 HTML 片段", + "runtime.expand_code": "展开", + "runtime.collapse_code": "折叠", + "runtime.copy_code": "复制", + "runtime.run_html": "运行", + "runtime.code_copied": "已复制代码", + "runtime.copy_failed": "复制失败", + "runtime.html_runner": "HTML 预览", + "runtime.pick_html": "选择", + "runtime.picking_html": "选择元素", + "runtime.html_ready": "HTML 已运行", + "runtime.html_pick_hint": "第一次点击预览范围,第二次点击确认", + "runtime.html_pick_confirm_hint": "再次点击确认", + "runtime.close": "关闭", + "runtime.cancel": "取消", + "runtime.retry": "重试", + "runtime.image_preview": "图片预览", + "runtime.open_image_preview": "点击放大查看", "runtime.download": "下载", "runtime.send": "发送", "runtime.total": "共 {count} 条", @@ -278,7 +420,8 @@ const I18N = { "runtime.not_found": "未命中", "runtime.api_start_hint": "请先在 WebUI 中启动机器人进程。", "chat.title": "智能对话", - "chat.subtitle": "虚拟私聊 system#42。", + "chat.subtitle": + "虚拟私聊 system#42。该会话由 WebUI 发起,权限为 superadmin;私聊里可直接使用 /命令。", "about.title": "项目信息", "about.subtitle": "关于 Undefined 项目的作者及许可协议。", "about.author": "作者", @@ -310,6 +453,7 @@ const I18N = { "cmd.tab_probes": "跳转到 探针", "cmd.tab_memory": "跳转到 备忘录", "cmd.tab_memes": "跳转到 表情包", + "cmd.tab_schedules": "跳转到 定时任务", "cmd.tab_cognitive": "跳转到 认知记忆", "cmd.refresh": "刷新当前页面", "cmd.logout": "退出登录", @@ -325,6 +469,7 @@ const I18N = { "landing.probes": "Probe Hub", "landing.memory": "Memory Hub", "landing.memes": "Meme Library", + "landing.schedules": "Schedules", "landing.runtime": "Runtime API", "landing.chat": "AI Dialog", "landing.about": "About", @@ -344,6 +489,7 @@ const I18N = { "tabs.probes": "Probe Hub", "tabs.memory": "Memory Hub", "tabs.memes": "Memes", + "tabs.schedules": "Schedules", "tabs.runtime": "Runtime API", "tabs.chat": "AI Dialog", "tabs.about": "About", @@ -563,6 +709,53 @@ const I18N = { "memes.reindex_queued": "Reindex job queued", "memes.select_prompt": "Select a meme first", "memes.confirm_delete": "Delete this meme?", + "schedules.title": "Schedules", + "schedules.subtitle": "View, create, and edit active scheduled tasks.", + "schedules.refresh": "Refresh", + "schedules.new": "New Task", + "schedules.total": "Total", + "schedules.self_count": "Self Calls", + "schedules.multi_count": "Multi-tool", + "schedules.limited_count": "Limited", + "schedules.search_placeholder": "Search tasks...", + "schedules.editor_new": "New Task", + "schedules.editor_edit": "Edit Task", + "schedules.task_id": "Task ID", + "schedules.task_name": "Task Name", + "schedules.cron": "Crontab", + "schedules.max_executions": "Max Runs", + "schedules.target_type": "Target Type", + "schedules.target_group": "Group", + "schedules.target_private": "Private", + "schedules.target_id": "Target ID", + "schedules.mode_single": "Single Tool", + "schedules.mode_multi": "Multi-tool", + "schedules.mode_self": "Self Call", + "schedules.tool_name": "Tool Name", + "schedules.tool_args": "Tool Args JSON", + "schedules.execution_mode": "Execution Mode", + "schedules.execution_serial": "Serial", + "schedules.execution_parallel": "Parallel", + "schedules.tools_json": "Tools JSON", + "schedules.self_instruction": "Future Instruction", + "schedules.reset": "Reset", + "schedules.delete": "Delete", + "schedules.save": "Save", + "schedules.untitled": "Untitled Task", + "schedules.no_results": "No matching tasks.", + "schedules.empty": "No scheduled tasks.", + "schedules.no_target": "No target", + "schedules.next_run": "Next run", + "schedules.positive_int_error": "{label} must be a positive integer", + "schedules.cron_required": "Crontab is required", + "schedules.self_required": "Future instruction is required", + "schedules.tools_required": "At least one tool is required", + "schedules.tool_required": "Tool name is required", + "schedules.loaded": "Loaded {count} tasks", + "schedules.saved": "Schedule saved", + "schedules.save_failed": "Save failed", + "schedules.confirm_delete": "Delete this scheduled task?", + "schedules.deleted": "Schedule deleted", "runtime.title": "Runtime Hub", "runtime.subtitle": "Probes plus memory/profile read-only queries.", "runtime.refresh_all": "Refresh All", @@ -585,12 +778,107 @@ const I18N = { "runtime.profiles_placeholder": "Search profile keyword...", "runtime.cognitive_profile_get": "Get Profile by Entity", "runtime.fetch": "Fetch", - "runtime.chat_title": "AI Chat (virtual private chat system#42)", - "runtime.chat_hint": - "This WebUI session runs as superadmin; slash commands work directly in private chat.", "runtime.chat_placeholder": "Type a message, or run /help directly", + "runtime.chat_log_label": "Chat messages", + "runtime.chat_auto_scroll": "Auto-scroll", + "runtime.chat_clear": "Clear History", + "runtime.chat_clear_confirm": + "Clear the current WebChat conversation history? Long-term and cognitive memory are not affected.", + "runtime.chat_cleared": "Chat history cleared", + "runtime.chat_conversations": "Conversations", + "runtime.chat_new_conversation": "New Chat", + "runtime.chat_conversation_created": "New chat created", + "runtime.chat_no_conversations": "No conversations", + "runtime.chat_rename_conversation": "Rename conversation", + "runtime.chat_delete_conversation": "Delete conversation", + "runtime.chat_delete_confirm": + "Delete this WebChat conversation and its history?", + "runtime.chat_message_count": "{count} messages", + "runtime.chat_title_pending": "Generating title", + "runtime.chat_loading_more": "Loading earlier messages...", + "runtime.chat_streaming": "Generating", + "runtime.chat_reconnecting": "Restoring connection...", + "runtime.chat_cancelled": "Generation cancelled", + "runtime.chat_running": "A chat job is already running.", + "runtime.chat_command_hint": "Choose a command, Enter/Tab to insert", + "runtime.chat_command_hint_subcommand": + "Choose a subcommand, Enter/Tab to insert", + "runtime.chat_command_loading": "Loading available commands...", + "runtime.chat_command_empty": "No matching command", + "runtime.chat_command_unknown_command": + "Keep typing the full command, or choose from command matches", + "runtime.chat_command_subcommand_empty": "No matching subcommand", + "runtime.chat_command_subcommands": "{count} subcommands", + "runtime.command_help": "Command help", + "runtime.command_usage": "Usage", + "runtime.command_example": "Example", + "runtime.command_aliases": "Aliases", + "runtime.command_no_subcommands_note": + "This command has no subcommands. Add arguments using the usage above, then send.", + "runtime.chat_stage_received": "Received", + "runtime.chat_stage_processing": "Processing", + "runtime.chat_stage_recording_history": "Recording history", + "runtime.chat_stage_running_command": "Running command", + "runtime.chat_stage_command_done": "Command done", + "runtime.chat_stage_asking_ai": "Entering AI", + "runtime.chat_stage_building_context": "Building context", + "runtime.chat_stage_checking_long_term_memory": "Checking memory", + "runtime.chat_stage_searching_cognitive_memory": "Searching memory", + "runtime.chat_stage_loading_chat_history": "Loading history", + "runtime.chat_stage_context_ready": "Context ready", + "runtime.chat_stage_selecting_model": "Selecting model", + "runtime.chat_stage_waiting_model": "Waiting for model", + "runtime.chat_stage_preparing_tools": "Preparing tools", + "runtime.chat_stage_waiting_tools": "Waiting for tools", + "runtime.chat_stage_sending_message": "Sending message", + "runtime.chat_stage_retrying_model": "Retrying model", + "runtime.chat_stage_finalizing": "Finalizing", + "runtime.chat_stage_done": "Done", + "runtime.tool": "Tool", + "runtime.agent": "Agent", + "runtime.tool_input": "Input", + "runtime.tool_output": "Output", + "runtime.message": "Message", + "runtime.end": "End", + "runtime.running": "Running", + "runtime.done": "Done", + "runtime.sending": "Sending", + "runtime.sent": "Sent", + "runtime.ended": "Ended", + "runtime.error": "Error", + "runtime.cancelled": "Cancelled", "runtime.image": "Image", - "runtime.image_added": "Image inserted", + "runtime.attach_file": "Attach file", + "runtime.attachment_added": "Attached 1 file", + "runtime.attachments_added": "Attached {count} files", + "runtime.attachment_kind_image": "Image", + "runtime.attachment_kind_file": "File", + "runtime.remove_attachment": "Remove attachment", + "runtime.quote": "Quote", + "runtime.quote_selection": "Quote selection", + "runtime.reference_added": "Reference added", + "runtime.remove_reference": "Remove reference", + "runtime.reference_message": "Quoted AI", + "runtime.reference_selection": "Quoted selection", + "runtime.reference_html": "Quoted HTML snippet", + "runtime.expand_code": "Expand", + "runtime.collapse_code": "Collapse", + "runtime.copy_code": "Copy", + "runtime.run_html": "Run", + "runtime.code_copied": "Code copied", + "runtime.copy_failed": "Copy failed", + "runtime.html_runner": "HTML preview", + "runtime.pick_html": "Pick", + "runtime.picking_html": "Picking", + "runtime.html_ready": "HTML is running", + "runtime.html_pick_hint": + "Click once to preview, click again to confirm", + "runtime.html_pick_confirm_hint": "Click again to confirm", + "runtime.close": "Close", + "runtime.cancel": "Cancel", + "runtime.retry": "Retry", + "runtime.image_preview": "Image preview", + "runtime.open_image_preview": "Click to enlarge", "runtime.download": "Download", "runtime.send": "Send", "runtime.total": "{count} items", @@ -602,7 +890,8 @@ const I18N = { "runtime.api_start_hint": "Please start the bot process in WebUI first.", "chat.title": "AI Dialog", - "chat.subtitle": "Virtual private session system#42.", + "chat.subtitle": + "Virtual private session system#42. This WebUI session runs as superadmin; slash commands work directly in private chat.", "about.title": "About Project", "about.subtitle": "Information about authors and open source licenses.", "about.author": "Author", @@ -635,6 +924,7 @@ const I18N = { "cmd.tab_probes": "Go to Probes", "cmd.tab_memory": "Go to Memory", "cmd.tab_memes": "Go to Memes", + "cmd.tab_schedules": "Go to Schedules", "cmd.tab_cognitive": "Go to Cognitive", "cmd.refresh": "Refresh current page", "cmd.logout": "Logout", diff --git a/src/Undefined/webui/static/js/log-view.js b/src/Undefined/webui/static/js/log-view.js index 9959474b..7fa165b1 100644 --- a/src/Undefined/webui/static/js/log-view.js +++ b/src/Undefined/webui/static/js/log-view.js @@ -1,3 +1,5 @@ +const LOG_TAIL_LINES = 1000; + async function fetchLogs(force = false) { if (!force && !shouldFetch("logs")) return; const container = get("logContainer"); @@ -12,7 +14,7 @@ async function fetchLogs(force = false) { } try { const params = new URLSearchParams({ - lines: "200", + lines: String(LOG_TAIL_LINES), type: state.logType, }); if (state.logFile) params.set("file", state.logFile); @@ -175,7 +177,10 @@ function startLogStream() { return false; if (!state.logStreamEnabled) return false; state.logStreamFailed = false; - const params = new URLSearchParams({ lines: "200", type: state.logType }); + const params = new URLSearchParams({ + lines: String(LOG_TAIL_LINES), + type: state.logType, + }); const stream = new EventSource(`/api/logs/stream?${params.toString()}`); state.logStream = stream; stream.onmessage = (event) => { diff --git a/src/Undefined/webui/static/js/main.js b/src/Undefined/webui/static/js/main.js index a9b495ce..0677e76a 100644 --- a/src/Undefined/webui/static/js/main.js +++ b/src/Undefined/webui/static/js/main.js @@ -45,6 +45,22 @@ function renderAboutChangelogEntry(entry) { container.appendChild(list); } +function syncMainContentLayout() { + const mainContent = document.querySelector(".main-content"); + if (mainContent) { + mainContent.classList.toggle("chat-layout", state.tab === "chat"); + } + + const appContent = get("appContent"); + if (appContent && state.authenticated) { + if (state.view === "app") { + appContent.style.display = state.tab === "chat" ? "grid" : "block"; + } else { + appContent.style.display = "none"; + } + } +} + function renderAboutChangelog(payload) { aboutChangelogPayload = payload; const select = get("about-changelog-select"); @@ -119,7 +135,6 @@ function refreshUI() { if (state.view === "app") { if (state.authenticated) { - get("appContent").style.display = "block"; if (!state.configLoaded) loadConfig(); if ( window.RuntimeController && @@ -133,6 +148,12 @@ function refreshUI() { ) { window.MemesController.onTabActivated(state.tab); } + if ( + window.SchedulesController && + typeof window.SchedulesController.onTabActivated === "function" + ) { + window.SchedulesController.onTabActivated(state.tab); + } } else { get("appContent").style.display = "none"; state.configLoaded = false; @@ -143,10 +164,7 @@ function refreshUI() { if (!state.authenticated) state.mobileDrawerOpen = false; - const mainContent = document.querySelector(".main-content"); - if (mainContent) { - mainContent.classList.toggle("chat-layout", state.tab === "chat"); - } + syncMainContentLayout(); if (initialState && initialState.version) get("about-version-display").innerText = initialState.version; @@ -172,10 +190,7 @@ function switchTab(tab) { abortPendingRequests(); // Cancel pending requests from previous tab state.tab = tab; state.mobileDrawerOpen = false; - const mainContent = document.querySelector(".main-content"); - if (mainContent) { - mainContent.classList.toggle("chat-layout", tab === "chat"); - } + syncMainContentLayout(); document.querySelectorAll(".nav-item").forEach((el) => { el.classList.toggle("active", el.getAttribute("data-tab") === tab); }); @@ -215,6 +230,12 @@ function switchTab(tab) { ) { window.MemesController.onTabActivated(tab); } + if ( + window.SchedulesController && + typeof window.SchedulesController.onTabActivated === "function" + ) { + window.SchedulesController.onTabActivated(tab); + } if (tab === "about") { maybeLoadAboutChangelog(); } @@ -332,11 +353,17 @@ const _cmdCommands = [ action: () => switchTab("memes"), keys: "6", }, + { + id: "schedules", + label: () => t("cmd.tab_schedules"), + action: () => switchTab("schedules"), + keys: "7", + }, { id: "cognitive", label: () => t("cmd.tab_cognitive"), action: () => switchTab("cognitive"), - keys: "7", + keys: "8", }, { id: "refresh", @@ -476,6 +503,12 @@ async function init() { ) { window.MemesController.init(); } + if ( + window.SchedulesController && + typeof window.SchedulesController.init === "function" + ) { + window.SchedulesController.init(); + } document.querySelectorAll('[data-action="toggle-lang"]').forEach((btn) => { btn.addEventListener("click", () => { diff --git a/src/Undefined/webui/static/js/runtime.js b/src/Undefined/webui/static/js/runtime.js index c2f8c3f0..17e8b657 100644 --- a/src/Undefined/webui/static/js/runtime.js +++ b/src/Undefined/webui/static/js/runtime.js @@ -6,7 +6,51 @@ runtimeMetaLoaded: false, runtimeEnabled: true, chatBusy: false, + chatConversationsLoaded: false, + chatConversationsLoading: false, + chatConversations: [], + currentChatConversationId: "", + activeJobConversationId: "", + recentlyCreatedConversationId: "", + chatCommandsLoaded: false, + chatCommandsLoading: false, + chatCommandsLoadedAt: 0, + chatCommands: [], + chatCommandsError: "", + chatCommandPaletteOpen: false, + chatCommandMatches: [], + chatCommandActiveIndex: 0, + chatCommandContext: null, chatHistoryLoaded: false, + activeJobId: null, + chatCancelBusy: false, + lastEventSeq: 0, + chatHistoryCursor: null, + chatHistoryHasMore: false, + chatHistoryLoading: false, + chatTopLoadSuppressedUntil: 0, + chatAutoScroll: true, + streamingMessageId: null, + activeChatMessageId: null, + chatPollTimer: null, + chatPollBackoffMs: 500, + chatClockTimer: null, + activeJobResumeTimer: null, + activeJobResumeAttempts: 0, + toolBlocks: new Map(), + toolCollapseTimers: new Map(), + chatAttachments: [], + chatAttachmentSeq: 0, + chatReferences: [], + chatReferenceSeq: 0, + pendingSelectionReference: null, + selectionQuoteButton: null, + imageViewerPreviousFocus: null, + chatConversationDrawerOpen: false, + htmlRunnerSource: "", + htmlRunnerPickMode: false, + htmlRunnerResize: null, + htmlRunnerDrag: null, probeTimer: null, queryBusy: { memory: false, @@ -16,6 +60,40 @@ }, }; const RUNTIME_DISABLED_ERROR = "Runtime API disabled"; + const CHAT_AUTO_SCROLL_STORAGE_KEY = "undefined_webchat_auto_scroll"; + const CHAT_POLL_INTERVAL_MS = 500; + const CHAT_CLOCK_INTERVAL_MS = 500; + const CHAT_TOP_LOAD_SUPPRESS_MS = 900; + const TOOL_AUTO_COLLAPSE_MIN_VISIBLE_MS = 2000; + const ACTIVE_JOB_RESUME_MAX_ATTEMPTS = 20; + const CHAT_INLINE_IMAGE_MAX_BYTES = 12 * 1024 * 1024; + const CHAT_ATTACHMENT_RAIL_BASE_WIDTH = 72; + const CHAT_ATTACHMENT_RAIL_STEP_WIDTH = 56; + const CHAT_ATTACHMENT_RAIL_MAX_WIDTH = 240; + const CHAT_ATTACHMENT_CARD_MAX_WIDTH = 132; + const CHAT_ATTACHMENT_CARD_MIN_WIDTH = 36; + const CHAT_ATTACHMENT_GAP_WIDTH = 6; + const CHAT_ATTACHMENT_COMPRESSED_GAP_WIDTH = 4; + const CHAT_ATTACHMENT_COMPRESSED_COUNT = 5; + const CHAT_REFERENCE_MAX_CHARS = 4000; + const CHAT_REFERENCE_PREVIEW_CHARS = 180; + const CHAT_COMMAND_CACHE_MS = 30000; + const CHAT_COMMAND_MAX_MATCHES = 8; + const CODE_COLLAPSE_LINE_THRESHOLD = 8; + const HTML_RUNNER_MIN_WIDTH = 360; + const HTML_RUNNER_MIN_HEIGHT = 280; + const HTML_RUNNER_VIEWPORT_MARGIN = 12; + + function prefersReducedMotion() { + return ( + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); + } + + function chatScrollBehavior() { + return prefersReducedMotion() ? "auto" : "smooth"; + } function i18nFormat(key, params = {}) { let text = t(key); @@ -25,6 +103,39 @@ return text; } + function currentChatConversationId() { + return String(runtimeState.currentChatConversationId || "").trim(); + } + + function chatUrl(path, params = {}) { + const query = new URLSearchParams(); + const conversationId = currentChatConversationId(); + if (conversationId) query.set("conversation_id", conversationId); + Object.entries(params || {}).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") return; + query.set(key, String(value)); + }); + const suffix = query.toString(); + return suffix ? `${path}?${suffix}` : path; + } + + function runtimeChatJobEventsUrls(jobId, params) { + const encoded = encodeURIComponent(jobId); + const query = new URLSearchParams(); + const conversationId = + runtimeState.activeJobConversationId || currentChatConversationId(); + if (conversationId) query.set("conversation_id", conversationId); + Object.entries(params || {}).forEach(([key, value]) => { + if (value === null || value === undefined || value === "") return; + query.set(key, String(value)); + }); + const suffix = query.toString(); + return [ + `/api/v1/management/runtime/chat/jobs/${encoded}/events?${suffix}`, + `/api/runtime/chat/jobs/${encoded}/events?${suffix}`, + ]; + } + function setJsonBlock(id, payload) { const el = get(id); if (!el) return; @@ -448,453 +559,3407 @@ ); } - function appendChatMessage(role, content) { + function hasMarkdownBlockquote(content) { + return String(content || "") + .split(/\r?\n/) + .some((line) => /^\s*>/.test(line)); + } + + function shouldRenderChatMarkdown(role, content) { + return role !== "user" || hasMarkdownBlockquote(content); + } + + function chatRenderOptions(attachments) { + return { attachments }; + } + + function appendChatMessage(role, content, options = {}) { const log = get("runtimeChatLog"); - if (!log) return; + if (!log) return null; const isBot = role !== "user"; - const contentClass = isBot + const useMarkdown = shouldRenderChatMarkdown(role, content); + const contentClass = useMarkdown ? "runtime-chat-content markdown" : "runtime-chat-content"; const item = document.createElement("div"); item.className = `runtime-chat-item ${role}`; - item.innerHTML = `
${role === "user" ? "You" : "AI"}
${renderChatContent(content, isBot)}
`; - log.appendChild(item); - log.scrollTop = log.scrollHeight; + if (options.id) item.dataset.messageId = options.id; + if (options.jobId) item.dataset.jobId = options.jobId; + const roleHtml = isBot + ? `AI` + : `You`; + const contentHtml = renderChatContent( + content, + useMarkdown, + chatRenderOptions(options.attachments), + ); + item.innerHTML = `
${roleHtml}
${contentHtml}
`; + if (!isBot) { + item.dataset.retryContent = String(content || "").trim(); + } + if (isBot) { + const roleEl = item.querySelector(".runtime-chat-role"); + if (roleEl) { + const cancelButton = document.createElement("button"); + cancelButton.className = + "runtime-chat-quote-btn runtime-chat-cancel-btn"; + cancelButton.type = "button"; + cancelButton.dataset.cancelJob = ""; + cancelButton.textContent = t("runtime.cancel"); + cancelButton.hidden = true; + roleEl.appendChild(cancelButton); + + const quoteButton = document.createElement("button"); + quoteButton.className = "runtime-chat-quote-btn"; + quoteButton.type = "button"; + quoteButton.dataset.quoteMessage = "1"; + quoteButton.textContent = t("runtime.quote"); + roleEl.appendChild(quoteButton); + } + } else { + const roleEl = item.querySelector(".runtime-chat-role"); + if (roleEl) { + const retryButton = document.createElement("button"); + retryButton.className = + "runtime-chat-quote-btn runtime-chat-retry-btn"; + retryButton.type = "button"; + retryButton.dataset.retryMessage = "1"; + retryButton.textContent = t("runtime.retry"); + retryButton.hidden = true; + roleEl.appendChild(retryButton); + } + } + if (options.prepend) { + log.insertBefore(item, log.firstChild); + } else { + log.appendChild(item); + if (options.scroll !== false) scrollChatToBottom(); + } + syncChatMessageActions(); + return item; } - function clearChatMessages() { + function formatDurationMs(value) { + const ms = Number(value); + if (!Number.isFinite(ms) || ms <= 0) return ""; + if (ms < 1000) return `${Math.max(1, Math.round(ms))}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = Math.floor(seconds % 60); + return `${minutes}m ${remainder}s`; + } + + function messageQuoteSourceLabel(type) { + if (type === "html") return t("runtime.reference_html"); + if (type === "selection") return t("runtime.reference_selection"); + return t("runtime.reference_message"); + } + + function scrollChatToBottom() { + if (!runtimeState.chatAutoScroll) return; const log = get("runtimeChatLog"); if (!log) return; - log.innerHTML = ""; + suppressChatTopHistoryLoad(); + log.scrollTo({ + top: log.scrollHeight, + behavior: chatScrollBehavior(), + }); } - function parseCqAttributes(raw) { - const attrs = {}; - String(raw || "") - .split(",") - .forEach((part) => { - const idx = part.indexOf("="); - if (idx <= 0) return; - const key = part.slice(0, idx).trim(); - const value = part.slice(idx + 1).trim(); - if (!key) return; - attrs[key] = value; + function forceScrollChatToBottom() { + const log = get("runtimeChatLog"); + if (!log) return; + suppressChatTopHistoryLoad(); + log.scrollTo({ + top: log.scrollHeight, + behavior: chatScrollBehavior(), + }); + } + + function suppressChatTopHistoryLoad() { + runtimeState.chatTopLoadSuppressedUntil = Math.max( + runtimeState.chatTopLoadSuppressedUntil || 0, + Date.now() + CHAT_TOP_LOAD_SUPPRESS_MS, + ); + } + + function isChatTopHistoryLoadSuppressed() { + return Date.now() < (runtimeState.chatTopLoadSuppressedUntil || 0); + } + + function forceScrollChatToBottomSoon() { + suppressChatTopHistoryLoad(); + forceScrollChatToBottom(); + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => { + forceScrollChatToBottom(); + requestAnimationFrame(forceScrollChatToBottom); }); - return attrs; + } else { + setTimeout(forceScrollChatToBottom, 0); + } + setTimeout(forceScrollChatToBottom, 80); + setTimeout(forceScrollChatToBottom, 260); + setTimeout(forceScrollChatToBottom, 700); } - function resolveCqImageSource(attrs) { - const raw = String((attrs && (attrs.url || attrs.file)) || "").trim(); - if (!raw) return ""; - if (raw.startsWith("base64://")) { - const payload = raw.slice("base64://".length).trim(); - return payload ? `data:image/png;base64,${payload}` : ""; + function scrollChatToBottomSoon() { + if (!runtimeState.chatAutoScroll) return; + scrollChatToBottom(); + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(scrollChatToBottom); + return; } - if (raw.startsWith("file://")) { - const localPath = raw.slice("file://".length).trim(); - return localPath - ? `/api/runtime/chat/image?path=${encodeURIComponent(localPath)}` - : ""; + setTimeout(scrollChatToBottom, 0); + } + + function updateChatMessage(item, content, role = "bot", options = {}) { + if (!item) return; + const contentEl = item.querySelector(".runtime-chat-content"); + if (!contentEl) return; + const useMarkdown = shouldRenderChatMarkdown(role, content); + contentEl.classList.toggle("markdown", useMarkdown); + contentEl.innerHTML = renderChatContent( + content, + useMarkdown, + chatRenderOptions(options.attachments), + ); + } + + function currentChatJobId() { + return runtimeState.activeJobId ? String(runtimeState.activeJobId) : ""; + } + + function findActiveChatMessage(jobId = "") { + const byJob = String(jobId || "").trim(); + if (byJob) { + const existingForJob = document.querySelector( + `[data-job-id="${CSS.escape(byJob)}"]`, + ); + if (existingForJob) return existingForJob; } - if (raw.startsWith("/") || /^[A-Za-z]:[\\/]/.test(raw)) { - return `/api/runtime/chat/image?path=${encodeURIComponent(raw)}`; + if (runtimeState.activeChatMessageId) { + const existing = document.querySelector( + `[data-message-id="${CSS.escape(runtimeState.activeChatMessageId)}"]`, + ); + if (existing) return existing; } - if ( - raw.startsWith("http://") || - raw.startsWith("https://") || - raw.startsWith("data:image/") - ) { - return raw; + if (runtimeState.streamingMessageId) { + const existing = document.querySelector( + `[data-message-id="${CSS.escape(runtimeState.streamingMessageId)}"]`, + ); + if (existing) return existing; } - return ""; + return null; } - function formatFileSize(bytes) { - const n = Number(bytes); - if (!Number.isFinite(n) || n <= 0) return ""; - if (n < 1024) return n + "B"; - if (n < 1024 * 1024) return (n / 1024).toFixed(1) + "KB"; - return (n / 1024 / 1024).toFixed(2) + "MB"; + function ensureStreamingMessage(jobId = "") { + const resolvedJobId = String(jobId || currentChatJobId()).trim(); + const existing = findActiveChatMessage(resolvedJobId); + if (existing) { + if (resolvedJobId) existing.dataset.jobId = resolvedJobId; + runtimeState.activeChatMessageId = + existing.dataset.messageId || null; + syncChatMessageActions(); + return existing; + } + const id = `stream-${Date.now()}`; + runtimeState.streamingMessageId = id; + runtimeState.activeChatMessageId = id; + const item = appendChatMessage("bot", "", { + id, + jobId: resolvedJobId || null, + }); + if (item) item.classList.add("streaming"); + syncChatMessageActions(); + return item; } - function renderFileCard(attrs) { - const fileId = escapeHtml(String(attrs.id || "").trim()); - const name = escapeHtml(String(attrs.name || "file").trim()); - const size = formatFileSize(attrs.size); - if (!fileId) return `[file]`; - const href = `/api/runtime/chat/file?id=${encodeURIComponent(fileId)}`; - return ( - `
` + - `
📄
` + - `
` + - `
${name}
` + - (size ? `
${size}
` : "") + - `
` + - `${t("runtime.download") || "Download"}` + - `
` - ); + function ensureTimelineNodeContainer(item) { + if (!item) return null; + let container = item.querySelector(".runtime-chat-timeline"); + if (!container) { + container = document.createElement("div"); + container.className = "runtime-chat-timeline"; + const contentEl = item.querySelector(".runtime-chat-content"); + if (contentEl) contentEl.remove(); + item.appendChild(container); + } + return container; } - function renderChatContent(content, useMarkdown) { - const text = String(content || ""); + function appendRawChatContent(item, content) { + const text = String(content || "").trim(); + if (!item || !text) return; + item.dataset.rawContent = [item.dataset.rawContent || "", text] + .filter(Boolean) + .join("\n\n"); + } - // Extract CQ file codes into placeholders - const filePattern = /\[CQ:file,([^\]]+)\]/g; - const filePlaceholders = []; - const step1 = text.replace(filePattern, (match, attrStr) => { - const attrs = parseCqAttributes(attrStr); - const idx = filePlaceholders.length; - filePlaceholders.push(renderFileCard(attrs)); - return `CQFILEPH${idx}CQFILEPH`; - }); + function appendTimelineMessage(item, content, role = "bot", options = {}) { + const text = String(content || "").trim(); + if (!item || !text) return null; + const timeline = ensureTimelineNodeContainer(item); + if (!timeline) return null; + const node = document.createElement("div"); + const useMarkdown = shouldRenderChatMarkdown(role, text); + node.className = useMarkdown + ? "runtime-chat-content markdown" + : "runtime-chat-content"; + node.innerHTML = renderChatContent( + text, + useMarkdown, + chatRenderOptions(options.attachments), + ); + timeline.appendChild(node); + appendRawChatContent(item, text); + return node; + } - // Extract CQ image codes into placeholders before markdown parsing - const imagePattern = /\[CQ:image,([^\]]+)\]/g; - const images = []; - const processed = step1.replace(imagePattern, (match, attrStr) => { - const attrs = parseCqAttributes(attrStr); - const src = resolveCqImageSource(attrs); - if (src) { - const idx = images.length; - images.push( - `image`, - ); - return `CQIMGPH${idx}CQIMGPH`; - } - return match; + function renderHistoryAttachment(item) { + if (!item || typeof item !== "object") return ""; + if (attachmentIsImage(item)) { + const source = attachmentPreviewUrl(item.uid || "", item); + if (!source) return ""; + return chatImageMarkup( + source, + item.display_name || item.name || "", + ); + } + const fileId = String( + item.file_id || item.source_ref || item.uid || "", + ).trim(); + if (!fileId) return ""; + return renderFileCard({ + id: fileId, + name: item.display_name || item.name || fileId, + size: item.size, }); + } - let html; - if (useMarkdown && typeof marked !== "undefined" && marked.parse) { - try { - html = marked.parse(processed, { breaks: true, gfm: true }); - } catch (_e) { - html = escapeHtml(processed); - } - } else { - html = escapeHtml(processed); + function attachmentIsImage(item) { + return !!( + item && + (item.kind === "image" || + String(item.media_type || "") + .trim() + .startsWith("image/")) + ); + } + + function attachmentPreviewUrl(uid, item = null) { + const cleanUid = String(uid || (item && item.uid) || "").trim(); + const previewUrl = String((item && item.preview_url) || "").trim(); + const previewMatch = previewUrl.match( + /\/api\/v1\/chat\/attachments\/([^/?#]+)\/preview(?:[?#].*)?$/, + ); + if (previewMatch) { + return `/api/runtime/chat/attachments/${previewMatch[1]}/preview`; + } + if (previewUrl) return previewUrl; + const sourceRef = String((item && item.source_ref) || "").trim(); + const sourceMatch = sourceRef.match( + /\/api\/v1\/chat\/attachments\/([^/?#]+)(?:[?#].*)?$/, + ); + if (sourceMatch) { + return `/api/runtime/chat/attachments/${sourceMatch[1]}/preview`; } + if ( + sourceRef.startsWith("http://") || + sourceRef.startsWith("https://") || + sourceRef.startsWith("data:image/") + ) { + return sourceRef; + } + return cleanUid + ? `/api/runtime/chat/attachments/${encodeURIComponent(cleanUid)}/preview` + : ""; + } - // Restore placeholders - for (let i = 0; i < images.length; i++) { - html = html.replace( - new RegExp(`CQIMGPH${i}CQIMGPH`, "g"), - images[i], + function buildAttachmentMarkup(attachments) { + // 图片已在正文内联渲染,附件区只保留非图片文件,避免重复 + const items = (Array.isArray(attachments) ? attachments : []).filter( + (item) => !attachmentIsImage(item), + ); + return items + .map((item) => renderHistoryAttachment(item)) + .filter(Boolean) + .join(""); + } + + function readChatAutoScrollPreference() { + try { + const value = window.localStorage.getItem( + CHAT_AUTO_SCROLL_STORAGE_KEY, ); + return value === null ? true : value !== "false"; + } catch (_error) { + return true; } - for (let i = 0; i < filePlaceholders.length; i++) { - // marked may wrap placeholder in

, strip it for block-level card - html = html.replace( - new RegExp(`

\\s*CQFILEPH${i}CQFILEPH\\s*

`, "g"), - filePlaceholders[i], - ); - html = html.replace( - new RegExp(`CQFILEPH${i}CQFILEPH`, "g"), - filePlaceholders[i], + } + + function writeChatAutoScrollPreference(enabled) { + try { + window.localStorage.setItem( + CHAT_AUTO_SCROLL_STORAGE_KEY, + enabled ? "true" : "false", ); + } catch (_error) { + // ignore storage failures in hardened browsers/private mode } + } - return html || escapeHtml(text); + function syncChatAutoScrollToggle() { + const input = get("runtimeChatAutoScroll"); + if (!input) return; + input.checked = runtimeState.chatAutoScroll; } - function readFileAsDataUrl(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result || "")); - reader.onerror = () => reject(new Error("File read failed")); - reader.readAsDataURL(file); + function setChatAutoScroll(enabled, { persist = true } = {}) { + runtimeState.chatAutoScroll = !!enabled; + syncChatAutoScrollToggle(); + if (persist) writeChatAutoScrollPreference(runtimeState.chatAutoScroll); + if (runtimeState.chatAutoScroll) scrollChatToBottomSoon(); + } + + function clearToolCollapseTimers() { + runtimeState.toolCollapseTimers.forEach((timer) => { + clearTimeout(timer); }); + runtimeState.toolCollapseTimers.clear(); } - async function parseJsonSafe(res) { - try { - return await res.json(); - } catch (_error) { - return null; + function refreshActiveChatTimers() { + const item = findActiveChatMessage(); + if (item) { + item.querySelectorAll(".runtime-chat-stage").forEach((stageEl) => { + updateChatStageDisplay(stageEl); + }); } + if (!runtimeState.toolBlocks.size) return; + runtimeState.toolBlocks.forEach((block) => { + if (!["done", "error", "cancelled"].includes(block.status)) { + updateToolDurationDisplay(block); + } + }); } - async function fetchJsonOrThrow(path) { - const res = await api(path); - const data = await parseJsonSafe(res); - if (!res.ok || (data && data.error)) { - throw new Error(buildRequestError(res, data)); - } - return data || {}; + function stopChatPolling() { + clearTimeout(runtimeState.chatPollTimer); + runtimeState.chatPollTimer = null; } - function buildRequestError(res, payload) { - const fallback = - `${res.status} ${res.statusText || "Request failed"}`.trim(); - if (!payload || typeof payload !== "object") return fallback; - const base = payload.error ? String(payload.error) : fallback; - return payload.detail ? `${base}: ${payload.detail}` : base; + function stopChatClock() { + clearInterval(runtimeState.chatClockTimer); + runtimeState.chatClockTimer = null; } - function appendRuntimeApiHint(message) { - const text = String(message || "").trim(); - if (!text) return text; - const normalized = text.toLowerCase(); - const unreachable = - normalized.includes("runtime api unreachable") || - normalized.includes("failed to fetch") || - normalized.includes("networkerror") || - normalized.includes(" 502 ") || - normalized.startsWith("502 "); - if (!unreachable) return text; - const hint = t("runtime.api_start_hint"); - if (!hint || text.includes(hint)) return text; - return `${text} ${hint}`; + function startChatClock() { + if (runtimeState.chatClockTimer) return; + runtimeState.chatClockTimer = setInterval(() => { + refreshActiveChatTimers(); + }, CHAT_CLOCK_INTERVAL_MS); } - async function consumeSse(res, onEvent) { - if (!res.body) return; - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - function emitBlock(rawBlock) { - const block = String(rawBlock || "").trim(); - if (!block) return; - let event = "message"; - const dataLines = []; - block.split("\n").forEach((line) => { - if (line.startsWith(":")) return; - if (line.startsWith("event:")) { - event = line.slice(6).trim() || "message"; - return; - } - if (line.startsWith("data:")) { - dataLines.push(line.slice(5).trimStart()); - } - }); - if (dataLines.length === 0) return; - const rawData = dataLines.join("\n"); - let payload = {}; - try { - payload = JSON.parse(rawData); - } catch (_error) { - payload = { raw: rawData }; + function stopActiveJobResumeTimer() { + clearTimeout(runtimeState.activeJobResumeTimer); + runtimeState.activeJobResumeTimer = null; + } + + function finishStreamingMessage() { + if (!runtimeState.streamingMessageId) return; + const item = document.querySelector( + `[data-message-id="${CSS.escape(runtimeState.streamingMessageId)}"]`, + ); + if (item) item.classList.remove("streaming"); + runtimeState.streamingMessageId = null; + syncChatMessageActions(); + } + + function finalizeActiveChatMessage(payload = null) { + const item = findActiveChatMessage(); + if (item) { + const durationMs = Number(payload && payload.duration_ms); + if (Number.isFinite(durationMs) && durationMs >= 0) { + setChatStage(item, { + stage: "done", + elapsed_ms: durationMs, + final: true, + }); + } else { + setChatStage(item, null); } - onEvent(event, payload); } + finishStreamingMessage(); + runtimeState.activeChatMessageId = null; + stopChatClock(); + syncChatMessageActions(); + } - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - buffer = buffer.replace(/\r\n/g, "\n"); - let boundary = buffer.indexOf("\n\n"); - while (boundary !== -1) { - const block = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); - emitBlock(block); - boundary = buffer.indexOf("\n\n"); - } + function chatStageLabel(stage) { + const key = `runtime.chat_stage_${String(stage || "").trim()}`; + const label = t(key); + if (label !== key) return label; + return String(stage || "").replace(/_/g, " "); + } + + function setChatStage(item, payload) { + if (!item) return; + const stageEl = item.querySelector(".runtime-chat-stage"); + if (!stageEl) return; + const stage = payload && payload.stage ? String(payload.stage) : ""; + if (!stage) { + stageEl.hidden = true; + stageEl.textContent = ""; + stageEl.removeAttribute("title"); + delete stageEl.dataset.stageLabel; + delete stageEl.dataset.stageDetail; + delete stageEl.dataset.stageBaseMs; + delete stageEl.dataset.stageReceivedAtMs; + stageEl.classList.remove("is-final"); + return; } - buffer += decoder.decode(); - if (buffer.trim()) emitBlock(buffer); + const label = chatStageLabel(stage); + const detail = String((payload && payload.detail) || "").trim(); + const elapsedMs = Number(payload && payload.elapsed_ms); + const duration = Number.isFinite(elapsedMs) ? elapsedMs : 0; + stageEl.hidden = false; + stageEl.classList.toggle("is-final", !!(payload && payload.final)); + stageEl.dataset.stageLabel = label; + stageEl.dataset.stageDetail = detail; + stageEl.dataset.stageBaseMs = String(duration); + stageEl.dataset.stageReceivedAtMs = String(monotonicNowMs()); + stageEl.title = detail ? `${label} · ${detail}` : label; + updateChatStageDisplay(stageEl); } - let _memoryMutating = false; + function updateChatStageDisplay(stageEl) { + if (!stageEl || stageEl.hidden) return; + const label = String(stageEl.dataset.stageLabel || "").trim(); + if (!label) return; + const baseMs = Number(stageEl.dataset.stageBaseMs); + const receivedAtMs = Number(stageEl.dataset.stageReceivedAtMs); + const elapsedMs = stageEl.classList.contains("is-final") + ? baseMs + : baseMs + Math.max(0, monotonicNowMs() - receivedAtMs); + const duration = formatDurationMs(elapsedMs); + const nextText = duration ? `${label} · ${duration}` : label; + if (stageEl.textContent !== nextText) { + stageEl.textContent = nextText; + } + } - function renderMemoryItems(payload) { - const container = get("runtimeMemoryList"); - const meta = get("runtimeMemoryMeta"); - if (!container || !meta) return; - const items = - payload && Array.isArray(payload.items) ? payload.items : []; - const queryInfo = - payload && payload.query && typeof payload.query === "object" - ? payload.query - : {}; - if (!Array.isArray(items) || items.length === 0) { - meta.textContent = i18nFormat("runtime.total", { count: 0 }); - container.innerHTML = `
${t("runtime.empty")}
`; - return; + function toolStatusLabel(block) { + if (block.uiHint === "webchat_private_send") { + return block.status === "done" + ? t("runtime.sent") + : block.status === "error" + ? t("runtime.error") + : t("runtime.sending"); } - const parts = [i18nFormat("runtime.total", { count: items.length })]; - const queryText = String(queryInfo.q || "").trim(); - if (queryText) parts.push(`q=${queryText}`); - const topK = String(queryInfo.top_k || "").trim(); - if (topK) parts.push(`top_k=${topK}`); - const timeFrom = String(queryInfo.time_from || "").trim(); - if (timeFrom) parts.push(`from=${timeFrom}`); - const timeTo = String(queryInfo.time_to || "").trim(); - if (timeTo) parts.push(`to=${timeTo}`); - meta.textContent = parts.join(" · "); - container.innerHTML = items - .map((item) => { - const uuid = escapeHtml(item.uuid || ""); - const fact = escapeHtml(item.fact || ""); - const created = escapeHtml(item.created_at || ""); - return `
${uuid}
${created}
${fact}
`; - }) - .join(""); + if (block.uiHint === "webchat_end" && block.status === "done") { + return t("runtime.ended"); + } + if (block.status === "done") return t("runtime.done"); + if (block.status === "error") return t("runtime.error"); + if (block.status === "cancelled") return t("runtime.cancelled"); + return t("runtime.running"); + } - container.querySelectorAll(".memory-btn-edit").forEach((btn) => { - btn.addEventListener("click", () => - startEditMemory(btn.dataset.uuid), + function toolDisplayLabel(block) { + if (block.uiHint === "webchat_private_send") { + return t("runtime.message"); + } + if (block.uiHint === "webchat_end") { + return t("runtime.end"); + } + return block.isAgent ? t("runtime.agent") : t("runtime.tool"); + } + + function formatToolPreview(raw) { + const text = String(raw || "").trim(); + if (!text) return { text: "", isStructured: false, value: null }; + try { + const parsed = JSON.parse(text); + return { + text, + isStructured: parsed !== null && typeof parsed === "object", + value: parsed, + }; + } catch (_error) { + try { + const normalized = text + .replace( + /([{,]\s*)'([^'\\]*(?:\\.[^'\\]*)*)'\s*:/g, + '$1"$2":', + ) + .replace( + /:\s*'([^'\\]*(?:\\.[^'\\]*)*)'(?=\s*[,}])/g, + ':"$1"', + ) + .replace( + /([[,]\s*)'([^'\\]*(?:\\.[^'\\]*)*)'(?=\s*[\],])/g, + '$1"$2"', + ) + .replace(/\bNone\b/g, "null") + .replace(/\bTrue\b/g, "true") + .replace(/\bFalse\b/g, "false"); + const parsed = JSON.parse(normalized); + return { + text, + isStructured: parsed !== null && typeof parsed === "object", + value: parsed, + }; + } catch (_compatError) { + return { text, isStructured: false, value: null }; + } + } + } + + function renderStructuredToolValue(value) { + if (Array.isArray(value)) { + if (!value.length) { + return `[]`; + } + return ( + `
` + + value + .map( + (item, index) => + `
` + + `${index}` + + `
${renderStructuredToolValue(item)}
` + + `
`, + ) + .join("") + + `
` ); - }); - container.querySelectorAll(".memory-btn-delete").forEach((btn) => { - btn.addEventListener("click", () => deleteMemory(btn.dataset.uuid)); - }); + } + if (value && typeof value === "object") { + const entries = Object.entries(value); + if (!entries.length) { + return `{}`; + } + return ( + `
` + + entries + .map( + ([key, item]) => + `
` + + `${escapeHtml(key)}` + + `
${renderStructuredToolValue(item)}
` + + `
`, + ) + .join("") + + `
` + ); + } + if (typeof value === "boolean") { + return `${value ? "true" : "false"}`; + } + if (typeof value === "number") { + return `${escapeHtml(value)}`; + } + if (value === null || value === undefined) { + return `null`; + } + return `${renderChatContent(String(value), false)}`; } - function startEditMemory(uuid) { - const container = get("runtimeMemoryList"); - if (!container) return; - const itemEl = container.querySelector( - `.runtime-list-item[data-uuid="${CSS.escape(uuid)}"]`, + function renderToolPreviewSection(labelKey, raw, options = {}) { + const preview = formatToolPreview(raw); + if (!preview.text) return ""; + const label = t(labelKey); + const bodyClass = preview.isStructured + ? "runtime-tool-preview-body is-structured" + : "runtime-tool-preview-body"; + const body = preview.isStructured + ? `
${renderStructuredToolValue(preview.value)}
` + : `
${renderChatContent(preview.text, !!options.markdown)}
`; + return ( + `
` + + `
${escapeHtml(label)}
` + + body + + `
` ); - if (!itemEl) return; - const factEl = itemEl.querySelector(".runtime-list-fact"); - if (!factEl || factEl.dataset.editing === "true") return; + } - const currentText = factEl.textContent || ""; - factEl.dataset.editing = "true"; - factEl.innerHTML = ""; + function renderToolBlock(block) { + const label = toolDisplayLabel(block); + const statusLabel = toolStatusLabel(block); + const durationLabel = formatDurationMs(runningDurationMs(block)); + const callId = toolCallIdentity(block); + const stageLabel = block.currentStage + ? chatStageLabel(block.currentStage) + : ""; + const showLiveAgentStage = + block.isAgent && + stageLabel && + !["done", "error", "cancelled"].includes(block.status); + const metaLabel = showLiveAgentStage ? stageLabel : statusLabel; + const titleHtml = + `` + + `${escapeHtml(block.name || "--")}` + + `${escapeHtml(durationLabel)}` + + ``; + const args = renderToolPreviewSection( + "runtime.tool_input", + block.argumentsPreview, + { markdown: false }, + ); + const result = renderToolPreviewSection( + "runtime.tool_output", + block.resultPreview, + { markdown: true }, + ); + const timeline = Array.isArray(block.timeline) + ? block.timeline.map(renderToolTimelineItem).join("") + : ""; + const children = + !timeline && Array.isArray(block.children) + ? block.children.map((child) => renderToolBlock(child)).join("") + : ""; + const childContent = timeline || children; + const childHtml = childContent + ? `
${childContent}
` + : ""; + const openAttr = block.autoOpen ? " open" : ""; + const hintClass = block.uiHint + ? ` ${escapeHtml(String(block.uiHint).replace(/_/g, "-"))}` + : ""; + const kindClass = block.isAgent ? " is-agent" : " is-tool"; + return ( + `
` + + `${titleHtml}${escapeHtml(metaLabel)}${escapeHtml(label)}` + + args + + childHtml + + result + + `
` + ); + } - const textarea = document.createElement("textarea"); - textarea.className = "form-control memory-edit-area"; - textarea.value = currentText; + function renderToolTimelineItem(entry) { + if (!entry || typeof entry !== "object") return ""; + if (entry.type === "message") { + const content = String(entry.content || "").trim(); + if (!content) return ""; + const contentHtml = renderChatContent( + content, + true, + chatRenderOptions(entry.attachments), + ); + return `
${contentHtml}
`; + } + if (entry.type === "stage") { + return ""; + } + if (entry.type === "call" && entry.call) { + return renderToolBlock(entry.call); + } + return ""; + } - const actions = document.createElement("div"); - actions.className = "memory-edit-actions"; - const saveBtn = document.createElement("button"); - saveBtn.className = "btn btn-sm"; - saveBtn.textContent = "保存"; - const cancelBtn = document.createElement("button"); - cancelBtn.className = "btn btn-sm"; - cancelBtn.textContent = "取消"; - actions.append(saveBtn, cancelBtn); - factEl.append(textarea, actions); - textarea.focus(); + function toolBlockKey(payload, blocks) { + return ( + String( + payload && payload.webchat_call_id + ? payload.webchat_call_id + : "", + ) || + String( + payload && payload.tool_call_id ? payload.tool_call_id : "", + ) || + String(payload && payload.name ? payload.name : "") || + `tool-${blocks.size + 1}` + ); + } - cancelBtn.addEventListener("click", () => { - delete factEl.dataset.editing; - factEl.innerHTML = ""; - factEl.textContent = currentText; - }); + function normalizeToolCallNode(node) { + if (!node || typeof node !== "object") return null; + const children = Array.isArray(node.children) + ? node.children.map(normalizeToolCallNode).filter(Boolean) + : []; + const timeline = Array.isArray(node.timeline) + ? node.timeline.map(normalizeHistoryTimelineNode).filter(Boolean) + : []; + return { + name: String(node.name || ""), + isAgent: !!node.is_agent, + status: String(node.status || "done"), + argumentsPreview: String(node.arguments_preview || ""), + resultPreview: String(node.result_preview || ""), + uiHint: String(node.ui_hint || ""), + durationMs: + node.duration_ms !== undefined + ? Number(node.duration_ms) + : undefined, + currentStage: String(node.current_stage || ""), + currentStageDetail: String(node.current_stage_detail || ""), + currentStageElapsedMs: + node.current_stage_elapsed_ms !== undefined + ? Number(node.current_stage_elapsed_ms) + : undefined, + children, + timeline, + autoOpen: false, + }; + } - saveBtn.addEventListener("click", () => - updateMemory(uuid, textarea.value), - ); + function normalizeHistoryTimelineNode(node) { + if (!node || typeof node !== "object") return null; + const type = String(node.type || "").trim(); + if (type === "message") { + return { + type, + content: String(node.content || ""), + }; + } + if (type === "stage") { + return { + type, + stage: String(node.stage || ""), + detail: String(node.detail || ""), + elapsedMs: + node.elapsed_ms !== undefined + ? Number(node.elapsed_ms) + : undefined, + stageElapsedMs: + node.stage_elapsed_ms !== undefined + ? Number(node.stage_elapsed_ms) + : undefined, + }; + } + if (type === "call") { + const call = normalizeToolCallNode(node.call); + return call ? { type, call } : null; + } + return null; + } - textarea.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - e.preventDefault(); - cancelBtn.click(); + function monotonicNowMs() { + return typeof performance !== "undefined" && + typeof performance.now === "function" + ? performance.now() + : Date.now(); + } + + function backendDurationClock(payload, field = "duration_ms") { + const durationMs = Number(payload && payload[field]); + if (!Number.isFinite(durationMs) || durationMs < 0) return null; + return { + baseMs: durationMs, + receivedAtMs: monotonicNowMs(), + }; + } + + function runningDurationMs(block) { + const baseMs = Number(block && block.durationBaseMs); + const receivedAtMs = Number(block && block.durationReceivedAtMs); + if (!Number.isFinite(baseMs) || baseMs < 0) { + return Number(block && block.durationMs); + } + if ( + ["done", "error", "cancelled"].includes(String(block.status || "")) + ) { + return baseMs; + } + if (!Number.isFinite(receivedAtMs) || receivedAtMs <= 0) { + return baseMs; + } + return Math.max(0, baseMs + monotonicNowMs() - receivedAtMs); + } + + function updateToolDurationDisplay(block) { + const identity = toolCallIdentity(block); + if (!identity) return; + const durationLabel = formatDurationMs(runningDurationMs(block)); + const selector = `[data-tool-duration-for="${CSS.escape(identity)}"]`; + document.querySelectorAll(selector).forEach((node) => { + if (node.textContent !== durationLabel) { + node.textContent = durationLabel; } - if (e.key === "Enter" && e.ctrlKey) { - e.preventDefault(); - saveBtn.click(); + const nextHidden = !durationLabel; + if (node.hidden !== nextHidden) { + node.hidden = nextHidden; } }); } - async function createMemory() { - if (_memoryMutating) return; - const input = get("memoryCreateInput"); - if (!input) return; - const fact = String(input.value || "").trim(); - if (!fact) { - showToast("记忆内容不能为空", "warning"); - return; + function isToolLifecycleStart(status) { + return status === "tool_start" || status === "agent_start"; + } + + function isToolLifecycleEnd(status) { + return status === "tool_end" || status === "agent_end"; + } + + function reduceToolBlock(blocks, payload, status) { + const key = toolBlockKey(payload, blocks); + if (!blocks.has(key) && payload && payload.tool_call_id) { + const nameKey = String(payload.name || ""); + if (nameKey && blocks.has(nameKey)) { + blocks.set(key, blocks.get(nameKey)); + blocks.delete(nameKey); + } } - _memoryMutating = true; - const btn = get("btnMemoryCreate"); - if (btn) btn.disabled = true; - try { - const res = await api("/api/runtime/memory", { - method: "POST", - body: JSON.stringify({ fact }), - }); - const data = await parseJsonSafe(res); - if (!res.ok || (data && data.error)) { - throw new Error(buildRequestError(res, data)); + const previous = blocks.get(key) || {}; + const isStart = isToolLifecycleStart(status); + const isEnd = isToolLifecycleEnd(status); + const isSnapshot = status === "tool_snapshot"; + const durationClock = backendDurationClock(payload); + const previousUiHint = String(previous.uiHint || ""); + const nextUiHint = String( + (payload && payload.ui_hint) || previousUiHint, + ); + const nextStatus = String((payload && payload.status) || "").trim(); + const nextArguments = String( + (payload && payload.arguments_preview) || + previous.argumentsPreview || + "", + ); + const block = { + ...previous, + webchatCallId: key, + name: String((payload && payload.name) || previous.name || ""), + isAgent: !!( + (payload && payload.is_agent) || + previous.isAgent || + status === "agent_start" || + status === "agent_end" + ), + status: + nextStatus || + (status === "tool_end" || status === "agent_end" + ? payload && payload.ok === false + ? "error" + : "done" + : "running"), + argumentsPreview: nextArguments, + resultPreview: String( + (payload && payload.result_preview) || + previous.resultPreview || + "", + ), + uiHint: nextUiHint, + durationMs: + durationClock && isEnd + ? durationClock.baseMs + : payload && payload.duration_ms !== undefined + ? Number(payload.duration_ms) + : previous.durationMs, + durationBaseMs: + durationClock && (isSnapshot || isEnd) + ? durationClock.baseMs + : isStart + ? 0 + : previous.durationBaseMs, + durationReceivedAtMs: + durationClock && (isSnapshot || isEnd) + ? durationClock.receivedAtMs + : isStart + ? monotonicNowMs() + : previous.durationReceivedAtMs, + backendStartedAt: Number( + (payload && payload.started_at) || + previous.backendStartedAt || + 0, + ), + currentStage: + isEnd && !(payload && payload.current_stage) + ? "" + : String( + (payload && payload.current_stage) || + previous.currentStage || + "", + ), + currentStageDetail: + isEnd && !(payload && payload.current_stage_detail) + ? "" + : String( + (payload && payload.current_stage_detail) || + previous.currentStageDetail || + "", + ), + currentStageElapsedMs: + isEnd && !(payload && payload.current_stage_elapsed_ms) + ? undefined + : payload && payload.current_stage_elapsed_ms !== undefined + ? Number(payload.current_stage_elapsed_ms) + : previous.currentStageElapsedMs, + autoOpen: isStart || isSnapshot ? true : !!previous.autoOpen, + localStartedAtMs: isStart + ? monotonicNowMs() + : previous.localStartedAtMs, + finishedAtMs: isEnd ? monotonicNowMs() : previous.finishedAtMs, + parentWebchatCallId: String( + (payload && payload.parent_webchat_call_id) || + previous.parentWebchatCallId || + "", + ), + children: Array.isArray(previous.children) ? previous.children : [], + timeline: Array.isArray(previous.timeline) ? previous.timeline : [], + }; + blocks.set(key, block); + return block; + } + + function topLevelToolKey(blocks, key) { + let currentKey = String(key || "").trim(); + const seen = new Set(); + while (currentKey && blocks.has(currentKey) && !seen.has(currentKey)) { + seen.add(currentKey); + const block = blocks.get(currentKey); + const parentKey = String(block.parentWebchatCallId || "").trim(); + if (!parentKey || !blocks.has(parentKey)) return currentKey; + currentKey = parentKey; + } + return currentKey || String(key || ""); + } + + function timelineToolKey(payload, blocks) { + return toolBlockKey(payload, blocks); + } + + function toolCallIdentity(block) { + if (!block) return ""; + return String(block.webchatCallId || block.name || "").trim(); + } + + function toolRenderSignature(block) { + if (!block) return ""; + const childSignature = Array.isArray(block.children) + ? block.children.map(toolRenderSignature).join("\u001e") + : ""; + const timelineSignature = Array.isArray(block.timeline) + ? block.timeline + .map((entry) => { + if (!entry || typeof entry !== "object") return ""; + if (entry.type === "call") { + return `call:${toolRenderSignature(entry.call)}`; + } + if (entry.type === "message") { + return [ + "message", + entry.content, + JSON.stringify(entry.attachments || []), + ] + .map((value) => String(value || "")) + .join(":"); + } + if (entry.type === "stage") { + return ["stage", entry.seq, entry.stage, entry.detail] + .map((value) => String(value || "")) + .join(":"); + } + return String(entry.type || ""); + }) + .join("\u001e") + : ""; + return [ + block.webchatCallId, + block.parentWebchatCallId, + block.name, + block.isAgent, + block.status, + block.autoOpen, + block.argumentsPreview, + block.resultPreview, + block.uiHint, + block.currentStage, + block.currentStageDetail, + childSignature, + timelineSignature, + ] + .map((value) => String(value || "")) + .join("\u001f"); + } + + function updateToolMetaDisplay(block) { + if (!block) return; + const identity = toolCallIdentity(block); + if (!identity) return; + updateToolDurationDisplay(block); + const statusLabel = toolStatusLabel(block); + const stageLabel = block.currentStage + ? chatStageLabel(block.currentStage) + : ""; + const showLiveAgentStage = + block.isAgent && + stageLabel && + !["done", "error", "cancelled"].includes(block.status); + const metaLabel = showLiveAgentStage ? stageLabel : statusLabel; + const selector = `[data-tool-status-for="${CSS.escape(identity)}"]`; + document.querySelectorAll(selector).forEach((node) => { + if (node.textContent !== metaLabel) { + node.textContent = metaLabel; } - showToast("记忆已添加", "success"); - input.value = ""; - await searchMemory(); - } catch (err) { - showToast(`添加失败: ${err.message || err}`, "error"); - } finally { - _memoryMutating = false; - if (btn) btn.disabled = false; + }); + } + + function renderToolNodeIfChanged(node, block) { + if (!node || !block) return null; + const nextSignature = toolRenderSignature(block); + if (node.dataset.renderSignature === nextSignature) { + updateToolMetaDisplay(block); + return node; } + node.innerHTML = renderToolBlock(block); + node.dataset.renderSignature = nextSignature; + return node; } - async function updateMemory(uuid, newFact) { - const fact = String(newFact || "").trim(); - if (!fact) { - showToast("记忆内容不能为空", "warning"); + function appendToolTimelineEntry(parent, entry) { + if (!parent || !entry) return; + const timeline = Array.isArray(parent.timeline) ? parent.timeline : []; + if (entry.type === "call" && entry.call) { + const identity = toolCallIdentity(entry.call); + const existingIndex = timeline.findIndex( + (item) => + item.type === "call" && + toolCallIdentity(item.call) === identity, + ); + if (existingIndex >= 0) { + timeline[existingIndex] = entry; + } else { + timeline.push(entry); + } + parent.timeline = timeline; return; } - if (_memoryMutating) return; - _memoryMutating = true; - try { - const res = await api( - `/api/runtime/memory/${encodeURIComponent(uuid)}`, - { - method: "PATCH", - body: JSON.stringify({ fact }), - }, + if (entry.type === "stage") { + const entrySeq = Number(entry.seq); + const existingIndex = timeline.findIndex( + (item) => + item.type === "stage" && + Number(item.seq) === entrySeq && + String(item.stage || "") === String(entry.stage || ""), ); - const data = await parseJsonSafe(res); - if (!res.ok || (data && data.error)) { - throw new Error(buildRequestError(res, data)); + if (existingIndex >= 0) { + timeline[existingIndex] = entry; + } else { + timeline.push(entry); } - showToast("记忆已更新", "success"); - await searchMemory(); - } catch (err) { - showToast(`更新失败: ${err.message || err}`, "error"); - } finally { - _memoryMutating = false; + parent.timeline = timeline; + return; } + timeline.push(entry); + parent.timeline = timeline; } - async function deleteMemory(uuid) { - if (_memoryMutating) return; - if (!confirm(`确认删除记忆 ${uuid.slice(0, 8)}…?`)) return; - _memoryMutating = true; - try { - const res = await api( - `/api/runtime/memory/${encodeURIComponent(uuid)}`, - { - method: "DELETE", - }, - ); - const data = await parseJsonSafe(res); - if (!res.ok || (data && data.error)) { - throw new Error(buildRequestError(res, data)); - } - showToast("记忆已删除", "success"); - await searchMemory(); - } catch (err) { - showToast(`删除失败: ${err.message || err}`, "error"); - } finally { - _memoryMutating = false; + function reduceAgentStageBlock(blocks, payload, _seq = 0) { + const key = toolBlockKey(payload, blocks); + const previous = blocks.get(key) || {}; + const parentCandidate = String( + (payload && payload.parent_webchat_call_id) || + previous.parentWebchatCallId || + "", + ).trim(); + const parentKey = parentCandidate === key ? "" : parentCandidate; + const stage = String((payload && payload.stage) || "").trim(); + const block = { + ...previous, + webchatCallId: key, + name: String( + (payload && (payload.agent_name || payload.name)) || + previous.name || + "", + ), + isAgent: true, + status: String( + (payload && payload.status) || previous.status || "running", + ), + argumentsPreview: previous.argumentsPreview || "", + resultPreview: previous.resultPreview || "", + uiHint: previous.uiHint || "", + currentStage: stage || previous.currentStage || "", + currentStageDetail: String( + (payload && payload.detail) || + previous.currentStageDetail || + "", + ), + currentStageElapsedMs: + payload && payload.stage_elapsed_ms !== undefined + ? Number(payload.stage_elapsed_ms) + : previous.currentStageElapsedMs, + durationMs: previous.durationMs, + durationBaseMs: previous.durationBaseMs, + durationReceivedAtMs: previous.durationReceivedAtMs, + backendStartedAt: previous.backendStartedAt, + autoOpen: !!previous.autoOpen, + parentWebchatCallId: parentKey, + children: Array.isArray(previous.children) ? previous.children : [], + timeline: Array.isArray(previous.timeline) ? previous.timeline : [], + }; + blocks.set(key, block); + return block; + } + + function agentStageRenderSignature(block) { + if (!block) return ""; + return [ + block.webchatCallId, + block.parentWebchatCallId, + block.name, + block.status, + block.currentStage, + block.currentStageDetail, + ] + .map((value) => String(value || "")) + .join("\u001f"); + } + + function redrawToolTimelineNode(item, blocks, key) { + const timeline = ensureTimelineNodeContainer(item); + if (!timeline) return null; + const rootKey = topLevelToolKey(blocks, key); + const root = blocks.get(rootKey); + if (!root) return null; + let node = timeline.querySelector( + `[data-tool-key="${CSS.escape(rootKey)}"]`, + ); + if (!node) { + node = document.createElement("div"); + node.className = "runtime-chat-tools"; + node.dataset.toolKey = rootKey; + timeline.appendChild(node); } + return renderToolNodeIfChanged(node, root); } - function setListMessage(metaId, listId, message) { - const meta = get(metaId); - const list = get(listId); - const msg = String(message || "").trim() || t("runtime.empty"); - if (meta) meta.textContent = msg; - if (list) { - list.innerHTML = `
${escapeHtml(msg)}
`; + function scheduleToolAutoCollapse(item, blocks, key, block) { + if (!item || !block || block.status === "running") return; + const timerKey = String(key || "").trim(); + if (!timerKey) return; + if (runtimeState.toolCollapseTimers.has(timerKey)) { + clearTimeout(runtimeState.toolCollapseTimers.get(timerKey)); + runtimeState.toolCollapseTimers.delete(timerKey); } + const collapse = () => { + runtimeState.toolCollapseTimers.delete(timerKey); + const latest = blocks.get(timerKey); + if (!latest) return; + latest.autoOpen = false; + redrawToolTimelineNode(item, blocks, timerKey); + }; + runtimeState.toolCollapseTimers.set( + timerKey, + setTimeout(collapse, TOOL_AUTO_COLLAPSE_MIN_VISIBLE_MS), + ); } - function renderCognitiveItems(metaId, listId, payload) { - const meta = get(metaId); - const list = get(listId); - if (!meta || !list) return; - const items = - payload && Array.isArray(payload.items) ? payload.items : []; + function upsertTimelineToolBlock(item, blocks, payload, status) { + if (!item) return null; + const key = timelineToolKey(payload, blocks); + const previousRootKey = topLevelToolKey(blocks, key); + const previousRoot = blocks.get(previousRootKey); + const previousSignature = toolRenderSignature(previousRoot); + const block = reduceToolBlock(blocks, payload, status); + const timeline = ensureTimelineNodeContainer(item); + if (!timeline) return null; + const parentKey = String( + (payload && payload.parent_webchat_call_id) || "", + ).trim(); + if (parentKey && blocks.has(parentKey)) { + const parent = blocks.get(parentKey); + const previousParentSignature = toolRenderSignature(parent); + const blockIdentity = toolCallIdentity(block); + const siblings = Array.isArray(parent.children) + ? parent.children.filter( + (child) => toolCallIdentity(child) !== blockIdentity, + ) + : []; + parent.children = [...siblings, block]; + appendToolTimelineEntry(parent, { type: "call", call: block }); + const nextParentSignature = toolRenderSignature(parent); + const parentNode = timeline.querySelector( + `[data-tool-key="${CSS.escape(parentKey)}"]`, + ); + if (parentNode) { + if ( + status === "tool_snapshot" && + previousParentSignature === nextParentSignature + ) { + updateToolMetaDisplay(block); + updateToolMetaDisplay(parent); + return parentNode; + } + renderToolNodeIfChanged(parentNode, parent); + if (isToolLifecycleEnd(status)) { + scheduleToolAutoCollapse(item, blocks, key, block); + } + return parentNode; + } + const rootKey = topLevelToolKey(blocks, parentKey); + const root = blocks.get(rootKey); + const rootNode = timeline.querySelector( + `[data-tool-key="${CSS.escape(rootKey)}"]`, + ); + if (root && rootNode) { + const previousRootSignature = + rootNode.dataset.renderSignature || + toolRenderSignature(root); + const nextRootSignature = toolRenderSignature(root); + if ( + status === "tool_snapshot" && + previousRootSignature === nextRootSignature + ) { + updateToolMetaDisplay(block); + updateToolMetaDisplay(root); + return rootNode; + } + renderToolNodeIfChanged(rootNode, root); + if (isToolLifecycleEnd(status)) { + scheduleToolAutoCollapse(item, blocks, key, block); + } + return rootNode; + } + } + let node = timeline.querySelector( + `[data-tool-key="${CSS.escape(key)}"]`, + ); + if (!node) { + node = document.createElement("div"); + node.className = "runtime-chat-tools"; + node.dataset.toolKey = key; + timeline.appendChild(node); + } + if (status === "tool_snapshot" && previousSignature) { + const nextSignature = toolRenderSignature(block); + if (previousSignature === nextSignature) { + updateToolMetaDisplay(block); + return node; + } + } + renderToolNodeIfChanged(node, block); + if (isToolLifecycleEnd(status)) { + scheduleToolAutoCollapse(item, blocks, key, block); + } + return node; + } + + function appendNestedTimelineMessage(item, blocks, payload, content) { + const parentKey = String( + (payload && payload.parent_webchat_call_id) || "", + ).trim(); + if (!parentKey || !blocks.has(parentKey)) return false; + const parent = blocks.get(parentKey); + appendToolTimelineEntry(parent, { + type: "message", + content, + attachments: payload && payload.attachments, + }); + redrawToolTimelineNode(item, blocks, parentKey); + appendRawChatContent(item, content); + return true; + } + + function upsertToolBlock(payload, status, jobId = "") { + const item = ensureStreamingMessage(jobId); + if (!item) return; + upsertTimelineToolBlock(item, runtimeState.toolBlocks, payload, status); + scrollChatToBottomSoon(); + } + + function upsertToolSnapshot(payload, jobId = "") { + const item = ensureStreamingMessage(jobId); + if (!item) return; + upsertTimelineToolBlock( + item, + runtimeState.toolBlocks, + payload, + "tool_snapshot", + ); + } + + function upsertAgentStageBlock(payload, jobId = "", seq = 0) { + const item = ensureStreamingMessage(jobId); + if (!item) return; + const blocks = runtimeState.toolBlocks; + const key = timelineToolKey(payload, blocks); + const previousSignature = agentStageRenderSignature(blocks.get(key)); + const block = reduceAgentStageBlock(blocks, payload, seq); + if ( + previousSignature && + previousSignature === agentStageRenderSignature(block) + ) { + return; + } + const parentKey = String(block.parentWebchatCallId || "").trim(); + const timeline = ensureTimelineNodeContainer(item); + if (!timeline) return; + if (parentKey && blocks.has(parentKey)) { + const parent = blocks.get(parentKey); + const previousParentSignature = toolRenderSignature(parent); + const blockIdentity = toolCallIdentity(block); + const siblings = Array.isArray(parent.children) + ? parent.children.filter( + (child) => toolCallIdentity(child) !== blockIdentity, + ) + : []; + parent.children = [...siblings, block]; + appendToolTimelineEntry(parent, { type: "call", call: block }); + const nextParentSignature = toolRenderSignature(parent); + if (previousParentSignature === nextParentSignature) { + updateToolMetaDisplay(block); + updateToolMetaDisplay(parent); + } else { + redrawToolTimelineNode(item, blocks, parentKey); + } + } else { + let node = timeline.querySelector( + `[data-tool-key="${CSS.escape(key)}"]`, + ); + if (!node) { + node = document.createElement("div"); + node.className = "runtime-chat-tools"; + node.dataset.toolKey = key; + timeline.appendChild(node); + } + renderToolNodeIfChanged(node, block); + } + scrollChatToBottomSoon(); + } + + function historyWebchatEvents(item) { + const webchat = item && item.webchat; + const events = + webchat && Array.isArray(webchat.events) ? webchat.events : []; + return events.filter((entry) => { + const event = entry && String(entry.event || ""); + return ( + event === "tool_start" || + event === "tool_end" || + event === "agent_start" || + event === "agent_end" || + event === "agent_stage" || + event === "message" + ); + }); + } + + function renderHistoryTimeline(item, message) { + const events = historyWebchatEvents(item); + if (!message || !events.length) return false; + const calls = + item && item.webchat && Array.isArray(item.webchat.calls) + ? item.webchat.calls + : []; + const timelineItems = + item && item.webchat && Array.isArray(item.webchat.timeline) + ? item.webchat.timeline + : []; + if (timelineItems.length) { + const timeline = ensureTimelineNodeContainer(message); + if (timeline) { + timelineItems + .map(normalizeHistoryTimelineNode) + .filter(Boolean) + .forEach((entry, index) => { + if (entry.type === "message") { + appendTimelineMessage( + message, + entry.content, + "bot", + ); + return; + } + if (entry.type !== "call" || !entry.call) return; + const node = document.createElement("div"); + node.className = "runtime-chat-tools"; + node.dataset.toolKey = `history-call-${index}`; + node.innerHTML = renderToolBlock(entry.call); + timeline.appendChild(node); + }); + } + return true; + } + if (calls.length) { + const timeline = ensureTimelineNodeContainer(message); + if (timeline) { + calls + .map(normalizeToolCallNode) + .filter(Boolean) + .forEach((block, index) => { + const node = document.createElement("div"); + node.className = "runtime-chat-tools"; + node.dataset.toolKey = `history-call-${index}`; + node.innerHTML = renderToolBlock(block); + timeline.appendChild(node); + }); + } + events + .filter((entry) => entry.event === "message") + .forEach((entry) => { + appendTimelineMessage( + message, + entry.payload && + (entry.payload.content ?? entry.payload.message), + "bot", + { + attachments: + entry.payload && entry.payload.attachments, + }, + ); + }); + return true; + } + const blocks = new Map(); + events.forEach((entry) => { + if (entry.event === "message") { + appendTimelineMessage( + message, + entry.payload && + (entry.payload.content ?? entry.payload.message), + "bot", + { + attachments: entry.payload && entry.payload.attachments, + }, + ); + return; + } + upsertTimelineToolBlock( + message, + blocks, + entry.payload || {}, + entry.event, + ); + }); + return true; + } + + function appendHistoryChatItem(item, options = {}) { + const role = item && item.role === "bot" ? "bot" : "user"; + const content = String((item && item.content) || "").trim(); + const attachmentMarkup = buildAttachmentMarkup( + item && item.attachments, + ); + const hasTimeline = + role === "bot" && historyWebchatEvents(item).length > 0; + if (!content && !hasTimeline && !attachmentMarkup) return null; + const message = appendChatMessage(role, content, { + ...options, + attachments: item && item.attachments, + }); + if (!message) return null; + if (hasTimeline) { + const contentEl = message.querySelector(".runtime-chat-content"); + if (contentEl) contentEl.innerHTML = ""; + renderHistoryTimeline(item, message); + if (!message.dataset.rawContent && content) { + appendTimelineMessage(message, content, role, { + attachments: item && item.attachments, + }); + } + } + if (attachmentMarkup) { + const contentEl = message.querySelector(".runtime-chat-content"); + if (contentEl) { + contentEl.insertAdjacentHTML("beforeend", attachmentMarkup); + } + } + const webchat = item && item.webchat; + const durationMs = Number(webchat && webchat.duration_ms); + if (role === "bot" && Number.isFinite(durationMs) && durationMs >= 0) { + setChatStage(message, { + stage: "done", + elapsed_ms: durationMs, + final: true, + }); + } + if (!content && !attachmentMarkup && !message.dataset.rawContent) { + message.classList.add("tool-only"); + } + return message; + } + + function clearChatMessages() { + const log = get("runtimeChatLog"); + if (!log) return; + clearToolCollapseTimers(); + log.innerHTML = ""; + runtimeState.streamingMessageId = null; + runtimeState.activeChatMessageId = null; + runtimeState.toolBlocks.clear(); + stopChatClock(); + syncChatMessageActions(); + } + + function chatMessageHasBody(item) { + if (!item) return false; + const raw = String(item.dataset.rawContent || "").trim(); + if (raw) return true; + const timeline = item.querySelector(".runtime-chat-timeline"); + if (timeline && timeline.children.length > 0) return true; + const content = item.querySelector(".runtime-chat-content"); + if (content && String(content.textContent || "").trim()) return true; + return !!item.querySelector( + ".runtime-chat-image, .runtime-chat-file-card, .runtime-chat-tools", + ); + } + + function removeEmptyChatMessage(item) { + if (!item || chatMessageHasBody(item)) return false; + item.remove(); + return true; + } + + function lastChatItem() { + const log = get("runtimeChatLog"); + if (!log) return null; + const items = [...log.querySelectorAll(".runtime-chat-item")]; + return items.length ? items[items.length - 1] : null; + } + + function hasBotReplyAfter(item) { + let node = item ? item.nextElementSibling : null; + while (node) { + if ( + node.classList && + node.classList.contains("runtime-chat-item") + ) { + if ( + node.classList.contains("bot") && + chatMessageHasBody(node) + ) { + return true; + } + if (node.classList.contains("user")) return false; + } + node = node.nextElementSibling; + } + return false; + } + + function syncActiveCancelButtons() { + const activeJobId = currentChatJobId(); + document.querySelectorAll("[data-cancel-job]").forEach((button) => { + button.hidden = true; + button.disabled = true; + button.classList.remove("is-visible"); + button.dataset.cancelJob = ""; + }); + if (!activeJobId) return; + const item = findActiveChatMessage(activeJobId); + const button = item && item.querySelector("[data-cancel-job]"); + if (!button) return; + button.hidden = false; + button.disabled = runtimeState.chatCancelBusy; + button.classList.add("is-visible"); + button.dataset.cancelJob = activeJobId; + } + + function syncChatRetryButtons() { + const busy = !!(runtimeState.chatBusy || runtimeState.activeJobId); + const lastItem = lastChatItem(); + document.querySelectorAll("[data-retry-message]").forEach((button) => { + const item = button.closest(".runtime-chat-item.user"); + const canRetry = + !busy && + item && + item === lastItem && + !hasBotReplyAfter(item) && + String(item.dataset.retryContent || "").trim(); + button.hidden = !canRetry; + button.disabled = !canRetry; + button.classList.toggle("is-visible", !!canRetry); + }); + } + + function syncChatMessageActions() { + syncActiveCancelButtons(); + syncChatRetryButtons(); + } + + function parseCqAttributes(raw) { + const attrs = {}; + String(raw || "") + .split(",") + .forEach((part) => { + const idx = part.indexOf("="); + if (idx <= 0) return; + const key = part.slice(0, idx).trim(); + const value = part.slice(idx + 1).trim(); + if (!key) return; + attrs[key] = value; + }); + return attrs; + } + + function resolveCqImageSource(attrs) { + const raw = String((attrs && (attrs.url || attrs.file)) || "").trim(); + if (!raw) return ""; + if (raw.startsWith("base64://")) { + const payload = raw.slice("base64://".length).trim(); + return payload ? `data:image/png;base64,${payload}` : ""; + } + if (raw.startsWith("file://")) { + const localPath = raw.slice("file://".length).trim(); + return localPath + ? `/api/runtime/chat/image?path=${encodeURIComponent(localPath)}` + : ""; + } + if (raw.startsWith("/") || /^[A-Za-z]:[\\/]/.test(raw)) { + return `/api/runtime/chat/image?path=${encodeURIComponent(raw)}`; + } + if ( + raw.startsWith("http://") || + raw.startsWith("https://") || + raw.startsWith("data:image/") + ) { + return raw; + } + return ""; + } + + function chatImageMarkup(source, alt = "") { + const src = String(source || "").trim(); + if (!src) return ""; + const label = + String(alt || "").trim() || t("runtime.image_preview") || "image"; + return `${escapeHtml(label)}`; + } + + function formatFileSize(bytes) { + const n = Number(bytes); + if (!Number.isFinite(n) || n <= 0) return ""; + if (n < 1024) return n + "B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + "KB"; + return (n / 1024 / 1024).toFixed(2) + "MB"; + } + + function fileKind(file) { + const type = String((file && file.type) || "").toLowerCase(); + return type.startsWith("image/") ? "image" : "file"; + } + + function formatAttachmentName(file) { + return ( + String((file && file.name) || "attachment").trim() || "attachment" + ); + } + + function renderPendingChatAttachments() { + const container = get("runtimeChatAttachments"); + if (!container) return; + const inputRow = container.closest(".runtime-chat-input-row"); + if (!runtimeState.chatAttachments.length) { + container.hidden = true; + container.innerHTML = ""; + if (inputRow) { + inputRow.classList.remove( + "has-attachments", + "is-attachment-rail-full", + "is-attachment-compressed", + ); + inputRow.style.setProperty( + "--chat-attachment-rail-width", + "0px", + ); + inputRow.style.setProperty( + "--chat-attachment-card-width", + `${CHAT_ATTACHMENT_CARD_MAX_WIDTH}px`, + ); + } + return; + } + container.hidden = false; + if (inputRow) { + const count = runtimeState.chatAttachments.length; + const width = Math.min( + CHAT_ATTACHMENT_RAIL_MAX_WIDTH, + CHAT_ATTACHMENT_RAIL_BASE_WIDTH + + count * CHAT_ATTACHMENT_RAIL_STEP_WIDTH, + ); + const gapWidth = + count >= CHAT_ATTACHMENT_COMPRESSED_COUNT + ? CHAT_ATTACHMENT_COMPRESSED_GAP_WIDTH + : CHAT_ATTACHMENT_GAP_WIDTH; + const cardWidth = Math.max( + CHAT_ATTACHMENT_CARD_MIN_WIDTH, + Math.min( + CHAT_ATTACHMENT_CARD_MAX_WIDTH, + Math.floor( + (width - Math.max(0, count - 1) * gapWidth) / count, + ), + ), + ); + inputRow.classList.toggle("has-attachments", count > 0); + inputRow.classList.toggle( + "is-attachment-rail-full", + width >= CHAT_ATTACHMENT_RAIL_MAX_WIDTH, + ); + inputRow.classList.toggle( + "is-attachment-compressed", + count >= CHAT_ATTACHMENT_COMPRESSED_COUNT, + ); + inputRow.style.setProperty( + "--chat-attachment-rail-width", + `${width}px`, + ); + inputRow.style.setProperty( + "--chat-attachment-card-width", + `${cardWidth}px`, + ); + } + container.innerHTML = runtimeState.chatAttachments + .map((item) => { + const kindLabel = + item.kind === "image" + ? t("runtime.attachment_kind_image") + : t("runtime.attachment_kind_file"); + const preview = item.previewUrl + ? `` + : ``; + return ( + `
` + + `${preview}` + + `` + + `${escapeHtml(item.name)}` + + `${escapeHtml(kindLabel)}${item.sizeLabel ? ` · ${escapeHtml(item.sizeLabel)}` : ""}` + + `` + + `` + + `
` + ); + }) + .join(""); + container + .querySelectorAll("[data-attachment-remove]") + .forEach((button) => { + button.addEventListener("click", () => { + const id = String( + button.getAttribute("data-attachment-remove") || "", + ); + const removed = runtimeState.chatAttachments.find( + (item) => item.id === id, + ); + if (removed && removed.previewUrl) { + URL.revokeObjectURL(removed.previewUrl); + } + runtimeState.chatAttachments = + runtimeState.chatAttachments.filter( + (item) => item.id !== id, + ); + renderPendingChatAttachments(); + }); + }); + } + + function addChatFiles(files, { source = "picker" } = {}) { + const selected = Array.from(files || []).filter(Boolean); + if (!selected.length) return 0; + const added = []; + for (const file of selected) { + const name = formatAttachmentName(file); + const size = Number(file.size || 0); + const kind = fileKind(file); + added.push({ + id: `att-${Date.now()}-${runtimeState.chatAttachmentSeq++}`, + file, + kind, + name, + previewUrl: kind === "image" ? URL.createObjectURL(file) : "", + size, + sizeLabel: formatFileSize(size), + source, + }); + } + runtimeState.chatAttachments.push(...added); + renderPendingChatAttachments(); + const messageKey = + added.length === 1 + ? "runtime.attachment_added" + : "runtime.attachments_added"; + showToast( + i18nFormat(messageKey, { count: added.length }), + "success", + 1800, + ); + return added.length; + } + + function clearChatAttachments() { + runtimeState.chatAttachments.forEach((item) => { + if (item.previewUrl) URL.revokeObjectURL(item.previewUrl); + }); + runtimeState.chatAttachments = []; + renderPendingChatAttachments(); + } + + function normalizeReferenceText(text) { + return String(text || "") + .replace(/\r\n?/g, "\n") + .replace(/\n{4,}/g, "\n\n\n") + .trim(); + } + + function truncateReferenceText(text, maxChars = CHAT_REFERENCE_MAX_CHARS) { + const value = normalizeReferenceText(text); + if (value.length <= maxChars) return value; + return `${value.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; + } + + function referencePreview(text) { + const value = normalizeReferenceText(text).replace(/\s+/g, " "); + if (value.length <= CHAT_REFERENCE_PREVIEW_CHARS) return value; + return `${value.slice(0, CHAT_REFERENCE_PREVIEW_CHARS - 1).trimEnd()}…`; + } + + function renderPendingChatReferences() { + const container = get("runtimeChatReferences"); + if (!container) return; + if (!runtimeState.chatReferences.length) { + container.hidden = true; + container.innerHTML = ""; + return; + } + container.hidden = false; + container.innerHTML = runtimeState.chatReferences + .map((item) => { + const label = messageQuoteSourceLabel(item.type); + const preview = referencePreview(item.text); + return ( + `
` + + `` + + `` + + `${escapeHtml(label)}` + + `${escapeHtml(preview)}` + + `` + + `` + + `
` + ); + }) + .join(""); + container + .querySelectorAll("[data-reference-remove]") + .forEach((button) => { + button.addEventListener("click", () => { + const id = String( + button.getAttribute("data-reference-remove") || "", + ); + runtimeState.chatReferences = + runtimeState.chatReferences.filter( + (item) => item.id !== id, + ); + renderPendingChatReferences(); + }); + }); + } + + function addChatReference({ type = "message", text = "" } = {}) { + const value = truncateReferenceText(text); + if (!value) return false; + runtimeState.chatReferences.push({ + id: `ref-${Date.now()}-${runtimeState.chatReferenceSeq++}`, + type, + text: value, + }); + renderPendingChatReferences(); + showToast(t("runtime.reference_added"), "success", 1600); + const input = get("runtimeChatInput"); + if (input) input.focus(); + return true; + } + + function clearChatReferences() { + runtimeState.chatReferences = []; + renderPendingChatReferences(); + } + + function formatChatReferencesAsMarkdown(references) { + const items = Array.isArray(references) ? references : []; + if (!items.length) return ""; + return items + .map((item) => { + const label = messageQuoteSourceLabel(item.type); + const lines = normalizeReferenceText(item.text).split("\n"); + return [`> ${label}:`, ...lines.map((line) => `> ${line}`)] + .join("\n") + .trim(); + }) + .filter(Boolean) + .join("\n\n"); + } + + function buildChatMessageWithReferences(message, references) { + const quote = formatChatReferencesAsMarkdown(references); + const body = String(message || "").trim(); + return [quote, body].filter(Boolean).join("\n\n").trim(); + } + + function chatMessageTextForQuote(item) { + if (!item) return ""; + const raw = String(item.dataset.rawContent || "").trim(); + if (raw) return raw; + const content = item.querySelector(".runtime-chat-content"); + if (content) return normalizeReferenceText(content.innerText || ""); + const timeline = item.querySelector(".runtime-chat-timeline"); + return timeline ? normalizeReferenceText(timeline.innerText || "") : ""; + } + + function hideSelectionQuoteButton() { + if (runtimeState.selectionQuoteButton) { + runtimeState.selectionQuoteButton.hidden = true; + } + runtimeState.pendingSelectionReference = null; + } + + function ensureSelectionQuoteButton() { + if (runtimeState.selectionQuoteButton) { + return runtimeState.selectionQuoteButton; + } + const button = document.createElement("button"); + button.className = "runtime-chat-selection-quote"; + button.type = "button"; + button.textContent = t("runtime.quote_selection"); + button.hidden = true; + button.addEventListener("click", () => { + const text = runtimeState.pendingSelectionReference; + if (text) addChatReference({ type: "selection", text }); + hideSelectionQuoteButton(); + }); + document.body.appendChild(button); + runtimeState.selectionQuoteButton = button; + return button; + } + + function maybeShowSelectionQuoteButton() { + const selection = window.getSelection ? window.getSelection() : null; + const text = normalizeReferenceText( + selection ? selection.toString() : "", + ); + if (!selection || !text) { + hideSelectionQuoteButton(); + return; + } + const log = get("runtimeChatLog"); + const anchorNode = selection.anchorNode; + const focusNode = selection.focusNode; + const anchorElement = + anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE + ? anchorNode + : anchorNode && anchorNode.parentElement; + const focusElement = + focusNode && focusNode.nodeType === Node.ELEMENT_NODE + ? focusNode + : focusNode && focusNode.parentElement; + const anchorMessage = + anchorElement && anchorElement.closest(".runtime-chat-item.bot"); + const focusMessage = + focusElement && focusElement.closest(".runtime-chat-item.bot"); + if (!log || !anchorMessage || anchorMessage !== focusMessage) { + hideSelectionQuoteButton(); + return; + } + const range = selection.rangeCount ? selection.getRangeAt(0) : null; + if (!range) { + hideSelectionQuoteButton(); + return; + } + const rect = range.getBoundingClientRect(); + const button = ensureSelectionQuoteButton(); + runtimeState.pendingSelectionReference = text; + button.textContent = t("runtime.quote_selection"); + button.hidden = false; + button.style.left = `${Math.max(12, Math.min(rect.left + rect.width / 2, window.innerWidth - 12))}px`; + button.style.top = `${Math.max(12, rect.top - 38)}px`; + } + + function renderFileCard(attrs) { + const fileId = escapeHtml(String(attrs.id || "").trim()); + const name = escapeHtml(String(attrs.name || "file").trim()); + const size = formatFileSize(attrs.size); + if (!fileId) return `[file]`; + const href = `/api/runtime/chat/file?id=${encodeURIComponent(fileId)}`; + return ( + `
` + + `
📄
` + + `
` + + `
${name}
` + + (size ? `
${size}
` : "") + + `
` + + `${t("runtime.download") || "Download"}` + + `
` + ); + } + + function isSafeRenderedUrl(url) { + const text = String(url || "").trim(); + if (!text) return false; + try { + const parsed = new URL(text, window.location.origin); + return ["http:", "https:", "mailto:"].includes(parsed.protocol); + } catch (_error) { + return false; + } + } + + function isSafeRenderedImageUrl(url) { + const text = String(url || "").trim(); + if (!text) return false; + try { + const parsed = new URL(text, window.location.origin); + return ["http:", "https:"].includes(parsed.protocol); + } catch (_error) { + return false; + } + } + + const SAFE_HTML_TAGS = new Set([ + "a", + "article", + "aside", + "b", + "blockquote", + "br", + "caption", + "code", + "del", + "details", + "div", + "em", + "footer", + "header", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "img", + "kbd", + "li", + "main", + "nav", + "mark", + "ol", + "p", + "pre", + "s", + "section", + "small", + "span", + "strong", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "u", + "ul", + ]); + const DROP_HTML_TAGS = new Set([ + "canvas", + "embed", + "form", + "head", + "iframe", + "input", + "link", + "math", + "meta", + "object", + "script", + "style", + "svg", + "template", + "title", + "video", + ]); + const STANDALONE_HTML_ROOT_TAGS = new Set([ + "article", + "aside", + "blockquote", + "body", + "details", + "div", + "footer", + "header", + "html", + "main", + "nav", + "ol", + "p", + "section", + "table", + "ul", + ]); + + function sanitizeIntegerAttribute(element, name, min, max) { + const value = Number.parseInt(element.getAttribute(name) || "", 10); + if (!Number.isFinite(value) || value < min || value > max) { + element.removeAttribute(name); + return; + } + element.setAttribute(name, String(value)); + } + + function sanitizeHtmlElement(element) { + const tag = element.tagName.toLowerCase(); + [...element.attributes].forEach((attr) => { + const name = attr.name.toLowerCase(); + if (name.startsWith("on") || name === "style") { + element.removeAttribute(attr.name); + return; + } + if (name === "href" && tag === "a") { + if (!isSafeRenderedUrl(attr.value)) { + element.removeAttribute(attr.name); + return; + } + element.setAttribute("rel", "noreferrer"); + return; + } + if (name === "src" && tag === "img") { + if (!isSafeRenderedImageUrl(attr.value)) { + element.remove(); + return; + } + element.classList.add("runtime-chat-image"); + element.setAttribute("loading", "lazy"); + element.setAttribute("data-chat-image-preview", "1"); + element.setAttribute("title", t("runtime.open_image_preview")); + return; + } + if (["alt", "title"].includes(name)) return; + if ( + ["th", "td"].includes(tag) && + ["colspan", "rowspan"].includes(name) + ) { + sanitizeIntegerAttribute(element, name, 1, 20); + return; + } + if (tag === "ol" && name === "start") { + sanitizeIntegerAttribute(element, name, 1, 9999); + return; + } + element.removeAttribute(attr.name); + }); + } + + function sanitizeHtmlNode(node) { + if (node.nodeType === Node.TEXT_NODE) return; + if (node.nodeType !== Node.ELEMENT_NODE) { + node.remove(); + return; + } + const element = node; + const tag = element.tagName.toLowerCase(); + if (DROP_HTML_TAGS.has(tag)) { + element.remove(); + return; + } + if (!SAFE_HTML_TAGS.has(tag)) { + [...element.childNodes].forEach(sanitizeHtmlNode); + const parent = element.parentNode; + if (!parent) { + element.remove(); + return; + } + while (element.firstChild) { + parent.insertBefore(element.firstChild, element); + } + element.remove(); + return; + } + sanitizeHtmlElement(element); + [...element.childNodes].forEach(sanitizeHtmlNode); + } + + function sanitizeHtmlSnippet(html) { + const raw = String(html || ""); + if (!raw.trim() || typeof document === "undefined") + return escapeHtml(raw); + const template = document.createElement("template"); + template.innerHTML = raw; + [...template.content.childNodes].forEach(sanitizeHtmlNode); + return template.innerHTML; + } + + function looksLikeStandaloneHtml(text) { + const raw = String(text || "").trim(); + if (!raw || !raw.includes("<") || !raw.includes(">")) return false; + if (/^```/.test(raw)) return false; + if (/^]*>/i); + if (!firstTag) return false; + const tag = firstTag[1].toLowerCase(); + if (tag === "html" || tag === "body") return true; + if (!STANDALONE_HTML_ROOT_TAGS.has(tag)) return false; + return new RegExp(`\\s*$`, "i").test(raw); + } + + const CODE_LANGUAGE_ALIASES = { + c: "c", + cc: "cpp", + cjs: "javascript", + cs: "csharp", + htm: "xml", + html: "xml", + js: "javascript", + jsonc: "json", + jsx: "javascript", + md: "markdown", + mjs: "javascript", + plaintext: "plaintext", + plain: "plaintext", + py: "python", + sh: "bash", + shell: "bash", + ts: "typescript", + tsx: "typescript", + txt: "plaintext", + vue: "xml", + xhtml: "xml", + yml: "yaml", + }; + + function normalizeCodeLanguage(language) { + const raw = String(language || "") + .trim() + .toLowerCase() + .replace(/^language-/, "") + .split(/\s+/)[0] + .replace(/[^a-z0-9_+#.-]/g, ""); + return CODE_LANGUAGE_ALIASES[raw] || raw || "text"; + } + + function highlightCodeBlock(code, language) { + const lang = normalizeCodeLanguage(language); + if (lang === "text" || lang === "plaintext") { + return escapeHtml(code); + } + if (typeof hljs === "undefined") { + return escapeHtml(code); + } + try { + if (hljs.getLanguage && hljs.getLanguage(lang)) { + return hljs.highlight(code, { + ignoreIllegals: true, + language: lang, + }).value; + } + return hljs.highlightAuto(code).value; + } catch (_e) { + return escapeHtml(code); + } + } + + function isRunnableHtmlCode(code, language) { + const lang = normalizeCodeLanguage(language); + if (["html", "xml", "xhtml"].includes(lang)) return true; + const raw = String(code || "").trim(); + if (!raw) return false; + return ( + /^/i.test(raw)) + ); + } + + function codeBlockLanguageLabel(language) { + const lang = normalizeCodeLanguage(language); + return lang === "text" ? "code" : lang; + } + + function shouldCollapseCodeBlock(code) { + const lines = String(code || "").split(/\r?\n/).length; + return lines > CODE_COLLAPSE_LINE_THRESHOLD; + } + + function createSafeMarkedRenderer() { + if (typeof marked === "undefined" || !marked.Renderer) return null; + const renderer = new marked.Renderer(); + renderer.html = ({ text }) => sanitizeHtmlSnippet(text || ""); + renderer.code = (token, legacyLanguage) => { + const codeText = + token && typeof token === "object" + ? String(token.text || "") + : String(token || ""); + const language = + token && typeof token === "object" + ? token.lang + : legacyLanguage; + const normalizedLanguage = normalizeCodeLanguage(language); + const encodedCode = encodeURIComponent(codeText); + const canRunHtml = isRunnableHtmlCode(codeText, normalizedLanguage); + const languageClass = + normalizedLanguage && normalizedLanguage !== "text" + ? ` language-${escapeHtml(normalizedLanguage)}` + : ""; + const isCollapsible = shouldCollapseCodeBlock(codeText); + const collapsedClass = isCollapsible ? " is-collapsed" : ""; + return ( + `
` + + `
` + + `${escapeHtml(codeBlockLanguageLabel(normalizedLanguage))}` + + `` + + (isCollapsible + ? `` + : "") + + `` + + (canRunHtml + ? `` + : "") + + `` + + `
` + + `
` +
+                `` +
+                `${highlightCodeBlock(codeText, normalizedLanguage)}` +
+                `
` + + `
` + ); + }; + renderer.blockquote = ({ tokens }) => { + const parser = renderer.parser || marked.Parser; + const body = + parser && typeof parser.parse === "function" + ? parser.parse(tokens || []) + : ""; + return `
${body}
`; + }; + renderer.link = ({ href, title, tokens }) => { + const parser = renderer.parser || marked.Parser; + const label = + parser && typeof parser.parseInline === "function" + ? parser.parseInline(tokens || []) + : escapeHtml(href || ""); + if (!isSafeRenderedUrl(href)) return label; + const rawHref = String(href || "").trim(); + const parsed = new URL(rawHref, window.location.origin); + const safeHref = escapeHtml( + parsed.origin === window.location.origin && + !rawHref.match(/^[a-z][a-z0-9+.-]*:/i) + ? `${parsed.pathname}${parsed.search}${parsed.hash}` + : parsed.toString(), + ); + const safeTitle = title + ? ` title="${escapeHtml(String(title))}"` + : ""; + return ( + `` + + `${label}` + ); + }; + renderer.image = (tokenOrHref, title, text) => { + const token = + tokenOrHref && typeof tokenOrHref === "object" + ? tokenOrHref + : { href: tokenOrHref, title, text }; + const href = String(token.href || "").trim(); + const label = String(token.text || "").trim(); + return isSafeRenderedImageUrl(href) + ? chatImageMarkup(href, label) + : escapeHtml(label); + }; + return renderer; + } + + function renderChatContent(content, useMarkdown, options = {}) { + const text = String(content || ""); + const attachments = Array.isArray(options.attachments) + ? options.attachments + : []; + const attachmentByUid = new Map(); + attachments.forEach((item) => { + const uid = String((item && item.uid) || "").trim(); + if (uid) attachmentByUid.set(uid, item); + }); + + const attachmentPattern = + /<(?:attachment|pic)\s+uid=["']([^"']+)["']\s*\/?\s*>/gi; + const attachmentPlaceholders = []; + const step0 = text.replace(attachmentPattern, (_match, uid) => { + const trimmedUid = String(uid || "").trim(); + const attachment = attachmentByUid.get(trimmedUid) || null; + if (attachment && !attachmentIsImage(attachment)) return ""; + if (!attachment && !trimmedUid.startsWith("pic_")) return ""; + const source = attachmentPreviewUrl(trimmedUid, attachment); + if (!source) return ""; + const idx = attachmentPlaceholders.length; + attachmentPlaceholders.push( + chatImageMarkup( + source, + (attachment && + (attachment.display_name || attachment.name)) || + "", + ), + ); + return `ATTACHPH${idx}ATTACHPH`; + }); + + // Extract CQ file codes into placeholders + const filePattern = /\[CQ:file,([^\]]+)\]/g; + const filePlaceholders = []; + const step1 = step0.replace(filePattern, (_match, attrStr) => { + const attrs = parseCqAttributes(attrStr); + const idx = filePlaceholders.length; + filePlaceholders.push(renderFileCard(attrs)); + return `CQFILEPH${idx}CQFILEPH`; + }); + + // Extract CQ image codes into placeholders before markdown parsing + const imagePattern = /\[CQ:image,([^\]]+)\]/g; + const images = []; + const processed = step1.replace(imagePattern, (match, attrStr) => { + const attrs = parseCqAttributes(attrStr); + const src = resolveCqImageSource(attrs); + if (src) { + const idx = images.length; + images.push(chatImageMarkup(src)); + return `CQIMGPH${idx}CQIMGPH`; + } + return match; + }); + + let html; + if (useMarkdown && looksLikeStandaloneHtml(processed)) { + html = sanitizeHtmlSnippet(processed); + } else if ( + useMarkdown && + typeof marked !== "undefined" && + marked.parse + ) { + try { + html = marked.parse(processed, { + breaks: true, + gfm: true, + renderer: createSafeMarkedRenderer(), + }); + } catch (_e) { + html = escapeHtml(processed); + } + } else { + html = escapeHtml(processed); + } + + // Restore placeholders + for (let i = 0; i < images.length; i++) { + html = html.replace( + new RegExp(`CQIMGPH${i}CQIMGPH`, "g"), + images[i], + ); + } + for (let i = 0; i < filePlaceholders.length; i++) { + // marked may wrap placeholder in

, strip it for block-level card + html = html.replace( + new RegExp(`

\\s*CQFILEPH${i}CQFILEPH\\s*

`, "g"), + filePlaceholders[i], + ); + html = html.replace( + new RegExp(`CQFILEPH${i}CQFILEPH`, "g"), + filePlaceholders[i], + ); + } + // Restore attachment image placeholders + for (let i = 0; i < attachmentPlaceholders.length; i++) { + html = html.replace( + new RegExp(`ATTACHPH${i}ATTACHPH`, "g"), + attachmentPlaceholders[i], + ); + } + + return html || escapeHtml(text); + } + + function openChatImageViewer(image) { + if (!image) return; + const source = String( + image.currentSrc || image.getAttribute("src") || "", + ).trim(); + if (!source) return; + const viewer = get("runtimeChatImageViewer"); + const viewerImage = get("runtimeChatImageViewerImage"); + const caption = get("runtimeChatImageViewerCaption"); + const closeButton = get("btnRuntimeChatImageViewerClose"); + if (!viewer || !viewerImage) return; + const alt = String(image.getAttribute("alt") || "").trim(); + runtimeState.imageViewerPreviousFocus = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + viewerImage.src = source; + viewerImage.alt = alt || t("runtime.image_preview"); + if (caption) { + caption.textContent = + alt && alt !== "image" ? alt : t("runtime.image_preview"); + } + viewer.hidden = false; + viewer.classList.add("is-open"); + viewer.setAttribute("aria-hidden", "false"); + if (closeButton) closeButton.focus({ preventScroll: true }); + } + + function closeChatImageViewer() { + const viewer = get("runtimeChatImageViewer"); + const viewerImage = get("runtimeChatImageViewerImage"); + if (!viewer) return; + viewer.classList.remove("is-open"); + viewer.hidden = true; + viewer.setAttribute("aria-hidden", "true"); + if (viewerImage) { + viewerImage.removeAttribute("src"); + viewerImage.alt = ""; + } + const previousFocus = runtimeState.imageViewerPreviousFocus; + runtimeState.imageViewerPreviousFocus = null; + if ( + previousFocus && + typeof previousFocus.focus === "function" && + document.contains(previousFocus) + ) { + previousFocus.focus({ preventScroll: true }); + } + } + + function decodeCodeBlockPayload(block) { + const encoded = String((block && block.dataset.code) || ""); + if (!encoded) return ""; + try { + return decodeURIComponent(encoded); + } catch (_error) { + return ""; + } + } + + async function copyTextToClipboard(text) { + const value = String(text || ""); + if (!value) return false; + if ( + navigator.clipboard && + typeof navigator.clipboard.writeText === "function" + ) { + try { + await navigator.clipboard.writeText(value); + return true; + } catch (_error) { + // fall through to textarea fallback + } + } + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch (_error) { + ok = false; + } finally { + textarea.remove(); + } + return ok; + } + + async function copyCodeBlock(block) { + const text = decodeCodeBlockPayload(block); + const ok = await copyTextToClipboard(text); + showToast( + ok ? t("runtime.code_copied") : t("runtime.copy_failed"), + ok ? "success" : "error", + 1800, + ); + } + + function runHtmlCodeBlock(block) { + const code = decodeCodeBlockPayload(block); + if (!code) return; + if (typeof openHtmlRunner === "function") { + openHtmlRunner(code, { + language: String((block && block.dataset.language) || "html"), + }); + return; + } + showToast(t("runtime.run_html"), "info", 1200); + } + + function toggleCodeBlock(block) { + if (!block) return; + const nextCollapsed = !block.classList.contains("is-collapsed"); + block.classList.toggle("is-collapsed", nextCollapsed); + const button = block.querySelector("[data-code-toggle]"); + if (button) { + button.textContent = nextCollapsed + ? button.getAttribute("data-collapsed-label") || + t("runtime.expand_code") + : button.getAttribute("data-expanded-label") || + t("runtime.collapse_code"); + button.setAttribute( + "aria-expanded", + nextCollapsed ? "false" : "true", + ); + } + } + + function buildHtmlRunnerDocument(source) { + const raw = String(source || "").trim(); + if (!raw) return ""; + if (/^` + + `` + + `${raw}` + ); + } + + function createHtmlRunnerNonce() { + if ( + window.crypto && + typeof window.crypto.getRandomValues === "function" + ) { + const bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + } + return String(Date.now()) + Math.random().toString(16).slice(2); + } + + function htmlRunnerCspMeta(nonce) { + const safeNonce = escapeHtml(String(nonce || "")); + return ( + `` + ); + } + + function htmlRunnerPickerScript(nonce) { + const confirmHint = JSON.stringify(t("runtime.html_pick_confirm_hint")); + const nonceAttr = escapeHtml(String(nonce || "")); + return ``; + } + + function injectHtmlRunnerSecurity(html) { + const nonce = createHtmlRunnerNonce(); + const csp = htmlRunnerCspMeta(nonce); + const script = htmlRunnerPickerScript(nonce); + let secured = String(html || ""); + if (/]*>/i.test(secured)) { + secured = secured.replace(/]*>/i, (match) => match + csp); + } else if (/]*>/i.test(secured)) { + secured = secured.replace( + /]*>/i, + (match) => `${match}${csp}`, + ); + } else { + secured = `${csp}${secured}`; + } + if (/<\/body>/i.test(secured)) { + return secured.replace(/<\/body>/i, `${script}`); + } + return `${secured}${script}`; + } + + function syncHtmlRunnerPickModeToFrame() { + const frame = get("runtimeHtmlRunnerFrame"); + if (frame && frame.contentWindow) { + frame.contentWindow.postMessage( + { + type: "webui-html-pick", + active: !!runtimeState.htmlRunnerPickMode, + }, + "*", + ); + } + } + + function setHtmlRunnerPickMode(active) { + runtimeState.htmlRunnerPickMode = !!active; + const runner = get("runtimeHtmlRunner"); + const button = get("btnRuntimeHtmlPick"); + if (runner) runner.classList.toggle("is-picking", !!active); + if (button) { + button.textContent = active + ? t("runtime.picking_html") + : t("runtime.pick_html"); + button.classList.toggle("is-active", !!active); + button.setAttribute("aria-pressed", active ? "true" : "false"); + } + syncHtmlRunnerPickModeToFrame(); + if (active) showToast(t("runtime.html_pick_hint"), "info", 1800); + } + + function clampHtmlRunnerSize(width, height) { + const viewportWidth = Math.max( + 0, + window.innerWidth - HTML_RUNNER_VIEWPORT_MARGIN * 2, + ); + const viewportHeight = Math.max( + 0, + window.innerHeight - HTML_RUNNER_VIEWPORT_MARGIN * 2, + ); + const minWidth = Math.min(HTML_RUNNER_MIN_WIDTH, viewportWidth); + const minHeight = Math.min(HTML_RUNNER_MIN_HEIGHT, viewportHeight); + const maxWidth = Math.max(minWidth, viewportWidth); + const maxHeight = Math.max(minHeight, viewportHeight); + return { + width: Math.min(Math.max(width, minWidth), maxWidth), + height: Math.min(Math.max(height, minHeight), maxHeight), + }; + } + + function clampHtmlRunnerPosition(left, top, width, height) { + const maxLeft = Math.max( + HTML_RUNNER_VIEWPORT_MARGIN, + window.innerWidth - width - HTML_RUNNER_VIEWPORT_MARGIN, + ); + const maxTop = Math.max( + HTML_RUNNER_VIEWPORT_MARGIN, + window.innerHeight - height - HTML_RUNNER_VIEWPORT_MARGIN, + ); + return { + left: Math.min( + Math.max(Number(left), HTML_RUNNER_VIEWPORT_MARGIN), + maxLeft, + ), + top: Math.min( + Math.max(Number(top), HTML_RUNNER_VIEWPORT_MARGIN), + maxTop, + ), + }; + } + + function setHtmlRunnerRect(left, top, width, height) { + const runner = get("runtimeHtmlRunner"); + if (!runner) return; + const size = clampHtmlRunnerSize(Number(width), Number(height)); + const position = clampHtmlRunnerPosition( + Number(left), + Number(top), + size.width, + size.height, + ); + runner.style.left = `${position.left}px`; + runner.style.top = `${position.top}px`; + runner.style.width = `${size.width}px`; + runner.style.height = `${size.height}px`; + } + + function setHtmlRunnerSize(width, height) { + const runner = get("runtimeHtmlRunner"); + if (!runner) return; + const rect = runner.getBoundingClientRect(); + setHtmlRunnerRect(rect.left, rect.top, width, height); + } + + function clearHtmlRunnerInteraction(pointerId = null) { + const runner = get("runtimeHtmlRunner"); + const resize = runtimeState.htmlRunnerResize; + const drag = runtimeState.htmlRunnerDrag; + if (pointerId === null || (resize && resize.pointerId === pointerId)) { + runtimeState.htmlRunnerResize = null; + if (runner) runner.classList.remove("is-resizing"); + } + if (pointerId === null || (drag && drag.pointerId === pointerId)) { + runtimeState.htmlRunnerDrag = null; + if (runner) runner.classList.remove("is-dragging"); + } + } + + function ensureHtmlRunnerInitialRect(runner) { + if (!runner || (runner.style.left && runner.style.top)) return; + const initialWidth = Math.min( + 760, + window.innerWidth - HTML_RUNNER_VIEWPORT_MARGIN * 2, + ); + const initialHeight = Math.min( + 360, + window.innerHeight - HTML_RUNNER_VIEWPORT_MARGIN * 2, + ); + setHtmlRunnerRect( + window.innerWidth - initialWidth - 32, + window.innerHeight - initialHeight - 32, + initialWidth, + initialHeight, + ); + } + + function openHtmlRunner(source, options = {}) { + const runner = get("runtimeHtmlRunner"); + const frame = get("runtimeHtmlRunnerFrame"); + const meta = get("runtimeHtmlRunnerMeta"); + if (!runner || !frame) return; + const html = buildHtmlRunnerDocument(source); + if (!html) return; + runtimeState.htmlRunnerSource = String(source || ""); + runtimeState.htmlRunnerPickMode = false; + runner.hidden = false; + runner.classList.remove("is-picking"); + clearHtmlRunnerInteraction(); + ensureHtmlRunnerInitialRect(runner); + const button = get("btnRuntimeHtmlPick"); + if (button) { + button.textContent = t("runtime.pick_html"); + button.classList.remove("is-active"); + button.setAttribute("aria-pressed", "false"); + } + if (meta) { + meta.textContent = String(options.language || "html").toUpperCase(); + } + frame.srcdoc = injectHtmlRunnerSecurity(html); + showToast(t("runtime.html_ready"), "success", 1200); + } + + function closeHtmlRunner() { + const runner = get("runtimeHtmlRunner"); + const frame = get("runtimeHtmlRunnerFrame"); + clearHtmlRunnerInteraction(); + if (runner) runner.hidden = true; + if (frame) frame.srcdoc = ""; + runtimeState.htmlRunnerSource = ""; + runtimeState.htmlRunnerPickMode = false; + const button = get("btnRuntimeHtmlPick"); + if (button) { + button.textContent = t("runtime.pick_html"); + button.classList.remove("is-active"); + button.setAttribute("aria-pressed", "false"); + } + } + + function handleHtmlRunnerPicked(html) { + const picked = String(html || "").trim(); + if (!picked) return; + setHtmlRunnerPickMode(false); + addChatReference({ type: "html", text: picked }); + } + + function startHtmlRunnerResize(event) { + const runner = get("runtimeHtmlRunner"); + if (!runner) return; + event.preventDefault(); + event.stopPropagation(); + const pointerId = event.pointerId; + const rect = runner.getBoundingClientRect(); + runtimeState.htmlRunnerResize = { + pointerId, + startX: event.clientX, + startY: event.clientY, + startWidth: rect.width, + startHeight: rect.height, + }; + runner.classList.add("is-resizing"); + const handle = event.currentTarget; + if (handle && typeof handle.setPointerCapture === "function") { + handle.setPointerCapture(pointerId); + } + } + + function moveHtmlRunnerResize(event) { + const state = runtimeState.htmlRunnerResize; + if (!state || state.pointerId !== event.pointerId) return; + event.preventDefault(); + setHtmlRunnerSize( + state.startWidth + event.clientX - state.startX, + state.startHeight + event.clientY - state.startY, + ); + } + + function stopHtmlRunnerResize(event) { + const state = runtimeState.htmlRunnerResize; + if (!state || state.pointerId !== event.pointerId) return; + const handle = event.currentTarget; + if (handle && typeof handle.releasePointerCapture === "function") { + handle.releasePointerCapture(state.pointerId); + } + clearHtmlRunnerInteraction(state.pointerId); + } + + function startHtmlRunnerDrag(event) { + const target = event.target; + if (target instanceof Element && target.closest("button")) return; + const runner = get("runtimeHtmlRunner"); + if (!runner) return; + event.preventDefault(); + const pointerId = event.pointerId; + const rect = runner.getBoundingClientRect(); + runtimeState.htmlRunnerDrag = { + pointerId, + startX: event.clientX, + startY: event.clientY, + startLeft: rect.left, + startTop: rect.top, + startWidth: rect.width, + startHeight: rect.height, + }; + runner.classList.add("is-dragging"); + const handle = event.currentTarget; + if (handle && typeof handle.setPointerCapture === "function") { + handle.setPointerCapture(pointerId); + } + } + + function moveHtmlRunnerDrag(event) { + const state = runtimeState.htmlRunnerDrag; + if (!state || state.pointerId !== event.pointerId) return; + event.preventDefault(); + setHtmlRunnerRect( + state.startLeft + event.clientX - state.startX, + state.startTop + event.clientY - state.startY, + state.startWidth, + state.startHeight, + ); + } + + function stopHtmlRunnerDrag(event) { + const state = runtimeState.htmlRunnerDrag; + if (!state || state.pointerId !== event.pointerId) return; + const handle = event.currentTarget; + if (handle && typeof handle.releasePointerCapture === "function") { + handle.releasePointerCapture(state.pointerId); + } + clearHtmlRunnerInteraction(state.pointerId); + } + + function clampVisibleHtmlRunner() { + const runner = get("runtimeHtmlRunner"); + if (!runner || runner.hidden) return; + const rect = runner.getBoundingClientRect(); + setHtmlRunnerRect(rect.left, rect.top, rect.width, rect.height); + } + + function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(new Error("File read failed")); + reader.readAsDataURL(file); + }); + } + + async function parseJsonSafe(res) { + try { + return await res.json(); + } catch (_error) { + return null; + } + } + + async function uploadChatFile(file) { + const form = new FormData(); + form.append("file", file, formatAttachmentName(file)); + const res = await api("/api/runtime/chat/files", { + method: "POST", + body: form, + }); + const data = await parseJsonSafe(res); + if (!res.ok || (data && data.error)) { + throw new Error(buildRequestError(res, data)); + } + if (!data || !data.id) { + throw new Error("missing file id"); + } + return data; + } + + async function attachmentToMessageSegment(item) { + const file = item && item.file; + if (!file) return ""; + if ( + item.kind === "image" && + Number(file.size || 0) <= CHAT_INLINE_IMAGE_MAX_BYTES + ) { + const dataUrl = await readFileAsDataUrl(file); + const base64 = String(dataUrl).split(",", 2)[1] || ""; + if (!base64) return ""; + return `[CQ:image,file=base64://${base64}]`; + } + const uploaded = await uploadChatFile(file); + const id = String(uploaded.id || ""); + const name = String(uploaded.name || item.name || "file"); + const size = Number(uploaded.size || item.size || 0); + return `[CQ:file,id=${id},name=${name},size=${size}]`; + } + + async function buildChatMessageWithAttachments( + message, + attachments, + references = runtimeState.chatReferences, + ) { + const quotedMessage = buildChatMessageWithReferences( + message, + references, + ); + const parts = [quotedMessage].filter(Boolean); + for (const item of attachments || []) { + const segment = await attachmentToMessageSegment(item); + if (segment) parts.push(segment); + } + return parts.join("\n").trim(); + } + + async function fetchJsonOrThrow(path, options = {}) { + const res = await api(path, options); + const data = await parseJsonSafe(res); + if (!res.ok || (data && data.error)) { + throw new Error(buildRequestError(res, data)); + } + return data || {}; + } + + function buildRequestError(res, payload) { + const fallback = + `${res.status} ${res.statusText || "Request failed"}`.trim(); + if (!payload || typeof payload !== "object") return fallback; + const base = payload.error ? String(payload.error) : fallback; + return payload.detail ? `${base}: ${payload.detail}` : base; + } + + function appendRuntimeApiHint(message) { + const text = String(message || "").trim(); + if (!text) return text; + const normalized = text.toLowerCase(); + const unreachable = + normalized.includes("runtime api unreachable") || + normalized.includes("failed to fetch") || + normalized.includes("networkerror") || + normalized.includes(" 502 ") || + normalized.startsWith("502 "); + if (!unreachable) return text; + const hint = t("runtime.api_start_hint"); + if (!hint || text.includes(hint)) return text; + return `${text} ${hint}`; + } + + let _memoryMutating = false; + + function renderMemoryItems(payload) { + const container = get("runtimeMemoryList"); + const meta = get("runtimeMemoryMeta"); + if (!container || !meta) return; + const items = + payload && Array.isArray(payload.items) ? payload.items : []; + const queryInfo = + payload && payload.query && typeof payload.query === "object" + ? payload.query + : {}; + if (!Array.isArray(items) || items.length === 0) { + meta.textContent = i18nFormat("runtime.total", { count: 0 }); + container.innerHTML = `
${t("runtime.empty")}
`; + return; + } + const parts = [i18nFormat("runtime.total", { count: items.length })]; + const queryText = String(queryInfo.q || "").trim(); + if (queryText) parts.push(`q=${queryText}`); + const topK = String(queryInfo.top_k || "").trim(); + if (topK) parts.push(`top_k=${topK}`); + const timeFrom = String(queryInfo.time_from || "").trim(); + if (timeFrom) parts.push(`from=${timeFrom}`); + const timeTo = String(queryInfo.time_to || "").trim(); + if (timeTo) parts.push(`to=${timeTo}`); + meta.textContent = parts.join(" · "); + container.innerHTML = items + .map((item) => { + const uuid = escapeHtml(item.uuid || ""); + const fact = escapeHtml(item.fact || ""); + const created = escapeHtml(item.created_at || ""); + return `
${uuid}
${created}
${fact}
`; + }) + .join(""); + + container.querySelectorAll(".memory-btn-edit").forEach((btn) => { + btn.addEventListener("click", () => + startEditMemory(btn.dataset.uuid), + ); + }); + container.querySelectorAll(".memory-btn-delete").forEach((btn) => { + btn.addEventListener("click", () => deleteMemory(btn.dataset.uuid)); + }); + } + + function startEditMemory(uuid) { + const container = get("runtimeMemoryList"); + if (!container) return; + const itemEl = container.querySelector( + `.runtime-list-item[data-uuid="${CSS.escape(uuid)}"]`, + ); + if (!itemEl) return; + const factEl = itemEl.querySelector(".runtime-list-fact"); + if (!factEl || factEl.dataset.editing === "true") return; + + const currentText = factEl.textContent || ""; + factEl.dataset.editing = "true"; + factEl.innerHTML = ""; + + const textarea = document.createElement("textarea"); + textarea.className = "form-control memory-edit-area"; + textarea.value = currentText; + + const actions = document.createElement("div"); + actions.className = "memory-edit-actions"; + const saveBtn = document.createElement("button"); + saveBtn.className = "btn btn-sm"; + saveBtn.textContent = "保存"; + const cancelBtn = document.createElement("button"); + cancelBtn.className = "btn btn-sm"; + cancelBtn.textContent = "取消"; + actions.append(saveBtn, cancelBtn); + factEl.append(textarea, actions); + textarea.focus(); + + cancelBtn.addEventListener("click", () => { + delete factEl.dataset.editing; + factEl.innerHTML = ""; + factEl.textContent = currentText; + }); + + saveBtn.addEventListener("click", () => + updateMemory(uuid, textarea.value), + ); + + textarea.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + e.preventDefault(); + cancelBtn.click(); + } + if (e.key === "Enter" && e.ctrlKey) { + e.preventDefault(); + saveBtn.click(); + } + }); + } + + async function createMemory() { + if (_memoryMutating) return; + const input = get("memoryCreateInput"); + if (!input) return; + const fact = String(input.value || "").trim(); + if (!fact) { + showToast("记忆内容不能为空", "warning"); + return; + } + _memoryMutating = true; + const btn = get("btnMemoryCreate"); + if (btn) btn.disabled = true; + try { + const res = await api("/api/runtime/memory", { + method: "POST", + body: JSON.stringify({ fact }), + }); + const data = await parseJsonSafe(res); + if (!res.ok || (data && data.error)) { + throw new Error(buildRequestError(res, data)); + } + showToast("记忆已添加", "success"); + input.value = ""; + await searchMemory(); + } catch (err) { + showToast(`添加失败: ${err.message || err}`, "error"); + } finally { + _memoryMutating = false; + if (btn) btn.disabled = false; + } + } + + async function updateMemory(uuid, newFact) { + const fact = String(newFact || "").trim(); + if (!fact) { + showToast("记忆内容不能为空", "warning"); + return; + } + if (_memoryMutating) return; + _memoryMutating = true; + try { + const res = await api( + `/api/runtime/memory/${encodeURIComponent(uuid)}`, + { + method: "PATCH", + body: JSON.stringify({ fact }), + }, + ); + const data = await parseJsonSafe(res); + if (!res.ok || (data && data.error)) { + throw new Error(buildRequestError(res, data)); + } + showToast("记忆已更新", "success"); + await searchMemory(); + } catch (err) { + showToast(`更新失败: ${err.message || err}`, "error"); + } finally { + _memoryMutating = false; + } + } + + async function deleteMemory(uuid) { + if (_memoryMutating) return; + if (!confirm(`确认删除记忆 ${uuid.slice(0, 8)}…?`)) return; + _memoryMutating = true; + try { + const res = await api( + `/api/runtime/memory/${encodeURIComponent(uuid)}`, + { + method: "DELETE", + }, + ); + const data = await parseJsonSafe(res); + if (!res.ok || (data && data.error)) { + throw new Error(buildRequestError(res, data)); + } + showToast("记忆已删除", "success"); + await searchMemory(); + } catch (err) { + showToast(`删除失败: ${err.message || err}`, "error"); + } finally { + _memoryMutating = false; + } + } + + function setListMessage(metaId, listId, message) { + const meta = get(metaId); + const list = get(listId); + const msg = String(message || "").trim() || t("runtime.empty"); + if (meta) meta.textContent = msg; + if (list) { + list.innerHTML = `
${escapeHtml(msg)}
`; + } + } + + function renderCognitiveItems(metaId, listId, payload) { + const meta = get(metaId); + const list = get(listId); + if (!meta || !list) return; + const items = + payload && Array.isArray(payload.items) ? payload.items : []; const count = Number.isFinite(Number(payload && payload.count)) ? Number(payload.count) : items.length; @@ -904,439 +3969,1756 @@ return; } - const preferredMetaKeys = [ - "timestamp_local", - "request_type", - "group_id", - "user_id", - "sender_id", - "entity_type", - "entity_id", - "request_id", + const preferredMetaKeys = [ + "timestamp_local", + "request_type", + "group_id", + "user_id", + "sender_id", + "entity_type", + "entity_id", + "request_id", + ]; + + list.innerHTML = items + .map((item, index) => { + const doc = escapeHtml( + String((item && item.document) || "").trim(), + ); + const md = + item && typeof item.metadata === "object" && item.metadata + ? item.metadata + : {}; + const dist = formatNumeric(item && item.distance); + const rerank = formatNumeric(item && item.rerank_score); + const timestamp = escapeHtml( + String(md.timestamp_local || "").trim(), + ); + const headLabel = timestamp || `#${index + 1}`; + const tags = []; + if (dist) + tags.push( + `distance ${dist}`, + ); + if (rerank) + tags.push( + `rerank ${rerank}`, + ); + + const metaRows = preferredMetaKeys + .filter( + (key) => + md[key] !== undefined && + md[key] !== null && + String(md[key]).trim() !== "", + ) + .map((key) => { + const raw = md[key]; + const text = + raw && typeof raw === "object" + ? JSON.stringify(raw) + : String(raw); + return `${escapeHtml(key)}${escapeHtml(text)}`; + }) + .join(""); + + return `
+
${headLabel}
${tags.join("")}
+
${doc || "--"}
+ ${metaRows ? `
${metaRows}
` : ""} +
`; + }) + .join(""); + } + + function renderProfileDetail(payload) { + const meta = get("runtimeProfileMeta"); + const container = get("runtimeProfileResult"); + if (!meta || !container) return; + if (!payload || typeof payload !== "object") { + setListMessage( + "runtimeProfileMeta", + "runtimeProfileResult", + t("runtime.empty"), + ); + return; + } + + const entityType = escapeHtml(String(payload.entity_type || "").trim()); + const entityId = escapeHtml(String(payload.entity_id || "").trim()); + const profileRaw = String(payload.profile || "").trim(); + const profile = renderStructuredText(profileRaw); + const found = !!payload.found; + const status = found ? t("runtime.found") : t("runtime.not_found"); + + meta.textContent = `${entityType || "-"} / ${entityId || "-"} · ${status}`; + container.innerHTML = `
+
${entityType || "-"}${entityId || "-"}
+
${profile || t("runtime.empty")}
+
`; + } + + function setProbeUnavailable(message) { + const msg = String(message || RUNTIME_DISABLED_ERROR); + renderInternalProbe({ error: msg }); + renderExternalProbe({ error: msg }); + } + + function setMemoryUnavailable(message) { + const msg = String(message || RUNTIME_DISABLED_ERROR); + setListMessage("runtimeMemoryMeta", "runtimeMemoryList", msg); + setListMessage("runtimeEventsMeta", "runtimeEventsResult", msg); + setListMessage("runtimeProfilesMeta", "runtimeProfilesResult", msg); + setListMessage("runtimeProfileMeta", "runtimeProfileResult", msg); + } + + async function fetchRuntimeMeta() { + return fetchJsonOrThrow([ + "/api/v1/management/runtime/meta", + "/api/runtime/meta", + ]); + } + + async function ensureRuntimeEnabled() { + if (runtimeState.runtimeMetaLoaded) { + return runtimeState.runtimeEnabled; + } + const meta = await fetchRuntimeMeta(); + runtimeState.runtimeMetaLoaded = true; + runtimeState.runtimeEnabled = !!(meta && meta.enabled); + return runtimeState.runtimeEnabled; + } + + async function fetchInternalProbe() { + const data = await fetchJsonOrThrow([ + "/api/v1/management/runtime/probes/internal", + "/api/runtime/probes/internal", + ]); + renderInternalProbe(data); + } + + async function fetchExternalProbe() { + const data = await fetchJsonOrThrow([ + "/api/v1/management/runtime/probes/external", + "/api/runtime/probes/external", + ]); + renderExternalProbe(data); + } + + async function searchMemory() { + if (!(await ensureRuntimeEnabled())) { + setMemoryUnavailable(t("runtime.disabled")); + return; + } + const query = readInputValue("runtimeMemoryQuery"); + const topK = readInputValue("runtimeMemoryTopK"); + const timeFrom = readInputValue("runtimeMemoryTimeFrom"); + const timeTo = readInputValue("runtimeMemoryTimeTo"); + const params = new URLSearchParams(); + appendQueryParam(params, "q", query); + appendPositiveIntParam(params, "top_k", topK); + appendQueryParam(params, "time_from", timeFrom); + appendQueryParam(params, "time_to", timeTo); + const data = await fetchJsonOrThrow( + `/api/runtime/memory?${params.toString()}`, + ); + renderMemoryItems(data); + } + + async function searchEvents() { + if (!(await ensureRuntimeEnabled())) { + setListMessage( + "runtimeEventsMeta", + "runtimeEventsResult", + t("runtime.disabled"), + ); + return; + } + const query = readInputValue("runtimeEventsQuery"); + if (!query) { + setListMessage( + "runtimeEventsMeta", + "runtimeEventsResult", + "q is required", + ); + return; + } + const params = new URLSearchParams(); + appendQueryParam(params, "q", query); + appendPositiveIntParam( + params, + "top_k", + readInputValue("runtimeEventsTopK"), + ); + appendQueryParam( + params, + "request_type", + readInputValue("runtimeEventsRequestType"), + ); + appendQueryParam( + params, + "target_user_id", + readInputValue("runtimeEventsTargetUserId"), + ); + appendQueryParam( + params, + "target_group_id", + readInputValue("runtimeEventsTargetGroupId"), + ); + appendQueryParam( + params, + "sender_id", + readInputValue("runtimeEventsSenderId"), + ); + appendQueryParam( + params, + "time_from", + readInputValue("runtimeEventsTimeFrom"), + ); + appendQueryParam( + params, + "time_to", + readInputValue("runtimeEventsTimeTo"), + ); + const data = await fetchJsonOrThrow( + `/api/runtime/cognitive/events?${params.toString()}`, + ); + renderCognitiveItems("runtimeEventsMeta", "runtimeEventsResult", data); + } + + async function searchProfiles() { + if (!(await ensureRuntimeEnabled())) { + setListMessage( + "runtimeProfilesMeta", + "runtimeProfilesResult", + t("runtime.disabled"), + ); + return; + } + const query = readInputValue("runtimeProfilesQuery"); + if (!query) { + setListMessage( + "runtimeProfilesMeta", + "runtimeProfilesResult", + "q is required", + ); + return; + } + const params = new URLSearchParams(); + appendQueryParam(params, "q", query); + appendPositiveIntParam( + params, + "top_k", + readInputValue("runtimeProfilesTopK"), + ); + appendQueryParam( + params, + "entity_type", + readInputValue("runtimeProfilesEntityType"), + ); + const data = await fetchJsonOrThrow( + `/api/runtime/cognitive/profiles?${params.toString()}`, + ); + renderCognitiveItems( + "runtimeProfilesMeta", + "runtimeProfilesResult", + data, + ); + } + + async function fetchProfileByEntity() { + if (!(await ensureRuntimeEnabled())) { + setListMessage( + "runtimeProfileMeta", + "runtimeProfileResult", + t("runtime.disabled"), + ); + return; + } + const entityType = readInputValue("runtimeProfileEntityType"); + const entityId = readInputValue("runtimeProfileEntityId"); + if (!entityType || !entityId) { + setListMessage( + "runtimeProfileMeta", + "runtimeProfileResult", + "entity_type/entity_id are required", + ); + return; + } + const data = await fetchJsonOrThrow( + `/api/runtime/cognitive/profile/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`, + ); + renderProfileDetail(data); + } + + async function runQueryAction(kind, buttonId, action) { + if (runtimeState.queryBusy[kind]) return; + runtimeState.queryBusy[kind] = true; + const button = get(buttonId); + setButtonLoading(button, true); + try { + await action(); + } catch (error) { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + } finally { + setButtonLoading(button, false); + runtimeState.queryBusy[kind] = false; + } + } + + function normalizeChatCommandText(value) { + return String(value || "") + .trim() + .replace(/^\/+/, "") + .toLowerCase(); + } + + function commandSearchText(command) { + const parts = [ + command && command.name, + command && command.trigger, + command && command.description, + command && command.usage, + ...((command && command.aliases) || []), + ...((command && command.alias_triggers) || []), ]; + return parts.map((item) => String(item || "").toLowerCase()).join(" "); + } - list.innerHTML = items - .map((item, index) => { - const doc = escapeHtml( - String((item && item.document) || "").trim(), - ); - const md = - item && typeof item.metadata === "object" && item.metadata - ? item.metadata - : {}; - const dist = formatNumeric(item && item.distance); - const rerank = formatNumeric(item && item.rerank_score); - const timestamp = escapeHtml( - String(md.timestamp_local || "").trim(), + function commandMatchesQuery(command, query) { + const normalized = normalizeChatCommandText(query); + if (!normalized) return true; + const name = String((command && command.name) || "").toLowerCase(); + const aliases = Array.isArray(command && command.aliases) + ? command.aliases + : []; + if (name.startsWith(normalized)) return true; + if ( + aliases.some((alias) => + String(alias || "") + .toLowerCase() + .startsWith(normalized), + ) + ) { + return true; + } + return commandSearchText(command).includes(normalized); + } + + function subcommandMatchesQuery(subcommand, query) { + const normalized = normalizeChatCommandText(query); + if (!normalized) return true; + const name = String( + (subcommand && subcommand.name) || "", + ).toLowerCase(); + const haystack = [ + subcommand && subcommand.name, + subcommand && subcommand.trigger, + subcommand && subcommand.description, + subcommand && subcommand.args, + subcommand && subcommand.usage, + ] + .map((item) => String(item || "").toLowerCase()) + .join(" "); + return name.startsWith(normalized) || haystack.includes(normalized); + } + + function buildChatCommandContext(input) { + const value = String((input && input.value) || ""); + const cursor = + input && typeof input.selectionStart === "number" + ? input.selectionStart + : value.length; + const beforeCursor = value.slice(0, cursor); + if (!beforeCursor.startsWith("/")) return null; + if (beforeCursor.includes("\n")) return null; + const afterCursor = value.slice(cursor); + if (afterCursor.includes("\n")) return null; + const leadingLine = value.split(/\r?\n/, 1)[0] || value; + if (leadingLine !== value) return null; + const tokens = beforeCursor.split(/\s+/); + const tokenCount = tokens.filter((token) => token.length > 0).length; + if (tokenCount > 2) return null; + const commandToken = tokens[0] || ""; + const commandQuery = normalizeChatCommandText(commandToken); + const hasCommandBoundary = + /\s$/.test(beforeCursor) || tokens.length > 1; + const subcommandQuery = + hasCommandBoundary && tokens.length > 1 + ? normalizeChatCommandText(tokens[tokens.length - 1] || "") + : ""; + return { + value, + cursor, + commandToken, + commandQuery, + subcommandQuery, + hasCommandBoundary, + tokenCount, + mode: hasCommandBoundary ? "subcommand" : "command", + }; + } + + function findChatCommandByNameOrAlias(name) { + const normalized = normalizeChatCommandText(name); + if (!normalized) return null; + return ( + runtimeState.chatCommands.find((command) => { + if ( + String((command && command.name) || "").toLowerCase() === + normalized + ) { + return true; + } + const aliases = Array.isArray(command && command.aliases) + ? command.aliases + : []; + return aliases.some( + (alias) => String(alias || "").toLowerCase() === normalized, ); - const headLabel = timestamp || `#${index + 1}`; - const tags = []; - if (dist) - tags.push( - `distance ${dist}`, - ); - if (rerank) - tags.push( - `rerank ${rerank}`, - ); + }) || null + ); + } - const metaRows = preferredMetaKeys - .filter( - (key) => - md[key] !== undefined && - md[key] !== null && - String(md[key]).trim() !== "", - ) - .map((key) => { - const raw = md[key]; - const text = - raw && typeof raw === "object" - ? JSON.stringify(raw) - : String(raw); - return `${escapeHtml(key)}${escapeHtml(text)}`; - }) - .join(""); + function chatCommandDisplayName(command, typedName) { + const normalizedTyped = normalizeChatCommandText(typedName); + if (!normalizedTyped) return String((command && command.name) || ""); + if ( + String((command && command.name) || "").toLowerCase() === + normalizedTyped + ) { + return String(command.name || ""); + } + const aliases = Array.isArray(command && command.aliases) + ? command.aliases + : []; + const matchedAlias = aliases.find( + (alias) => String(alias || "").toLowerCase() === normalizedTyped, + ); + return matchedAlias ? String(matchedAlias) : String(command.name || ""); + } - return `
-
${headLabel}
${tags.join("")}
-
${doc || "--"}
- ${metaRows ? `
${metaRows}
` : ""} -
`; - }) - .join(""); + async function loadChatCommands({ force = false } = {}) { + const now = Date.now(); + if ( + !force && + runtimeState.chatCommandsLoaded && + now - runtimeState.chatCommandsLoadedAt < CHAT_COMMAND_CACHE_MS + ) { + return runtimeState.chatCommands; + } + if (runtimeState.chatCommandsLoading) return runtimeState.chatCommands; + runtimeState.chatCommandsLoading = true; + try { + const data = await fetchJsonOrThrow( + "/api/runtime/commands?scope=webui", + ); + runtimeState.chatCommands = Array.isArray(data.commands) + ? data.commands + : []; + runtimeState.chatCommandsLoaded = true; + runtimeState.chatCommandsLoadedAt = Date.now(); + runtimeState.chatCommandsError = ""; + } catch (error) { + runtimeState.chatCommandsError = appendRuntimeApiHint( + error.message || error, + ); + throw error; + } finally { + runtimeState.chatCommandsLoading = false; + } + return runtimeState.chatCommands; } - function renderProfileDetail(payload) { - const meta = get("runtimeProfileMeta"); - const container = get("runtimeProfileResult"); - if (!meta || !container) return; - if (!payload || typeof payload !== "object") { - setListMessage( - "runtimeProfileMeta", - "runtimeProfileResult", - t("runtime.empty"), + function currentChatCommandMatches(context) { + if (!context) return []; + const commandMatchesForQuery = (query) => + runtimeState.chatCommands + .filter((item) => commandMatchesQuery(item, query)) + .slice(0, CHAT_COMMAND_MAX_MATCHES) + .map((item) => ({ type: "command", command: item })); + if (context.mode === "subcommand") { + const command = findChatCommandByNameOrAlias(context.commandQuery); + const subcommands = Array.isArray(command && command.subcommands) + ? command.subcommands + : []; + if (!command) { + return commandMatchesForQuery(context.commandQuery); + } + if (!subcommands.length) { + return []; + } + return subcommands + .filter((item) => + subcommandMatchesQuery(item, context.subcommandQuery), + ) + .slice(0, CHAT_COMMAND_MAX_MATCHES) + .map((item) => ({ + type: "subcommand", + command, + subcommand: item, + typedCommandName: chatCommandDisplayName( + command, + context.commandQuery, + ), + })); + } + return commandMatchesForQuery(context.commandQuery); + } + + function commandPaletteItemLabel(match) { + if (!match) return ""; + if (match.type === "subcommand") { + const commandName = match.typedCommandName || match.command.name; + return `/${commandName} ${match.subcommand.name}`; + } + return `/${match.command.name}`; + } + + function commandPaletteItemDescription(match) { + if (!match) return ""; + if (match.type === "subcommand") { + return String(match.subcommand.description || "").trim(); + } + return String(match.command.description || "").trim(); + } + + function commandPaletteItemUsage(match) { + if (!match) return ""; + if (match.type === "subcommand") { + return String( + match.subcommand.usage || match.subcommand.trigger || "", + ).trim(); + } + return String( + match.command.usage || match.command.trigger || "", + ).trim(); + } + + function commandPaletteItemMeta(match) { + if (!match || match.type !== "command") return ""; + const aliases = Array.isArray(match.command.aliases) + ? match.command.aliases + : []; + const subcommands = Array.isArray(match.command.subcommands) + ? match.command.subcommands + : []; + const parts = []; + if (aliases.length) + parts.push(aliases.map((item) => `/${item}`).join(", ")); + if (subcommands.length) { + parts.push( + i18nFormat("runtime.chat_command_subcommands", { + count: subcommands.length, + }), ); + } + return parts.join(" · "); + } + + function commandTextWithTypedTrigger(command, typedName, text) { + const raw = String(text || "").trim(); + if (!raw || !command) return raw; + const canonicalName = String(command.name || "").trim(); + const displayName = chatCommandDisplayName(command, typedName); + if (!canonicalName || !displayName) return raw; + const canonicalTrigger = `/${canonicalName}`; + const displayTrigger = `/${displayName}`; + if ( + raw === canonicalTrigger || + raw.startsWith(`${canonicalTrigger} `) + ) { + return `${displayTrigger}${raw.slice(canonicalTrigger.length)}`; + } + return raw; + } + + function commandAliasText(command) { + const aliases = Array.isArray(command && command.aliases) + ? command.aliases + : []; + return aliases + .map((item) => String(item || "").trim()) + .filter(Boolean) + .map((item) => `/${item}`) + .join(", "); + } + + function renderChatCommandNoSubcommandsHelp(command, context) { + const typedCommandName = chatCommandDisplayName( + command, + context.commandQuery, + ); + const label = `/${typedCommandName || command.name}`; + const description = String(command.description || "").trim(); + const usage = commandTextWithTypedTrigger( + command, + typedCommandName, + command.usage || command.trigger || label, + ); + const example = commandTextWithTypedTrigger( + command, + typedCommandName, + command.example || "", + ); + const aliases = commandAliasText(command); + const rows = [ + usage + ? [ + t("runtime.command_usage"), + `${escapeHtml(usage)}`, + ] + : null, + example + ? [ + t("runtime.command_example"), + `${escapeHtml(example)}`, + ] + : null, + aliases + ? [ + t("runtime.command_aliases"), + `${escapeHtml(aliases)}`, + ] + : null, + ].filter(Boolean); + return ( + `
` + + `
` + + `${escapeHtml(label)}` + + `${escapeHtml(t("runtime.command_help"))}` + + `
` + + (description + ? `
${escapeHtml(description)}
` + : "") + + (rows.length + ? `
` + + rows + .map( + ([name, value]) => + `${escapeHtml(name)}${value}`, + ) + .join("") + + `
` + : "") + + `
${escapeHtml(t("runtime.command_no_subcommands_note"))}
` + + `
` + ); + } + + function chatCommandPaletteEmptyHtml() { + const context = runtimeState.chatCommandContext; + if (!context || context.mode !== "subcommand") { + return `
${escapeHtml(t("runtime.chat_command_empty"))}
`; + } + const command = findChatCommandByNameOrAlias(context.commandQuery); + if (!command) { + return `
${escapeHtml(t("runtime.chat_command_unknown_command"))}
`; + } + const subcommands = Array.isArray(command.subcommands) + ? command.subcommands + : []; + if (!subcommands.length) { + return renderChatCommandNoSubcommandsHelp(command, context); + } + return `
${escapeHtml(t("runtime.chat_command_subcommand_empty"))}
`; + } + + function renderChatCommandPalette() { + const palette = get("runtimeChatCommandPalette"); + if (!palette) return; + const matches = runtimeState.chatCommandMatches; + palette.classList.toggle( + "is-open", + runtimeState.chatCommandPaletteOpen, + ); + if (!runtimeState.chatCommandPaletteOpen) { + palette.hidden = true; + palette.innerHTML = ""; + return; + } + palette.hidden = false; + if ( + !matches.length && + (runtimeState.chatCommandsLoading || + (!runtimeState.chatCommandsLoaded && + !runtimeState.chatCommandsError)) + ) { + palette.innerHTML = `
${escapeHtml(t("runtime.chat_command_loading"))}
`; + return; + } + if (runtimeState.chatCommandsError && !matches.length) { + palette.innerHTML = `
${escapeHtml(runtimeState.chatCommandsError)}
`; + return; + } + if (!matches.length) { + palette.innerHTML = chatCommandPaletteEmptyHtml(); return; } + const hint = + runtimeState.chatCommandContext && + runtimeState.chatCommandContext.mode === "subcommand" + ? t("runtime.chat_command_hint_subcommand") + : t("runtime.chat_command_hint"); + palette.innerHTML = + `
${escapeHtml(hint)}
` + + matches + .map((match, index) => { + const active = + index === runtimeState.chatCommandActiveIndex + ? " active" + : ""; + const label = commandPaletteItemLabel(match); + const description = commandPaletteItemDescription(match); + const usage = commandPaletteItemUsage(match); + const meta = commandPaletteItemMeta(match); + return ( + `` + ); + }) + .join(""); + } - const entityType = escapeHtml(String(payload.entity_type || "").trim()); - const entityId = escapeHtml(String(payload.entity_id || "").trim()); - const profileRaw = String(payload.profile || "").trim(); - const profile = renderStructuredText(profileRaw); - const found = !!payload.found; - const status = found ? t("runtime.found") : t("runtime.not_found"); + function openChatCommandPalette() { + runtimeState.chatCommandPaletteOpen = true; + renderChatCommandPalette(); + } - meta.textContent = `${entityType || "-"} / ${entityId || "-"} · ${status}`; - container.innerHTML = `
-
${entityType || "-"}${entityId || "-"}
-
${profile || t("runtime.empty")}
-
`; + function closeChatCommandPalette() { + runtimeState.chatCommandPaletteOpen = false; + runtimeState.chatCommandMatches = []; + runtimeState.chatCommandActiveIndex = 0; + runtimeState.chatCommandContext = null; + renderChatCommandPalette(); } - function setProbeUnavailable(message) { - const msg = String(message || RUNTIME_DISABLED_ERROR); - renderInternalProbe({ error: msg }); - renderExternalProbe({ error: msg }); + async function updateChatCommandPalette({ forceLoad = false } = {}) { + const input = get("runtimeChatInput"); + const context = buildChatCommandContext(input); + if (!context) { + closeChatCommandPalette(); + return; + } + runtimeState.chatCommandContext = context; + openChatCommandPalette(); + try { + await loadChatCommands({ force: forceLoad }); + } catch (_error) { + runtimeState.chatCommandMatches = []; + runtimeState.chatCommandActiveIndex = 0; + renderChatCommandPalette(); + return; + } + runtimeState.chatCommandMatches = currentChatCommandMatches(context); + runtimeState.chatCommandActiveIndex = Math.min( + runtimeState.chatCommandActiveIndex, + Math.max(0, runtimeState.chatCommandMatches.length - 1), + ); + renderChatCommandPalette(); } - function setMemoryUnavailable(message) { - const msg = String(message || RUNTIME_DISABLED_ERROR); - setListMessage("runtimeMemoryMeta", "runtimeMemoryList", msg); - setListMessage("runtimeEventsMeta", "runtimeEventsResult", msg); - setListMessage("runtimeProfilesMeta", "runtimeProfilesResult", msg); - setListMessage("runtimeProfileMeta", "runtimeProfileResult", msg); + function replaceChatCommandInput(match) { + const input = get("runtimeChatInput"); + const context = + runtimeState.chatCommandContext || buildChatCommandContext(input); + if (!input || !context || !match) return; + let nextValue = ""; + if (match.type === "subcommand") { + const commandName = match.typedCommandName || match.command.name; + nextValue = `/${commandName} ${match.subcommand.name}`; + } else { + nextValue = `/${match.command.name}`; + } + const usage = commandPaletteItemUsage(match); + const usageSuffix = usage.replace(nextValue, "").trim(); + if (usageSuffix) nextValue = `${nextValue} `; + input.value = nextValue; + const cursor = input.value.length; + input.setSelectionRange(cursor, cursor); + input.focus(); + closeChatCommandPalette(); + } + + function chooseActiveChatCommandMatch() { + const matches = runtimeState.chatCommandMatches; + if (!matches.length) return false; + const index = Math.min( + Math.max(runtimeState.chatCommandActiveIndex, 0), + matches.length - 1, + ); + replaceChatCommandInput(matches[index]); + return true; + } + + function moveChatCommandActive(delta) { + const matches = runtimeState.chatCommandMatches; + if (!matches.length) return false; + const next = + (runtimeState.chatCommandActiveIndex + delta + matches.length) % + matches.length; + runtimeState.chatCommandActiveIndex = next; + renderChatCommandPalette(); + return true; + } + + function chatConversationTitle(item) { + return ( + String(item && item.title ? item.title : "").trim() || + t("runtime.chat_new_conversation") + ); + } + + function updateCurrentConversationTitle() { + const titleEl = get("runtimeChatCurrentTitle"); + const metaEl = get("runtimeChatCurrentMeta"); + const conversation = runtimeState.chatConversations.find( + (item) => String(item.id) === currentChatConversationId(), + ); + if (titleEl) { + titleEl.textContent = conversation + ? chatConversationTitle(conversation) + : t("runtime.chat_new_conversation"); + } + if (metaEl) { + const count = conversation + ? Number(conversation.message_count || 0) + : 0; + const status = conversation + ? String(conversation.title_status || "") + : ""; + const parts = [ + i18nFormat("runtime.chat_message_count", { count }), + status === "pending" || status === "failed" + ? t("runtime.chat_title_pending") + : "", + ].filter(Boolean); + metaEl.textContent = parts.join(" · "); + } + } + + function renderChatConversationList() { + const list = get("runtimeChatConversations"); + if (!list) return; + const conversations = Array.isArray(runtimeState.chatConversations) + ? runtimeState.chatConversations + : []; + if (!conversations.length) { + list.innerHTML = `
${escapeHtml(t("runtime.chat_no_conversations"))}
`; + updateCurrentConversationTitle(); + return; + } + const activeId = currentChatConversationId(); + list.innerHTML = conversations + .map((item) => { + const id = String(item.id || ""); + const active = id && id === activeId; + const running = !!item.is_running; + const newlyCreated = + id && id === runtimeState.recentlyCreatedConversationId; + const title = chatConversationTitle(item); + const updated = String(item.updated_at || "").replace("T", " "); + return ( + `
` + + `` + + `` + + `` + + `
` + ); + }) + .join(""); + updateCurrentConversationTitle(); + } + + function setChatConversationDrawerOpen(open) { + runtimeState.chatConversationDrawerOpen = !!open; + const drawer = document.querySelector(".runtime-chat-sidebar"); + const toggle = get("runtimeChatConversationDrawerToggle"); + if (drawer) { + drawer.classList.toggle( + "is-open", + runtimeState.chatConversationDrawerOpen, + ); + } + if (toggle) { + toggle.setAttribute( + "aria-expanded", + runtimeState.chatConversationDrawerOpen ? "true" : "false", + ); + } } - async function fetchRuntimeMeta() { - return fetchJsonOrThrow([ - "/api/v1/management/runtime/meta", - "/api/runtime/meta", - ]); + function canToggleChatConversationDrawer() { + return window.innerWidth <= 768; } - async function ensureRuntimeEnabled() { - if (runtimeState.runtimeMetaLoaded) { - return runtimeState.runtimeEnabled; + function syncChatBusyControls() { + const sendButton = get("btnRuntimeChatSend"); + if (sendButton) { + sendButton.disabled = !!runtimeState.activeJobId; + sendButton.classList.toggle("is-loading", !!runtimeState.chatBusy); + sendButton.setAttribute( + "aria-busy", + runtimeState.chatBusy ? "true" : "false", + ); } - const meta = await fetchRuntimeMeta(); - runtimeState.runtimeMetaLoaded = true; - runtimeState.runtimeEnabled = !!(meta && meta.enabled); - return runtimeState.runtimeEnabled; + syncChatMessageActions(); } - async function fetchInternalProbe() { - const data = await fetchJsonOrThrow([ - "/api/v1/management/runtime/probes/internal", - "/api/runtime/probes/internal", - ]); - renderInternalProbe(data); + async function loadChatConversations({ selectFirst = true } = {}) { + if (runtimeState.chatConversationsLoading) return; + runtimeState.chatConversationsLoading = true; + try { + const data = await fetchJsonOrThrow( + "/api/runtime/chat/conversations", + ); + runtimeState.chatConversations = Array.isArray(data.conversations) + ? data.conversations + : []; + const activeJob = + data && data.active_job && data.active_job.job_id + ? data.active_job + : null; + if (activeJob && activeJob.conversation_id) { + const previousJobId = runtimeState.activeJobId + ? String(runtimeState.activeJobId) + : ""; + const nextJobId = String(activeJob.job_id || ""); + if (nextJobId && previousJobId !== nextJobId) { + runtimeState.lastEventSeq = 0; + runtimeState.streamingMessageId = null; + runtimeState.activeChatMessageId = null; + runtimeState.toolBlocks.clear(); + clearToolCollapseTimers(); + } + runtimeState.activeJobId = nextJobId; + runtimeState.chatBusy = true; + runtimeState.activeJobConversationId = String( + activeJob.conversation_id, + ); + runtimeState.currentChatConversationId = + runtimeState.activeJobConversationId; + } + if ( + selectFirst && + !runtimeState.currentChatConversationId && + runtimeState.chatConversations.length + ) { + runtimeState.currentChatConversationId = String( + runtimeState.chatConversations[0].id || "", + ); + } + runtimeState.chatConversationsLoaded = true; + renderChatConversationList(); + if (!runtimeState.chatConversations.length && selectFirst) { + await createChatConversation({ switchTo: true }); + } + } finally { + runtimeState.chatConversationsLoading = false; + } } - async function fetchExternalProbe() { - const data = await fetchJsonOrThrow([ - "/api/v1/management/runtime/probes/external", - "/api/runtime/probes/external", - ]); - renderExternalProbe(data); + async function createChatConversation({ switchTo = true } = {}) { + if (runtimeState.chatBusy || runtimeState.activeJobId) { + showToast(t("runtime.chat_running"), "warning", 3000); + return null; + } + const data = await fetchJsonOrThrow("/api/runtime/chat/conversations", { + method: "POST", + body: JSON.stringify({}), + }); + const conversation = + data && data.conversation ? data.conversation : null; + if (!conversation || !conversation.id) return null; + runtimeState.chatConversations = [ + conversation, + ...runtimeState.chatConversations.filter( + (item) => String(item.id) !== String(conversation.id), + ), + ]; + runtimeState.recentlyCreatedConversationId = String(conversation.id); + if (switchTo) { + await switchChatConversation(String(conversation.id)); + setChatConversationDrawerOpen(false); + } else { + renderChatConversationList(); + } + showToast(t("runtime.chat_conversation_created"), "success", 1800); + window.setTimeout(() => { + if ( + runtimeState.recentlyCreatedConversationId === + String(conversation.id) + ) { + runtimeState.recentlyCreatedConversationId = ""; + renderChatConversationList(); + } + }, 1300); + return conversation; } - async function searchMemory() { - if (!(await ensureRuntimeEnabled())) { - setMemoryUnavailable(t("runtime.disabled")); + async function renameChatConversation(conversationId) { + const id = String(conversationId || "").trim(); + if (!id) return; + const current = runtimeState.chatConversations.find( + (item) => String(item.id) === id, + ); + const nextTitle = window.prompt( + t("runtime.chat_rename_conversation"), + current ? chatConversationTitle(current) : "", + ); + if (nextTitle === null) return; + const title = String(nextTitle || "").trim(); + if (!title) return; + const data = await fetchJsonOrThrow( + `/api/runtime/chat/conversations/${encodeURIComponent(id)}`, + { + method: "PATCH", + body: JSON.stringify({ title }), + }, + ); + const updated = data && data.conversation ? data.conversation : null; + if (updated && updated.id) { + runtimeState.chatConversations = runtimeState.chatConversations.map( + (item) => + String(item.id) === String(updated.id) ? updated : item, + ); + renderChatConversationList(); + } + } + + async function deleteChatConversation(conversationId) { + const id = String(conversationId || "").trim(); + if (!id) return; + if (runtimeState.chatBusy || runtimeState.activeJobId) { + showToast(t("runtime.chat_running"), "warning", 3000); return; } - const query = readInputValue("runtimeMemoryQuery"); - const topK = readInputValue("runtimeMemoryTopK"); - const timeFrom = readInputValue("runtimeMemoryTimeFrom"); - const timeTo = readInputValue("runtimeMemoryTimeTo"); - const params = new URLSearchParams(); - appendQueryParam(params, "q", query); - appendPositiveIntParam(params, "top_k", topK); - appendQueryParam(params, "time_from", timeFrom); - appendQueryParam(params, "time_to", timeTo); - const data = await fetchJsonOrThrow( - `/api/runtime/memory?${params.toString()}`, + if (!window.confirm(t("runtime.chat_delete_confirm"))) return; + await fetchJsonOrThrow( + `/api/runtime/chat/conversations/${encodeURIComponent(id)}`, + { method: "DELETE" }, ); - renderMemoryItems(data); + runtimeState.chatConversations = runtimeState.chatConversations.filter( + (item) => String(item.id) !== id, + ); + if (currentChatConversationId() === id) { + const next = runtimeState.chatConversations[0]; + if (next && next.id) { + await switchChatConversation(String(next.id)); + } else { + runtimeState.currentChatConversationId = ""; + resetChatConversationState(); + clearChatMessages(); + renderChatConversationList(); + } + } else { + renderChatConversationList(); + } + setChatConversationDrawerOpen(false); } - async function searchEvents() { - if (!(await ensureRuntimeEnabled())) { - setListMessage( - "runtimeEventsMeta", - "runtimeEventsResult", - t("runtime.disabled"), - ); + function resetChatConversationState() { + runtimeState.chatHistoryLoaded = false; + runtimeState.chatHistoryCursor = null; + runtimeState.chatHistoryHasMore = false; + runtimeState.chatHistoryLoading = false; + runtimeState.chatTopLoadSuppressedUntil = 0; + runtimeState.lastEventSeq = 0; + runtimeState.streamingMessageId = null; + runtimeState.activeChatMessageId = null; + runtimeState.toolBlocks.clear(); + clearToolCollapseTimers(); + hideSelectionQuoteButton(); + closeChatCommandPalette(); + clearChatAttachments(); + clearChatReferences(); + const input = get("runtimeChatInput"); + if (input) input.value = ""; + } + + async function switchChatConversation(conversationId) { + const id = String(conversationId || "").trim(); + if (!id) return; + if (id === currentChatConversationId()) { + setChatConversationDrawerOpen(false); return; } - const query = readInputValue("runtimeEventsQuery"); - if (!query) { - setListMessage( - "runtimeEventsMeta", - "runtimeEventsResult", - "q is required", - ); + if ( + runtimeState.activeJobId && + runtimeState.activeJobConversationId !== id + ) { + runtimeState.currentChatConversationId = id; + resetChatConversationState(); + clearChatMessages(); + renderChatConversationList(); + await loadChatHistory(true); + syncChatBusyControls(); + setChatConversationDrawerOpen(false); return; } - const params = new URLSearchParams(); - appendQueryParam(params, "q", query); - appendPositiveIntParam( - params, - "top_k", - readInputValue("runtimeEventsTopK"), - ); - appendQueryParam( - params, - "request_type", - readInputValue("runtimeEventsRequestType"), - ); - appendQueryParam( - params, - "target_user_id", - readInputValue("runtimeEventsTargetUserId"), - ); - appendQueryParam( - params, - "target_group_id", - readInputValue("runtimeEventsTargetGroupId"), - ); - appendQueryParam( - params, - "sender_id", - readInputValue("runtimeEventsSenderId"), - ); - appendQueryParam( - params, - "time_from", - readInputValue("runtimeEventsTimeFrom"), - ); - appendQueryParam( - params, - "time_to", - readInputValue("runtimeEventsTimeTo"), - ); - const data = await fetchJsonOrThrow( - `/api/runtime/cognitive/events?${params.toString()}`, + stopChatPolling(); + stopChatClock(); + runtimeState.currentChatConversationId = id; + if (!runtimeState.activeJobId) { + runtimeState.activeJobConversationId = ""; + runtimeState.chatBusy = false; + } + setButtonLoading(get("btnRuntimeChatSend"), false); + resetChatConversationState(); + clearChatMessages(); + renderChatConversationList(); + await loadChatHistory(true); + if ( + runtimeState.activeJobId && + runtimeState.activeJobConversationId === id + ) { + ensureStreamingMessage(runtimeState.activeJobId); + attachChatJob( + runtimeState.activeJobId, + runtimeState.lastEventSeq, + ).catch(() => {}); + } + syncChatBusyControls(); + setChatConversationDrawerOpen(false); + } + + async function loadChatHistory( + force = false, + { resumeActiveJob = true } = {}, + ) { + if (!currentChatConversationId()) { + await loadChatConversations(); + } + if (!currentChatConversationId()) return; + if (runtimeState.chatHistoryLoaded && !force) { + if (resumeActiveJob) { + await resumeActiveChatJob(); + } + return; + } + runtimeState.chatHistoryLoading = true; + const res = await api( + chatUrl("/api/runtime/chat/history", { limit: 50 }), ); - renderCognitiveItems("runtimeEventsMeta", "runtimeEventsResult", data); + const data = await parseJsonSafe(res); + if (!res.ok || (data && data.error)) { + runtimeState.chatHistoryLoading = false; + throw new Error(buildRequestError(res, data)); + } + + clearChatMessages(); + const items = data && Array.isArray(data.items) ? data.items : []; + items.forEach((item) => { + appendHistoryChatItem(item, { scroll: false }); + }); + runtimeState.chatHistoryCursor = + data && data.next_before !== undefined ? data.next_before : null; + runtimeState.chatHistoryHasMore = !!(data && data.has_more); + runtimeState.chatHistoryLoaded = true; + runtimeState.chatHistoryLoading = false; + forceScrollChatToBottomSoon(); + if (resumeActiveJob) { + await resumeActiveChatJob(); + } } - async function searchProfiles() { - if (!(await ensureRuntimeEnabled())) { - setListMessage( - "runtimeProfilesMeta", - "runtimeProfilesResult", - t("runtime.disabled"), + async function loadOlderChatHistory() { + const log = get("runtimeChatLog"); + if ( + !log || + runtimeState.chatHistoryLoading || + !runtimeState.chatHistoryHasMore || + isChatTopHistoryLoadSuppressed() + ) + return; + runtimeState.chatHistoryLoading = true; + const loader = get("runtimeChatLoadMore"); + if (loader) loader.textContent = t("runtime.chat_loading_more"); + const previousHeight = log.scrollHeight; + const previousTop = log.scrollTop; + const before = runtimeState.chatHistoryCursor; + try { + const res = await api( + chatUrl("/api/runtime/chat/history", { + limit: 50, + before, + }), + ); + const data = await parseJsonSafe(res); + if (!res.ok || (data && data.error)) { + throw new Error(buildRequestError(res, data)); + } + const items = data && Array.isArray(data.items) ? data.items : []; + for (let idx = items.length - 1; idx >= 0; idx -= 1) { + appendHistoryChatItem(items[idx], { + prepend: true, + scroll: false, + }); + } + runtimeState.chatHistoryCursor = + data && data.next_before !== undefined + ? data.next_before + : null; + runtimeState.chatHistoryHasMore = !!(data && data.has_more); + log.scrollTop = previousTop + (log.scrollHeight - previousHeight); + } catch (error) { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + } finally { + runtimeState.chatHistoryLoading = false; + if (loader) loader.textContent = ""; + } + } + + function applyChatEvent(event, payload, seq = 0) { + if (seq) + runtimeState.lastEventSeq = Math.max( + runtimeState.lastEventSeq, + seq, ); + const eventJobId = + payload && payload.job_id ? String(payload.job_id) : ""; + const eventConversationId = + payload && payload.conversation_id + ? String(payload.conversation_id) + : ""; + const eventForCurrentConversation = + !eventConversationId || + eventConversationId === currentChatConversationId(); + if (event === "meta") { + if (payload && payload.job_id) { + runtimeState.activeJobId = String(payload.job_id); + runtimeState.activeJobConversationId = String( + payload.conversation_id || currentChatConversationId(), + ); + const existing = findActiveChatMessage( + runtimeState.activeJobId, + ); + if (existing) existing.dataset.jobId = runtimeState.activeJobId; + } return; } - const query = readInputValue("runtimeProfilesQuery"); - if (!query) { - setListMessage( - "runtimeProfilesMeta", - "runtimeProfilesResult", - "q is required", + if (!eventForCurrentConversation) { + if ( + (event === "done" || event === "error") && + (!eventJobId || eventJobId === runtimeState.activeJobId) + ) { + stopChatPolling(); + runtimeState.activeJobId = null; + runtimeState.activeJobConversationId = ""; + runtimeState.chatBusy = false; + setButtonLoading(get("btnRuntimeChatSend"), false); + syncChatBusyControls(); + syncChatMessageActions(); + loadChatConversations({ selectFirst: false }).catch(() => {}); + } + return; + } + if (event === "stage") { + const item = ensureStreamingMessage(eventJobId); + if (!item) return; + setChatStage(item, payload || {}); + return; + } + if (event === "agent_stage") { + upsertAgentStageBlock(payload || {}, eventJobId, seq); + return; + } + if ( + event === "tool_start" || + event === "tool_end" || + event === "agent_start" || + event === "agent_end" + ) { + upsertToolBlock(payload || {}, event, eventJobId); + return; + } + if (event === "message") { + const content = String( + payload && (payload.content ?? payload.message) + ? (payload.content ?? payload.message) + : "", + ).trim(); + if (!content) return; + const item = ensureStreamingMessage(eventJobId); + if (!item) return; + const nested = appendNestedTimelineMessage( + item, + runtimeState.toolBlocks, + payload || {}, + content, ); + if (!nested) { + appendTimelineMessage(item, content, "bot", { + attachments: payload && payload.attachments, + }); + } + finishStreamingMessage(); + scrollChatToBottomSoon(); return; } - const params = new URLSearchParams(); - appendQueryParam(params, "q", query); - appendPositiveIntParam( - params, - "top_k", - readInputValue("runtimeProfilesTopK"), - ); - appendQueryParam( - params, - "entity_type", - readInputValue("runtimeProfilesEntityType"), - ); - const data = await fetchJsonOrThrow( - `/api/runtime/cognitive/profiles?${params.toString()}`, - ); - renderCognitiveItems( - "runtimeProfilesMeta", - "runtimeProfilesResult", - data, - ); + if (event === "done") { + stopChatPolling(); + if ( + payload && + payload.reply && + runtimeState.streamingMessageId && + !( + document.querySelector( + `[data-message-id="${CSS.escape(runtimeState.streamingMessageId)}"]`, + )?.dataset.rawContent || "" + ).trim() + ) { + const item = ensureStreamingMessage(eventJobId); + if (item) { + const content = String(payload.reply); + appendTimelineMessage(item, content, "bot", { + attachments: payload && payload.attachments, + }); + scrollChatToBottomSoon(); + } + } + finalizeActiveChatMessage(payload || {}); + runtimeState.activeJobId = null; + runtimeState.activeJobConversationId = ""; + runtimeState.chatBusy = false; + runtimeState.chatHistoryLoaded = true; + setButtonLoading(get("btnRuntimeChatSend"), false); + syncChatBusyControls(); + syncChatMessageActions(); + loadChatConversations({ selectFirst: false }).catch(() => {}); + return; + } + if (event === "error") { + stopChatPolling(); + const item = findActiveChatMessage(eventJobId); + finalizeActiveChatMessage(); + removeEmptyChatMessage(item); + runtimeState.activeJobId = null; + runtimeState.activeJobConversationId = ""; + runtimeState.chatBusy = false; + setButtonLoading(get("btnRuntimeChatSend"), false); + syncChatBusyControls(); + syncChatMessageActions(); + const message = String( + payload && (payload.error || payload.message) + ? payload.error || payload.message + : "stream error", + ); + if (message === "cancelled") { + showToast(t("runtime.chat_cancelled"), "warning", 1800); + } else { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(message)}`, + "error", + 5000, + ); + } + } } - async function fetchProfileByEntity() { - if (!(await ensureRuntimeEnabled())) { - setListMessage( - "runtimeProfileMeta", - "runtimeProfileResult", - t("runtime.disabled"), + function applyChatEventsPayload(data, jobId) { + const events = data && Array.isArray(data.events) ? data.events : []; + events + .filter((entry) => entry && typeof entry === "object") + .sort((a, b) => Number(a.seq || 0) - Number(b.seq || 0)) + .forEach((entry) => { + applyChatEvent( + String(entry.event || ""), + entry.payload || {}, + Number(entry.seq || 0), + ); + }); + const job = data && data.job ? data.job : null; + applyChatJobSnapshot(job, jobId); + if ( + job && + runtimeState.activeJobId === jobId && + ["done", "error", "cancelled"].includes(String(job.status || "")) + ) { + applyChatEvent( + job.status === "done" ? "done" : "error", + job.status === "done" + ? job + : { + error: job.error || job.status, + job_id: job.job_id || jobId, + conversation_id: job.conversation_id || "", + duration_ms: job.duration_ms, + }, + Number(job.last_seq || runtimeState.lastEventSeq), ); + } + } + + function applyChatJobSnapshot(job, jobId) { + if (!job || runtimeState.activeJobId !== jobId) return; + const jobConversationId = String(job.conversation_id || "").trim(); + if ( + jobConversationId && + jobConversationId !== currentChatConversationId() + ) { return; } - const entityType = readInputValue("runtimeProfileEntityType"); - const entityId = readInputValue("runtimeProfileEntityId"); - if (!entityType || !entityId) { - setListMessage( - "runtimeProfileMeta", - "runtimeProfileResult", - "entity_type/entity_id are required", + const item = ensureStreamingMessage(jobId); + if (!item) return; + const status = String(job.status || ""); + if (!["done", "error", "cancelled"].includes(status)) { + const stage = String(job.current_stage || "").trim(); + if (stage) { + setChatStage(item, { + stage, + detail: job.current_stage_detail || "", + elapsed_ms: job.elapsed_ms, + }); + } + } + const toolCalls = Array.isArray(job.current_tool_calls) + ? job.current_tool_calls + : []; + toolCalls.forEach((payload) => { + upsertToolSnapshot(payload || {}, jobId); + }); + const agentStages = Array.isArray(job.current_agent_stages) + ? job.current_agent_stages + : []; + agentStages.forEach((payload) => { + upsertAgentStageBlock( + payload || {}, + jobId, + Number(job.last_seq || 0), ); + }); + } + + async function pollChatJob(jobId) { + if (runtimeState.activeJobId !== jobId) return; + runtimeState.chatBusy = true; + setButtonLoading(get("btnRuntimeChatSend"), true); + syncChatBusyControls(); + try { + const data = await fetchJsonOrThrow([ + ...runtimeChatJobEventsUrls(jobId, { + after: String(runtimeState.lastEventSeq), + format: "json", + }), + ]); + runtimeState.chatPollBackoffMs = CHAT_POLL_INTERVAL_MS; + applyChatEventsPayload(data, jobId); + } catch (error) { + if (runtimeState.activeJobId === jobId) { + showToast(t("runtime.chat_reconnecting"), "warning", 1800); + runtimeState.chatPollBackoffMs = Math.min( + 8000, + Math.max( + CHAT_POLL_INTERVAL_MS, + runtimeState.chatPollBackoffMs * 1.6, + ), + ); + } else { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + } + } + if (runtimeState.activeJobId !== jobId || !runtimeState.chatBusy) { + stopChatPolling(); return; } - const data = await fetchJsonOrThrow( - `/api/runtime/cognitive/profile/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`, + stopChatPolling(); + runtimeState.chatPollTimer = setTimeout(() => { + pollChatJob(jobId).catch(() => {}); + }, runtimeState.chatPollBackoffMs); + } + + async function attachChatJob(jobId, after = 0) { + stopChatPolling(); + runtimeState.activeJobId = jobId; + runtimeState.activeJobConversationId = + runtimeState.activeJobConversationId || currentChatConversationId(); + runtimeState.lastEventSeq = Number(after || 0); + runtimeState.chatBusy = true; + runtimeState.chatPollBackoffMs = CHAT_POLL_INTERVAL_MS; + startChatClock(); + setButtonLoading(get("btnRuntimeChatSend"), true); + syncChatBusyControls(); + pollChatJob(jobId).catch(() => {}); + } + + async function resumeActiveChatJob() { + const localJobId = runtimeState.activeJobId + ? String(runtimeState.activeJobId) + : ""; + const localConversationId = String( + runtimeState.activeJobConversationId || "", ); - renderProfileDetail(data); + if ( + localJobId && + localConversationId && + localConversationId !== currentChatConversationId() + ) { + return; + } + try { + const data = await fetchJsonOrThrow( + chatUrl("/api/runtime/chat/jobs/active"), + ); + const job = data && data.job ? data.job : null; + if (!job || !job.job_id) { + stopActiveJobResumeTimer(); + runtimeState.activeJobResumeAttempts = 0; + if (localJobId) { + stopChatPolling(); + stopChatClock(); + runtimeState.activeJobId = null; + runtimeState.activeJobConversationId = ""; + runtimeState.chatBusy = false; + setButtonLoading(get("btnRuntimeChatSend"), false); + syncChatBusyControls(); + await loadChatHistory(true, { resumeActiveJob: false }); + loadChatConversations({ selectFirst: false }).catch( + () => {}, + ); + } + return; + } + stopActiveJobResumeTimer(); + runtimeState.activeJobResumeAttempts = 0; + const jobId = String(job.job_id); + if (localJobId !== jobId) { + runtimeState.lastEventSeq = 0; + runtimeState.streamingMessageId = null; + runtimeState.activeChatMessageId = null; + runtimeState.toolBlocks.clear(); + clearToolCollapseTimers(); + } + if (job.conversation_id) { + runtimeState.currentChatConversationId = String( + job.conversation_id, + ); + runtimeState.activeJobConversationId = String( + job.conversation_id, + ); + renderChatConversationList(); + } + runtimeState.activeJobId = jobId; + ensureStreamingMessage(jobId); + attachChatJob(jobId, runtimeState.lastEventSeq).catch(() => {}); + } catch (_error) { + runtimeState.activeJobResumeAttempts += 1; + if ( + runtimeState.activeJobResumeAttempts > + ACTIVE_JOB_RESUME_MAX_ATTEMPTS + ) { + stopActiveJobResumeTimer(); + return; + } + const delay = Math.min( + 8000, + 1000 * runtimeState.activeJobResumeAttempts, + ); + stopActiveJobResumeTimer(); + runtimeState.activeJobResumeTimer = setTimeout(() => { + resumeActiveChatJob().catch(() => {}); + }, delay); + } } - async function runQueryAction(kind, buttonId, action) { - if (runtimeState.queryBusy[kind]) return; - runtimeState.queryBusy[kind] = true; - const button = get(buttonId); - setButtonLoading(button, true); + async function clearChatHistory() { + if (runtimeState.chatBusy || runtimeState.activeJobId) { + showToast(t("runtime.chat_running"), "warning", 3000); + return; + } + if (!window.confirm(t("runtime.chat_clear_confirm"))) return; try { - await action(); + const res = await api(chatUrl("/api/runtime/chat/history"), { + method: "DELETE", + }); + const data = await parseJsonSafe(res); + if (!res.ok || (data && data.error)) { + throw new Error(buildRequestError(res, data)); + } + clearChatMessages(); + runtimeState.chatHistoryLoaded = true; + runtimeState.chatHistoryCursor = null; + runtimeState.chatHistoryHasMore = false; + stopChatPolling(); + loadChatConversations({ selectFirst: false }).catch(() => {}); + showToast(t("runtime.chat_cleared"), "success", 2200); } catch (error) { showToast( `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, "error", 5000, ); - } finally { - setButtonLoading(button, false); - runtimeState.queryBusy[kind] = false; } } - async function loadChatHistory(force = false) { - if (runtimeState.chatHistoryLoaded && !force) return; - const res = await api("/api/runtime/chat/history?limit=200"); - const data = await parseJsonSafe(res); - if (!res.ok || (data && data.error)) { - throw new Error(buildRequestError(res, data)); + async function cancelActiveChatJob(jobId = currentChatJobId()) { + const resolvedJobId = String(jobId || "").trim(); + if (!resolvedJobId || runtimeState.chatCancelBusy) return; + runtimeState.chatCancelBusy = true; + syncChatMessageActions(); + try { + const data = await fetchJsonOrThrow( + `/api/runtime/chat/jobs/${encodeURIComponent(resolvedJobId)}/cancel`, + { method: "POST" }, + ); + applyChatJobSnapshot(data || {}, resolvedJobId); + if (runtimeState.activeJobId === resolvedJobId) { + applyChatEvent( + "error", + { + error: "cancelled", + job_id: resolvedJobId, + conversation_id: + (data && data.conversation_id) || + runtimeState.activeJobConversationId || + currentChatConversationId(), + duration_ms: data && data.duration_ms, + }, + Number(data && data.last_seq ? data.last_seq : 0), + ); + } + } catch (error) { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + } finally { + runtimeState.chatCancelBusy = false; + syncChatMessageActions(); } - - clearChatMessages(); - const items = data && Array.isArray(data.items) ? data.items : []; - items.forEach((item) => { - const role = item && item.role === "bot" ? "bot" : "user"; - const content = String((item && item.content) || "").trim(); - if (!content) return; - appendChatMessage(role, content); - }); - runtimeState.chatHistoryLoaded = true; } - async function sendChatMessage() { + async function sendChatMessage(options = {}) { if (runtimeState.chatBusy) return; const input = get("runtimeChatInput"); const button = get("btnRuntimeChatSend"); - if (!input) return; - const message = (input.value || "").trim(); - if (!message) return; + const retryMessage = String(options.retryMessage || "").trim(); + if (!input && !retryMessage) return; + if (!currentChatConversationId()) { + await createChatConversation({ switchTo: true }); + } + if (!currentChatConversationId()) return; + const message = + retryMessage || String((input && input.value) || "").trim(); + const attachments = [...runtimeState.chatAttachments]; + const references = [...runtimeState.chatReferences]; + const outboundAttachments = retryMessage ? [] : attachments; + const outboundReferences = retryMessage ? [] : references; + if ( + !message && + !outboundAttachments.length && + !outboundReferences.length + ) + return; runtimeState.chatBusy = true; setButtonLoading(button, true); - appendChatMessage("user", message); - input.value = ""; + syncChatBusyControls(); try { - const res = await api("/api/runtime/chat", { - method: "POST", - headers: { Accept: "text/event-stream" }, - body: JSON.stringify({ message, stream: true }), - }); - - const contentType = ( - res.headers.get("Content-Type") || "" - ).toLowerCase(); - if (contentType.includes("text/event-stream") && res.body) { - let replied = false; - let streamError = ""; - let donePayload = null; - await consumeSse(res, (event, payload) => { - if (event === "message") { - const content = String( - payload && (payload.content ?? payload.message) - ? (payload.content ?? payload.message) - : "", - ).trim(); - if (!content) return; - appendChatMessage("bot", content); - replied = true; - return; - } - if (event === "error") { - streamError = String( - payload && (payload.error || payload.message) - ? payload.error || payload.message - : "stream error", - ); - return; - } - if (event === "done") { - donePayload = payload; - } - }); - if (streamError) { - throw new Error(streamError); - } - if (!replied && donePayload && donePayload.reply) { - appendChatMessage("bot", String(donePayload.reply)); - replied = true; - } - if (!replied) { - appendChatMessage("bot", t("runtime.empty")); - } - runtimeState.chatHistoryLoaded = true; - return; + const outboundMessage = await buildChatMessageWithAttachments( + message, + outboundAttachments, + outboundReferences, + ); + if (!outboundMessage) { + throw new Error("message is required"); + } + clearToolCollapseTimers(); + stopChatPolling(); + stopChatClock(); + runtimeState.toolBlocks.clear(); + runtimeState.streamingMessageId = null; + runtimeState.activeChatMessageId = null; + runtimeState.lastEventSeq = 0; + if (!retryMessage) { + appendChatMessage("user", outboundMessage); } + if (!retryMessage && input) { + input.value = ""; + closeChatCommandPalette(); + clearChatAttachments(); + clearChatReferences(); + } + forceScrollChatToBottomSoon(); + const res = await api("/api/runtime/chat/jobs", { + method: "POST", + body: JSON.stringify({ + message: outboundMessage, + conversation_id: currentChatConversationId(), + reuse_previous_user_message: !!retryMessage, + }), + }); const data = await parseJsonSafe(res); if (!res.ok || (data && data.error)) { throw new Error(buildRequestError(res, data)); } - - const messages = - data && Array.isArray(data.messages) ? data.messages : []; - if (messages.length > 0) { - messages.forEach((msg) => - appendChatMessage("bot", String(msg || "")), - ); - } else if (data && data.reply) { - appendChatMessage("bot", String(data.reply)); - } else { - appendChatMessage("bot", t("runtime.empty")); + const jobId = data && data.job_id ? String(data.job_id) : ""; + if (!jobId) { + throw new Error("missing job_id"); } - runtimeState.chatHistoryLoaded = true; + ensureStreamingMessage(); + forceScrollChatToBottomSoon(); + await attachChatJob(jobId, 0); } catch (error) { + runtimeState.chatBusy = false; + setButtonLoading(button, false); + syncChatBusyControls(); showToast( `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, "error", 5000, ); - } finally { - runtimeState.chatBusy = false; - setButtonLoading(button, false); } } - async function handleChatImagePicked(event) { + function retryChatMessage(item) { + const message = String( + (item && item.dataset.retryContent) || "", + ).trim(); + if (!message) return; + sendChatMessage({ retryMessage: message }).catch((error) => { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + }); + } + + function handleChatFilesPicked(event) { const input = event && event.target ? event.target : null; const files = input && input.files ? Array.from(input.files) : []; const chatInput = get("runtimeChatInput"); if (!chatInput || files.length === 0) return; try { - for (const file of files) { - if (!file || !String(file.type || "").startsWith("image/")) - continue; - const dataUrl = await readFileAsDataUrl(file); - const base64 = String(dataUrl).split(",", 2)[1] || ""; - if (!base64) continue; - if (chatInput.value && !chatInput.value.endsWith("\n")) { - chatInput.value += "\n"; - } - chatInput.value += `[CQ:image,file=base64://${base64}]`; - } - showToast(t("runtime.image_added"), "success", 1800); + addChatFiles(files, { source: "picker" }); chatInput.focus(); } catch (error) { showToast( @@ -1402,7 +5784,9 @@ }); }; const bindEnterMany = (ids, handler) => { - ids.forEach((id) => bindEnter(id, handler)); + for (const id of ids) { + bindEnter(id, handler); + } }; const probeRefresh = get("btnProbeRefresh"); @@ -1481,24 +5865,405 @@ const sendBtn = get("btnRuntimeChatSend"); if (sendBtn) sendBtn.addEventListener("click", sendChatMessage); - const imageBtn = get("btnRuntimeChatImage"); - const imageInput = get("runtimeChatImageInput"); - if (imageBtn && imageInput) { - imageBtn.addEventListener("click", () => { - imageInput.click(); + const newChatBtn = get("btnRuntimeChatNew"); + if (newChatBtn) { + newChatBtn.addEventListener("click", () => { + createChatConversation({ switchTo: true }).catch((error) => { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + }); + }); + } + + const conversationDrawerToggle = get( + "runtimeChatConversationDrawerToggle", + ); + if (conversationDrawerToggle) { + conversationDrawerToggle.addEventListener("click", () => { + if (!canToggleChatConversationDrawer()) return; + const shouldOpen = !runtimeState.chatConversationDrawerOpen; + setChatConversationDrawerOpen(shouldOpen); + }); + } + + setChatAutoScroll(readChatAutoScrollPreference(), { + persist: false, + }); + const autoScrollToggle = get("runtimeChatAutoScroll"); + if (autoScrollToggle) { + autoScrollToggle.addEventListener("change", () => { + setChatAutoScroll(autoScrollToggle.checked); + }); + } + + const chatLog = get("runtimeChatLog"); + const conversationList = get("runtimeChatConversations"); + const commandPalette = get("runtimeChatCommandPalette"); + if (commandPalette) { + commandPalette.addEventListener("pointerdown", (event) => { + event.preventDefault(); + }); + commandPalette.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const item = target.closest("[data-command-match-index]"); + if (!item) return; + const index = Number.parseInt( + item.getAttribute("data-command-match-index") || "-1", + 10, + ); + if ( + !Number.isFinite(index) || + index < 0 || + index >= runtimeState.chatCommandMatches.length + ) { + return; + } + runtimeState.chatCommandActiveIndex = index; + replaceChatCommandInput(runtimeState.chatCommandMatches[index]); + }); + commandPalette.addEventListener("mousemove", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const item = target.closest("[data-command-match-index]"); + if (!item) return; + const index = Number.parseInt( + item.getAttribute("data-command-match-index") || "-1", + 10, + ); + if ( + Number.isFinite(index) && + index >= 0 && + index < runtimeState.chatCommandMatches.length && + runtimeState.chatCommandActiveIndex !== index + ) { + runtimeState.chatCommandActiveIndex = index; + renderChatCommandPalette(); + } + }); + } + if (conversationList) { + conversationList.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const selectButton = target.closest( + "[data-conversation-select]", + ); + if (selectButton) { + switchChatConversation( + selectButton.getAttribute("data-conversation-select"), + ).catch((error) => { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + }); + return; + } + const renameButton = target.closest( + "[data-conversation-rename]", + ); + if (renameButton) { + renameChatConversation( + renameButton.getAttribute("data-conversation-rename"), + ).catch((error) => { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + }); + return; + } + const deleteButton = target.closest( + "[data-conversation-delete]", + ); + if (deleteButton) { + deleteChatConversation( + deleteButton.getAttribute("data-conversation-delete"), + ).catch((error) => { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + }); + } + }); + } + if (chatLog) { + chatLog.addEventListener("scroll", () => { + if (isChatTopHistoryLoadSuppressed()) return; + if (chatLog.scrollTop <= 32) { + loadOlderChatHistory(); + } + }); + chatLog.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const chatImage = target.closest( + ".runtime-chat-image[data-chat-image-preview]", + ); + if (chatImage) { + event.preventDefault(); + openChatImageViewer(chatImage); + return; + } + const toggleButton = target.closest("[data-code-toggle]"); + if (toggleButton) { + const block = toggleButton.closest(".runtime-code-block"); + if (block) toggleCodeBlock(block); + return; + } + const copyButton = target.closest("[data-code-copy]"); + if (copyButton) { + const block = copyButton.closest(".runtime-code-block"); + if (block) copyCodeBlock(block); + return; + } + const runButton = target.closest("[data-code-run-html]"); + if (runButton) { + const block = runButton.closest(".runtime-code-block"); + if (block) runHtmlCodeBlock(block); + return; + } + const quoteButton = target.closest("[data-quote-message]"); + if (quoteButton) { + const item = quoteButton.closest(".runtime-chat-item.bot"); + const text = chatMessageTextForQuote(item); + if (text) addChatReference({ type: "message", text }); + return; + } + const cancelButton = target.closest("[data-cancel-job]"); + if (cancelButton) { + const jobId = String( + cancelButton.getAttribute("data-cancel-job") || "", + ).trim(); + cancelActiveChatJob(jobId).catch(() => {}); + return; + } + const retryButton = target.closest("[data-retry-message]"); + if (retryButton) { + const item = retryButton.closest(".runtime-chat-item.user"); + retryChatMessage(item); + } + }); + chatLog.addEventListener("mouseup", () => { + setTimeout(maybeShowSelectionQuoteButton, 0); + }); + chatLog.addEventListener("keyup", () => { + setTimeout(maybeShowSelectionQuoteButton, 0); + }); + } + + const imageViewer = get("runtimeChatImageViewer"); + if (imageViewer) { + imageViewer.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + if ( + target.closest("[data-chat-image-viewer-close]") || + !target.closest(".runtime-chat-image-viewer-figure") + ) { + closeChatImageViewer(); + } + }); + } + + const attachBtn = get("btnRuntimeChatImage"); + const fileInput = get("runtimeChatFileInput"); + if (attachBtn && fileInput) { + attachBtn.addEventListener("click", () => { + fileInput.click(); }); - imageInput.addEventListener("change", handleChatImagePicked); + fileInput.addEventListener("change", handleChatFilesPicked); } const chatInput = get("runtimeChatInput"); if (chatInput) { + chatInput.addEventListener("focus", () => { + hideSelectionQuoteButton(); + updateChatCommandPalette().catch(() => {}); + }); chatInput.addEventListener("keydown", (event) => { + if (runtimeState.chatCommandPaletteOpen) { + if (event.key === "ArrowDown") { + if (moveChatCommandActive(1)) event.preventDefault(); + return; + } + if (event.key === "ArrowUp") { + if (moveChatCommandActive(-1)) event.preventDefault(); + return; + } + if (event.key === "Escape") { + closeChatCommandPalette(); + event.preventDefault(); + return; + } + if (event.key === "Tab") { + if (chooseActiveChatCommandMatch()) { + event.preventDefault(); + } + return; + } + if (event.key === "Enter" && !event.shiftKey) { + if (chooseActiveChatCommandMatch()) { + event.preventDefault(); + return; + } + } + } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendChatMessage(); } }); + chatInput.addEventListener("input", () => { + runtimeState.chatCommandActiveIndex = 0; + updateChatCommandPalette().catch(() => {}); + }); + chatInput.addEventListener("keyup", (event) => { + if ( + [ + "ArrowLeft", + "ArrowRight", + "Home", + "End", + "Backspace", + "Delete", + ].includes(event.key) + ) { + updateChatCommandPalette().catch(() => {}); + } + }); + chatInput.addEventListener("blur", () => { + window.setTimeout(closeChatCommandPalette, 120); + }); + chatInput.addEventListener("paste", (event) => { + const files = + event.clipboardData && event.clipboardData.files + ? Array.from(event.clipboardData.files) + : []; + if (!files.length) return; + event.preventDefault(); + addChatFiles(files, { source: "paste" }); + }); + } + const inputRow = document.querySelector(".runtime-chat-input-row"); + if (inputRow) { + inputRow.addEventListener("dragover", (event) => { + event.preventDefault(); + }); + inputRow.addEventListener("drop", (event) => { + const files = + event.dataTransfer && event.dataTransfer.files + ? Array.from(event.dataTransfer.files) + : []; + if (!files.length) return; + event.preventDefault(); + addChatFiles(files, { source: "drop" }); + if (chatInput) chatInput.focus(); + }); + } + + const htmlRunnerClose = get("btnRuntimeHtmlClose"); + if (htmlRunnerClose) { + htmlRunnerClose.addEventListener("click", closeHtmlRunner); + } + const htmlRunnerPick = get("btnRuntimeHtmlPick"); + if (htmlRunnerPick) { + htmlRunnerPick.addEventListener("click", () => { + setHtmlRunnerPickMode(!runtimeState.htmlRunnerPickMode); + }); + } + const htmlRunnerToolbar = document.querySelector( + ".runtime-html-runner-toolbar", + ); + if (htmlRunnerToolbar) { + htmlRunnerToolbar.addEventListener( + "pointerdown", + startHtmlRunnerDrag, + ); + htmlRunnerToolbar.addEventListener( + "pointermove", + moveHtmlRunnerDrag, + ); + htmlRunnerToolbar.addEventListener("pointerup", stopHtmlRunnerDrag); + htmlRunnerToolbar.addEventListener( + "pointercancel", + stopHtmlRunnerDrag, + ); + htmlRunnerToolbar.addEventListener( + "lostpointercapture", + stopHtmlRunnerDrag, + ); + } + const htmlRunnerFrame = get("runtimeHtmlRunnerFrame"); + if (htmlRunnerFrame) { + htmlRunnerFrame.addEventListener("load", () => { + syncHtmlRunnerPickModeToFrame(); + }); + } + const htmlRunnerResize = get("runtimeHtmlRunnerResize"); + if (htmlRunnerResize) { + htmlRunnerResize.addEventListener( + "pointerdown", + startHtmlRunnerResize, + ); + htmlRunnerResize.addEventListener( + "pointermove", + moveHtmlRunnerResize, + ); + htmlRunnerResize.addEventListener( + "pointerup", + stopHtmlRunnerResize, + ); + htmlRunnerResize.addEventListener( + "pointercancel", + stopHtmlRunnerResize, + ); + htmlRunnerResize.addEventListener( + "lostpointercapture", + stopHtmlRunnerResize, + ); } + window.addEventListener("pointerup", (event) => { + clearHtmlRunnerInteraction(event.pointerId); + }); + window.addEventListener("pointercancel", (event) => { + clearHtmlRunnerInteraction(event.pointerId); + }); + window.addEventListener("blur", () => { + clearHtmlRunnerInteraction(); + }); + window.addEventListener("keydown", (event) => { + if (event.key === "Escape" && imageViewer && !imageViewer.hidden) { + event.preventDefault(); + closeChatImageViewer(); + } + }); + window.addEventListener("message", (event) => { + const frame = get("runtimeHtmlRunnerFrame"); + if (!frame || event.source !== frame.contentWindow) return; + const data = event.data; + if (!data) return; + if (data.type === "webui-html-picker-ready") { + syncHtmlRunnerPickModeToFrame(); + return; + } + if (data.type !== "webui-html-picked") return; + handleHtmlRunnerPicked(data.html); + }); + window.addEventListener("resize", clampVisibleHtmlRunner); + window.addEventListener("resize", () => { + if (window.innerWidth > 768) { + setChatConversationDrawerOpen(false); + } + }); } const PROBE_REFRESH_INTERVAL = 5000; @@ -1535,13 +6300,24 @@ return; } if (tab === "chat") { - loadChatHistory().catch((error) => { - showToast( - `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, - "error", - 5000, - ); - }); + loadChatConversations() + .then(() => loadChatHistory()) + .catch((error) => { + showToast( + `${t("runtime.failed")}: ${appendRuntimeApiHint(error.message || error)}`, + "error", + 5000, + ); + resumeActiveChatJob().catch(() => {}); + }); + forceScrollChatToBottomSoon(); + window.addEventListener( + "online", + () => { + resumeActiveChatJob().catch(() => {}); + }, + { once: true }, + ); } } diff --git a/src/Undefined/webui/static/js/schedules.js b/src/Undefined/webui/static/js/schedules.js new file mode 100644 index 00000000..4dd1f12d --- /dev/null +++ b/src/Undefined/webui/static/js/schedules.js @@ -0,0 +1,580 @@ +(function () { + const SELF_TOOL_NAME = "scheduler.call_self"; + + const scheduleState = { + initialized: false, + loaded: false, + busy: false, + tasks: [], + selectedId: "", + draftNew: true, + search: "", + }; + + function i18nFormat(key, params = {}) { + let text = t(key); + Object.keys(params).forEach((name) => { + text = text.replaceAll(`{${name}}`, String(params[name])); + }); + return text; + } + + function parseJsonText(value, fallback, label) { + const text = String(value || "").trim(); + if (!text) return fallback; + try { + return JSON.parse(text); + } catch (error) { + throw new Error(`${label}: ${error.message || error}`); + } + } + + function prettyJson(value) { + return JSON.stringify(value === undefined ? null : value, null, 2); + } + + async function parseJsonSafe(response) { + try { + return await response.json(); + } catch (_error) { + return null; + } + } + + function requestError(response, payload) { + const fallback = + `${response.status} ${response.statusText || "Request failed"}`.trim(); + if (!payload || typeof payload !== "object") return fallback; + const base = payload.error ? String(payload.error) : fallback; + return payload.detail ? `${base}: ${payload.detail}` : base; + } + + function singleSelfTool(task) { + return ( + Array.isArray(task.tools) && + task.tools.length === 1 && + task.tools[0] && + task.tools[0].tool_name === SELF_TOOL_NAME + ); + } + + function selfInstructionOfTask(task) { + const explicit = String(task.self_instruction || "").trim(); + if (explicit) return explicit; + if (task.tool_name === SELF_TOOL_NAME && task.tool_args) { + return String(task.tool_args.prompt || "").trim(); + } + if (singleSelfTool(task) && task.tools[0].tool_args) { + return String(task.tools[0].tool_args.prompt || "").trim(); + } + return ""; + } + + function modeOfTask(task) { + if (singleSelfTool(task)) return "self_instruction"; + if (task.mode === "multi" || task.mode === "self_instruction") { + return task.mode; + } + if (task.mode === "single") return "single"; + if (task.self_instruction || task.tool_name === SELF_TOOL_NAME) { + return "self_instruction"; + } + if (Array.isArray(task.tools) && task.tools.length) return "multi"; + return "single"; + } + + function modeLabel(mode) { + if (mode === "self_instruction") return t("schedules.mode_self"); + if (mode === "multi") return t("schedules.mode_multi"); + return t("schedules.mode_single"); + } + + function taskTitle(task) { + return ( + String(task.task_name || "").trim() || + String(task.task_id || "").trim() || + t("schedules.untitled") + ); + } + + function formatDateTime(value) { + const text = String(value || "").trim(); + if (!text) return "--"; + const date = new Date(text); + if (Number.isNaN(date.getTime())) return text; + return date.toLocaleString(); + } + + function setStatus(message, type = "") { + const status = get("scheduleEditorStatus"); + if (!status) return; + status.textContent = message || ""; + status.className = `status-msg ${type}`.trim(); + } + + function setPageStatus(message) { + const status = get("scheduleStatus"); + if (status) status.textContent = message || ""; + } + + function setBusy(loading) { + scheduleState.busy = !!loading; + [ + "btnSchedulesRefresh", + "btnSchedulesNew", + "btnScheduleReset", + "btnScheduleDelete", + "btnScheduleSave", + ].forEach((id) => { + const button = get(id); + if (button) button.disabled = scheduleState.busy; + }); + } + + function updateSummary() { + const total = scheduleState.tasks.length; + const selfCount = scheduleState.tasks.filter( + (task) => modeOfTask(task) === "self_instruction", + ).length; + const multiCount = scheduleState.tasks.filter( + (task) => modeOfTask(task) === "multi", + ).length; + const limitedCount = scheduleState.tasks.filter( + (task) => + task.max_executions !== null && + task.max_executions !== undefined, + ).length; + const values = { + scheduleStatTotal: total, + scheduleStatSelf: selfCount, + scheduleStatMulti: multiCount, + scheduleStatLimited: limitedCount, + }; + Object.entries(values).forEach(([id, value]) => { + const el = get(id); + if (el) el.textContent = String(value); + }); + } + + function filteredTasks() { + const query = scheduleState.search.trim().toLowerCase(); + if (!query) return scheduleState.tasks; + return scheduleState.tasks.filter((task) => { + const haystack = [ + task.task_id, + task.task_name, + task.cron, + task.tool_name, + task.self_instruction, + task.target_id, + task.target_type, + ] + .map((value) => String(value || "").toLowerCase()) + .join(" "); + return haystack.includes(query); + }); + } + + function renderList() { + updateSummary(); + const list = get("scheduleList"); + if (!list) return; + const items = filteredTasks(); + if (!items.length) { + list.innerHTML = `
${escapeHtml( + scheduleState.tasks.length + ? t("schedules.no_results") + : t("schedules.empty"), + )}
`; + return; + } + list.innerHTML = items + .map((task) => { + const taskId = String(task.task_id || ""); + const selected = taskId === scheduleState.selectedId; + const mode = modeOfTask(task); + const nextRun = formatDateTime(task.next_run_time); + const target = task.target_id + ? `${task.target_type || "group"}:${task.target_id}` + : t("schedules.no_target"); + return ``; + }) + .join(""); + list.querySelectorAll("[data-task-id]").forEach((item) => { + item.addEventListener("click", () => { + selectTask(item.getAttribute("data-task-id") || ""); + }); + }); + } + + function setMode(mode) { + const normalized = + mode === "multi" || mode === "self_instruction" ? mode : "single"; + document + .querySelectorAll('input[name="scheduleMode"]') + .forEach((input) => { + input.checked = input.value === normalized; + }); + const single = get("scheduleSingleFields"); + const multi = get("scheduleMultiFields"); + const self = get("scheduleSelfFields"); + if (single) + single.style.display = normalized === "single" ? "" : "none"; + if (multi) multi.style.display = normalized === "multi" ? "" : "none"; + if (self) + self.style.display = + normalized === "self_instruction" ? "" : "none"; + const badge = get("scheduleEditorBadge"); + if (badge) badge.textContent = modeLabel(normalized); + } + + function currentMode() { + const checked = document.querySelector( + 'input[name="scheduleMode"]:checked', + ); + return checked ? checked.value : "single"; + } + + function emptyDraft() { + return { + task_id: "", + task_name: "", + cron: "0 9 * * *", + target_type: "group", + target_id: null, + max_executions: null, + tool_name: "", + tool_args: {}, + tools: [], + execution_mode: "serial", + self_instruction: "", + }; + } + + function populateEditor(task, isNew) { + scheduleState.draftNew = !!isNew; + const source = task || emptyDraft(); + const taskIdInput = get("scheduleTaskId"); + if (taskIdInput) { + taskIdInput.value = source.task_id || ""; + taskIdInput.disabled = !scheduleState.draftNew; + } + const fields = { + scheduleTaskName: source.task_name || "", + scheduleCron: source.cron || "0 9 * * *", + scheduleTargetId: source.target_id || "", + scheduleMaxExecutions: source.max_executions || "", + scheduleToolName: + source.tool_name === SELF_TOOL_NAME + ? "" + : source.tool_name || "", + scheduleSelfInstruction: selfInstructionOfTask(source), + }; + Object.entries(fields).forEach(([id, value]) => { + const el = get(id); + if (el) el.value = String(value || ""); + }); + const targetType = get("scheduleTargetType"); + if (targetType) targetType.value = source.target_type || "group"; + const executionMode = get("scheduleExecutionMode"); + if (executionMode) + executionMode.value = source.execution_mode || "serial"; + const args = get("scheduleToolArgs"); + if (args) args.value = prettyJson(source.tool_args || {}); + const tools = get("scheduleToolsJson"); + if (tools) { + const value = + Array.isArray(source.tools) && source.tools.length + ? source.tools + : source.tool_name + ? [ + { + tool_name: source.tool_name, + tool_args: source.tool_args || {}, + }, + ] + : []; + tools.value = prettyJson(value); + } + const label = get("scheduleEditorModeLabel"); + if (label) + label.textContent = scheduleState.draftNew + ? t("schedules.editor_new") + : t("schedules.editor_edit"); + const editorId = get("scheduleEditorTaskId"); + if (editorId) editorId.textContent = source.task_id || "--"; + const deleteBtn = get("btnScheduleDelete"); + if (deleteBtn) + deleteBtn.style.display = scheduleState.draftNew ? "none" : ""; + setMode(modeOfTask(source)); + setStatus(""); + } + + function selectTask(taskId) { + const task = scheduleState.tasks.find( + (item) => item.task_id === taskId, + ); + if (!task) return; + scheduleState.selectedId = taskId; + populateEditor(task, false); + renderList(); + } + + function newTask() { + scheduleState.selectedId = ""; + populateEditor(emptyDraft(), true); + renderList(); + } + + function readPositiveInt(id, label) { + const el = get(id); + const raw = String((el && el.value) || "").trim(); + if (!raw) return null; + const value = Number.parseInt(raw, 10); + if (!Number.isFinite(value) || value < 1) { + throw new Error( + i18nFormat("schedules.positive_int_error", { label }), + ); + } + return value; + } + + function buildPayload() { + const mode = currentMode(); + const cron = String(get("scheduleCron")?.value || "").trim(); + if (!cron) throw new Error(t("schedules.cron_required")); + const payload = { + mode, + task_name: String(get("scheduleTaskName")?.value || "").trim(), + cron_expression: cron, + target_type: String(get("scheduleTargetType")?.value || "group"), + target_id: readPositiveInt( + "scheduleTargetId", + t("schedules.target_id"), + ), + max_executions: readPositiveInt( + "scheduleMaxExecutions", + t("schedules.max_executions"), + ), + }; + if (scheduleState.draftNew) { + const taskId = String(get("scheduleTaskId")?.value || "").trim(); + if (taskId) payload.task_id = taskId; + } + + if (mode === "self_instruction") { + const instruction = String( + get("scheduleSelfInstruction")?.value || "", + ).trim(); + if (!instruction) throw new Error(t("schedules.self_required")); + payload.self_instruction = instruction; + return payload; + } + + if (mode === "multi") { + const tools = parseJsonText( + get("scheduleToolsJson")?.value, + [], + t("schedules.tools_json"), + ); + if (!Array.isArray(tools) || tools.length === 0) { + throw new Error(t("schedules.tools_required")); + } + payload.tools = tools; + payload.execution_mode = String( + get("scheduleExecutionMode")?.value || "serial", + ); + return payload; + } + + const toolName = String(get("scheduleToolName")?.value || "").trim(); + if (!toolName) throw new Error(t("schedules.tool_required")); + payload.tool_name = toolName; + payload.tool_args = parseJsonText( + get("scheduleToolArgs")?.value, + {}, + t("schedules.tool_args"), + ); + return payload; + } + + async function refresh() { + setBusy(true); + setPageStatus(t("common.loading")); + try { + const response = await api("/api/runtime/schedules", { + signal: getAbortSignal("schedules"), + }); + const payload = await parseJsonSafe(response); + if (!response.ok || (payload && payload.error)) { + throw new Error(requestError(response, payload)); + } + scheduleState.tasks = Array.isArray(payload?.items) + ? payload.items + : []; + scheduleState.loaded = true; + setPageStatus( + i18nFormat("schedules.loaded", { + count: scheduleState.tasks.length, + }), + ); + if (scheduleState.selectedId) { + const selected = scheduleState.tasks.find( + (task) => task.task_id === scheduleState.selectedId, + ); + if (selected) populateEditor(selected, false); + else newTask(); + } else if (!scheduleState.draftNew && scheduleState.tasks.length) { + selectTask(scheduleState.tasks[0].task_id); + } else { + populateEditor(emptyDraft(), true); + } + renderList(); + } catch (error) { + if (error?.name === "AbortError") return; + setPageStatus(t("runtime.failed")); + showToast( + `${t("runtime.failed")}: ${error.message || error}`, + "error", + 5000, + ); + } finally { + setBusy(false); + } + } + + async function save(event) { + if (event) event.preventDefault(); + if (scheduleState.busy) return; + let payload; + try { + payload = buildPayload(); + } catch (error) { + setStatus(error.message || String(error), "error"); + return; + } + setBusy(true); + setStatus(t("config.saving")); + try { + const url = scheduleState.draftNew + ? "/api/runtime/schedules" + : `/api/runtime/schedules/${encodeURIComponent(scheduleState.selectedId)}`; + const response = await api(url, { + method: scheduleState.draftNew ? "POST" : "PATCH", + body: JSON.stringify(payload), + }); + const data = await parseJsonSafe(response); + if (!response.ok || (data && data.error)) { + throw new Error(requestError(response, data)); + } + const task = data?.task || null; + if (task?.task_id) { + scheduleState.selectedId = task.task_id; + const index = scheduleState.tasks.findIndex( + (item) => item.task_id === task.task_id, + ); + if (index >= 0) scheduleState.tasks.splice(index, 1, task); + else scheduleState.tasks.unshift(task); + populateEditor(task, false); + } + renderList(); + setStatus(t("schedules.saved"), "success"); + showToast(t("schedules.saved"), "success"); + await refresh(); + } catch (error) { + setStatus(error.message || String(error), "error"); + showToast( + `${t("schedules.save_failed")}: ${error.message || error}`, + "error", + 5000, + ); + } finally { + setBusy(false); + } + } + + async function removeSelected() { + if ( + scheduleState.draftNew || + !scheduleState.selectedId || + scheduleState.busy + ) + return; + if (!confirm(t("schedules.confirm_delete"))) return; + setBusy(true); + try { + const response = await api( + `/api/runtime/schedules/${encodeURIComponent(scheduleState.selectedId)}`, + { method: "DELETE" }, + ); + const payload = await parseJsonSafe(response); + if (!response.ok || (payload && payload.error)) { + throw new Error(requestError(response, payload)); + } + scheduleState.tasks = scheduleState.tasks.filter( + (task) => task.task_id !== scheduleState.selectedId, + ); + showToast(t("schedules.deleted"), "success"); + newTask(); + renderList(); + await refresh(); + } catch (error) { + showToast( + `${t("runtime.failed")}: ${error.message || error}`, + "error", + 5000, + ); + } finally { + setBusy(false); + } + } + + function bindEvents() { + get("btnSchedulesRefresh")?.addEventListener("click", refresh); + get("btnSchedulesNew")?.addEventListener("click", newTask); + get("btnScheduleReset")?.addEventListener("click", () => { + if (scheduleState.selectedId) selectTask(scheduleState.selectedId); + else newTask(); + }); + get("btnScheduleDelete")?.addEventListener("click", () => { + removeSelected(); + }); + get("scheduleEditor")?.addEventListener("submit", save); + get("scheduleSearchInput")?.addEventListener("input", (event) => { + scheduleState.search = String(event.target.value || ""); + renderList(); + }); + document + .querySelectorAll('input[name="scheduleMode"]') + .forEach((input) => { + input.addEventListener("change", () => setMode(input.value)); + }); + } + + const controller = { + init() { + if (scheduleState.initialized) return; + scheduleState.initialized = true; + bindEvents(); + newTask(); + }, + onTabActivated(tab) { + if (tab !== "schedules") return; + if (typeof state !== "undefined" && !state.authenticated) return; + if (!scheduleState.loaded) refresh(); + }, + refresh, + }; + + window.SchedulesController = controller; +})(); diff --git a/src/Undefined/webui/static/js/ui.js b/src/Undefined/webui/static/js/ui.js index f455ba2c..cf42d837 100644 --- a/src/Undefined/webui/static/js/ui.js +++ b/src/Undefined/webui/static/js/ui.js @@ -5,6 +5,12 @@ function updateI18N() { document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { el.placeholder = t(el.getAttribute("data-i18n-placeholder")); }); + document.querySelectorAll("[data-i18n-aria-label]").forEach((el) => { + el.setAttribute( + "aria-label", + t(el.getAttribute("data-i18n-aria-label")), + ); + }); updateToggleLabels(); updateCommentTexts(); updateConfigSearchIndex(); diff --git a/src/Undefined/webui/static/js/vendor/highlight.min.js b/src/Undefined/webui/static/js/vendor/highlight.min.js new file mode 100644 index 00000000..6e1a09e6 --- /dev/null +++ b/src/Undefined/webui/static/js/vendor/highlight.min.js @@ -0,0 +1,1244 @@ +/*! + Highlight.js v11.11.1 (git: 08cb242e7d) + (c) 2006-2024 Josh Goebel and other contributors + License: BSD-3-Clause + */ +var hljs=function(){"use strict";function e(n){ +return n instanceof Map?n.clear=n.delete=n.set=()=>{ +throw Error("map is read-only")}:n instanceof Set&&(n.add=n.clear=n.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach((t=>{ +const a=n[t],i=typeof a;"object"!==i&&"function"!==i||Object.isFrozen(a)||e(a) +})),n}class n{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function t(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function a(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] +;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const i=e=>!!e.scope +;class r{constructor(e,n){ +this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ +this.buffer+=t(e)}openNode(e){if(!i(e))return;const n=((e,{prefix:n})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const t=e.split(".") +;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") +}return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)} +closeNode(e){i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const s=(e={})=>{const n={children:[]} +;return Object.assign(n,e),n};class o{constructor(){ +this.rootNode=s(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const n=s({scope:e}) +;this.add(n),this.stack.push(n)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ +return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), +n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +o._collapse(e)})))}}class l extends o{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,n){const t=e.root +;n&&(t.scope="language:"+n),this.add(t)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function c(e){ +return e?"string"==typeof e?e:e.source:null}function d(e){return b("(?=",e,")")} +function g(e){return b("(?:",e,")*")}function u(e){return b("(?:",e,")?")} +function b(...e){return e.map((e=>c(e))).join("")}function m(...e){const n=(e=>{ +const n=e[e.length-1] +;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} +})(e);return"("+(n.capture?"":"?:")+e.map((e=>c(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t +;let a=c(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} +i+=a.substring(0,e.index), +a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], +"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} +const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",w="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",v="\\b(0b[01]+)",N={ +begin:"\\\\[\\s\\S]",relevance:0},k={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[N]},x={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[N]},O=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, +contains:[]},t);i.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return i.contains.push({begin:b(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i +},M=O("//","$"),A=O("/\\*","\\*/"),S=O("#","$");var C=Object.freeze({ +__proto__:null,APOS_STRING_MODE:k,BACKSLASH_ESCAPE:N,BINARY_NUMBER_MODE:{ +scope:"number",begin:v,relevance:0},BINARY_NUMBER_RE:v,COMMENT:O, +C_BLOCK_COMMENT_MODE:A,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", +begin:w,relevance:0},C_NUMBER_RE:w,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:S,IDENT_RE:f, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+E,relevance:0}, +NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:x,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[N,{begin:/\[/,end:/\]/,relevance:0,contains:[N]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const n=/^#![ ]*\// +;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),a({scope:"meta",begin:n, +end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:f,relevance:0},UNDERSCORE_IDENT_RE:E, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:E,relevance:0}});function T(e,n){ +"."===e.input[e.index-1]&&n.ignoreMatch()}function R(e,n){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function D(e,n){ +n&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=T,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function I(e,n){ +Array.isArray(e.illegal)&&(e.illegal=m(...e.illegal))}function L(e,n){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function B(e,n){ +void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] +})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ +relevance:0,contains:[Object.assign(t,{endsParent:!0})] +},e.relevance=0,delete t.beforeMatch +},F=["of","and","for","in","not","or","if","then","parent","list","value"] +;function z(e,n,t="keyword"){const a=Object.create(null) +;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ +Object.assign(a,z(e[t],n,t))})),a;function i(e,t){ +n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") +;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ +return n?Number(n):(e=>F.includes(e.toLowerCase()))(e)?0:1}const U={},P=e=>{ +console.error(e)},K=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ +U[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),U[`${e}/${n}`]=!0) +},H=Error();function G(e,n,{key:t}){let a=0;const i=e[t],r={},s={} +;for(let e=1;e<=n.length;e++)s[e+a]=i[e],r[e+a]=!0,a+=p(n[e-1]) +;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function Z(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw P("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +H +;if("object"!=typeof e.beginScope||null===e.beginScope)throw P("beginScope must be object"), +H;G(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw P("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +H +;if("object"!=typeof e.endScope||null===e.endScope)throw P("endScope must be object"), +H;G(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function W(e){ +function n(n,t){ +return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) +}class t{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,n){ +n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const n=this.matcherRe.exec(e);if(!n)return null +;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] +;return n.splice(0,t),Object.assign(n,a)}}class i{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t +;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), +n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ +this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ +const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex +;let t=n.exec(e) +;if(this.resumingScanAtSamePosition())if(t&&t.index===this.lastIndex);else{ +const n=this.getMatcher(0);n.lastIndex=this.lastIndex+1,t=n.exec(e)} +return t&&(this.regexIndex+=t.position+1, +this.regexIndex===this.count&&this.considerAll()),t}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r +;if(r.isCompiled)return o +;[R,L,Z,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), +r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +l=r.keywords.$pattern, +delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=z(r.keywords,e.case_insensitive)), +o.keywordPatternRe=n(l,!0), +s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(o.endRe=n(o.end)), +o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), +r.illegal&&(o.illegalRe=n(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ +variants:null},n)))),e.cachedVariants?e.cachedVariants:Q(e)?a(e,{ +starts:e.starts?a(e.starts):null +}):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) +})),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i +;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function Q(e){ +return!!e&&(e.endsWithParent||Q(e.starts))}class X extends Error{ +constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} +const V=t,J=a,Y=Symbol("nomatch"),ee=t=>{ +const a=Object.create(null),i=Object.create(null),r=[];let s=!0 +;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:l};function _(e){ +return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" +;"object"==typeof n?(a=e, +t=n.ignoreIllegals,i=n.language):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), +q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};O("before:highlight",r) +;const s=r.result?r.result:f(r.language,r.code,t) +;return s.code=r.code,O("after:highlight",s),s}function f(e,t,i,r){ +const l=Object.create(null);function c(){if(!O.keywords)return void A.addText(S) +;let e=0;O.keywordPatternRe.lastIndex=0;let n=O.keywordPatternRe.exec(S),t="" +;for(;n;){t+=S.substring(e,n.index) +;const i=v.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,O.keywords[a]);if(r){ +const[e,a]=r +;if(A.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ +const t=v.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] +;e=O.keywordPatternRe.lastIndex,n=O.keywordPatternRe.exec(S)}var a +;t+=S.substring(e),A.addText(t)}function d(){null!=O.subLanguage?(()=>{ +if(""===S)return;let e=null;if("string"==typeof O.subLanguage){ +if(!a[O.subLanguage])return void A.addText(S) +;e=f(O.subLanguage,S,!0,M[O.subLanguage]),M[O.subLanguage]=e._top +}else e=E(S,O.subLanguage.length?O.subLanguage:null) +;O.relevance>0&&(C+=e.relevance),A.__addSublanguage(e._emitter,e.language) +})():c(),S=""}function g(e,n){ +""!==e&&(A.startScope(n),A.addText(e),A.endScope())}function u(e,n){let t=1 +;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} +const a=v.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(S=i,c(),S=""),t++}} +function b(e,n){ +return e.scope&&"string"==typeof e.scope&&A.openNode(v.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(g(S,v.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +S=""):e.beginScope._multi&&(u(e.beginScope,n),S="")),O=Object.create(e,{parent:{ +value:O}}),O}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) +;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ +return 0===O.matcher.regexIndex?(S+=e[0],1):(D=!0,0)}function h(e){ +const n=e[0],a=t.substring(e.index),i=m(O,e,a);if(!i)return Y;const r=O +;O.endScope&&O.endScope._wrap?(d(), +g(n,O.endScope._wrap)):O.endScope&&O.endScope._multi?(d(), +u(O.endScope,e)):r.skip?S+=n:(r.returnEnd||r.excludeEnd||(S+=n), +d(),r.excludeEnd&&(S=n));do{ +O.scope&&A.closeNode(),O.skip||O.subLanguage||(C+=O.relevance),O=O.parent +}while(O!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} +let y={};function w(a,r){const o=r&&r[0];if(S+=a,null==o)return d(),0 +;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ +if(S+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +;throw n.languageName=e,n.badRule=y.rule,n}return 1} +if(y=r,"begin"===r.type)return(e=>{ +const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] +;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) +;return a.skip?S+=t:(a.excludeBegin&&(S+=t), +d(),a.returnBegin||a.excludeBegin||(S=t)),b(a,e),a.returnBegin?0:t.length})(r) +;if("illegal"===r.type&&!i){ +const e=Error('Illegal lexeme "'+o+'" for mode "'+(O.scope||"")+'"') +;throw e.mode=O,e}if("end"===r.type){const e=h(r);if(e!==Y)return e} +if("illegal"===r.type&&""===o)return S+="\n",1 +;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return S+=o,o.length}const v=N(e) +;if(!v)throw P(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const k=W(v);let x="",O=r||k;const M={},A=new p.__emitter(p);(()=>{const e=[] +;for(let n=O;n!==v;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach((e=>A.openNode(e)))})();let S="",C=0,T=0,R=0,D=!1;try{ +if(v.__emitTokens)v.__emitTokens(t,A);else{for(O.matcher.considerAll();;){ +R++,D?D=!1:O.matcher.considerAll(),O.matcher.lastIndex=T +;const e=O.matcher.exec(t);if(!e)break;const n=w(t.substring(T,e.index),e) +;T=e.index+n}w(t.substring(T))}return A.finalize(),x=A.toHTML(),{language:e, +value:x,relevance:C,illegal:!1,_emitter:A,_top:O}}catch(n){ +if(n.message&&n.message.includes("Illegal"))return{language:e,value:V(t), +illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, +context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:x},_emitter:A};if(s)return{ +language:e,value:V(t),illegal:!1,relevance:0,errorRaised:n,_emitter:A,_top:O} +;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ +const n={value:V(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(N).filter(x).map((n=>f(n,e,!1))) +;i.unshift(t);const r=i.sort(((e,n)=>{ +if(e.relevance!==n.relevance)return n.relevance-e.relevance +;if(e.language&&n.language){if(N(e.language).supersetOf===n.language)return 1 +;if(N(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s +;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ +let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" +;const t=p.languageDetectRe.exec(n);if(t){const n=N(t[1]) +;return n||(K(o.replace("{}",t[1])), +K("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} +return n.split(/\s+/).find((e=>_(e)||N(e)))})(e);if(_(t))return +;if(O("before:highlightElement",{el:e,language:t +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new X("One of your code blocks includes unescaped HTML.",e.innerHTML) +;n=e;const a=n.textContent,r=t?h(a,{language:t,ignoreIllegals:!0}):E(a) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,n,t)=>{const a=n&&i[n]||t +;e.classList.add("hljs"),e.classList.add("language-"+a) +})(e,t,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),O("after:highlightElement",{el:e,result:r,text:a})}let w=!1;function v(){ +if("loading"===document.readyState)return w||window.addEventListener("DOMContentLoaded",(()=>{ +v()}),!1),void(w=!0);document.querySelectorAll(p.cssSelector).forEach(y)} +function N(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} +function k(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +i[e.toLowerCase()]=n}))}function x(e){const n=N(e) +;return n&&!n.disableAutodetect}function O(e,n){const t=e;r.forEach((e=>{ +e[t]&&e[t](n)}))}Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:v, +highlightElement:y, +highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), +q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=J(p,e)}, +initHighlighting:()=>{ +v(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +v(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ +if(P("Language definition for '{}' could not be registered.".replace("{}",e)), +!s)throw n;P(n),i=c} +i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&k(i.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete a[e] +;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, +listLanguages:()=>Object.keys(a),getLanguage:N,registerAliases:k, +autoDetection:x,inherit:J,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ +e["before:highlightBlock"](Object.assign({block:n.el},n)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ +e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, +removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.11.1",t.regex={concat:b, +lookahead:d,either:m,optional:u,anyNumberOfTimes:g} +;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t +},ne=ee({});ne.newInstance=()=>ee({});const te=e=>({IMPORTANT:{scope:"meta", +begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ +scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, +FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, +ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}),ae=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","optgroup","option","p","picture","q","quote","samp","section","select","source","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video","defs","g","marker","mask","pattern","svg","switch","symbol","feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feFlood","feGaussianBlur","feImage","feMerge","feMorphology","feOffset","feSpecularLighting","feTile","feTurbulence","linearGradient","radialGradient","stop","circle","ellipse","image","line","path","polygon","polyline","rect","text","use","textPath","tspan","foreignObject","clipPath"],ie=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"].sort().reverse(),re=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"].sort().reverse(),se=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"].sort().reverse(),oe=["accent-color","align-content","align-items","align-self","alignment-baseline","all","anchor-name","animation","animation-composition","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-range","animation-range-end","animation-range-start","animation-timeline","animation-timing-function","appearance","aspect-ratio","backdrop-filter","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-position-x","background-position-y","background-repeat","background-size","baseline-shift","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-end-end-radius","border-end-start-radius","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-start-end-radius","border-start-start-radius","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-align","box-decoration-break","box-direction","box-flex","box-flex-group","box-lines","box-ordinal-group","box-orient","box-pack","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","color-scheme","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","contain-intrinsic-block-size","contain-intrinsic-height","contain-intrinsic-inline-size","contain-intrinsic-size","contain-intrinsic-width","container","container-name","container-type","content","content-visibility","counter-increment","counter-reset","counter-set","cue","cue-after","cue-before","cursor","cx","cy","direction","display","dominant-baseline","empty-cells","enable-background","field-sizing","fill","fill-opacity","fill-rule","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flood-color","flood-opacity","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-optical-sizing","font-palette","font-size","font-size-adjust","font-smooth","font-smoothing","font-stretch","font-style","font-synthesis","font-synthesis-position","font-synthesis-small-caps","font-synthesis-style","font-synthesis-weight","font-variant","font-variant-alternates","font-variant-caps","font-variant-east-asian","font-variant-emoji","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","forced-color-adjust","gap","glyph-orientation-horizontal","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphenate-character","hyphenate-limit-chars","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","initial-letter","initial-letter-align","inline-size","inset","inset-area","inset-block","inset-block-end","inset-block-start","inset-inline","inset-inline-end","inset-inline-start","isolation","justify-content","justify-items","justify-self","kerning","left","letter-spacing","lighting-color","line-break","line-height","line-height-step","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","margin-trim","marker","marker-end","marker-mid","marker-start","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","masonry-auto-flow","math-depth","math-shift","math-style","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","offset","offset-anchor","offset-distance","offset-path","offset-position","offset-rotate","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-anchor","overflow-block","overflow-clip-margin","overflow-inline","overflow-wrap","overflow-x","overflow-y","overlay","overscroll-behavior","overscroll-behavior-block","overscroll-behavior-inline","overscroll-behavior-x","overscroll-behavior-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page","page-break-after","page-break-before","page-break-inside","paint-order","pause","pause-after","pause-before","perspective","perspective-origin","place-content","place-items","place-self","pointer-events","position","position-anchor","position-visibility","print-color-adjust","quotes","r","resize","rest","rest-after","rest-before","right","rotate","row-gap","ruby-align","ruby-position","scale","scroll-behavior","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scroll-timeline","scroll-timeline-axis","scroll-timeline-name","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","shape-rendering","speak","speak-as","src","stop-color","stop-opacity","stroke","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke-width","tab-size","table-layout","text-align","text-align-all","text-align-last","text-anchor","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-skip","text-decoration-skip-ink","text-decoration-style","text-decoration-thickness","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-size-adjust","text-transform","text-underline-offset","text-underline-position","text-wrap","text-wrap-mode","text-wrap-style","timeline-scope","top","touch-action","transform","transform-box","transform-origin","transform-style","transition","transition-behavior","transition-delay","transition-duration","transition-property","transition-timing-function","translate","unicode-bidi","user-modify","user-select","vector-effect","vertical-align","view-timeline","view-timeline-axis","view-timeline-inset","view-timeline-name","view-transition-name","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","white-space-collapse","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","x","y","z-index","zoom"].sort().reverse(),le=re.concat(se).sort().reverse() +;var ce="[0-9](_*[0-9])*",de=`\\.(${ce})`,ge="[0-9a-fA-F](_*[0-9a-fA-F])*",ue={ +className:"number",variants:[{ +begin:`(\\b(${ce})((${de})|\\.)?|(${de}))[eE][+-]?(${ce})[fFdD]?\\b`},{ +begin:`\\b(${ce})((${de})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${de})[fFdD]?\\b`},{begin:`\\b(${ce})[fFdD]\\b`},{ +begin:`\\b0[xX]((${ge})\\.?|(${ge})?\\.(${ge}))[pP][+-]?(${ce})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${ge})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function be(e,n,t){return-1===t?"":e.replace(n,(a=>be(e,n,t-1)))} +const me="[A-Za-z$_][0-9A-Za-z$_]*",pe=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends","using"],_e=["true","false","null","undefined","NaN","Infinity"],he=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],fe=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ee=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],ye=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],we=[].concat(Ee,he,fe) +;function ve(e){const n=e.regex,t=me,a={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const t=e[0].length+e.index,a=e.input[t] +;if("<"===a||","===a)return void n.ignoreMatch();let i +;">"===a&&(((e,{after:n})=>{const t="e+"\\s*\\(")), +n.concat("(?!",N.join("|"),")")),t,n.lookahead(/\s*\(/)), +className:"title.function",relevance:0};var N;const k={ +begin:n.concat(/\./,n.lookahead(n.concat(t,/(?![0-9A-Za-z$_(])/))),end:t, +excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},x={ +match:[/get|set/,/\s+/,t,/(?=\()/],className:{1:"keyword",3:"title.function"}, +contains:[{begin:/\(\)/},f] +},O="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+e.UNDERSCORE_IDENT_RE+")\\s*=>",M={ +match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(O)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[f]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{ +PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-z])/, +contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ +scope:"attr",match:t+n.lookahead(":"),relevance:0},M,{ +begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ +className:"function",begin:O,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/(\s*)\(/,end:/\)/, +excludeBegin:!0,excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0 +},{match:/\s+/,relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:a.begin, +"on:begin":a.isTrulyOpeningTag,end:a.end}],subLanguage:"xml",contains:[{ +begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},w,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[f,e.inherit(e.TITLE_MODE,{begin:t, +className:"title.function"})]},{match:/\.\.\./,relevance:0},k,{match:"\\$"+t, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[f]},v,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},E,x,{match:/\$[(.]/}]}} +const Ne=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),ke=["Protocol","Type"].map(Ne),xe=["init","self"].map(Ne),Oe=["Any","Self"],Me=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","package","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ae=["false","nil","true"],Se=["assignment","associativity","higherThan","left","lowerThan","none","right"],Ce=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],Te=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Re=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),De=m(Re,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Ie=b(Re,De,"*"),Le=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),Be=m(Le,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),$e=b(Le,Be,"*"),Fe=b(/[A-Z]/,Be,"*"),ze=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,$e,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],je=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;var Ue=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} +;Object.assign(t,{className:"variable",variants:[{ +begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE] +},r=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),s={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(o);const l={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},c=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),d={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","time","for","while","until","in","do","done","case","esac","coproc","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[c,e.SHEBANG(),d,l,r,s,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{ +className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}},grmr_c:e=>{ +const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ +match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{match:/\b(0b[01']+)/},{ +match:/(-?)\b([\d']+(\.[\d']*)?|\.[\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)/ +},{ +match:/(-?)\b(0[xX][a-fA-F0-9]+(?:'[a-fA-F0-9]+)*(?:\.[a-fA-F0-9]*(?:'[a-fA-F0-9]*)*)?(?:[pP][-+]?[0-9]+)?(l|L)?(u|U)?)/ +},{match:/(-?)\b\d+(?:'\d+)*(?:\.\d*(?:'\d*)*)?(?:[eE][-+]?\d+)?/}],relevance:0 +},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef elifdef elifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","typeof","typeof_unqual","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_BitInt","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal96","_Decimal128","_Decimal64x","_Decimal128x","_Float16","_Float32","_Float64","_Float128","_Float32x","_Float64x","_Float128x","const","static","constexpr","complex","bool","imaginary"], +literal:"true false NULL", +built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" +},b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:b.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:b.concat(["self"]),relevance:0}]),relevance:0},p={ +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, +keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/, +end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s] +}]},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, +disableAutodetect:!0,illegal:"=]/,contains:[{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, +strings:o,keywords:u}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="(?!struct)("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{ +begin:"[+-]?(?:(?:[0-9](?:'?[0-9])*\\.(?:[0-9](?:'?[0-9])*)?|\\.[0-9](?:'?[0-9])*)(?:[Ee][+-]?[0-9](?:'?[0-9])*)?|[0-9](?:'?[0-9])*[Ee][+-]?[0-9](?:'?[0-9])*|0[Xx](?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*(?:\\.(?:[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)?)?|\\.[0-9A-Fa-f](?:'?[0-9A-Fa-f])*)[Pp][+-]?[0-9](?:'?[0-9])*)(?:[Ff](?:16|32|64|128)?|(BF|bf)16|[Ll]|)" +},{ +begin:"[+-]?\\b(?:0[Bb][01](?:'?[01])*|0[Xx][0-9A-Fa-f](?:'?[0-9A-Fa-f])*|0(?:'?[0-7])*|[1-9](?:'?[0-9])*)(?:[Uu](?:LL?|ll?)|[Uu][Zz]?|(?:LL?|ll?)[Uu]?|[Zz][Uu]|)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], +keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], +literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","flat_map","flat_set","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +},b={className:"function.dispatch",relevance:0,keywords:{ +_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] +}, +begin:n.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,n.lookahead(/(<[^<>]+>|)\s*\(/)) +},m=[b,c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],p={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:m.concat(["self"]),relevance:0}]),relevance:0},_={className:"function", +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{ +begin:/:/,endsWithParent:!0,contains:[o,l]},{relevance:0,match:/,/},{ +className:"params",begin:/\(/,end:/\)/,keywords:u,relevance:0, +contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,end:/\)/,keywords:u, +relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] +},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C++", +aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"",keywords:u,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:u},{ +match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], +className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","args","async","await","by","descending","dynamic","equals","file","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","record","remove","required","scoped","select","set","unmanaged","value|0","var","when","where","with","yield"]), +built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], +literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ +begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ +begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},r=e.inherit(i,{illegal:/\n/}),s={className:"subst",begin:/\{/,end:/\}/, +keywords:n},o=e.inherit(s,{illegal:/\n/}),l={className:"string",begin:/\$"/, +end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ +},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) +;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], +o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const g={variants:[{className:"string", +begin:/"""("*)(?!")(.|\n)*?"""\1/,relevance:1 +},c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},u={begin:"<",end:">", +contains:[{beginKeywords:"in out"},t] +},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ +begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], +keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, +contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ +begin:"\x3c!--|--\x3e"},{begin:""}]}] +}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", +end:"$",keywords:{ +keyword:"if else elif endif define undef warning error line region endregion pragma checksum" +}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" +},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ +className:"string",begin:/"/,end:/"/}]},{ +beginKeywords:"new return throw await else",relevance:0},{className:"function", +begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ +beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", +relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", +begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, +contains:[g,a,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ +const n=e.regex,t=te(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ +keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, +contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ +},t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 +},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 +},t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ +begin:":("+re.join("|")+")"},{begin:":(:)?("+se.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+oe.join("|")+")\\b"},{ +begin:/:/,end:/[;}{]/, +contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,...a,{ +begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" +},contains:[...a,{className:"string",begin:/[^)]/,endsWithParent:!0, +excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", +relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ +},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:ie.join(" ")},contains:[{ +begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ +className:"selector-tag",begin:"\\b("+ae.join("|")+")\\b"}]}},grmr_diff:e=>{ +const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ +className:"meta",relevance:10, +match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) +},{className:"comment",variants:[{ +begin:n.either(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/), +end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{ +className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, +end:/$/}]}},grmr_go:e=>{const n={ +keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"], +type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], +literal:["true","false","iota","nil"], +built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] +};return{name:"Go",aliases:["golang"],keywords:n,illegal:"{const n=e.regex +;return{name:"GraphQL",aliases:["gql"],case_insensitive:!0,disableAutodetect:!1, +keywords:{ +keyword:["query","mutation","subscription","type","input","schema","directive","interface","union","scalar","fragment","enum","on"], +literal:["true","false","null"]}, +contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{ +scope:"punctuation",match:/[.]{3}/,relevance:0},{scope:"punctuation", +begin:/[\!\(\)\:\=\[\]\{\|\}]{1}/,relevance:0},{scope:"variable",begin:/\$/, +end:/\W/,excludeEnd:!0,relevance:0},{scope:"meta",match:/@\w+/,excludeEnd:!0},{ +scope:"symbol",begin:n.concat(/[_A-Za-z][_0-9A-Za-z]*/,n.lookahead(/\s*:/)), +relevance:0}],illegal:[/[;<']/,/BEGIN/]}},grmr_ini:e=>{const n=e.regex,t={ +className:"number",relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{ +begin:e.NUMBER_RE}]},a=e.COMMENT();a.variants=[{begin:/;/,end:/$/},{begin:/#/, +end:/$/}];const i={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{ +begin:/\$\{(.*?)\}/}]},r={className:"literal", +begin:/\bon|off|true|false|yes|no\b/},s={className:"string", +contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ +begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] +},o={begin:/\[/,end:/\]/,contains:[a,r,i,s,t,"self"],relevance:0 +},l=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ +name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, +contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ +begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), +className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+be("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits","goto","when"], +literal:["false","true","null"], +type:["char","boolean","long","float","int","byte","short","double"], +built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:i,illegal:/<\/|#/, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, +relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ +begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/, +className:"string",contains:[e.BACKSLASH_ESCAPE] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ +match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,t],className:{ +1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ +begin:[n.concat(/(?!else)/,t),/\s+/,t,/\s+/,/=(?!=)/],className:{1:"type", +3:"variable",5:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", +3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"new throw return else",relevance:0},{ +begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ +2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0, +contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,ue,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},ue,r]}},grmr_javascript:ve, +grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", +beginKeywords:n.join(" ")};return{name:"JSON",aliases:["jsonc"],keywords:{ +literal:n},contains:[{className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/, +relevance:1.01},{match:/[{}[\],:]/,className:"punctuation",relevance:0 +},e.QUOTE_STRING_MODE,t,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +illegal:"\\S"}},grmr_kotlin:e=>{const n={ +keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", +built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", +literal:"true false null"},t={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" +},a={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},i={ +className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", +variants:[{begin:'"""',end:'"""(?=[^"])',contains:[i,a]},{begin:"'",end:"'", +illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, +contains:[e.BACKSLASH_ESCAPE,i,a]}]};a.contains.push(r);const s={ +className:"meta", +begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" +},o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, +end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] +},l=ue,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, +contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], +{name:"Kotlin",aliases:["kt","kts"],keywords:n, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", +begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", +begin:/@\w+/}]}},t,s,o,{className:"function",beginKeywords:"fun",end:"[(]|$", +returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ +begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, +contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, +keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, +endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 +},e.C_LINE_COMMENT_MODE,c,s,o,r,e.C_NUMBER_MODE]},c]},{ +begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ +3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, +illegal:"extends implements",contains:[{ +beginKeywords:"public protected internal private constructor" +},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, +excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, +excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", +end:"$",illegal:"\n"},l]}},grmr_less:e=>{ +const n=te(e),t=le,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, +relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", +attribute:ie.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +relevance:0} +;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ +begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", +excludeEnd:!0} +},n.HEXCOLOR,d,l("variable","@@?"+a,10),l("variable","@\\{"+a+"\\}"),l("built_in","~?`[^`]*?`"),{ +className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 +},n.IMPORTANT,{beginKeywords:"and not"},n.FUNCTION_DISPATCH);const g=s.concat({ +begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, +contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", +returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+oe.join("|")+")\\b", +end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] +},m={className:"keyword", +begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", +starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:s,relevance:0}},p={ +className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a +}],starts:{end:"[;}]",returnEnd:!0,contains:g}},_={variants:[{ +begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, +returnEnd:!0,illegal:"[<='$\"]",relevance:0, +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ +begin:"\\b("+ae.join("|")+")\\b",className:"selector-tag" +},n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ +className:"selector-pseudo",begin:":("+re.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+se.join("|")+")"},{begin:/\(/, +end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ +begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} +;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), +{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:r}}, +grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] +},i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 +})];return{name:"Lua",aliases:["pluto"],keywords:{ +$pattern:e.UNDERSCORE_IDENT_RE,literal:"true false nil", +keyword:"and break do else elseif end for goto if in local not or repeat return then until while", +built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" +},contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", +contains:[e.inherit(e.TITLE_MODE,{ +begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", +begin:"\\(",endsWithParent:!0,contains:i}].concat(i) +},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", +begin:n,end:t,contains:[a],relevance:5}])}},grmr_makefile:e=>{const n={ +className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%{ +const n={begin:/<\/?[A-Za-z_]/,end:">",subLanguage:"xml",relevance:0},t={ +variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0},{ +begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, +relevance:2},{ +begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), +relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ +begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/ +},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, +returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", +excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", +end:"\\]",excludeBegin:!0,excludeEnd:!0}]},a={className:"strong",contains:[], +variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] +},i={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ +begin:/_(?![_\s])/,end:/_/,relevance:0}]},r=e.inherit(a,{contains:[] +}),s=e.inherit(i,{contains:[]});a.contains.push(s),i.contains.push(r) +;let o=[n,t];return[a,i,r,s].forEach((e=>{e.contains=e.contains.concat(o) +})),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:o},{ +begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", +contains:o}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", +end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:o, +end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ +begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ +begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ +className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]},{scope:"literal", +match:/&([a-zA-Z0-9]+|#[0-9]{1,7}|#[Xx][0-9a-fA-F]{1,6});/}]}}, +grmr_objectivec:e=>{const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, +keyword:["@interface","@class","@protocol","@implementation"]};return{ +name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], +keywords:{"variable.language":["this","super"],$pattern:n, +keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], +literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], +built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"], +type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"] +},illegal:"/,end:/$/,illegal:"\\n" +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", +begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, +contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, +relevance:0}]}},grmr_perl:e=>{const n=e.regex,t=/[dualxmsipngr]{0,12}/,a={ +$pattern:/[\w.]+/, +keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot class close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl field fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map method mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" +},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:a},r={begin:/->\{/, +end:/\}/},s={scope:"attr",match:/\s+:\s*\w+(\s*\(.*?\))?/},o={scope:"variable", +variants:[{begin:/\$\d/},{ +begin:n.concat(/[$%@](?!")(\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") +},{begin:/[$%@](?!")[^\s\w{=]|\$=/,relevance:0}],contains:[s]},l={ +className:"number",variants:[{match:/0?\.[0-9][0-9_]+\b/},{ +match:/\bv?(0|[1-9][0-9_]*(\.[0-9_]+)?|[1-9][0-9_]*)\b/},{ +match:/\b0[0-7][0-7_]*\b/},{match:/\b0x[0-9a-fA-F][0-9a-fA-F_]*\b/},{ +match:/\b0b[0-1][0-1_]*\b/}],relevance:0 +},c=[e.BACKSLASH_ESCAPE,i,o],d=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],g=(e,a,i="\\1")=>{ +const r="\\1"===i?i:n.concat(i,a) +;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) +},u=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),b=[o,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ +endsWithParent:!0}),r,{className:"string",contains:c,variants:[{ +begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", +end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ +begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", +relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", +contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ +begin:"-?\\w+\\s*=>",relevance:0}]},l,{ +begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", +keywords:"split return print reverse grep",relevance:0, +contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ +begin:g("s|tr|y",n.either(...d,{capture:!0}))},{begin:g("s|tr|y","\\(","\\)")},{ +begin:g("s|tr|y","\\[","\\]")},{begin:g("s|tr|y","\\{","\\}")}],relevance:2},{ +className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ +begin:u("(?:m|qr)?",/\//,/\//)},{begin:u("m|qr",n.either(...d,{capture:!0 +}),/\1/)},{begin:u("m|qr",/\(/,/\)/)},{begin:u("m|qr",/\[/,/\]/)},{ +begin:u("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub method", +end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE,s] +},{className:"class",beginKeywords:"class",end:"[;{]",excludeEnd:!0,relevance:5, +contains:[e.TITLE_MODE,s,l]},{begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$", +end:"^__END__$",subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$", +className:"comment"}]}];return i.contains=b,r.contains=b,{name:"Perl", +aliases:["pl","pm"],keywords:a,contains:b}},grmr_php:e=>{ +const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r=n.concat(/[A-Z]+/,t),s={ +scope:"variable",match:"\\$+"+a},o={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},l=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),c="[ \t\n]",d={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(o)}),l,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(o),"on:begin":(e,n)=>{ +n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},g={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},u=["false","null","true"],b=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],m=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],p={ +keyword:b,literal:(e=>{const n=[];return e.forEach((e=>{ +n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) +})),n})(u),built_in:m},_=e=>e.map((e=>e.replace(/\|\d+$/,""))),h={variants:[{ +match:[/new/,n.concat(c,"+"),n.concat("(?!",_(m).join("\\b|"),"\\b)"),i],scope:{ +1:"keyword",4:"title.class"}}]},f=n.concat(a,"\\b(?!\\()"),E={variants:[{ +match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),f],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),f],scope:{1:"title.class", +3:"variable.constant"}},{match:[i,n.concat("::",n.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[i,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},y={scope:"attr", +match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},w={relevance:0, +begin:/\(/,end:/\)/,keywords:p,contains:[y,s,E,e.C_BLOCK_COMMENT_MODE,d,g,h] +},v={relevance:0, +match:[/\b/,n.concat("(?!fn\\b|function\\b|",_(b).join("\\b|"),"|",_(m).join("\\b|"),"\\b)"),a,n.concat(c,"*"),n.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[w]};w.contains.push(v) +;const N=[y,E,e.C_BLOCK_COMMENT_MODE,d,g,h],k={ +begin:n.concat(/#\[\s*\\?/,n.either(i,r)),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:u,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:u,keyword:["new","array"]}, +contains:["self",...N]},...N,{scope:"meta",variants:[{match:i},{match:r}]}]} +;return{case_insensitive:!1,keywords:p, +contains:[k,e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{ +contains:[{scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},s,v,E,{ +match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},h,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:p, +contains:["self",k,s,E,e.C_BLOCK_COMMENT_MODE,d,g]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},d,g]} +},grmr_php_template:e=>({name:"PHP template",subLanguage:"xml",contains:[{ +begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*", +end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 +},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null, +skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null, +contains:null,skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text", +aliases:["text","txt"],disableAutodetect:!0}),grmr_python:e=>{ +const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ +$pattern:/[A-Za-z]\w+|__\w+__/,keyword:a, +built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], +type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] +},r={className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/, +end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},l={ +className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([uU]|[rR])'/,end:/'/, +relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ +begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, +end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,o,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},c="[0-9](_?[0-9])*",d=`(\\b(${c}))?\\.(${c})|\\b(${c})\\.`,g="\\b|"+a.join("|"),u={ +className:"number",relevance:0,variants:[{ +begin:`(\\b(${c})|(${d}))[eE][+-]?(${c})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ +begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${g})`},{ +begin:`\\b0[bB](_?[01])+[lL]?(?=${g})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${g})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${c})[jJ](?=${g})` +}]},b={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i, +contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ +className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, +contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ +name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, +illegal:/(<\/|\?)|=>/,contains:[r,u,{scope:"variable.language",match:/\bself\b/ +},{beginKeywords:"if",relevance:0},{match:/\bor\b/,scope:"keyword" +},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{1:"keyword", +3:"title.function"},contains:[m]},{variants:[{ +match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], +scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, +grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta.prompt", +starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ +begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ +const n=e.regex,t=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,a=n.either(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),i=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,r=n.either(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/) +;return{name:"R",keywords:{$pattern:t, +keyword:"function if in break next repeat else for while", +literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", +built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm" +},contains:[e.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",match:/@examples/, +starts:{end:n.lookahead(n.either(/\n^#'\s*(?=@[a-zA-Z]+)/,/\n^(?!#')/)), +endsParent:!0}},{scope:"doctag",begin:"@param",end:/$/,contains:[{ +scope:"variable",variants:[{match:t},{match:/`(?:\\.|[^`\\])+`/}],endsParent:!0 +}]},{scope:"doctag",match:/@[a-zA-Z]+/},{scope:"keyword",match:/\\[a-zA-Z]+/}] +}),e.HASH_COMMENT_MODE,{scope:"string",contains:[e.BACKSLASH_ESCAPE], +variants:[e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', +relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{ +1:"operator",2:"number"},match:[i,a]},{scope:{1:"operator",2:"number"}, +match:[/%[^%]*%/,a]},{scope:{1:"punctuation",2:"number"},match:[r,a]},{scope:{ +2:"number"},match:[/[^a-zA-Z0-9._]|^/,a]}]},{scope:{3:"operator"}, +match:[t,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:i},{ +match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:r},{begin:"`",end:"`", +contains:[{begin:/\\./}]}]}},grmr_ruby:e=>{ +const n=e.regex,t="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",a=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(a,/(::\w+)*/),r={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},s={className:"doctag",begin:"@[A-Za-z]+"},o={ +begin:"#<",end:">"},l=[e.COMMENT("#","$",{contains:[s] +}),e.COMMENT("^=begin","^=end",{contains:[s],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/, +end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,c], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",u={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},b={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:r}]},m=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:r},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:a,scope:"title.class"},{ +match:[/def/,/\s+/,t],scope:{1:"keyword",3:"title.function"},contains:[b]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|(?!=)/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(o,l),relevance:0}].concat(o,l) +;c.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, +grmr_rust:e=>{ +const n=e.regex,t=/(r#)?/,a=n.concat(t,e.UNDERSCORE_IDENT_RE),i=n.concat(t,e.IDENT_RE),r={ +className:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,i,n.lookahead(/\s*\(/)) +},s="([ui](8|16|32|64|128|size)|f(32|64))?",o=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],l=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:l, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","union","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:o},illegal:""},r]}}, +grmr_scss:e=>{const n=te(e),t=se,a=re,i="@[a-z-]+",r={className:"variable", +begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", +case_insensitive:!0,illegal:"[=/|']", +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ +className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ +className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 +},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", +begin:"\\b("+ae.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:":("+a.join("|")+")"},{className:"selector-pseudo", +begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, +contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", +begin:"\\b("+oe.join("|")+")\\b"},{ +begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" +},{begin:/:/,end:/[;}{]/,relevance:0, +contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] +},{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ +begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, +keyword:"and or not only",attribute:ie.join(" ")},contains:[{begin:i, +className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" +},r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] +},n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", +aliases:["console","shellsession"],contains:[{className:"meta.prompt", +begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +subLanguage:"bash"}}]}),grmr_sql:e=>{ +const n=e.regex,t=e.COMMENT("--","$"),a=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],i=a,r=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!a.includes(e))),s={ +match:n.concat(/\b/,n.either(...i),/\s*\(/),relevance:0,keywords:{built_in:i}} +;function o(e){ +return n.concat(/\b/,n.either(...e.map((e=>e.replace(/\s+/,"\\s+")))),/\b/)} +const l={scope:"keyword", +match:o(["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"]), +relevance:0};return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t +;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) +})(r,{when:e=>e.length<3}),literal:["true","false","unknown"], +type:["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"], +built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] +},contains:[{scope:"type", +match:o(["double precision","large object","with timezone","without timezone"]) +},l,s,{scope:"variable",match:/@[a-z0-9][a-z0-9_]*/},{scope:"string",variants:[{ +begin:/'/,end:/'/,contains:[{match:/''/}]}]},{begin:/"/,end:/"/,contains:[{ +match:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{scope:"operator", +match:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}, +grmr_swift:e=>{const n={match:/\s+/,relevance:0},t=e.COMMENT("/\\*","\\*/",{ +contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={match:[/\./,m(...ke,...xe)], +className:{2:"keyword"}},r={match:b(/\./,m(...Me)),relevance:0 +},s=Me.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +className:"keyword", +match:m(...Me.filter((e=>"string"!=typeof e)).concat(Oe).map(Ne),...xe)}]},l={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Ce),literal:Ae},c=[i,r,o],g=[{ +match:b(/\./,m(...Te)),relevance:0},{className:"built_in", +match:b(/\b/,m(...Te),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Ie},{match:`\\.(\\.|${De})+`}] +}],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, +variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ +match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ +},{match:/\b0b([01]_*)+\b/}]},E=(e="")=>({className:"subst",variants:[{ +match:b(/\\/,e,/[0\\tnr"']/)},{match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] +}),y=(e="")=>({className:"subst",match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) +}),w=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ +}),v=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),w(e)] +}),N=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),w(e)]}),k={ +className:"string", +variants:[v(),v("#"),v("##"),v("###"),N(),N("#"),N("##"),N("###")] +},x=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],O={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:x},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, +contains:[...x,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},A={ +scope:"regexp",variants:[M("###"),M("##"),M("#"),O]},S={match:b(/`/,$e,/`/) +},C=[S,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${Be}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:je,contains:[...p,f,k]}]}},{ +scope:"keyword",match:b(/@/,m(...ze),d(m(/\(/,/\s+/)))},{scope:"meta", +match:b(/@/,$e)}],R={match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,Be,"+") +},{className:"type",match:Fe,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Fe)),relevance:0}]},D={ +begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) +;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ +match:b($e,/\s*:/),keywords:"_|0",relevance:0 +},...a,A,...c,...g,...p,f,k,...C,...T,R]},L={begin://, +keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, +contains:[{begin:m(d(b($e,/\s*:/)),d(b($e,/\s+/,$e,/\s*:/))),end:/:/, +relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", +match:$e}]},...a,...c,...p,f,k,...T,R,I],endsParent:!0,illegal:/["']/},$={ +match:[/(func|macro)/,/\s+/,m(S.match,$e,Ie)],className:{1:"keyword", +3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},F={ +match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, +contains:[L,B,n],illegal:/\[|%/},z={match:[/operator/,/\s+/,Ie],className:{ +1:"keyword",3:"title"}},j={begin:[/precedencegroup/,/\s+/,Fe],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...Se,...Ae],end:/}/},U={ +begin:[/(struct|protocol|class|extension|enum|actor)/,/\s+/,$e,/\s*/], +beginScope:{1:"keyword",3:"title.class"},keywords:l,contains:[L,...c,{begin:/:/, +end:/\{/,keywords:l,contains:[{scope:"title.class.inherited",match:Fe},...c], +relevance:0}]};for(const e of k.variants){ +const n=e.contains.find((e=>"interpol"===e.label));n.keywords=l +;const t=[...c,...g,...p,f,k,...C];n.contains=[...t,{begin:/\(/,end:/\)/, +contains:["self",...t]}]}return{name:"Swift",keywords:l,contains:[...a,$,F,{ +match:[/class\b/,/\s+/,/func\b/,/\s+/,/\b[A-Za-z_][A-Za-z0-9_]*\b/],scope:{ +1:"keyword",3:"keyword",5:"title.function"}},{match:[/class\b/,/\s+/,/var\b/], +scope:{1:"keyword",3:"keyword"}},U,z,j,{beginKeywords:"import",end:/$/, +contains:[...a],relevance:0},A,...c,...g,...p,f,k,...C,...T,R,I]}}, +grmr_typescript:e=>{ +const n=e.regex,t=ve(e),a=me,i=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],r={ +begin:[/namespace/,/\s+/,e.IDENT_RE],beginScope:{1:"keyword",3:"title.class"} +},s={beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:{ +keyword:"interface extends",built_in:i},contains:[t.exports.CLASS_REFERENCE] +},o={$pattern:me, +keyword:pe.concat(["type","interface","public","private","protected","implements","declare","abstract","readonly","enum","override","satisfies"]), +literal:_e,built_in:we.concat(i),"variable.language":ye},l={className:"meta", +begin:"@"+a},c=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} +;Object.assign(t.keywords,o),t.exports.PARAMS_CONTAINS.push(l) +;const d=t.contains.find((e=>"attr"===e.scope)),g=Object.assign({},d,{ +match:n.concat(a,n.lookahead(/\s*\?:/))}) +;return t.exports.PARAMS_CONTAINS.push([t.exports.CLASS_REFERENCE,d,g]), +t.contains=t.contains.concat([l,r,s,g]), +c(t,"shebang",e.SHEBANG()),c(t,"use_strict",{className:"meta",relevance:10, +begin:/^\s*['"]use strict['"]/ +}),t.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(t,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),t},grmr_vbnet:e=>{ +const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ +className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ +begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ +begin:n.concat(/# */,n.either(a,t),/ +/,n.either(i,r),/ *#/)}] +},o=e.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}] +}),l=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) +;return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0, +classNameAliases:{label:"symbol"},keywords:{ +keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", +built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort", +type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", +literal:"true false nothing"}, +illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ +className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, +end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, +variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ +},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ +begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ +className:"label",begin:/^\w+:/},o,l,{className:"meta", +begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, +end:/$/,keywords:{ +keyword:"const disable else elseif enable end externalsource if region then"}, +contains:[l]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) +;return n.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/, +keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"] +},contains:[e.COMMENT(/;;/,/$/),n,{match:[/(?:offset|align)/,/\s*/,/=/], +className:{1:"keyword",3:"operator"}},{className:"variable",begin:/\$[\w_]+/},{ +match:/(\((?!;)|\))+/,className:"punctuation",relevance:0},{ +begin:[/(?:func|call|call_indirect)/,/\s+/,/\$[^\s)]+/],className:{1:"keyword", +3:"title.function"}},e.QUOTE_STRING_MODE,{match:/(i32|i64|f32|f64)(?!\.)/, +className:"type"},{className:"keyword", +match:/\b(f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|nearest|neg?|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|store(?:8|16|32)?|sqrt|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))\b/ +},{className:"number",relevance:0, +match:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/ +}]}},grmr_xml:e=>{ +const n=e.regex,t=n.concat(/[\p{L}_]/u,n.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),a={ +className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},i={begin:/\s/, +contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] +},r=e.inherit(i,{begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{ +className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),l={ +endsWithParent:!0,illegal:/`]+/}]}]}]};return{ +name:"HTML, XML", +aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], +case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[i,o,s,r,{begin:/\[/,end:/\]/,contains:[{ +className:"meta",begin://,contains:[i,r,o,s]}]}] +},e.COMMENT(//,{relevance:10}),{begin://, +relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, +relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ +className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:n.concat(//,/>/,/\s/)))), +end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ +className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ +className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} +},grmr_yaml:e=>{ +const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ +className:"string",relevance:0,variants:[{begin:/"/,end:/"/},{begin:/\S+/}], +contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{ +begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{variants:[{ +begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]},{begin:/"/,end:/"/},{ +begin:/[^\s,{}[\]]+/}]}),r={end:",",endsWithParent:!0,excludeEnd:!0,keywords:n, +relevance:0},s={begin:/\{/,end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={ +begin:"\\[",end:"\\]",contains:[r],illegal:"\\n",relevance:0},l=[{ +className:"attr",variants:[{begin:/[\w*@][\w*@ :()\./-]*:(?=[ \t]|$)/},{ +begin:/"[\w*@][\w*@ :()\./-]*":(?=[ \t]|$)/},{ +begin:/'[\w*@][\w*@ :()\./-]*':(?=[ \t]|$)/}]},{className:"meta", +begin:"^---\\s*$",relevance:10},{className:"string", +begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ +begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, +relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", +begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t +},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", +begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", +relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ +className:"number", +begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,{ +className:"string",relevance:0,begin:/'/,end:/'/,contains:[{match:/''/, +scope:"char.escape",relevance:0}]},a],c=[...l] +;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:l}}});const Pe=ne;for(const e of Object.keys(Ue)){ +const n=e.replace("grmr_","").replace("_","-");Pe.registerLanguage(n,Ue[e])} +return Pe}() +;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); \ No newline at end of file diff --git a/src/Undefined/webui/static/js/vendor/highlightjs.LICENSE b/src/Undefined/webui/static/js/vendor/highlightjs.LICENSE new file mode 100644 index 00000000..2250cc7e --- /dev/null +++ b/src/Undefined/webui/static/js/vendor/highlightjs.LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2006, Ivan Sagalaev. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/Undefined/webui/templates/index.html b/src/Undefined/webui/templates/index.html index 841ff956..5ae3b5d5 100644 --- a/src/Undefined/webui/templates/index.html +++ b/src/Undefined/webui/templates/index.html @@ -14,9 +14,10 @@ + - - + + @@ -54,6 +55,8 @@

配置控制台

data-i18n="landing.memory">记忆检索 + +
@@ -754,36 +761,229 @@

表情包库

- -
+ +
-

智能对话

-

虚拟私聊 system#42。

-
-
- -
-
AI Chat(虚拟私聊 system#42)
-

该会话由 WebUI 发起,权限为 superadmin;私聊里可直接使用 /命令。

-
-
- -
- - - +

定时任务

+

查看、创建和编辑运行中的调度任务。

+
+
+ + +
+
+ +
+
+ 总任务 + -- +
+
+ 自我督办 + -- +
+
+ 多工具 + -- +
+
+ 有限次数 + -- +
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
新建任务
+
--
+
+ -- +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+ +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+
+ + +
+
+
+

+ 智能对话 + 虚拟私聊 system#42。该会话由 WebUI 发起,权限为 superadmin;私聊里可直接使用 /命令。 +

+
+
+ + +
+
+ +
+ +
+
+
新对话
+
+
+
+
+
+ + + + +
+ + + +
+
@@ -834,10 +1034,18 @@

MIT License

+ + @@ -847,6 +1055,7 @@

MIT License

+ diff --git a/tests/test_ai_coordinator_queue_routing.py b/tests/test_ai_coordinator_queue_routing.py index 7a78a73f..327f834a 100644 --- a/tests/test_ai_coordinator_queue_routing.py +++ b/tests/test_ai_coordinator_queue_routing.py @@ -1,12 +1,15 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock import pytest +from Undefined.context import RequestContext from Undefined.services.ai_coordinator import AICoordinator +from Undefined.services.message_batcher import BufferedMessage from Undefined.services.coordinator import group as coordinator_group_module @@ -218,15 +221,55 @@ def test_build_prompt_limits_proactive_participation_to_technical_contexts() -> assert "普通闲聊、玩梗、吐槽、轻松互动:" not in prompt +def test_format_group_message_segment_preserves_known_attachment_tag() -> None: + coordinator: Any = object.__new__(AICoordinator) + item = BufferedMessage( + scope="group:12345", + sender_id=20001, + text='看图 ', + message_content=[], + attachments=[ + { + "uid": "pic_demo", + "kind": "image", + "media_type": "image", + "display_name": "demo.png", + } + ], + sender_name="member", + arrival_time=1_700_000_000, + is_private=False, + group_id=12345, + group_name="测试群", + ) + + prompt = AICoordinator._format_group_message_segment(coordinator, item) + + assert '' in prompt + assert '' not in prompt + assert "<attachment uid="pic_fake"/>" in prompt + + @pytest.mark.asyncio async def test_execute_auto_reply_send_msg_cb_passes_history_message( monkeypatch: pytest.MonkeyPatch, ) -> None: coordinator: Any = object.__new__(AICoordinator) sender = SimpleNamespace(send_group_message=AsyncMock()) + captured_extra_context: dict[str, Any] = {} + captured_resources: dict[str, Any] = {} async def _fake_ask(*_args: Any, **kwargs: Any) -> str: - await kwargs["send_message_callback"]("hello group") + extra_context = cast(dict[str, Any], kwargs.get("extra_context", {})) + captured_extra_context.update(extra_context) + current_context = RequestContext.current() + assert current_context is not None + captured_resources.update(current_context.get_resources()) + send_message_callback = cast( + Callable[[str], Awaitable[None]], + kwargs["send_message_callback"], + ) + await send_message_callback("hello group") return "" coordinator.config = SimpleNamespace(bot_qq=10000) @@ -257,6 +300,8 @@ async def _fake_ask(*_args: Any, **kwargs: Any) -> str: "sender_name": "member", "group_name": "测试群", "full_question": "prompt", + "message_ids": ["101", "102"], + "batched_count": 2, } ) @@ -266,3 +311,7 @@ async def _fake_ask(*_args: Any, **kwargs: Any) -> str: reply_to=None, history_message="hello group", ) + assert captured_extra_context["message_ids"] == ["101", "102"] + assert captured_extra_context["batched_count"] == 2 + assert captured_extra_context["current_input_is_batched"] is True + assert captured_resources["message_ids"] == ["101", "102"] diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 2dced7b0..df97c52e 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -13,6 +13,7 @@ from Undefined.attachments import ( AttachmentRecord, AttachmentRegistry, + append_attachment_text, attachment_refs_to_xml, register_message_attachments, render_message_with_pic_placeholders, @@ -137,6 +138,23 @@ def test_attachment_refs_to_xml_includes_url_reference_source() -> None: assert 'source_ref="https://example.com/big.zip"' in xml +def test_append_attachment_text_uses_unified_attachment_tags() -> None: + result = append_attachment_text( + "看这张", + [ + { + "uid": "pic_demo", + "kind": "image", + "media_type": "image", + "display_name": "demo.png", + } + ], + ) + + assert result == '看这张\n附件: ' + assert "[图片 uid=" not in result + + @pytest.mark.asyncio async def test_remote_attachment_above_limit_keeps_url_reference( tmp_path: Path, @@ -547,11 +565,47 @@ async def test_register_message_attachments_normalizes_webui_base64_image( assert len(result.attachments) == 1 uid = result.attachments[0]["uid"] + record = registry.resolve(uid, "webui") assert uid.startswith("pic_") - assert uid in result.normalized_text + assert record is not None + assert record.display_name == "image_2.png" + assert len(record.display_name) < 64 + assert f'' in result.normalized_text + assert "[图片 uid=" not in result.normalized_text assert "这张图" in result.normalized_text +@pytest.mark.asyncio +async def test_register_message_attachments_uses_short_data_url_image_name( + tmp_path: Path, +) -> None: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + ) + payload = base64.b64encode(_PNG_BYTES).decode("ascii") + + result = await register_message_attachments( + registry=registry, + segments=[ + { + "type": "image", + "data": {"file": f"data:image/png;base64,{payload}"}, + } + ], + scope_key="webui", + ) + + uid = result.attachments[0]["uid"] + record = registry.resolve(uid, "webui") + + assert record is not None + assert record.display_name == "image_1.png" + assert record.source_kind == "data_url_image" + assert len(record.display_name) < 64 + assert result.normalized_text == f'' + + @pytest.mark.asyncio async def test_register_message_attachments_recurses_into_forward_images( tmp_path: Path, diff --git a/tests/test_build_native_apps_script.py b/tests/test_build_native_apps_script.py new file mode 100644 index 00000000..bfe86ea3 --- /dev/null +++ b/tests/test_build_native_apps_script.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +from types import ModuleType + +import pytest + + +_SCRIPT_PATH = ( + Path(__file__).resolve().parent.parent / "scripts" / "build_native_apps.py" +) + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location( + "build_native_apps_script", _SCRIPT_PATH + ) + if spec is None or spec.loader is None: + raise RuntimeError("Could not load build_native_apps.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +build_native_apps = _load_script() + + +def _write_app_tree( + root: Path, app_dir: str, *, with_node_modules: bool = True +) -> None: + app_root = root / "apps" / app_dir + tauri_root = app_root / "src-tauri" + tauri_root.mkdir(parents=True) + (app_root / "package.json").write_text('{"scripts":{}}\n', encoding="utf-8") + if with_node_modules: + tauri_bin = app_root / "node_modules" / ".bin" + tauri_bin.mkdir(parents=True) + (tauri_bin / "tauri").write_text("", encoding="utf-8") + + +def _write_project(root: Path, *, with_node_modules: bool = True) -> None: + _write_app_tree(root, "undefined-console", with_node_modules=with_node_modules) + _write_app_tree(root, "undefined-chat", with_node_modules=with_node_modules) + + +def _options( + *, + product: str = "chat", + targets: str = "android", + android_abi: str = "arm64-v8a", + desktop_bundles: str = "deb", + output_dir: Path, + dry_run: bool = False, + no_install_deps: bool = False, + android_init: str = "auto", +) -> object: + return build_native_apps.BuildOptions( + product=product, + targets=targets, + android_abi=android_abi, + desktop_bundles=desktop_bundles, + output_dir=output_dir, + dry_run=dry_run, + no_install_deps=no_install_deps, + android_init=android_init, + ) + + +def test_selected_android_targets_supports_all() -> None: + targets = build_native_apps.selected_android_targets("all") + + assert [target.abi_label for target in targets] == [ + "arm64-v8a", + "armeabi-v7a", + "x86", + "x86_64", + ] + assert targets[0].tauri_target == "aarch64" + assert targets[0].rust_target == "aarch64-linux-android" + + +def test_build_environment_sets_android_paths(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(Path, "home", lambda: Path("/home/tester")) + env = build_native_apps.build_environment( + Path("/project"), + env={"ANDROID_HOME": "/home/tester/Android/Sdk", "PATH": "/usr/bin"}, + ) + + assert env["JAVA_HOME"] == "/usr/lib/jvm/java-17-openjdk" + assert env["ANDROID_HOME"] == "/home/tester/Android/Sdk" + assert env["ANDROID_SDK_ROOT"] == "/home/tester/Android/Sdk" + assert env["NDK_HOME"] == "/home/tester/Android/Sdk/ndk/27.2.12479018" + assert env["GRADLE_USER_HOME"] == "/home/tester/Android/Sdk/gradle" + assert env["PATH"].startswith("/usr/lib/jvm/java-17-openjdk/bin") + assert "/home/tester/Android/Sdk/cmdline-tools/latest/bin" in env["PATH"] + if Path("/opt/android-sdk/cmdline-tools/latest/bin").exists(): + assert "/opt/android-sdk/cmdline-tools/latest/bin" in env["PATH"] + assert env["PATH"].endswith("/usr/bin") + + +def test_make_build_tasks_android_chat_auto_initializes_and_checks( + tmp_path: Path, +) -> None: + _write_project(tmp_path) + options = _options(output_dir=tmp_path / "out") + + tasks = build_native_apps.make_build_tasks(options, project_root=tmp_path) + + assert len(tasks) == 1 + task = tasks[0] + assert task.product == "chat" + assert task.target_kind == "android" + assert task.label == "arm64-v8a" + assert [command.command for command in task.commands] == [ + ("npm", "run", "tauri:android:init"), + ("npm", "run", "tauri:android:prepare:check"), + ( + "npm", + "run", + "tauri:android:debug", + "--", + "--ci", + "--apk", + "--target", + "aarch64", + ), + ] + + +def test_make_build_tasks_auto_skips_android_init_when_generated( + tmp_path: Path, +) -> None: + _write_project(tmp_path) + (tmp_path / "apps" / "undefined-chat" / "src-tauri" / "gen" / "android").mkdir( + parents=True + ) + options = _options(output_dir=tmp_path / "out") + + tasks = build_native_apps.make_build_tasks(options, project_root=tmp_path) + + assert [command.command for command in tasks[0].commands] == [ + ("npm", "run", "tauri:android:prepare:check"), + ( + "npm", + "run", + "tauri:android:debug", + "--", + "--ci", + "--apk", + "--target", + "aarch64", + ), + ] + + +def test_make_build_tasks_includes_npm_ci_when_node_modules_missing( + tmp_path: Path, +) -> None: + _write_project(tmp_path, with_node_modules=False) + options = _options(output_dir=tmp_path / "out") + + tasks = build_native_apps.make_build_tasks(options, project_root=tmp_path) + + assert tasks[0].commands[0].command == ("npm", "ci") + + +def test_make_build_tasks_respects_no_install_deps( + tmp_path: Path, +) -> None: + _write_project(tmp_path, with_node_modules=False) + options = _options(output_dir=tmp_path / "out", no_install_deps=True) + + tasks = build_native_apps.make_build_tasks(options, project_root=tmp_path) + + assert ("npm", "ci") not in [command.command for command in tasks[0].commands] + + +def test_make_build_tasks_all_products_and_abis(tmp_path: Path) -> None: + _write_project(tmp_path) + options = _options( + product="all", + android_abi="all", + output_dir=tmp_path / "out", + ) + + tasks = build_native_apps.make_build_tasks(options, project_root=tmp_path) + + assert len(tasks) == 8 + assert {(task.product, task.label) for task in tasks} == { + ("console", "arm64-v8a"), + ("console", "armeabi-v7a"), + ("console", "x86"), + ("console", "x86_64"), + ("chat", "arm64-v8a"), + ("chat", "armeabi-v7a"), + ("chat", "x86"), + ("chat", "x86_64"), + } + console_init_count = sum( + command.command == ("npm", "run", "tauri:android:init") + for task in tasks + if task.product == "console" + for command in task.commands + ) + chat_init_count = sum( + command.command == ("npm", "run", "tauri:android:init") + for task in tasks + if task.product == "chat" + for command in task.commands + ) + chat_prepare_count = sum( + command.command == ("npm", "run", "tauri:android:prepare:check") + for task in tasks + if task.product == "chat" + for command in task.commands + ) + assert console_init_count == 1 + assert chat_init_count == 1 + assert chat_prepare_count == 1 + + +def test_make_build_tasks_desktop_linux_uses_no_strip( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_project(tmp_path) + monkeypatch.setattr(build_native_apps, "_is_linux", lambda: True) + options = _options(targets="desktop", output_dir=tmp_path / "out") + + tasks = build_native_apps.make_build_tasks(options, project_root=tmp_path) + + assert tasks[0].target_kind == "desktop" + assert tasks[0].commands[-1].command == ( + "npm", + "run", + "tauri:build", + "--", + "--ci", + "--bundles", + "deb", + ) + assert tasks[0].commands[-1].env["NO_STRIP"] == "true" + + +def test_make_build_tasks_desktop_rejects_non_linux( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_project(tmp_path) + monkeypatch.setattr(build_native_apps, "_is_linux", lambda: False) + options = _options(targets="desktop", output_dir=tmp_path / "out") + + with pytest.raises(RuntimeError, match="only supported on Linux"): + build_native_apps.make_build_tasks(options, project_root=tmp_path) + + +def test_collect_artifacts_copies_matching_files(tmp_path: Path) -> None: + _write_project(tmp_path) + source_dir = tmp_path / "apps" / "undefined-chat" / "src-tauri" / "target" + source_dir.mkdir(parents=True, exist_ok=True) + source = source_dir / "app-arm64-debug.apk" + source.write_text("apk", encoding="utf-8") + unsigned = source_dir / "app-arm64-debug-unsigned.apk" + unsigned.write_text("unsigned", encoding="utf-8") + options = _options(output_dir=tmp_path / "out", android_init="skip") + task = build_native_apps.make_build_tasks(options, project_root=tmp_path)[0] + + collected = build_native_apps.collect_artifacts( + (task,), + tmp_path / "out", + dry_run=False, + ) + + assert len(collected) == 1 + assert collected[0].name == "Undefined-Chat-android-arm64-v8a-app-arm64-debug.apk" + assert collected[0].read_text(encoding="utf-8") == "apk" + + +def test_collect_new_artifacts_only_copies_changed_files(tmp_path: Path) -> None: + _write_project(tmp_path) + source_dir = tmp_path / "apps" / "undefined-chat" / "src-tauri" / "target" + source_dir.mkdir(parents=True, exist_ok=True) + old_source = source_dir / "old.apk" + old_source.write_text("old", encoding="utf-8") + options = _options(output_dir=tmp_path / "out", android_init="skip") + task = build_native_apps.make_build_tasks(options, project_root=tmp_path)[0] + before = build_native_apps._artifact_snapshot(task) + + new_source = source_dir / "new.apk" + new_source.write_text("new", encoding="utf-8") + + collected = build_native_apps.collect_new_artifacts( + task, + tmp_path / "out", + before, + dry_run=False, + ) + + assert [path.name for path in collected] == [ + "Undefined-Chat-android-arm64-v8a-new.apk" + ] + assert not (tmp_path / "out" / "Undefined-Chat-android-arm64-v8a-old.apk").exists() + + +def test_command_build_stops_when_check_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + del tmp_path + parser = build_native_apps.build_parser() + args = parser.parse_args(["build", "--dry-run"]) + monkeypatch.setattr( + build_native_apps, + "check_environment", + lambda *, targets, android_abi: ( + build_native_apps.CheckItem("rustup", False, "missing", "install rustup"), + ), + ) + monkeypatch.setattr( + build_native_apps, + "make_build_tasks", + lambda options: pytest.fail("build should not continue after failed check"), + ) + + assert build_native_apps.command_build(args) == 1 diff --git a/tests/test_bump_version_script.py b/tests/test_bump_version_script.py new file mode 100644 index 00000000..46534bfd --- /dev/null +++ b/tests/test_bump_version_script.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import sys +from types import ModuleType +from typing import Any + +import pytest + + +_SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "bump_version.py" + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("bump_version_script", _SCRIPT_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("Could not load bump_version.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +bump_version = _load_script() + + +def _write_bump_project(root: Path, *, version: str = "1.2.3") -> None: + (root / "src" / "Undefined").mkdir(parents=True) + (root / "pyproject.toml").write_text( + f'[project]\nname = "Undefined-bot"\nversion = "{version}"\n', + encoding="utf-8", + ) + (root / "src" / "Undefined" / "__init__.py").write_text( + f'__version__ = "{version}"\n', + encoding="utf-8", + ) + + for app_dir, cargo_package in ( + ("undefined-console", "undefined_console"), + ("undefined-chat", "undefined_chat"), + ): + app_root = root / "apps" / app_dir + tauri_root = app_root / "src-tauri" + tauri_root.mkdir(parents=True) + (app_root / "package.json").write_text( + json.dumps({"name": app_dir, "version": version}, indent="\t") + "\n", + encoding="utf-8", + ) + (app_root / "package-lock.json").write_text( + json.dumps( + { + "name": app_dir, + "version": version, + "packages": {"": {"name": app_dir, "version": version}}, + }, + indent="\t", + ) + + "\n", + encoding="utf-8", + ) + (tauri_root / "Cargo.toml").write_text( + f'[package]\nname = "{cargo_package}"\nversion = "{version}"\n', + encoding="utf-8", + ) + (tauri_root / "tauri.conf.json").write_text( + json.dumps({"productName": app_dir, "version": version}, indent="\t") + + "\n", + encoding="utf-8", + ) + (tauri_root / "Cargo.lock").write_text( + f'version = 3\n\n[[package]]\nname = "{cargo_package}"\nversion = "{version}"\n\n[[package]]\nname = "dependency"\nversion = "9.9.9"\n', + encoding="utf-8", + ) + + +def _json_version(path: Path) -> str: + data = json.loads(path.read_text(encoding="utf-8")) + value = data["version"] + if not isinstance(value, str): + raise AssertionError(f"{path} version is not a string") + return value + + +def _package_lock_versions(path: Path) -> tuple[str, str]: + data = json.loads(path.read_text(encoding="utf-8")) + version = data["version"] + root_version = data["packages"][""]["version"] + if not isinstance(version, str) or not isinstance(root_version, str): + raise AssertionError(f"{path} package lock versions are not strings") + return version, root_version + + +def _cargo_lock_root_version(path: Path, package_name: str) -> str: + packages = tomllib_loads(path.read_text(encoding="utf-8"))["package"] + if not isinstance(packages, list): + raise AssertionError(f"{path} has no package list") + for package in packages: + if isinstance(package, dict) and package.get("name") == package_name: + version = package.get("version") + if not isinstance(version, str): + raise AssertionError(f"{path} {package_name} version is not a string") + return version + raise AssertionError(f"{path} is missing {package_name}") + + +def tomllib_loads(text: str) -> dict[str, Any]: + import tomllib + + return tomllib.loads(text) + + +def test_bump_project_versions_updates_console_and_chat_manifests_and_locks( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_bump_project(tmp_path) + sync_calls: list[Path] = [] + monkeypatch.setattr( + bump_version, + "sync_lock_files", + lambda project_root, *, dry_run: sync_calls.append(project_root), + ) + + result = bump_version.bump_project_versions( + "2.0.0", + project_root=tmp_path, + dry_run=False, + ) + + assert set(result.changed_paths) == { + "pyproject.toml", + "src/Undefined/__init__.py", + "apps/undefined-console/package.json", + "apps/undefined-console/package-lock.json", + "apps/undefined-console/src-tauri/Cargo.toml", + "apps/undefined-console/src-tauri/tauri.conf.json", + "apps/undefined-console/src-tauri/Cargo.lock", + "apps/undefined-chat/package.json", + "apps/undefined-chat/package-lock.json", + "apps/undefined-chat/src-tauri/Cargo.toml", + "apps/undefined-chat/src-tauri/tauri.conf.json", + "apps/undefined-chat/src-tauri/Cargo.lock", + } + assert sync_calls == [tmp_path.resolve()] + assert 'version = "2.0.0"' in (tmp_path / "pyproject.toml").read_text( + encoding="utf-8" + ) + assert '__version__ = "2.0.0"' in ( + tmp_path / "src" / "Undefined" / "__init__.py" + ).read_text(encoding="utf-8") + + for app_dir, cargo_package in ( + ("undefined-console", "undefined_console"), + ("undefined-chat", "undefined_chat"), + ): + app_root = tmp_path / "apps" / app_dir + tauri_root = app_root / "src-tauri" + assert _json_version(app_root / "package.json") == "2.0.0" + assert _package_lock_versions(app_root / "package-lock.json") == ( + "2.0.0", + "2.0.0", + ) + assert 'version = "2.0.0"' in (tauri_root / "Cargo.toml").read_text( + encoding="utf-8" + ) + assert _json_version(tauri_root / "tauri.conf.json") == "2.0.0" + assert ( + _cargo_lock_root_version(tauri_root / "Cargo.lock", cargo_package) + == "2.0.0" + ) + + +def test_bump_project_versions_dry_run_does_not_write_or_sync( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_bump_project(tmp_path) + monkeypatch.setattr( + bump_version, + "sync_lock_files", + lambda project_root, *, dry_run: pytest.fail("dry-run should not sync locks"), + ) + + result = bump_version.bump_project_versions( + "2.0.0", + project_root=tmp_path, + dry_run=True, + ) + + assert result.changed_paths + assert 'version = "1.2.3"' in (tmp_path / "pyproject.toml").read_text( + encoding="utf-8" + ) + + +def test_bump_project_versions_rejects_invalid_version(tmp_path: Path) -> None: + _write_bump_project(tmp_path) + + with pytest.raises(ValueError, match="不是合法的语义版本号"): + bump_version.bump_project_versions( + "2", + project_root=tmp_path, + dry_run=True, + ) diff --git a/tests/test_cognitive_chroma_scheduler.py b/tests/test_cognitive_chroma_scheduler.py new file mode 100644 index 00000000..d870575a --- /dev/null +++ b/tests/test_cognitive_chroma_scheduler.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import asyncio +import threading +from typing import Any + +import pytest + +from Undefined.cognitive.chroma_scheduler import ( + CHROMA_PRIORITY_BACKGROUND, + CHROMA_PRIORITY_FOREGROUND, + CHROMA_PRIORITY_MAINTENANCE, + ChromaOperationScheduler, +) + + +@pytest.mark.asyncio +async def test_chroma_scheduler_runs_one_operation_at_a_time() -> None: + scheduler = ChromaOperationScheduler() + active = 0 + max_active = 0 + + async def _submit(index: int) -> int: + def _work() -> int: + nonlocal active, max_active + active += 1 + max_active = max(max_active, active) + try: + return index + finally: + active -= 1 + + result, _receipt = await scheduler.run( + priority=CHROMA_PRIORITY_FOREGROUND, + operation="query", + collection="cognitive_events", + callback=_work, + ) + return int(result) + + results = await asyncio.gather(*[_submit(index) for index in range(8)]) + await scheduler.stop() + + assert results == list(range(8)) + assert max_active == 1 + + +@pytest.mark.asyncio +async def test_chroma_scheduler_prefers_foreground_over_background() -> None: + scheduler = ChromaOperationScheduler(foreground_burst=8) + release_first = threading.Event() + order: list[str] = [] + + def _blocking_first() -> str: + release_first.wait() + order.append("first") + return "first" + + def _record(name: str) -> str: + order.append(name) + return name + + async def _submit(name: str, priority: str, callback: Any | None = None) -> str: + result, _receipt = await scheduler.run( + priority=priority, + operation=name, + collection="cognitive_events", + callback=callback or (lambda: _record(name)), + ) + return str(result) + + first_task = asyncio.create_task( + _submit("first", CHROMA_PRIORITY_BACKGROUND, _blocking_first) + ) + while scheduler.snapshot().active is False: + await asyncio.sleep(0) + background_task = asyncio.create_task( + _submit("background", CHROMA_PRIORITY_BACKGROUND) + ) + foreground_task = asyncio.create_task( + _submit("foreground", CHROMA_PRIORITY_FOREGROUND) + ) + while sum(scheduler.snapshot().pending.values()) < 2: + await asyncio.sleep(0) + + release_first.set() + + assert await first_task == "first" + assert await foreground_task == "foreground" + assert await background_task == "background" + await scheduler.stop() + + assert order == ["first", "foreground", "background"] + + +@pytest.mark.asyncio +async def test_chroma_scheduler_gives_background_a_fairness_slot() -> None: + scheduler = ChromaOperationScheduler(foreground_burst=2) + release_first = threading.Event() + order: list[str] = [] + + def _blocking_first() -> str: + release_first.wait() + order.append("first") + return "first" + + def _record(name: str) -> str: + order.append(name) + return name + + async def _submit(name: str, priority: str, callback: Any | None = None) -> str: + result, _receipt = await scheduler.run( + priority=priority, + operation=name, + collection="cognitive_events", + callback=callback or (lambda: _record(name)), + ) + return str(result) + + tasks = [ + asyncio.create_task( + _submit("first", CHROMA_PRIORITY_FOREGROUND, _blocking_first) + ), + asyncio.create_task(_submit("fg1", CHROMA_PRIORITY_FOREGROUND)), + asyncio.create_task(_submit("fg2", CHROMA_PRIORITY_FOREGROUND)), + asyncio.create_task(_submit("fg3", CHROMA_PRIORITY_FOREGROUND)), + asyncio.create_task(_submit("maintenance", CHROMA_PRIORITY_MAINTENANCE)), + ] + while scheduler.snapshot().active is False: + await asyncio.sleep(0) + while sum(scheduler.snapshot().pending.values()) < 4: + await asyncio.sleep(0) + release_first.set() + + await asyncio.gather(*tasks) + await scheduler.stop() + + assert order == ["first", "fg1", "maintenance", "fg2", "fg3"] + + +@pytest.mark.asyncio +async def test_chroma_scheduler_cancelled_pending_operation_is_skipped() -> None: + scheduler = ChromaOperationScheduler() + release_first = threading.Event() + ran_cancelled = False + + def _blocking_first() -> str: + release_first.wait() + return "first" + + def _mark_cancelled() -> str: + nonlocal ran_cancelled + ran_cancelled = True + return "pending" + + first_task = asyncio.create_task( + scheduler.run( + priority=CHROMA_PRIORITY_FOREGROUND, + operation="first", + collection="cognitive_events", + callback=_blocking_first, + ) + ) + while scheduler.snapshot().active is False: + await asyncio.sleep(0) + + pending_task = asyncio.create_task( + scheduler.run( + priority=CHROMA_PRIORITY_BACKGROUND, + operation="pending", + collection="cognitive_events", + callback=lambda: _mark_cancelled(), + ) + ) + await asyncio.sleep(0) + pending_task.cancel() + with pytest.raises(asyncio.CancelledError): + await pending_task + + release_first.set() + await first_task + await scheduler.stop() + + assert ran_cancelled is False + + +@pytest.mark.asyncio +async def test_chroma_scheduler_propagates_operation_errors() -> None: + scheduler = ChromaOperationScheduler() + + def _raise() -> str: + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + await scheduler.run( + priority=CHROMA_PRIORITY_FOREGROUND, + operation="query", + collection="cognitive_events", + callback=_raise, + ) + await scheduler.stop() diff --git a/tests/test_cognitive_historian.py b/tests/test_cognitive_historian.py index 00a331ee..9f598a61 100644 --- a/tests/test_cognitive_historian.py +++ b/tests/test_cognitive_historian.py @@ -1,13 +1,16 @@ from __future__ import annotations import asyncio +import json from pathlib import Path from types import SimpleNamespace from typing import Any import pytest +from Undefined.cognitive.chroma_scheduler import CHROMA_PRIORITY_MAINTENANCE from Undefined.cognitive.historian import HistorianWorker +from Undefined.cognitive.historian.tools import _PROFILE_TOOL def _make_worker() -> HistorianWorker: @@ -117,6 +120,7 @@ async def test_merge_profile_target_user_queries_history_with_sender_or_user_id( class _FakeVectorStore: def __init__(self) -> None: self.where_calls: list[dict[str, Any]] = [] + self.priority_calls: list[str] = [] self.embed_query_calls = 0 async def embed_query(self, _query: str) -> list[float]: @@ -129,6 +133,7 @@ async def query_events( where = kwargs.get("where") if isinstance(where, dict): self.where_calls.append(where) + self.priority_calls.append(str(kwargs.get("priority", ""))) return [] class _FakeAIClient: @@ -182,6 +187,10 @@ async def submit_background_llm_call(self, **kwargs: Any) -> dict[str, Any]: assert vector_store.embed_query_calls == 1 assert {"sender_id": "123456"} in vector_store.where_calls assert {"user_id": "123456"} in vector_store.where_calls + assert vector_store.priority_calls == [ + CHROMA_PRIORITY_MAINTENANCE, + CHROMA_PRIORITY_MAINTENANCE, + ] @pytest.mark.asyncio @@ -273,3 +282,142 @@ def test_historian_profile_merge_prompt_profile_only_constraints() -> None: assert "skip=true" in merge assert "具体事件" in merge assert "曾/刚/最近" in merge + + +def test_profile_update_tool_does_not_cap_tags() -> None: + parameters: Any = _PROFILE_TOOL["function"]["parameters"] # type: ignore[index] + tags_schema: Any = parameters["properties"]["tags"] + + assert "maxItems" not in tags_schema + assert "最多 10 个" not in str(tags_schema) + + +@pytest.mark.asyncio +async def test_merge_profile_target_preserves_more_than_ten_tags() -> None: + class _FakeVectorStore: + async def embed_query(self, _query: str) -> list[float]: + return [0.1, 0.2] + + async def query_events( + self, _query: str, **_kwargs: Any + ) -> list[dict[str, Any]]: + return [] + + async def upsert_profile( + self, _profile_id: str, _document: str, metadata: dict[str, Any] + ) -> None: + upserted_metadata.append(metadata) + + class _FakeProfileStorage: + async def read_profile(self, _entity_type: str, _entity_id: str) -> str: + return "---\nname: 测试用户\n---\n- 旧侧写" + + async def write_profile( + self, _entity_type: str, _entity_id: str, content: str + ) -> None: + written_profiles.append(content) + + class _FakeAIClient: + agent_config = object() + + def __init__(self) -> None: + self.calls = 0 + + async def submit_background_llm_call(self, **_kwargs: Any) -> dict[str, Any]: + self.calls += 1 + if self.calls == 1: + return { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "read-1", + "function": { + "name": "read_profile", + "arguments": ( + '{"entity_type":"user","entity_id":"123456"}' + ), + }, + } + ] + } + } + ] + } + tags = [f"标签{i}" for i in range(12)] + args = { + "entity_type": "user", + "entity_id": "123456", + "skip": False, + "name": "测试用户", + "tags": tags, + "summary": "- 新侧写", + } + + return { + "choices": [ + { + "message": { + "tool_calls": [ + { + "id": "update-1", + "function": { + "name": "update_profile", + "arguments": json.dumps( + args, ensure_ascii=False + ), + }, + } + ] + } + } + ] + } + + written_profiles: list[str] = [] + upserted_metadata: list[dict[str, Any]] = [] + ai_client = _FakeAIClient() + worker = HistorianWorker( + job_queue=None, + vector_store=_FakeVectorStore(), + profile_storage=_FakeProfileStorage(), + ai_client=ai_client, + config_getter=lambda: SimpleNamespace(), + ) + job: dict[str, Any] = { + "observations": ["测试用户长期具有多个身份标签"], + "request_type": "private", + "user_id": "123456", + "group_id": "", + "sender_id": "123456", + "sender_name": "测试用户", + "group_name": "", + "timestamp_local": "2026-06-07T12:00:00+08:00", + "timezone": "Asia/Shanghai", + "request_id": "req-tags", + "end_seq": 1, + "message_ids": [], + "memo": "", + "source_message": "测试", + "recent_messages": [], + } + + result = await worker._merge_profile_target( + job=job, + canonical="测试用户(123456)具有多个长期身份标签", + event_id="job-tags", + target={ + "entity_type": "user", + "entity_id": "123456", + "perspective": "sender", + "preferred_name": "测试用户", + }, + target_index=1, + target_count=1, + ) + + assert result is True + assert len(written_profiles) == 1 + for index in range(12): + assert f"- 标签{index}" in written_profiles[0] diff --git a/tests/test_cognitive_service.py b/tests/test_cognitive_service.py index 16ef906a..84333281 100644 --- a/tests/test_cognitive_service.py +++ b/tests/test_cognitive_service.py @@ -5,6 +5,10 @@ import pytest +from Undefined.cognitive.chroma_scheduler import ( + CHROMA_PRIORITY_FOREGROUND, + CHROMA_PRIORITY_FOREGROUND_CRITICAL, +) from Undefined.cognitive.service import CognitiveService @@ -21,7 +25,9 @@ class _FakeVectorStore: def __init__(self) -> None: self.last_event_kwargs: dict[str, Any] | None = None self.last_profile_kwargs: dict[str, Any] | None = None - self.last_upsert_profile: tuple[str, str, dict[str, Any]] | None = None + self.last_upsert_profile: ( + tuple[str, str, dict[str, Any], dict[str, Any]] | None + ) = None self.event_calls: list[dict[str, Any]] = [] self.event_resolver: Callable[[dict[str, Any]], list[dict[str, Any]]] | None = ( None @@ -29,11 +35,11 @@ def __init__(self) -> None: async def query_events( self, - _query: str, + query: str, **kwargs: Any, ) -> list[dict[str, Any]]: self.last_event_kwargs = dict(kwargs) - self.event_calls.append(dict(kwargs)) + self.event_calls.append({"query": query, **dict(kwargs)}) if self.event_resolver is not None: return self.event_resolver(kwargs) return [] @@ -51,8 +57,9 @@ async def upsert_profile( profile_id: str, document: str, metadata: dict[str, Any], + **kwargs: Any, ) -> None: - self.last_upsert_profile = (profile_id, document, metadata) + self.last_upsert_profile = (profile_id, document, metadata, dict(kwargs)) class _FakeProfileStorage: @@ -87,6 +94,24 @@ def ensure_reranker(self) -> object | None: return self._reranker +class _FakeReranker: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def rerank( + self, query: str, documents: list[str], top_n: int | None = None + ) -> list[dict[str, Any]]: + self.calls.append({"query": query, "documents": documents, "top_n": top_n}) + return [ + { + "index": index, + "document": documents[index], + "relevance_score": float(len(documents) - index), + } + for index in range(len(documents) - 1, -1, -1) + ] + + @pytest.mark.asyncio async def test_enqueue_job_keeps_historian_reference_fields() -> None: queue = _FakeJobQueue() @@ -179,6 +204,10 @@ async def test_search_events_uses_reranker_when_cognitive_rerank_enabled() -> No assert vector_store.last_event_kwargs is not None assert vector_store.last_event_kwargs.get("reranker") is reranker + assert ( + vector_store.last_event_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -205,6 +234,10 @@ async def test_search_events_skips_reranker_when_cognitive_rerank_disabled() -> assert vector_store.last_event_kwargs is not None assert vector_store.last_event_kwargs.get("reranker") is None + assert ( + vector_store.last_event_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -226,6 +259,10 @@ async def test_search_profiles_skips_reranker_when_cognitive_rerank_disabled() - assert vector_store.last_profile_kwargs is not None assert vector_store.last_profile_kwargs.get("reranker") is None + assert ( + vector_store.last_profile_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -249,6 +286,10 @@ async def test_search_profiles_handles_none_top_k_and_empty_entity_type() -> Non assert vector_store.last_profile_kwargs is not None assert vector_store.last_profile_kwargs.get("top_k") == 8 assert vector_store.last_profile_kwargs.get("where") is None + assert ( + vector_store.last_profile_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -277,6 +318,10 @@ async def test_search_events_uses_runtime_reranker_when_enabled() -> None: assert runtime.ensure_reranker_calls == 1 assert vector_store.last_event_kwargs is not None assert vector_store.last_event_kwargs.get("reranker") is runtime._reranker + assert ( + vector_store.last_event_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -305,6 +350,10 @@ async def test_search_events_does_not_touch_runtime_reranker_when_disabled() -> assert runtime.ensure_reranker_calls == 0 assert vector_store.last_event_kwargs is not None assert vector_store.last_event_kwargs.get("reranker") is None + assert ( + vector_store.last_event_kwargs.get("priority") + == CHROMA_PRIORITY_FOREGROUND_CRITICAL + ) @pytest.mark.asyncio @@ -364,6 +413,7 @@ async def test_build_context_group_mode_uses_group_scope_with_boost() -> None: assert len(vector_store.event_calls) == 1 assert vector_store.event_calls[0].get("where") == {"request_type": "group"} assert vector_store.event_calls[0].get("top_k") == 4 + assert vector_store.event_calls[0].get("priority") == CHROMA_PRIORITY_FOREGROUND assert "当前群事件" in context assert "跨群事件" in context assert context.index("当前群事件") < context.index("跨群事件") @@ -433,6 +483,10 @@ def _resolve_events(kwargs: dict[str, Any]) -> list[dict[str, Any]]: assert len(vector_store.event_calls) == 2 where_clauses = [call.get("where") for call in vector_store.event_calls] assert {"request_type": "group"} in where_clauses + assert all( + call.get("priority") == CHROMA_PRIORITY_FOREGROUND + for call in vector_store.event_calls + ) assert any( isinstance(where, dict) and isinstance(where.get("$and"), list) @@ -518,6 +572,144 @@ def _resolve_events(kwargs: dict[str, Any]) -> list[dict[str, Any]]: assert len(vector_store.event_calls) == 2 assert vector_store.event_calls[0].get("query_embedding") == [0.12, 0.34] assert vector_store.event_calls[1].get("query_embedding") == [0.12, 0.34] + assert all( + call.get("priority") == CHROMA_PRIORITY_FOREGROUND + for call in vector_store.event_calls + ) + + +@pytest.mark.asyncio +async def test_build_context_batch_queries_each_message_then_reranks_batch() -> None: + vector_store = _FakeVectorStore() + reranker = _FakeReranker() + + def _resolve_events(kwargs: dict[str, Any]) -> list[dict[str, Any]]: + query = str(vector_store.event_calls[-1].get("query", "")) + if "周三" in query: + return [ + { + "document": "发版时间相关记忆", + "metadata": { + "timestamp_local": "2026-02-24 10:00:00", + "group_id": "1001", + "request_type": "group", + }, + "distance": 0.20, + }, + { + "document": "重复记忆", + "metadata": { + "timestamp_local": "2026-02-24 10:01:00", + "group_id": "1001", + "request_type": "group", + }, + "distance": 0.35, + }, + ] + if "后端服务" in query: + return [ + { + "document": "后端服务相关记忆", + "metadata": { + "timestamp_local": "2026-02-24 10:02:00", + "group_id": "1001", + "request_type": "group", + }, + "distance": 0.25, + }, + { + "document": "重复记忆", + "metadata": { + "timestamp_local": "2026-02-24 10:01:00", + "group_id": "1001", + "request_type": "group", + }, + "distance": 0.35, + }, + ] + return [] + + vector_store.event_resolver = _resolve_events + service = CognitiveService( + config_getter=lambda: SimpleNamespace( + enabled=True, + enable_rerank=True, + auto_top_k=2, + auto_scope_candidate_multiplier=2, + auto_current_group_boost=1.15, + rerank_candidate_multiplier=3, + time_decay_enabled=True, + time_decay_half_life_days_auto=14.0, + time_decay_boost=0.2, + time_decay_min_similarity=0.35, + ), + vector_store=vector_store, + job_queue=_FakeJobQueue(), + profile_storage=_FakeProfileStorage(), + reranker=reranker, + ) + + context = await service.build_context( + query="我周三要发版\n补充:是后端服务发版", + recall_queries=["我周三要发版", "补充:是后端服务发版"], + group_id="1001", + user_id="3001", + sender_id="3001", + request_type="group", + ) + + assert [call.get("query") for call in vector_store.event_calls] == [ + "我周三要发版", + "补充:是后端服务发版", + ] + assert all(call.get("reranker") is None for call in vector_store.event_calls) + assert len(reranker.calls) == 1 + assert reranker.calls[0] == { + "query": "我周三要发版\n补充:是后端服务发版", + "documents": ["发版时间相关记忆", "后端服务相关记忆", "重复记忆"], + "top_n": 2, + } + assert "重复记忆" in context + assert "后端服务相关记忆" in context + assert "发版时间相关记忆" not in context + assert context.index("重复记忆") < context.index("后端服务相关记忆") + + +@pytest.mark.asyncio +async def test_build_context_single_query_keeps_query_level_reranker() -> None: + vector_store = _FakeVectorStore() + reranker = _FakeReranker() + service = CognitiveService( + config_getter=lambda: SimpleNamespace( + enabled=True, + enable_rerank=True, + auto_top_k=2, + auto_scope_candidate_multiplier=2, + auto_current_group_boost=1.15, + rerank_candidate_multiplier=3, + time_decay_enabled=True, + time_decay_half_life_days_auto=14.0, + time_decay_boost=0.2, + time_decay_min_similarity=0.35, + ), + vector_store=vector_store, + job_queue=_FakeJobQueue(), + profile_storage=_FakeProfileStorage(), + reranker=reranker, + ) + + await service.build_context( + query="单条消息", + recall_queries=["单条消息"], + group_id="1001", + user_id="3001", + sender_id="3001", + request_type="group", + ) + + assert len(vector_store.event_calls) == 1 + assert vector_store.event_calls[0].get("reranker") is reranker + assert reranker.calls == [] @pytest.mark.asyncio @@ -525,9 +717,10 @@ async def test_build_context_downgrades_to_empty_when_auto_event_query_fails() - class _FailingVectorStore(_FakeVectorStore): async def query_events( self, - _query: str, + query: str, **kwargs: Any, ) -> list[dict[str, Any]]: + _ = query self.last_event_kwargs = dict(kwargs) raise RuntimeError("chroma transient failure") @@ -621,11 +814,12 @@ async def test_sync_profile_display_name_updates_existing_profile_and_vector() - assert "name: 新昵称" in profile_storage.last_write[2] assert "nickname: 新昵称" in profile_storage.last_write[2] assert vector_store.last_upsert_profile is not None - profile_id, document, metadata = vector_store.last_upsert_profile + profile_id, document, metadata, kwargs = vector_store.last_upsert_profile assert profile_id == "user:12345" assert "昵称: 新昵称" in document assert metadata["name"] == "新昵称" assert metadata["nickname"] == "新昵称" + assert kwargs.get("priority") == CHROMA_PRIORITY_FOREGROUND @pytest.mark.asyncio diff --git a/tests/test_cognitive_vector_store_compat.py b/tests/test_cognitive_vector_store_compat.py new file mode 100644 index 00000000..85372c03 --- /dev/null +++ b/tests/test_cognitive_vector_store_compat.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from Undefined.cognitive.vector_store_compat import call_vector_store_method + + +@pytest.mark.asyncio +async def test_call_vector_store_method_omits_priority_for_legacy_method() -> None: + calls: list[dict[str, Any]] = [] + + async def _legacy_method(value: str, *, top_k: int) -> str: + calls.append({"value": value, "top_k": top_k}) + return "ok" + + result = await call_vector_store_method( + _legacy_method, + "query", + priority="foreground", + top_k=3, + ) + + assert result == "ok" + assert calls == [{"value": "query", "top_k": 3}] + + +@pytest.mark.asyncio +async def test_call_vector_store_method_passes_priority_when_supported() -> None: + calls: list[dict[str, Any]] = [] + + async def _new_method(value: str, *, top_k: int, priority: str) -> str: + calls.append({"value": value, "top_k": top_k, "priority": priority}) + return "ok" + + result = await call_vector_store_method( + _new_method, + "query", + priority="foreground_critical", + top_k=3, + ) + + assert result == "ok" + assert calls == [{"value": "query", "top_k": 3, "priority": "foreground_critical"}] diff --git a/tests/test_cognitive_vector_store_metadata.py b/tests/test_cognitive_vector_store_metadata.py index d512b020..3edc0a35 100644 --- a/tests/test_cognitive_vector_store_metadata.py +++ b/tests/test_cognitive_vector_store_metadata.py @@ -8,6 +8,7 @@ import pytest from chromadb.errors import InternalError as ChromaInternalError +from Undefined.cognitive.chroma_scheduler import ChromaOperationScheduler from Undefined.cognitive.vector_store import _sanitize_metadata from Undefined.cognitive.vector_store import CognitiveVectorStore @@ -89,21 +90,24 @@ def query(self, **_kwargs: object) -> dict[str, list[list[object]]]: } store = CognitiveVectorStore.__new__(CognitiveVectorStore) - store._events_lock = asyncio.Lock() - store._profiles_lock = asyncio.Lock() + scheduler = ChromaOperationScheduler() + store._chroma_scheduler = scheduler fake_collection = _FakeCollection() store._events = cast(Any, fake_collection) store._profiles = cast(Any, object()) - results = await store._query( - fake_collection, - "测试查询", - 1, - None, - None, - 1, - query_embedding=[0.11, 0.22, 0.33], - ) + try: + results = await store._query( + fake_collection, + "测试查询", + 1, + None, + None, + 1, + query_embedding=[0.11, 0.22, 0.33], + ) + finally: + await scheduler.stop() assert fake_collection.calls == 3 assert results == [ diff --git a/tests/test_config_cognitive_historian_limits.py b/tests/test_config_cognitive_historian_limits.py index bb58f56b..36f98f60 100644 --- a/tests/test_config_cognitive_historian_limits.py +++ b/tests/test_config_cognitive_historian_limits.py @@ -13,6 +13,9 @@ def test_parse_cognitive_historian_reference_limits() -> None: "auto_current_group_boost": 1.3, "auto_current_private_boost": 1.6, }, + "vector_store": { + "scheduler_foreground_burst": 5, + }, "historian": { "recent_messages_inject_k": 21, "recent_message_line_max_len": 333, @@ -29,6 +32,7 @@ def test_parse_cognitive_historian_reference_limits() -> None: assert cfg.auto_current_group_boost == 1.3 assert cfg.auto_current_private_boost == 1.6 assert cfg.enable_rerank is False + assert cfg.vector_store_scheduler_foreground_burst == 5 def test_parse_cognitive_historian_reference_limits_defaults() -> None: @@ -41,3 +45,12 @@ def test_parse_cognitive_historian_reference_limits_defaults() -> None: assert cfg.auto_current_group_boost == 1.15 assert cfg.auto_current_private_boost == 1.25 assert cfg.enable_rerank is True + assert cfg.vector_store_scheduler_foreground_burst == 8 + + +def test_parse_cognitive_vector_store_scheduler_burst_clamps_to_positive() -> None: + cfg = _parse_cognitive_config( + {"cognitive": {"vector_store": {"scheduler_foreground_burst": 0}}} + ) + + assert cfg.vector_store_scheduler_foreground_burst == 1 diff --git a/tests/test_context_recent_messages_limit.py b/tests/test_context_recent_messages_limit.py index 987ea3d8..c3a4a620 100644 --- a/tests/test_context_recent_messages_limit.py +++ b/tests/test_context_recent_messages_limit.py @@ -84,3 +84,83 @@ async def _fake_recent( ) assert captured == [750] + + +@pytest.mark.asyncio +async def test_prompt_builder_filters_webchat_display_only_history() -> None: + class _Runtime: + def get_context_recent_messages_limit(self) -> int: + return 10 + + builder = PromptBuilder( + bot_qq=123456, + memory_storage=None, + end_summary_storage=_FakeEndSummaryStorage(), # type: ignore[arg-type] + runtime_config_getter=lambda: _Runtime(), + ) + messages: list[dict[str, Any]] = [] + + async def _fake_recent( + chat_id: str, + msg_type: str, + start: int, + limit: int, + ) -> list[dict[str, Any]]: + _ = (chat_id, msg_type, start, limit) + return [ + { + "type": "private", + "display_name": "Bot", + "user_id": "42", + "chat_id": "42", + "chat_name": "QQ用户42", + "timestamp": "2026-05-30 12:00:00", + "message": "", + "webchat": { + "display_only": True, + "events": [ + { + "seq": 2, + "event": "tool_end", + "payload": {"result_preview": "secret tool result"}, + } + ], + }, + }, + { + "type": "private", + "display_name": "Bot", + "user_id": "42", + "chat_id": "42", + "chat_name": "QQ用户42", + "timestamp": "2026-05-30 12:00:01", + "message": "可见回复", + "webchat": { + "display_only": True, + "events": [ + { + "seq": 3, + "event": "tool_end", + "payload": {"result_preview": "visible metadata"}, + } + ], + }, + }, + ] + + async with RequestContext(request_type="private", user_id=42, sender_id=10001): + await builder._inject_recent_messages( + messages, + _fake_recent, + None, + "hello", + ) + + history_message = next( + str(msg.get("content", "")) + for msg in messages + if "【历史消息存档】" in str(msg.get("content", "")) + ) + assert "secret tool result" not in history_message + assert "可见回复" in history_message + assert "visible metadata" not in history_message diff --git a/tests/test_end_tool.py b/tests/test_end_tool.py index 92be8277..b1eac426 100644 --- a/tests/test_end_tool.py +++ b/tests/test_end_tool.py @@ -7,6 +7,7 @@ from Undefined.context import RequestContext from Undefined.skills.tools.end.handler import execute +from Undefined.utils.message_turn import mark_message_sent_this_turn @pytest.mark.asyncio @@ -60,6 +61,21 @@ async def test_end_accepts_message_sent_flag_from_request_context_string_true() assert context["conversation_ended"] is True +@pytest.mark.asyncio +async def test_end_accepts_message_sent_flag_from_copied_tool_context() -> None: + send_context: dict[str, Any] = {"request_id": "req-send-copy"} + end_context: dict[str, Any] = {"request_id": "req-end-copy"} + + async with RequestContext(request_type="private", user_id=42): + mark_message_sent_this_turn(send_context) + result = await execute({"memo": "已发送消息"}, end_context) + + assert send_context["message_sent_this_turn"] is True + assert "message_sent_this_turn" not in end_context + assert result == "对话已结束" + assert end_context["conversation_ended"] is True + + class _FakeHistoryManager: def get_recent( self, chat_id: str, msg_type: str, start: int, end: int @@ -80,6 +96,8 @@ class _FakeCognitiveService: def __init__(self) -> None: self.last_context: dict[str, Any] | None = None self.last_force: bool | None = None + self.last_memo = "" + self.last_observations: list[str] = [] async def enqueue_job( self, @@ -91,6 +109,8 @@ async def enqueue_job( ) -> str: self.last_context = dict(context) self.last_force = bool(force) + self.last_memo = memo + self.last_observations = list(observations) return "job-test" @@ -117,6 +137,36 @@ async def test_end_ignores_removed_legacy_param_names() -> None: assert cognitive_service.last_context is None +@pytest.mark.asyncio +async def test_end_normalizes_undefined_project_name_misspellings() -> None: + cognitive_service = _FakeCognitiveService() + context: dict[str, Any] = { + "request_id": "req-normalize-project-name", + "cognitive_service": cognitive_service, + } + + result = await execute( + { + "memo": "已解释 Unfined 的记忆架构", + "observations": [ + "QQ号42(昵称system)在 WebUI 询问 Unfined 是否了解自身记忆架构", + "QQ号42(昵称system)提到 Undefind 的分层架构", + "QQ号42(昵称system)继续讨论 undefind", + ], + "force": True, + }, + context, + ) + + assert result == "对话已结束" + assert cognitive_service.last_memo == "已解释 Undefined 的记忆架构" + assert cognitive_service.last_observations == [ + "QQ号42(昵称system)在 WebUI 询问 Undefined 是否了解自身记忆架构", + "QQ号42(昵称system)提到 Undefined 的分层架构", + "QQ号42(昵称system)继续讨论 Undefined", + ] + + @pytest.mark.asyncio async def test_end_enriches_historian_reference_context() -> None: cognitive_service = _FakeCognitiveService() @@ -208,6 +258,85 @@ def get_recent( ] +class _DuplicateCurrentBatchHistoryManager: + def get_recent( + self, chat_id: str, msg_type: str, start: int, end: int + ) -> list[dict[str, Any]]: + _ = chat_id, msg_type, start, end + return [ + { + "type": "group", + "message_id": "100", + "timestamp": "2026-02-23 19:01:00", + "display_name": "旁观者", + "user_id": "99999", + "chat_id": "1082837821", + "chat_name": "bot测试群", + "message": "保留的旧历史", + }, + { + "type": "group", + "message_id": "101", + "timestamp": "2026-02-23 19:02:12", + "display_name": "洛泫", + "user_id": "120218451", + "chat_id": "1082837821", + "chat_name": "bot测试群", + "message": "我周三要发版", + }, + { + "type": "group", + "message_id": "102", + "timestamp": "2026-02-23 19:02:14", + "display_name": "洛泫", + "user_id": "120218451", + "chat_id": "1082837821", + "chat_name": "bot测试群", + "message": "补充:是后端服务发版", + }, + ] + + +@pytest.mark.asyncio +async def test_end_historian_recent_messages_drops_current_batch_duplicates() -> None: + cognitive_service = _FakeCognitiveService() + context: dict[str, Any] = { + "request_id": "req-historian-drop-current-batch", + "request_type": "group", + "group_id": "1082837821", + "user_id": "120218451", + "sender_id": "120218451", + "history_manager": _DuplicateCurrentBatchHistoryManager(), + "cognitive_service": cognitive_service, + "current_question": ( + '' + "我周三要发版" + '' + "补充:是后端服务发版" + "\n\n 【连续消息说明】以上 2 条 共同构成【当前输入批次】" + ), + } + + result = await execute( + {"observations": ["洛泫周三要进行后端服务发版"], "force": True}, + context, + ) + + assert result == "对话已结束" + recent = context.get("historian_recent_messages", []) + assert isinstance(recent, list) + recent_text = "\n".join(str(item) for item in recent) + assert "保留的旧历史" in recent_text + assert "我周三要发版" not in recent_text + assert "补充:是后端服务发版" not in recent_text + assert cognitive_service.last_context is not None + assert cognitive_service.last_context.get("historian_recent_messages") == recent + + @pytest.mark.asyncio async def test_end_uses_runtime_config_for_historian_reference_limits() -> None: cognitive_service = _FakeCognitiveService() diff --git a/tests/test_file_analysis_attachment_uid.py b/tests/test_file_analysis_attachment_uid.py index 715d5702..06da1334 100644 --- a/tests/test_file_analysis_attachment_uid.py +++ b/tests/test_file_analysis_attachment_uid.py @@ -1,13 +1,31 @@ from __future__ import annotations from pathlib import Path +from typing import Any import pytest -from Undefined.attachments import AttachmentRegistry +from Undefined.attachments import AttachmentRegistry, scope_from_context from Undefined.skills.agents.file_analysis_agent.tools.download_file import ( handler as download_file_handler, ) +from Undefined.utils.io import write_bytes +from Undefined.utils.paths import ensure_dir + + +def _download_context( + tmp_path: Path, + registry: AttachmentRegistry, +) -> dict[str, Any]: + return { + "attachment_registry": registry, + "request_type": "private", + "user_id": 12345, + "get_scope_from_context": scope_from_context, + "download_cache_dir": tmp_path / "downloads", + "ensure_dir_fn": ensure_dir, + "write_bytes_fn": write_bytes, + } @pytest.mark.asyncio @@ -28,16 +46,13 @@ async def test_download_file_supports_internal_attachment_uid( result = await download_file_handler.execute( {"file_source": record.uid}, - { - "attachment_registry": registry, - "request_type": "private", - "user_id": 12345, - }, + _download_context(tmp_path, registry), ) downloaded = Path(result) assert downloaded.is_file() - assert downloaded.name == "demo.txt" + assert downloaded.name.startswith("file_") + assert downloaded.suffix == ".txt" assert downloaded.read_bytes() == b"hello attachment" @@ -58,16 +73,36 @@ async def test_download_file_redownloads_url_backed_attachment_uid( display_name="demo.txt", ) + async def _fake_ensure_local_file(record: object) -> object: + return type( + "AttachmentLike", + (), + { + "uid": getattr(record, "uid"), + "kind": getattr(record, "kind"), + "media_type": getattr(record, "media_type"), + "display_name": getattr(record, "display_name"), + "source_ref": getattr(record, "source_ref"), + "local_path": "", + }, + )() + + captured_url: dict[str, str] = {} + async def _fake_download_from_url( url: str, temp_dir: Path, max_size_mb: float, task_uuid: str, + write_bytes_fn: object, ) -> str: - target = temp_dir / "demo.txt" - target.write_bytes(url.encode("utf-8")) + _ = max_size_mb, task_uuid, write_bytes_fn + captured_url["url"] = url + target = temp_dir / "file_from_source_ref.txt" + target.write_bytes(b"https://example.com/demo.txt") return str(target) + monkeypatch.setattr(registry, "ensure_local_file", _fake_ensure_local_file) monkeypatch.setattr( download_file_handler, "_download_from_url", @@ -76,14 +111,41 @@ async def _fake_download_from_url( result = await download_file_handler.execute( {"file_source": record.uid}, - { - "attachment_registry": registry, - "request_type": "private", - "user_id": 12345, - }, + _download_context(tmp_path, registry), ) downloaded = Path(result) assert downloaded.is_file() - assert downloaded.name == "demo.txt" + assert downloaded.name.startswith("file_") + assert downloaded.suffix == ".txt" assert downloaded.read_bytes() == b"https://example.com/demo.txt" + assert captured_url["url"] == "https://example.com/demo.txt" + + +@pytest.mark.asyncio +async def test_download_file_uses_random_name_for_unsafe_attachment_name( + tmp_path: Path, +) -> None: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + ) + record = await registry.register_bytes( + "private:12345", + b"image bytes", + kind="image", + display_name=f"base64://{'a' * 5000}.png", + source_kind="base64_image", + source_ref="segment:0", + ) + + result = await download_file_handler.execute( + {"file_source": record.uid}, + _download_context(tmp_path, registry), + ) + + downloaded = Path(result) + assert downloaded.is_file() + assert downloaded.name.startswith("image_") + assert len(downloaded.name) < 64 + assert downloaded.read_bytes() == b"image bytes" diff --git a/tests/test_github_client.py b/tests/test_github_client.py index 08ecdc7e..a1f69279 100644 --- a/tests/test_github_client.py +++ b/tests/test_github_client.py @@ -49,13 +49,15 @@ async def test_get_public_repo_info_parses_repo_and_contributor_count( monkeypatch: pytest.MonkeyPatch, ) -> None: calls: list[str] = [] + request_kwargs: list[dict[str, Any]] = [] async def fake_request_with_retry( _method: str, url: str, - **_kwargs: Any, + **kwargs: Any, ) -> _FakeResponse: calls.append(url) + request_kwargs.append(kwargs) if url.endswith("/contributors"): return _FakeResponse( [{"login": "alice"}], @@ -67,17 +69,30 @@ async def fake_request_with_retry( monkeypatch.setattr(client_module, "request_with_retry", fake_request_with_retry) - info = await client_module.get_public_repo_info("69gg/Undefined") + info = await client_module.get_public_repo_info( + "69gg/Undefined", + request_timeout=17.0, + request_retries=3, + context={"request_id": "github-test"}, + ) assert calls == [ "https://api.github.com/repos/69gg/Undefined", "https://api.github.com/repos/69gg/Undefined/contributors", ] + assert [item["timeout"] for item in request_kwargs] == [17.0, 17.0] + assert [item["retries"] for item in request_kwargs] == [3, 3] + assert [item["context"] for item in request_kwargs] == [ + {"request_id": "github-test"}, + {"request_id": "github-test"}, + ] assert info.repo_id == "69gg/Undefined" assert info.owner_login == "69gg" assert info.stars == 1234 assert info.forks == 56 assert info.open_issues == 7 + assert info.watchers == 89 + assert info.subscribers == 89 assert info.contributors == 42 assert info.topics == ("bot", "onebot") @@ -99,3 +114,33 @@ async def fake_request_with_retry( with pytest.raises(ValueError, match="仅支持 public"): await client_module.get_public_repo_info("69gg/Undefined") + + +@pytest.mark.asyncio +async def test_get_public_repo_info_uses_default_retry_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + request_kwargs: list[dict[str, Any]] = [] + + async def fake_request_with_retry( + _method: str, + url: str, + **kwargs: Any, + ) -> _FakeResponse: + request_kwargs.append(kwargs) + if url.endswith("/contributors"): + return _FakeResponse([{"login": "alice"}]) + return _FakeResponse(_repo_payload()) + + monkeypatch.setattr(client_module, "request_with_retry", fake_request_with_retry) + + await client_module.get_public_repo_info("69gg/Undefined") + + assert [item["timeout"] for item in request_kwargs] == [ + client_module.DEFAULT_REQUEST_TIMEOUT_SECONDS, + client_module.DEFAULT_REQUEST_TIMEOUT_SECONDS, + ] + assert [item["retries"] for item in request_kwargs] == [ + client_module.DEFAULT_REQUEST_RETRIES, + client_module.DEFAULT_REQUEST_RETRIES, + ] diff --git a/tests/test_github_config.py b/tests/test_github_config.py index bc41a368..71b7e1e0 100644 --- a/tests/test_github_config.py +++ b/tests/test_github_config.py @@ -28,6 +28,7 @@ def test_github_config_clamps_invalid_values(tmp_path: Path) -> None: "[github]\n" "auto_extract_enabled = true\n" "request_timeout_seconds = 99\n" + "request_retries = 99\n" "auto_extract_group_ids = [123456]\n" "auto_extract_private_ids = [20003]\n" "auto_extract_max_items = 99\n" @@ -36,11 +37,21 @@ def test_github_config_clamps_invalid_values(tmp_path: Path) -> None: assert config.github_auto_extract_enabled is True assert config.github_request_timeout_seconds == 60.0 + assert config.github_request_retries == 5 assert config.github_auto_extract_group_ids == [123456] assert config.github_auto_extract_private_ids == [20003] assert config.github_auto_extract_max_items == 10 +def test_github_config_retries_fallbacks(tmp_path: Path) -> None: + config = _load_config( + tmp_path, + ("[github]\nrequest_retries = -1\n"), + ) + + assert config.github_request_retries == 0 + + def test_github_auto_extract_allowlist_follows_global_access_when_empty( tmp_path: Path, ) -> None: diff --git a/tests/test_github_sender.py b/tests/test_github_sender.py index 28e0aaff..8dbbb58a 100644 --- a/tests/test_github_sender.py +++ b/tests/test_github_sender.py @@ -59,10 +59,9 @@ async def fake_render_html_to_image( assert proxy is None Path(output_path).write_bytes(b"png") + get_public_repo_info_mock = AsyncMock(return_value=_repo_info()) monkeypatch.setattr( - sender_module, - "get_public_repo_info", - AsyncMock(return_value=_repo_info()), + sender_module, "get_public_repo_info", get_public_repo_info_mock ) monkeypatch.setattr( sender_module, "render_html_to_image", fake_render_html_to_image @@ -80,9 +79,18 @@ async def fake_render_html_to_image( sender=sender, target_type="group", target_id=10001, + request_timeout=18.0, + request_retries=4, + context={"request_id": "sender-test"}, ) assert result == "已发送 GitHub 仓库卡片: 69gg/Undefined" + get_public_repo_info_mock.assert_awaited_once_with( + "69gg/Undefined", + request_timeout=18.0, + request_retries=4, + context={"request_id": "sender-test"}, + ) assert "69gg/Undefined" in rendered_html[0] assert "QQ bot platform" in rendered_html[0] assert "1,234" in rendered_html[0] diff --git a/tests/test_grok_search_tool.py b/tests/test_grok_search_tool.py index ce3dc955..b7870a0b 100644 --- a/tests/test_grok_search_tool.py +++ b/tests/test_grok_search_tool.py @@ -1,5 +1,8 @@ from __future__ import annotations +from datetime import datetime, timezone +import json +from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock @@ -9,12 +12,77 @@ from Undefined.skills.agents.web_agent.tools.grok_search import handler as grok_handler +def test_grok_search_system_prompt_uses_provided_time_and_search_rules() -> None: + prompt = grok_handler._build_grok_search_system_prompt( + datetime(2026, 5, 30, 12, 34, 56, tzinfo=timezone.utc) + ) + + assert "2026-05-30T12:34:56+00:00" in prompt + assert "不要以模型内部时间为准" in prompt + assert "必须先调用搜索" in prompt + assert "多个搜索工具" in prompt + assert "不可胡编乱造" in prompt + assert "必须给出来源" in prompt + + +def test_grok_search_schema_requires_natural_language_search_request() -> None: + config_path = ( + Path("src") + / "Undefined" + / "skills" + / "agents" + / "web_agent" + / "tools" + / "grok_search" + / "config.json" + ) + schema = json.loads(config_path.read_text(encoding="utf-8")) + parameters = schema["function"]["parameters"] + + assert parameters["required"] == ["search_request"] + assert "search_request" in parameters["properties"] + assert "query" not in parameters["properties"] + assert ( + "自然语言详细说明搜索内容和回答要求" + in parameters["properties"]["search_request"]["description"] + ) + assert "不要只给关键词" in schema["function"]["description"] + assert "不要主动把范围写死" in schema["function"]["description"] + assert ( + "不要主动添加用户未要求的硬性范围" + in parameters["properties"]["search_request"]["description"] + ) + + +@pytest.mark.asyncio +async def test_grok_search_requires_search_request() -> None: + ai_client = SimpleNamespace(submit_queued_llm_call=AsyncMock()) + + result = await grok_handler.execute( + {}, + { + "runtime_config": SimpleNamespace( + grok_search_enabled=True, + grok_model=SimpleNamespace( + api_url="https://grok.example/v1", + api_key="sk-grok", + model_name="grok-4-search", + ), + ), + "ai_client": ai_client, + }, + ) + + assert result == "请用 search_request 提供完整的自然语言搜索要求。" + ai_client.submit_queued_llm_call.assert_not_awaited() + + @pytest.mark.asyncio async def test_grok_search_returns_disabled_when_switch_is_off() -> None: ai_client = SimpleNamespace(submit_queued_llm_call=AsyncMock()) result = await grok_handler.execute( - {"query": "latest inference model releases"}, + {"search_request": "latest inference model releases"}, { "runtime_config": SimpleNamespace( grok_search_enabled=False, @@ -29,7 +97,7 @@ async def test_grok_search_returns_disabled_when_switch_is_off() -> None: @pytest.mark.asyncio -async def test_grok_search_returns_raw_result() -> None: +async def test_grok_search_returns_message_content_from_dict_response() -> None: ai_client = SimpleNamespace( submit_queued_llm_call=AsyncMock( return_value={ @@ -57,7 +125,12 @@ async def test_grok_search_returns_raw_result() -> None: ) result = await grok_handler.execute( - {"query": "请详细搜索 2026 年最新 AI 芯片发布信息"}, + { + "search_request": ( + "请搜索 2026 年最新 AI 芯片发布信息,重点比较发布时间、" + "供应商、面向推理还是训练、公开性能指标和权威来源。" + ) + }, { "runtime_config": SimpleNamespace( grok_search_enabled=True, @@ -68,11 +141,90 @@ async def test_grok_search_returns_raw_result() -> None: ) assert "这里是搜索结果摘要。" in result + assert "choices" not in result assert "参考链接:" not in result ai_client.submit_queued_llm_call.assert_awaited_once() kwargs = ai_client.submit_queued_llm_call.await_args.kwargs assert kwargs["model_config"] is grok_model assert kwargs["call_type"] == "agent_tool:grok_search" + assert kwargs["messages"][0]["role"] == "system" + assert "不要以模型内部时间为准" in kwargs["messages"][0]["content"] + assert "必须先调用搜索" in kwargs["messages"][0]["content"] + assert "多个搜索工具" in kwargs["messages"][0]["content"] + assert "必须给出来源" in kwargs["messages"][0]["content"] + assert kwargs["messages"][1] == { + "role": "user", + "content": ( + "请搜索 2026 年最新 AI 芯片发布信息,重点比较发布时间、" + "供应商、面向推理还是训练、公开性能指标和权威来源。" + ), + } + + +@pytest.mark.asyncio +async def test_grok_search_returns_message_content_from_json_string_response() -> None: + ai_client = SimpleNamespace( + submit_queued_llm_call=AsyncMock( + return_value=json.dumps( + { + "id": "chatcmpl-test", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": {"content": "JSON 字符串里的搜索摘要。"}, + } + ], + }, + ensure_ascii=False, + ) + ) + ) + grok_model = SimpleNamespace( + api_url="https://grok.example/v1", + api_key="sk-grok", + model_name="grok-4-search", + max_tokens=4096, + ) + + result = await grok_handler.execute( + {"search_request": "请搜索一个测试主题并返回摘要。"}, + { + "runtime_config": SimpleNamespace( + grok_search_enabled=True, + grok_model=grok_model, + ), + "ai_client": ai_client, + }, + ) + + assert result == "JSON 字符串里的搜索摘要。" + + +@pytest.mark.asyncio +async def test_grok_search_returns_original_text_when_json_parse_fails() -> None: + ai_client = SimpleNamespace( + submit_queued_llm_call=AsyncMock(return_value="{not valid json") + ) + grok_model = SimpleNamespace( + api_url="https://grok.example/v1", + api_key="sk-grok", + model_name="grok-4-search", + max_tokens=4096, + ) + + result = await grok_handler.execute( + {"search_request": "请搜索一个测试主题并返回摘要。"}, + { + "runtime_config": SimpleNamespace( + grok_search_enabled=True, + grok_model=grok_model, + ), + "ai_client": ai_client, + }, + ) + + assert result == "{not valid json" def test_runner_filters_grok_search_for_web_agent_when_disabled() -> None: diff --git a/tests/test_handlers_github_auto_extract.py b/tests/test_handlers_github_auto_extract.py index 6b1fb85f..9c36ed72 100644 --- a/tests/test_handlers_github_auto_extract.py +++ b/tests/test_handlers_github_auto_extract.py @@ -1,13 +1,16 @@ from __future__ import annotations +import logging from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock +import httpx import pytest import Undefined.handlers as handlers_module from Undefined.handlers import MessageHandler +import Undefined.github.sender as github_sender_module from Undefined.skills.pipelines import PipelineRegistry @@ -76,3 +79,54 @@ async def test_private_message_runs_github_auto_extract_before_ai_reply( handler.ai_coordinator.model_pool.handle_private_message.assert_not_called() handler.command_dispatcher.parse_command.assert_called_once_with("69gg/Undefined") handler.ai_coordinator.handle_private_reply.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_github_auto_extract_logs_exception_type_and_repr( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + sender_calls: list[dict[str, Any]] = [] + + async def fake_send_github_repo_card(**kwargs: Any) -> str: + sender_calls.append(kwargs) + raise httpx.ConnectError("") + + monkeypatch.setattr( + github_sender_module, + "send_github_repo_card", + fake_send_github_repo_card, + ) + + handler: Any = MessageHandler.__new__(MessageHandler) + handler.config = SimpleNamespace( + github_auto_extract_max_items=3, + github_request_timeout_seconds=11.0, + github_request_retries=4, + ) + handler.sender = SimpleNamespace() + + with caplog.at_level(logging.ERROR, logger="Undefined.handlers.auto_extract"): + await handler._handle_github_extract( + target_id=1067860266, + repo_ids=["69gg/Undefined"], + target_type="group", + ) + + log_text = caplog.text + assert "自动提取跳过 69gg/Undefined" in log_text + assert "exc_type=ConnectError" in log_text + assert "ConnectError('')" in log_text + assert sender_calls == [ + { + "repo_id": "69gg/Undefined", + "sender": handler.sender, + "target_type": "group", + "target_id": 1067860266, + "request_timeout": 11.0, + "request_retries": 4, + "context": { + "request_id": "github_auto_extract:group:1067860266:69gg/Undefined" + }, + } + ] diff --git a/tests/test_handlers_repeat.py b/tests/test_handlers_repeat.py index d649c981..e6a5d2ad 100644 --- a/tests/test_handlers_repeat.py +++ b/tests/test_handlers_repeat.py @@ -2,12 +2,14 @@ from __future__ import annotations +from pathlib import Path from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock import pytest +from Undefined.attachments import AttachmentRegistry from Undefined.handlers import ( MessageHandler, REPEAT_REPLY_HISTORY_PREFIX, @@ -105,6 +107,17 @@ def _group_event( } +_PNG_BYTES: bytes = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00" + b"\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc``\x00\x00\x00\x02\x00\x01" + b"\x0b\xe7\x02\x9d" + b"\x00\x00\x00\x00IEND\xaeB`\x82" +) + + # ── 基础:复读未启用时不触发 ── @@ -140,6 +153,87 @@ async def test_repeat_triggers_on_3_identical_from_different_senders() -> None: handler._bot_nickname_cache.get_nicknames.assert_not_called() +@pytest.mark.asyncio +async def test_repeat_renders_image_attachment_instead_of_sending_uid_text( + tmp_path: Path, +) -> None: + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + ) + record = await registry.register_bytes( + "group:30001", + _PNG_BYTES, + kind="image", + display_name="repeat.png", + source_kind="test", + ) + handler = _build_handler(repeat_enabled=True) + handler.sender.attachment_registry = registry + attachment_ref = record.prompt_ref() + text = f'' + + for uid in [20001, 20002, 20003]: + triggered = await handler._maybe_trigger_repeat( + 30001, + uid, + text, + attachments=[attachment_ref], + ) + + assert triggered is True + handler.sender.send_group_message.assert_called_once() + call = handler.sender.send_group_message.call_args + assert call.args[0] == 30001 + assert "[CQ:image,file=file://" in call.args[1] + assert " None: + handler = _build_handler(repeat_enabled=True) + text = '' + attachment_ref = { + "uid": "pic_missing_registry", + "kind": "image", + "media_type": "image", + "display_name": "missing.png", + } + + results = [ + await handler._maybe_trigger_repeat( + 30001, + uid, + text, + attachments=[attachment_ref], + ) + for uid in [20001, 20002, 20003] + ] + + assert results[-1] is False + handler.sender.send_group_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_repeat_releases_group_lock_before_sending() -> None: + handler = _build_handler(repeat_enabled=True) + observed_locked: list[bool] = [] + + async def _send_group_message(group_id: int, _text: str, **_kwargs: Any) -> None: + observed_locked.append(handler._get_repeat_lock(group_id).locked()) + + handler.sender.send_group_message = AsyncMock(side_effect=_send_group_message) + + for uid in [20001, 20002, 20003]: + await handler._maybe_trigger_repeat(30001, uid, "hello") + + assert observed_locked == [False] + + # ── 不触发:3条相同消息来自同一人 ── diff --git a/tests/test_history_level.py b/tests/test_history_level.py index a8a4f4a6..b96d14a5 100644 --- a/tests/test_history_level.py +++ b/tests/test_history_level.py @@ -87,6 +87,48 @@ async def fake_save(data: list[dict[str, object]], path: str) -> None: assert record["level"] == "" +@pytest.mark.asyncio +async def test_add_private_message_stores_webchat_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = MessageHistoryManager.__new__(MessageHistoryManager) + manager._private_message_history = {} + manager._max_records = 10000 + manager._initialized = asyncio.Event() + manager._initialized.set() + manager._private_locks = {} + + saved_data: dict[str, list[dict[str, object]]] = {} + + async def fake_save(data: list[dict[str, object]], path: str) -> None: + saved_data[path] = data + + monkeypatch.setattr(manager, "_save_history_to_file", fake_save) + + webchat: dict[str, object] = { + "display_only": True, + "job_id": "job_1", + "events": [ + { + "seq": 2, + "event": "tool_end", + "payload": {"tool_call_id": "call_1"}, + } + ], + } + await manager.add_private_message( + user_id=42, + text_content="", + display_name="Bot", + webchat=webchat, + ) + await manager.flush_pending_saves() + + record = manager._private_message_history["42"][0] + assert record["message"] == "" + assert record["webchat"] == webchat + + @pytest.mark.asyncio async def test_history_save_failure_keeps_pending_snapshot( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_history_load_compat.py b/tests/test_history_load_compat.py index 8cf57fae..9bded897 100644 --- a/tests/test_history_load_compat.py +++ b/tests/test_history_load_compat.py @@ -177,3 +177,67 @@ def get_recent( "description": "无语猫猫表情包", } ] + + +@pytest.mark.asyncio +async def test_recent_messages_lazily_backfills_meme_attachments_from_attachment_tag( + tmp_path: Path, +) -> None: + history_messages: list[dict[str, object]] = [ + { + "type": "group", + "chat_id": "30001", + "chat_name": "群30001", + "user_id": "10000", + "display_name": "Bot", + "timestamp": "2026-04-11 12:00:00", + "message": '', + } + ] + + class _DummyHistoryManager: + def get_recent( + self, _chat_id: str, _msg_type: str, _start: int, _end: int + ) -> list[dict[str, object]]: + return history_messages + + registry = AttachmentRegistry( + registry_path=tmp_path / "attachment_registry.json", + cache_dir=tmp_path / "attachments", + ) + registry.set_global_image_resolver( + lambda uid: ( + AttachmentRecord( + uid=uid, + scope_key="", + kind="image", + media_type="image", + display_name="meme.png", + source_kind="meme_library", + source_ref="file:///tmp/meme.png", + local_path=None, + mime_type="image/png", + sha256="deadbeef", + created_at="2026-04-11T12:00:00", + segment_data={}, + semantic_kind="meme", + description="无语猫猫表情包", + ) + if uid == "pic_global01" + else None + ) + ) + + recent_messages = await get_recent_messages_prefer_local( + chat_id="30001", + msg_type="group", + start=0, + end=10, + onebot_client=None, + history_manager=_DummyHistoryManager(), + bot_qq=10000, + attachment_registry=registry, + ) + + assert recent_messages[0]["attachments"][0]["uid"] == "pic_global01" + assert recent_messages[0]["attachments"][0]["description"] == "无语猫猫表情包" diff --git a/tests/test_llm_retry_suppression.py b/tests/test_llm_retry_suppression.py index c58a1746..67b6a9e1 100644 --- a/tests/test_llm_retry_suppression.py +++ b/tests/test_llm_retry_suppression.py @@ -150,6 +150,150 @@ async def _execute_tool( assert cast(AsyncMock, client.submit_queued_llm_call).await_count == 2 +@pytest.mark.asyncio +async def test_ai_ask_webchat_events_include_stage_and_tool_lifecycle() -> None: + client: Any = object.__new__(AIClient) + client.runtime_config = cast( + Any, + SimpleNamespace( + log_thinking=False, + ai_request_max_retries=0, + missing_tool_call_retries=0, + ), + ) + client._prompt_builder = cast( + Any, + SimpleNamespace( + build_messages=AsyncMock( + return_value=[{"role": "user", "content": "hello"}] + ), + end_summaries=[], + ), + ) + + seen_tool_context: dict[str, Any] = {} + + async def _execute_tool( + name: str, args: dict[str, Any], ctx: dict[str, Any] + ) -> str: + _ = args + seen_tool_context.update(ctx) + if name == "end": + ctx["conversation_ended"] = True + return "对话已结束" + return "tool result" + + client.tool_manager = cast( + Any, + SimpleNamespace( + get_openai_tools=lambda: [], + execute_tool=_execute_tool, + ), + ) + client._filter_tools_for_runtime_config = lambda tools: tools + client._get_runtime_config = cast(Any, lambda: client.runtime_config) + client.model_selector = cast(Any, SimpleNamespace(wait_ready=AsyncMock())) + client.chat_config = ChatModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="chat-model", + max_tokens=1024, + ) + client._find_chat_config_by_name = lambda _name: client.chat_config + + llm_results = [ + { + "choices": [ + { + "message": { + "content": "", + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "lookup", + "arguments": '{"q":"weather"}', + }, + } + ], + } + } + ] + }, + { + "choices": [ + { + "message": { + "content": "", + "tool_calls": [ + { + "id": "call_end", + "function": {"name": "end", "arguments": "{}"}, + } + ], + } + } + ] + }, + ] + + submit_index = 0 + + async def _submit_queued_llm_call(**kwargs: Any) -> dict[str, Any]: + nonlocal submit_index + assert "stream_event_callback" not in kwargs + result = llm_results[submit_index] + submit_index += 1 + return result + + client.submit_queued_llm_call = AsyncMock(side_effect=_submit_queued_llm_call) + client._search_wrapper = None + client._end_summary_storage = cast(Any, None) + client._send_private_message_callback = None + client._send_image_callback = None + client.memory_storage = None + client._knowledge_manager = None + client._cognitive_service = None + client._meme_service = None + client._crawl4ai_capabilities = SimpleNamespace( + available=False, + error=None, + proxy_config_available=False, + ) + events: list[tuple[str, dict[str, Any]]] = [] + + async def _webchat_event_callback(event: str, payload: dict[str, Any]) -> None: + events.append((event, dict(payload))) + + await AIClient.ask( + client, + "hello", + extra_context={"webchat_event_callback": _webchat_event_callback}, + ) + + event_names = [event for event, _payload in events] + assert "stage" in event_names + assert [event for event, _payload in events if event != "stage"] == [ + "tool_start", + "tool_end", + "tool_start", + "tool_end", + ] + stage_names = [ + str(payload.get("stage") or "") for event, payload in events if event == "stage" + ] + assert "building_context" in stage_names + assert "waiting_model" in stage_names + assert "waiting_tools" in stage_names + lifecycle_payloads = [payload for event, payload in events if event != "stage"] + assert lifecycle_payloads[0]["name"] == "lookup" + assert lifecycle_payloads[1]["result"] == "tool result" + assert lifecycle_payloads[2]["name"] == "end" + assert lifecycle_payloads[3]["result"] == "对话已结束" + assert callable(seen_tool_context.get("render_html_to_image")) + assert callable(seen_tool_context.get("render_markdown_to_html")) + + @pytest.mark.asyncio async def test_ai_ask_limits_missing_tool_call_retries() -> None: client: Any = object.__new__(AIClient) @@ -271,6 +415,78 @@ async def test_agent_runner_reraises_queued_llm_error(tmp_path: Path) -> None: ) +@pytest.mark.asyncio +async def test_agent_runner_emits_nested_webchat_agent_stage( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + agent_dir = tmp_path / "demo_agent" + (agent_dir / "tools").mkdir(parents=True) + + agent_config = AgentModelConfig( + api_url="https://api.openai.com/v1", + api_key="sk-test", + model_name="agent-model", + max_tokens=512, + ) + ai_client = SimpleNamespace( + agent_config=agent_config, + model_selector=SimpleNamespace( + select_agent_config=lambda config, **_kwargs: config + ), + submit_queued_llm_call=AsyncMock( + return_value={"choices": [{"message": {"content": "done"}}]} + ), + ) + events: list[tuple[str, dict[str, Any]]] = [] + + async def _webchat_event_callback(event: str, payload: dict[str, Any]) -> None: + events.append((event, dict(payload))) + + monkeypatch.setattr( + "Undefined.skills.agents.runner.context.AgentToolRegistry", + lambda *_args, **_kwargs: SimpleNamespace(get_tools_schema=lambda: []), + ) + + result = await run_agent_with_tools( + agent_name="demo_agent", + user_content="用户需求:测试", + empty_user_content_message="empty", + default_prompt="你是一个测试助手。", + context={ + "ai_client": cast(Any, ai_client), + "runtime_config": SimpleNamespace( + model_pool_enabled=False, + ai_request_max_retries=0, + ), + "queue_lane": "private", + "webchat_event_callback": _webchat_event_callback, + "webchat_parent_call_id": "call_agent", + "webchat_call_parent_id": "root_agent", + "webchat_depth": 1, + "webchat_agent_path": ["web_agent"], + }, + agent_dir=agent_dir, + logger=logging.getLogger("test_agent_runner_emits_webchat_agent_stage"), + max_iterations=3, + ) + + assert result == "done" + agent_stage_payloads = [ + payload for event, payload in events if event == "agent_stage" + ] + assert [str(payload.get("stage") or "") for payload in agent_stage_payloads] == [ + "context_ready", + "waiting_model", + "done", + ] + assert agent_stage_payloads[0]["webchat_call_id"] == "call_agent" + assert agent_stage_payloads[0]["parent_webchat_call_id"] == "root_agent" + assert agent_stage_payloads[0]["depth"] == 1 + assert agent_stage_payloads[0]["agent_path"] == ["web_agent"] + assert "model=agent-model" in str(agent_stage_payloads[1]["detail"]) + + @pytest.mark.asyncio async def test_submit_queued_llm_call_enqueues_requested_lane() -> None: client: Any = object.__new__(AIClient) diff --git a/tests/test_message_batcher_integration.py b/tests/test_message_batcher_integration.py index d4469e6c..f13f4cc3 100644 --- a/tests/test_message_batcher_integration.py +++ b/tests/test_message_batcher_integration.py @@ -99,6 +99,8 @@ async def test_two_group_messages_merge_into_single_request() -> None: request_data = await_args.args[0] assert request_data["batched_count"] == 2 assert request_data["text"] == "改成狗" # last 文本 + assert request_data["trigger_message_id"] == 2 + assert request_data["message_ids"] == ["1", "2"] assert "帮我画一只猫" in request_data["full_question"] assert "改成狗" in request_data["full_question"] assert "【连续消息说明】" in request_data["full_question"] @@ -139,6 +141,7 @@ async def test_first_at_bot_routes_batch_to_mention_lane() -> None: req = await_args.args[0] assert req["batched_count"] == 2 assert req["is_at_bot"] is True + assert req["message_ids"] == [] assert "(用户 @ 了你)" in req["full_question"] @@ -178,6 +181,7 @@ def _is_at(content: list[dict[str, Any]]) -> bool: assert mention_await is not None mention_req = mention_await.args[0] assert mention_req["batched_count"] == 1 + assert mention_req["message_ids"] == [] # 普通桶仍未发车 cast(AsyncMock, qm.add_group_normal_request).assert_not_called() @@ -226,6 +230,8 @@ async def test_private_consecutive_merge() -> None: assert await_args is not None req = await_args.args[0] assert req["batched_count"] == 2 + assert req["trigger_message_id"] == 11 + assert req["message_ids"] == ["10", "11"] assert "第一条" in req["full_question"] assert "第二条" in req["full_question"] @@ -273,6 +279,7 @@ async def test_superadmin_batched_routes_to_superadmin_lane() -> None: assert await_args is not None req = await_args.args[0] assert req["batched_count"] == 2 + assert req["message_ids"] == [] @pytest.mark.asyncio diff --git a/tests/test_naga_code_analysis_agent.py b/tests/test_naga_code_analysis_agent.py new file mode 100644 index 00000000..4ad59ca2 --- /dev/null +++ b/tests/test_naga_code_analysis_agent.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from Undefined.skills.agents.naga_code_analysis_agent.tools.read_naga_intro import ( + handler as read_naga_intro_handler, +) + + +AGENT_DIR = ( + Path(__file__).resolve().parent.parent + / "src" + / "Undefined" + / "skills" + / "agents" + / "naga_code_analysis_agent" +) + + +def test_prompt_and_intro_define_naga_only_scope() -> None: + prompt = (AGENT_DIR / "prompt.md").read_text("utf-8") + intro = (AGENT_DIR / "intro.md").read_text("utf-8") + + assert "分析第一步:调用read_naga_intro工具" in prompt + assert "非 NagaAgent 技术问题要说明越界并返回给主 AI 重新路由" in prompt + assert "不回答 Undefined 自身源码问题" in prompt + assert "不承担代码编写、修改、执行验证或打包交付任务" in prompt + assert "**仅限 NagaAgent 项目**,不回答 Undefined 自身源码问题" in intro + assert "用户上传/外部文件解析请用 `file_analysis_agent`" in intro + assert "代码编写、修改、执行验证和打包交付请用 `code_delivery_agent`" in intro + + +def test_config_description_defines_naga_only_scope() -> None: + cfg = json.loads((AGENT_DIR / "config.json").read_text("utf-8")) + description = cfg["function"]["description"] + + assert "仅用于 NagaAgent 项目" in description + assert "不负责 Undefined 自身源码" in description + assert "用户上传文件" in description + assert "代码交付任务" in description + + +@pytest.mark.asyncio +async def test_read_naga_intro_mentions_current_naga_layout() -> None: + result = await read_naga_intro_handler.execute({}, {}) + + assert "README 标识版本 5.1.0" in result + assert "api_format" in result + assert "anthropic" in result + assert "apiserver/routes/" in result + assert "agentserver/dogtag/" in result + assert "OpenClaw" in result + assert "mcpserver/mcp_manager.py" in result + assert "skills/*/SKILL.md" in result + assert "guide_engine/" in result + assert "frontend/electron/modules/backend.ts" in result + assert "build.py" in result + assert "docs/build-windows.md" in result diff --git a/tests/test_prepare_tauri_android_script.py b/tests/test_prepare_tauri_android_script.py new file mode 100644 index 00000000..2adbaa4e --- /dev/null +++ b/tests/test_prepare_tauri_android_script.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import sys +from types import ModuleType + +import pytest + + +_SCRIPT_PATH = ( + Path(__file__).resolve().parent.parent / "scripts" / "prepare_tauri_android.py" +) + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("prepare_tauri_android", _SCRIPT_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("Could not load prepare_tauri_android.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +prepare_tauri_android = _load_script() + + +def _write_app(root: Path, *, name: str = "undefined-chat") -> Path: + app_dir = root / "apps" / name + tauri_dir = app_dir / "src-tauri" + android_main = tauri_dir / "gen" / "android" / "app" / "src" / "main" + package_dir = android_main / "java" / "com" / "undefined" / "chat" + package_dir.mkdir(parents=True) + (app_dir / "package.json").write_text( + json.dumps({"name": name, "version": "1.0.0"}, indent="\t") + "\n", + encoding="utf-8", + ) + tauri_dir.mkdir(parents=True, exist_ok=True) + (tauri_dir / "tauri.conf.json").write_text( + json.dumps( + {"identifier": "com.undefined.chat", "version": "1.0.0"}, + indent="\t", + ) + + "\n", + encoding="utf-8", + ) + (package_dir / "MainActivity.kt").write_text( + "package com.undefined.chat\n\nclass MainActivity : TauriActivity()\n", + encoding="utf-8", + ) + (android_main / "AndroidManifest.xml").write_text( + """ + + + + + +""", + encoding="utf-8", + ) + return app_dir + + +def test_prepare_tauri_android_adds_chat_android_native_files(tmp_path: Path) -> None: + app_dir = _write_app(tmp_path) + + changed = prepare_tauri_android.prepare_tauri_android(app_dir) + + activity_path = ( + app_dir + / "src-tauri" + / "gen" + / "android" + / "app" + / "src" + / "main" + / "java" + / "com" + / "undefined" + / "chat" + / "HtmlPreviewActivity.kt" + ) + secret_plugin_path = activity_path.parent / "SecretPlugin.kt" + manifest_path = ( + app_dir + / "src-tauri" + / "gen" + / "android" + / "app" + / "src" + / "main" + / "AndroidManifest.xml" + ) + assert set(changed) == {activity_path, secret_plugin_path, manifest_path} + assert activity_path.read_text(encoding="utf-8") == ( + "package com.undefined.chat\n\nclass HtmlPreviewActivity : TauriActivity()\n" + ) + secret_plugin = secret_plugin_path.read_text(encoding="utf-8") + assert "class SecretPlugin" in secret_plugin + assert "@InvokeArg" in secret_plugin + assert "invoke.parseArgs(SecretPayload::class.java)" in secret_plugin + assert "invoke.parseArgs(SetSecretPayload::class.java)" in secret_plugin + assert "invoke.getString" not in secret_plugin + assert ".commit()" in secret_plugin + assert "AndroidKeyStore" in secret_plugin + assert "AES/GCM/NoPadding" in secret_plugin + manifest = manifest_path.read_text(encoding="utf-8") + assert 'android:name="com.undefined.chat.HtmlPreviewActivity"' in manifest + assert 'android:exported="false"' in manifest + + +def test_prepare_tauri_android_is_idempotent(tmp_path: Path) -> None: + app_dir = _write_app(tmp_path) + + prepare_tauri_android.prepare_tauri_android(app_dir) + + assert prepare_tauri_android.prepare_tauri_android(app_dir) == [] + + +def test_prepare_tauri_android_skips_non_chat_apps_without_android_gen( + tmp_path: Path, +) -> None: + app_dir = tmp_path / "apps" / "undefined-console" + app_dir.mkdir(parents=True) + (app_dir / "package.json").write_text( + json.dumps({"name": "undefined-console"}, indent="\t") + "\n", + encoding="utf-8", + ) + + assert prepare_tauri_android.prepare_tauri_android(app_dir) == [] + + +def test_prepare_tauri_android_check_reports_missing_patch(tmp_path: Path) -> None: + app_dir = _write_app(tmp_path) + + changed = prepare_tauri_android.prepare_tauri_android(app_dir, dry_run=True) + + assert len(changed) == 3 + assert not changed[0].exists() + + +def test_prepare_tauri_android_requires_generated_android_project( + tmp_path: Path, +) -> None: + app_dir = tmp_path / "apps" / "undefined-chat" + tauri_dir = app_dir / "src-tauri" + tauri_dir.mkdir(parents=True) + (app_dir / "package.json").write_text( + json.dumps({"name": "undefined-chat"}, indent="\t") + "\n", + encoding="utf-8", + ) + (tauri_dir / "tauri.conf.json").write_text( + json.dumps({"identifier": "com.undefined.chat"}, indent="\t") + "\n", + encoding="utf-8", + ) + + with pytest.raises(FileNotFoundError, match="tauri:android:init"): + prepare_tauri_android.prepare_tauri_android(app_dir) diff --git a/tests/test_prompt_builder_cognitive_query.py b/tests/test_prompt_builder_cognitive_query.py index bf3304ed..1f4de7ab 100644 --- a/tests/test_prompt_builder_cognitive_query.py +++ b/tests/test_prompt_builder_cognitive_query.py @@ -1,5 +1,7 @@ from typing import Any, cast +from Undefined.ai.prompts.cognitive import build_cognitive_per_message_queries +from Undefined.ai.prompts.cognitive import drop_current_message_if_duplicated from Undefined.ai.prompts import PromptBuilder @@ -38,6 +40,65 @@ def test_build_cognitive_query_uses_current_frame_raw_content() -> None: assert enhanced is False +def test_build_cognitive_query_uses_all_messages_in_current_batch() -> None: + builder = _make_builder() + question = """ +我周三要发版 + + +补充:是后端服务发版 + + +【连续消息说明】以上 2 条 是同一用户连续发送的消息 +【回复策略】 +你可以选择不回复""" + query, enhanced = builder._build_cognitive_query( + question, + extra_context={ + "group_id": 20001, + "sender_name": "测试用户", + "group_name": "研发讨论群", + "is_at_bot": False, + }, + ) + + assert query.startswith("我周三要发版\n补充:是后端服务发版\n语境: ") + assert "会话:群聊" in query + assert "发送者:测试用户" in query + assert "群:研发讨论群" in query + assert "连续消息说明" not in query + assert "回复策略" not in query + assert enhanced is True + + +def test_build_cognitive_per_message_queries_uses_each_current_message() -> None: + question = """ +我周三要发版 + + +补充:是后端服务发版 + + +【连续消息说明】以上 2 条 是同一用户连续发送的消息 +【回复策略】 +你可以选择不回复""" + queries, enhanced = build_cognitive_per_message_queries( + question, + extra_context={ + "group_id": 20001, + "sender_name": "测试用户", + "group_name": "研发讨论群", + "is_at_bot": False, + }, + ) + + assert queries == [ + "我周三要发版\n语境: 会话:群聊; 发送者:测试用户; 群:研发讨论群", + "补充:是后端服务发版\n语境: 会话:群聊; 发送者:测试用户; 群:研发讨论群", + ] + assert enhanced is True + + def test_build_cognitive_query_adds_light_context_for_short_content() -> None: builder = _make_builder() question = """ @@ -68,3 +129,45 @@ def test_build_cognitive_query_falls_back_to_plain_question() -> None: query, enhanced = builder._build_cognitive_query("直接提问:今天安排啥?") assert query == "直接提问:今天安排啥?" assert enhanced is False + + +def test_drop_current_message_if_duplicated_removes_whole_current_batch_tail() -> None: + recent_messages = [ + { + "type": "group", + "message_id": "100", + "display_name": "其他用户", + "user_id": "99999", + "chat_id": "20001", + "timestamp": "2026-02-24 11:59:00", + "message": "保留的历史消息", + }, + { + "type": "group", + "message_id": "101", + "display_name": "测试用户", + "user_id": "10001", + "chat_id": "20001", + "timestamp": "2026-02-24 12:00:00", + "message": "我周三要发版", + }, + { + "type": "group", + "message_id": "102", + "display_name": "测试用户", + "user_id": "10001", + "chat_id": "20001", + "timestamp": "2026-02-24 12:00:02", + "message": "补充:是后端服务发版", + }, + ] + question = """ +我周三要发版 + + +补充:是后端服务发版 +""" + + filtered = drop_current_message_if_duplicated(recent_messages, question) + + assert [msg["message"] for msg in filtered] == ["保留的历史消息"] diff --git a/tests/test_prompt_builder_message_order.py b/tests/test_prompt_builder_message_order.py index be4e49ff..1886d063 100644 --- a/tests/test_prompt_builder_message_order.py +++ b/tests/test_prompt_builder_message_order.py @@ -6,6 +6,7 @@ import pytest +from Undefined.ai.llm.sanitize import prepare_chat_completion_messages from Undefined.ai.prompts import PromptBuilder from Undefined.end_summary_storage import EndSummaryRecord from Undefined.memory import Memory @@ -29,6 +30,17 @@ async def build_context(self, **kwargs: Any) -> str: return "【认知记忆上下文】\n用户最近在排查缓存命中问题。" +class _RecordingCognitiveService: + enabled = True + + def __init__(self) -> None: + self.last_kwargs: dict[str, Any] | None = None + + async def build_context(self, **kwargs: Any) -> str: + self.last_kwargs = dict(kwargs) + return "【认知记忆上下文】\n用户最近在排查缓存命中问题。" + + @dataclass class _FakeAnthropicSkill: name: str @@ -90,6 +102,37 @@ def _make_builder() -> PromptBuilder: ) +def _make_builder_with_cognitive_service(cognitive_service: Any) -> PromptBuilder: + runtime_config = SimpleNamespace( + keyword_reply_enabled=False, + repeat_enabled=False, + inverted_question_enabled=False, + knowledge_enabled=False, + grok_search_enabled=False, + chat_model=SimpleNamespace( + model_name="gpt-test", + pool=SimpleNamespace(enabled=False), + thinking_enabled=False, + reasoning_enabled=False, + ), + vision_model=None, + agent_model=None, + embedding_model=None, + security_model=None, + grok_model=None, + cognitive=SimpleNamespace(enabled=True, recent_end_summaries_inject_k=0), + memes=None, + ) + return PromptBuilder( + bot_qq=0, + memory_storage=None, + end_summary_storage=cast(Any, _FakeEndSummaryStorage()), + runtime_config_getter=lambda: runtime_config, + anthropic_skill_registry=cast(Any, None), + cognitive_service=cast(Any, cognitive_service), + ) + + @pytest.mark.asyncio async def test_build_messages_places_each_rules_before_dynamic_context( monkeypatch: pytest.MonkeyPatch, @@ -144,7 +187,7 @@ async def _fake_recent_messages( "summary": "【短期行动记录(最近 1 条,带时间)】", "history": "【历史消息存档】", "time": "【当前时间】", - "current": "【当前消息】", + "current": "【当前输入批次】", } positions = { name: next( @@ -174,7 +217,123 @@ async def _fake_recent_messages( @pytest.mark.asyncio -async def test_build_messages_keeps_current_message_as_last_item( +async def test_build_messages_passes_per_message_recall_queries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cognitive_service = _RecordingCognitiveService() + builder = _make_builder_with_cognitive_service(cognitive_service) + + async def _fake_load_system_prompt() -> str: + return "系统提示词" + + async def _fake_load_each_rules() -> str: + return "" + + monkeypatch.setattr(builder, "_load_system_prompt", _fake_load_system_prompt) + monkeypatch.setattr(builder, "_load_each_rules", _fake_load_each_rules) + + await builder.build_messages( + """ +我周三要发版 + + +补充:是后端服务发版 +""", + extra_context={ + "group_id": 20001, + "sender_id": 10001, + "sender_name": "测试用户", + "group_name": "研发群", + "request_type": "group", + }, + ) + + assert cognitive_service.last_kwargs is not None + assert cognitive_service.last_kwargs["query"].startswith( + "我周三要发版\n补充:是后端服务发版\n语境: " + ) + assert cognitive_service.last_kwargs["recall_queries"] == [ + "我周三要发版\n语境: 会话:群聊; 发送者:测试用户; 群:研发群", + "补充:是后端服务发版\n语境: 会话:群聊; 发送者:测试用户; 群:研发群", + ] + + +@pytest.mark.asyncio +async def test_build_messages_keeps_cache_friendly_static_before_dynamic_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + builder = _make_builder() + + async def _fake_load_system_prompt() -> str: + return "系统提示词" + + async def _fake_load_each_rules() -> str: + return "固定规则" + + monkeypatch.setattr(builder, "_load_system_prompt", _fake_load_system_prompt) + monkeypatch.setattr(builder, "_load_each_rules", _fake_load_each_rules) + + async def _fake_recent_messages( + chat_id: str, msg_type: str, start: int, end: int + ) -> list[dict[str, Any]]: + _ = chat_id, msg_type, start, end + return [ + { + "type": "group", + "display_name": "测试用户", + "user_id": "10001", + "chat_id": "20001", + "chat_name": "研发群", + "timestamp": "2026-04-03 10:01:00", + "message": "上一条消息", + "attachments": [], + "role": "member", + "title": "", + } + ] + + messages = await builder.build_messages( + '\n继续看缓存问题\n', + get_recent_messages_callback=_fake_recent_messages, + extra_context={ + "group_id": 20001, + "sender_id": 10001, + "sender_name": "测试用户", + "group_name": "研发群", + "request_type": "group", + }, + ) + + labels = [ + "系统提示词", + "【当前运行环境配置】", + "【可用的 Anthropic Skills】", + "【强制规则 - 必须在进行任何操作前仔细阅读并严格遵守】", + "【memory.* 手动长期记忆(可编辑)】", + "【认知记忆上下文】", + "【短期行动记录(最近 1 条,带时间)】", + "【历史消息存档】", + "【当前时间】", + "【当前输入批次】", + ] + positions = [ + next( + idx + for idx, message in enumerate(messages) + if label in str(message.get("content", "")) + ) + for label in labels + ] + + assert positions == sorted(positions) + assert messages[-2]["role"] == "system" + assert "【当前时间】" in str(messages[-2].get("content", "")) + assert messages[-1]["role"] == "user" + assert "【当前输入批次】" in str(messages[-1].get("content", "")) + + +@pytest.mark.asyncio +async def test_build_messages_keeps_current_input_batch_as_last_item( monkeypatch: pytest.MonkeyPatch, ) -> None: builder = PromptBuilder( @@ -194,7 +353,84 @@ async def _fake_load_each_rules() -> str: messages = await builder.build_messages("直接提问:缓存是否命中?") - assert messages[-1] == { - "role": "user", - "content": "【当前消息】\n直接提问:缓存是否命中?", - } + assert messages[-1]["role"] == "user" + current_content = str(messages[-1].get("content", "")) + assert current_content.startswith("【当前输入批次】\n\n") + assert "直接提问:缓存是否命中?" in current_content + assert "" in current_content + assert "允许你回应和写入 end.observations 的当前输入" in current_content + assert "不能作为 end.observations 的新事实来源" in current_content + + +@pytest.mark.asyncio +async def test_system_prompt_as_user_keeps_current_batch_and_readonly_history_markers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + builder = PromptBuilder( + bot_qq=0, + memory_storage=None, + end_summary_storage=cast(Any, _FakeEndSummaryStorage()), + ) + + async def _fake_load_system_prompt() -> str: + return "系统提示词" + + async def _fake_load_each_rules() -> str: + return "固定规则" + + monkeypatch.setattr(builder, "_load_system_prompt", _fake_load_system_prompt) + monkeypatch.setattr(builder, "_load_each_rules", _fake_load_each_rules) + + async def _fake_recent_messages( + chat_id: str, msg_type: str, start: int, end: int + ) -> list[dict[str, Any]]: + _ = chat_id, msg_type, start, end + return [ + { + "type": "group", + "display_name": "测试用户", + "user_id": "10001", + "chat_id": "20001", + "chat_name": "研发群", + "timestamp": "2026-04-03 10:01:00", + "message": "只读历史消息", + "attachments": [], + "role": "member", + "title": "", + } + ] + + messages = await builder.build_messages( + '\n这次缓存为什么没命中?\n', + get_recent_messages_callback=_fake_recent_messages, + extra_context={ + "group_id": 20001, + "sender_id": 10001, + "sender_name": "测试用户", + "group_name": "研发群", + "request_type": "group", + }, + ) + + cfg: Any = SimpleNamespace( + reasoning_content_replay=False, + system_prompt_as_user=True, + ) + outbound = prepare_chat_completion_messages(cfg, messages) + + assert outbound + assert all( + str(message.get("role", "")).lower() not in {"system", "developer"} + for message in outbound + ) + assert outbound[0]["role"] == "user" + merged_content = str(outbound[0].get("content", "")) + assert "【历史消息存档】(只读上下文)" in merged_content + assert '' in merged_content + assert "【当前输入批次】" in merged_content + assert "" in merged_content + assert "这次缓存为什么没命中?" in merged_content + assert "不能作为 end.observations 的新事实来源" in merged_content + assert merged_content.index("【历史消息存档】") < merged_content.index( + "【当前输入批次】" + ) diff --git a/tests/test_react_message_emoji_tools.py b/tests/test_react_message_emoji_tools.py index 9b553457..05619c43 100644 --- a/tests/test_react_message_emoji_tools.py +++ b/tests/test_react_message_emoji_tools.py @@ -16,6 +16,7 @@ from Undefined.skills.toolsets.messages.react_message_emoji.handler import ( execute as react_message_emoji_execute, ) +from Undefined.utils.message_turn import mark_message_sent_this_turn def _runtime_config() -> Any: @@ -25,6 +26,10 @@ def _runtime_config() -> Any: ) +def _tool_context(**values: Any) -> dict[str, Any]: + return {"mark_message_sent_this_turn": mark_message_sent_this_turn, **values} + + @pytest.mark.asyncio async def test_react_message_emoji_uses_trigger_message_id_and_alias() -> None: onebot_client = SimpleNamespace( @@ -32,15 +37,15 @@ async def test_react_message_emoji_uses_trigger_message_id_and_alias() -> None: fetch_emoji_like=AsyncMock(return_value={"emoji_likes": []}), set_msg_emoji_like=AsyncMock(return_value={}), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 1001, - "sender_id": 2002, - "request_id": "req-react-1", - "trigger_message_id": 5555, - "runtime_config": _runtime_config(), - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=1001, + sender_id=2002, + request_id="req-react-1", + trigger_message_id=5555, + runtime_config=_runtime_config(), + onebot_client=onebot_client, + ) result = await react_message_emoji_execute({"emoji": "👍"}, context) @@ -58,14 +63,14 @@ async def test_react_message_emoji_skip_when_already_set() -> None: ), set_msg_emoji_like=AsyncMock(return_value={}), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 1001, - "request_id": "req-react-2", - "trigger_message_id": 6666, - "runtime_config": _runtime_config(), - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=1001, + request_id="req-react-2", + trigger_message_id=6666, + runtime_config=_runtime_config(), + onebot_client=onebot_client, + ) result = await react_message_emoji_execute({"emoji_id": 76}, context) @@ -80,14 +85,14 @@ async def test_react_message_emoji_reject_cross_session_by_default() -> None: fetch_emoji_like=AsyncMock(return_value={}), set_msg_emoji_like=AsyncMock(return_value={}), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 1001, - "request_id": "req-react-3", - "trigger_message_id": 7777, - "runtime_config": _runtime_config(), - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=1001, + request_id="req-react-3", + trigger_message_id=7777, + runtime_config=_runtime_config(), + onebot_client=onebot_client, + ) result = await react_message_emoji_execute({"emoji_id": 76}, context) @@ -106,14 +111,14 @@ async def delayed_set(*args: Any, **kwargs: Any) -> dict[str, Any]: fetch_emoji_like=AsyncMock(return_value={"emoji_likes": []}), set_msg_emoji_like=AsyncMock(side_effect=delayed_set), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 1001, - "request_id": "req-react-4", - "trigger_message_id": 8888, - "runtime_config": _runtime_config(), - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=1001, + request_id="req-react-4", + trigger_message_id=8888, + runtime_config=_runtime_config(), + onebot_client=onebot_client, + ) result_1, result_2 = await asyncio.gather( react_message_emoji_execute({"emoji_id": 76}, context), diff --git a/tests/test_release_notes_script.py b/tests/test_release_notes_script.py index 6b7e5cd0..c8d73630 100644 --- a/tests/test_release_notes_script.py +++ b/tests/test_release_notes_script.py @@ -85,6 +85,7 @@ def _write_release_project( ) -> None: (root / "src" / "Undefined").mkdir(parents=True) (root / "apps" / "undefined-console" / "src-tauri").mkdir(parents=True) + (root / "apps" / "undefined-chat" / "src-tauri").mkdir(parents=True) (root / "pyproject.toml").write_text( f'[project]\nname = "Undefined-bot"\nversion = "{build_version}"\n', encoding="utf-8", @@ -93,22 +94,34 @@ def _write_release_project( f'__version__ = "{build_version}"\n', encoding="utf-8", ) - (root / "apps" / "undefined-console" / "package.json").write_text( - f'{{"version":"{build_version}"}}\n', - encoding="utf-8", - ) - (root / "apps" / "undefined-console" / "package-lock.json").write_text( - f'{{"version":"{build_version}","packages":{{"":{{"version":"{build_version}"}}}}}}\n', - encoding="utf-8", - ) - (root / "apps" / "undefined-console" / "src-tauri" / "Cargo.toml").write_text( - f'[package]\nname = "undefined-console"\nversion = "{build_version}"\n', - encoding="utf-8", - ) - (root / "apps" / "undefined-console" / "src-tauri" / "tauri.conf.json").write_text( - f'{{"version":"{build_version}"}}\n', - encoding="utf-8", - ) + for app_dir, cargo_package in ( + ("undefined-console", "undefined_console"), + ("undefined-chat", "undefined_chat"), + ): + app_root = root / "apps" / app_dir + tauri_root = app_root / "src-tauri" + app_root.mkdir(parents=True, exist_ok=True) + tauri_root.mkdir(parents=True, exist_ok=True) + (app_root / "package.json").write_text( + f'{{"version":"{build_version}"}}\n', + encoding="utf-8", + ) + (app_root / "package-lock.json").write_text( + f'{{"version":"{build_version}","packages":{{"":{{"version":"{build_version}"}}}}}}\n', + encoding="utf-8", + ) + (tauri_root / "Cargo.toml").write_text( + f'[package]\nname = "{cargo_package}"\nversion = "{build_version}"\n', + encoding="utf-8", + ) + (tauri_root / "tauri.conf.json").write_text( + f'{{"version":"{build_version}"}}\n', + encoding="utf-8", + ) + (tauri_root / "Cargo.lock").write_text( + f'version = 3\n\n[[package]]\nname = "{cargo_package}"\nversion = "{build_version}"\n', + encoding="utf-8", + ) (root / "CHANGELOG.md").write_text( f""" ## {changelog_version} 测试版本 @@ -137,7 +150,17 @@ def test_validate_release_versions_accepts_matching_project(tmp_path: Path) -> N "pyproject.toml", "src/Undefined/__init__.py", "apps/undefined-console/package.json", + "apps/undefined-console/package-lock.json", + 'apps/undefined-console/package-lock.json packages[""]', "apps/undefined-console/src-tauri/Cargo.toml", + "apps/undefined-console/src-tauri/tauri.conf.json", + "apps/undefined-console/src-tauri/Cargo.lock undefined_console", + "apps/undefined-chat/package.json", + "apps/undefined-chat/package-lock.json", + 'apps/undefined-chat/package-lock.json packages[""]', + "apps/undefined-chat/src-tauri/Cargo.toml", + "apps/undefined-chat/src-tauri/tauri.conf.json", + "apps/undefined-chat/src-tauri/Cargo.lock undefined_chat", } @@ -169,6 +192,61 @@ def test_validate_release_versions_rejects_app_manifest_mismatch( ) +@pytest.mark.parametrize( + ("relative_path", "replacement", "match"), + [ + ( + "apps/undefined-chat/package.json", + '{"version":"1.2.4"}\n', + "apps/undefined-chat/package.json=1.2.4", + ), + ( + "apps/undefined-chat/package-lock.json", + '{"version":"1.2.4","packages":{"":{"version":"1.2.3"}}}\n', + "apps/undefined-chat/package-lock.json=1.2.4", + ), + ( + "apps/undefined-chat/package-lock.json", + '{"version":"1.2.3","packages":{"":{"version":"1.2.4"}}}\n', + 'apps/undefined-chat/package-lock.json packages\\[""\\]=1.2.4', + ), + ( + "apps/undefined-chat/src-tauri/Cargo.toml", + '[package]\nname = "undefined_chat"\nversion = "1.2.4"\n', + "apps/undefined-chat/src-tauri/Cargo.toml=1.2.4", + ), + ( + "apps/undefined-chat/src-tauri/tauri.conf.json", + '{"version":"1.2.4"}\n', + "apps/undefined-chat/src-tauri/tauri.conf.json=1.2.4", + ), + ( + "apps/undefined-chat/src-tauri/Cargo.lock", + 'version = 3\n\n[[package]]\nname = "undefined_chat"\nversion = "1.2.4"\n', + "apps/undefined-chat/src-tauri/Cargo.lock undefined_chat=1.2.4", + ), + ( + "apps/undefined-console/src-tauri/Cargo.lock", + 'version = 3\n\n[[package]]\nname = "undefined_console"\nversion = "1.2.4"\n', + "apps/undefined-console/src-tauri/Cargo.lock undefined_console=1.2.4", + ), + ], +) +def test_validate_release_versions_rejects_native_app_version_mismatch( + tmp_path: Path, + relative_path: str, + replacement: str, + match: str, +) -> None: + _write_release_project(tmp_path) + (tmp_path / relative_path).write_text(replacement, encoding="utf-8") + + with pytest.raises(release_notes.ReleaseValidationError, match=match): + release_notes.validate_release_versions( + tag_name="v1.2.3", project_root=tmp_path + ) + + def test_write_release_notes_uses_latest_changelog_entry( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_runtime_api_chat_attachments.py b/tests/test_runtime_api_chat_attachments.py new file mode 100644 index 00000000..f0b385c5 --- /dev/null +++ b/tests/test_runtime_api_chat_attachments.py @@ -0,0 +1,546 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +from aiohttp import FormData, web +from aiohttp.web_response import Response +from aiohttp.test_utils import TestClient, TestServer, make_mocked_request +import pytest + +from Undefined.api import RuntimeAPIServer +from Undefined.api._context import RuntimeAPIContext +from Undefined.api.routes import chat + + +@pytest.fixture(autouse=True) +def _isolate_webchat_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + + +class _DummyConfig: + def __init__(self, messages_send_url_file_max_size_mb: int | None = 7) -> None: + self.messages_send_url_file_max_size_mb = messages_send_url_file_max_size_mb + + +def _ctx(*, max_size_mb: int | None = 7) -> RuntimeAPIContext: + return RuntimeAPIContext( + config_getter=lambda: _DummyConfig(max_size_mb), + ai=SimpleNamespace(), + onebot=SimpleNamespace(), + scheduler=None, + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(), + history_manager=None, + naga_store=None, + ) + + +def _json(response: Response) -> Any: + text = response.text + assert text is not None + return json.loads(text) + + +def _openapi_request() -> web.Request: + return cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={}, + remote="127.0.0.1", + scheme="http", + host="127.0.0.1:8788", + ), + ), + ) + + +def _openapi_ctx() -> RuntimeAPIContext: + return RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace(openapi_enabled=True), + ), + ai=SimpleNamespace(), + onebot=SimpleNamespace(), + scheduler=None, + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(), + history_manager=None, + naga_store=None, + ) + + +async def _post_upload( + data: FormData, + *, + max_size_mb: int | None = 7, +) -> tuple[int, dict[str, Any]]: + app = web.Application() + ctx = _ctx(max_size_mb=max_size_mb) + + async def _handler(request: web.Request) -> web.Response: + return await chat.chat_attachment_upload_handler(ctx, request) + + app.router.add_post("/api/v1/chat/attachments", _handler) + client = TestClient(TestServer(app)) + await client.start_server() + try: + response = await client.post("/api/v1/chat/attachments", data=data) + payload = cast(dict[str, Any], await response.json()) + return response.status, payload + finally: + await client.close() + + +class _FailingReader: + def __init__(self, exc: Exception) -> None: + self._exc = exc + + async def next(self) -> object: + raise self._exc + + +class _FailingField: + name = "file" + filename = "broken.png" + + async def read_chunk(self, size: int = 8192) -> bytes: + _ = size + raise ValueError("bad chunk") + + +class _SingleFieldReader: + def __init__(self, field: object) -> None: + self._field = field + self._used = False + + async def next(self) -> object | None: + if self._used: + return None + self._used = True + return self._field + + +class _MultipartRequest(SimpleNamespace): + def __init__(self, multipart: Callable[[], Awaitable[object]]) -> None: + super().__init__() + self._multipart = multipart + + async def multipart(self) -> object: + return await self._multipart() + + +@pytest.mark.asyncio +async def test_chat_attachment_capabilities_reports_runtime_limit() -> None: + request = make_mocked_request("GET", "/api/v1/chat/attachments/capabilities") + + response = await chat.chat_attachment_capabilities_handler(_ctx(), request) + + assert response.status == 200 + payload_text = response.text + assert payload_text is not None + payload = json.loads(payload_text) + assert payload["max_upload_size_bytes"] == 7340032 + assert payload["multipart_field"] == "file" + + +@pytest.mark.asyncio +async def test_chat_attachment_capabilities_clamps_explicit_zero_limit() -> None: + request = make_mocked_request("GET", "/api/v1/chat/attachments/capabilities") + + response = await chat.chat_attachment_capabilities_handler( + _ctx(max_size_mb=0), request + ) + + assert response.status == 200 + payload_text = response.text + assert payload_text is not None + payload = json.loads(payload_text) + assert payload["max_upload_size_bytes"] == 1048576 + + +@pytest.mark.asyncio +async def test_openapi_spec_includes_chat_attachment_paths() -> None: + server = RuntimeAPIServer(_openapi_ctx(), host="127.0.0.1", port=8788) + + response = await server._openapi_handler(_openapi_request()) + + spec = _json(response) + paths = spec["paths"] + assert "/api/v1/chat/attachments/capabilities" in paths + assert "get" in paths["/api/v1/chat/attachments/capabilities"] + assert "/api/v1/chat/attachments" in paths + assert "post" in paths["/api/v1/chat/attachments"] + assert "/api/v1/chat/attachments/{attachment_id}" in paths + assert "get" in paths["/api/v1/chat/attachments/{attachment_id}"] + assert "/api/v1/chat/attachments/{attachment_id}/preview" in paths + assert "get" in paths["/api/v1/chat/attachments/{attachment_id}/preview"] + + +@pytest.mark.asyncio +async def test_openapi_spec_documents_native_chat_contract() -> None: + server = RuntimeAPIServer(_openapi_ctx(), host="127.0.0.1", port=8788) + + response = await server._openapi_handler(_openapi_request()) + + spec = _json(response) + serialized = json.dumps(spec, ensure_ascii=False) + assert "process-local single-flight" not in serialized + assert "CQ:file" not in serialized + assert "per-conversation single-flight" in serialized + assert "attachment_ids" in serialized + assert "requires_action" in serialized + assert "jobs[]" in serialized + + +def test_openapi_markdown_documents_native_chat_contract() -> None: + docs_path = Path(__file__).resolve().parents[1] / "docs" / "openapi.md" + text = docs_path.read_text(encoding="utf-8") + + assert "全局单飞" not in text + assert "全局 job 互斥" not in text + assert "CQ:file" not in text + assert "attachment_ids" in text + assert "requires_action" in text + assert "jobs[]" in text + assert "同一会话" in text + + +@pytest.mark.asyncio +async def test_chat_attachment_upload_requires_multipart() -> None: + request = make_mocked_request("POST", "/api/v1/chat/attachments") + + response = await chat.chat_attachment_upload_handler(_ctx(), request) + + assert response.status == 400 + assert response.text is not None + assert "multipart" in response.text.lower() + + +@pytest.mark.asyncio +async def test_chat_attachment_upload_accepts_image_multipart() -> None: + data = FormData() + data.add_field( + "file", + b"\x89PNG\r\n\x1a\n", + filename="photo.png", + content_type="application/octet-stream", + ) + + status, payload = await _post_upload(data) + + assert status == 201 + attachment = payload["attachment"] + assert attachment["size"] == 8 + assert attachment["media_type"] == "image/png" + assert attachment["kind"] == "image" + assert attachment["discarded"] is False + assert "poc_discarded" not in attachment + assert attachment["download_url"].startswith("/api/v1/chat/attachments/") + assert attachment["preview_url"].endswith("/preview") + + +@pytest.mark.asyncio +async def test_chat_attachment_upload_downloads_exact_bytes_and_previews_image() -> ( + None +): + app = web.Application() + ctx = _ctx() + + async def _upload(request: web.Request) -> web.Response: + return await chat.chat_attachment_upload_handler(ctx, request) + + async def _download(request: web.Request) -> web.StreamResponse: + return await chat.chat_attachment_download_handler(ctx, request) + + async def _preview(request: web.Request) -> web.StreamResponse: + return await chat.chat_attachment_preview_handler(ctx, request) + + app.router.add_post("/api/v1/chat/attachments", _upload) + app.router.add_get("/api/v1/chat/attachments/{attachment_id}", _download) + app.router.add_get("/api/v1/chat/attachments/{attachment_id}/preview", _preview) + client = TestClient(TestServer(app)) + await client.start_server() + try: + data = FormData() + image_bytes = b"\x89PNG\r\n\x1a\n" + data.add_field( + "file", + image_bytes, + filename='..\\evil"/photo.png', + content_type="application/octet-stream", + ) + upload_response = await client.post("/api/v1/chat/attachments", data=data) + upload_payload = cast(dict[str, Any], await upload_response.json()) + attachment = upload_payload["attachment"] + + download_response = await client.get( + f"/api/v1/chat/attachments/{attachment['id']}" + ) + preview_response = await client.get( + f"/api/v1/chat/attachments/{attachment['id']}/preview" + ) + + assert download_response.status == 200 + assert await download_response.read() == image_bytes + assert "filename=" in download_response.headers["Content-Disposition"] + assert "evil" not in download_response.headers["Content-Disposition"] + assert preview_response.status == 200 + assert await preview_response.read() == image_bytes + assert preview_response.headers["Content-Type"].startswith("image/png") + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_chat_attachment_preview_rejects_non_image_file() -> None: + app = web.Application() + ctx = _ctx() + + async def _upload(request: web.Request) -> web.Response: + return await chat.chat_attachment_upload_handler(ctx, request) + + async def _preview(request: web.Request) -> web.StreamResponse: + return await chat.chat_attachment_preview_handler(ctx, request) + + app.router.add_post("/api/v1/chat/attachments", _upload) + app.router.add_get("/api/v1/chat/attachments/{attachment_id}/preview", _preview) + client = TestClient(TestServer(app)) + await client.start_server() + try: + data = FormData() + data.add_field( + "file", + b"plain text", + filename="note.txt", + content_type="text/plain", + ) + upload_response = await client.post("/api/v1/chat/attachments", data=data) + upload_payload = cast(dict[str, Any], await upload_response.json()) + attachment = upload_payload["attachment"] + + preview_response = await client.get( + f"/api/v1/chat/attachments/{attachment['id']}/preview" + ) + payload = cast(dict[str, Any], await preview_response.json()) + + assert preview_response.status == 415 + assert "preview" in payload["error"].lower() + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_chat_attachment_upload_rejects_file_larger_than_limit() -> None: + data = FormData() + data.add_field( + "file", + b"x" * (1024 * 1024 + 1), + filename="large.bin", + content_type="application/octet-stream", + ) + + status, payload = await _post_upload(data, max_size_mb=1) + + assert status == 413 + assert payload["error"] == "file too large" + assert payload["max_upload_size_bytes"] == 1048576 + + +@pytest.mark.asyncio +async def test_chat_attachment_upload_requires_file_field_in_multipart() -> None: + data = FormData() + data.add_field( + "note", + b"hello", + filename="note.txt", + content_type="text/plain", + ) + + status, payload = await _post_upload(data) + + assert status == 400 + assert "file field" in str(payload["error"]).lower() + + +@pytest.mark.asyncio +async def test_chat_attachment_upload_accepts_file_after_other_parts() -> None: + data = FormData() + data.add_field("note", "metadata", content_type="text/plain") + data.add_field( + "file", + b"abc", + filename="avatar.jpg", + content_type="application/octet-stream", + ) + + status, payload = await _post_upload(data) + + assert status == 201 + attachment = payload["attachment"] + assert attachment["size"] == 3 + assert attachment["media_type"] == "image/jpeg" + assert attachment["kind"] == "image" + + +@pytest.mark.asyncio +async def test_chat_attachment_upload_sanitizes_windows_path_and_control_chars() -> ( + None +): + data = FormData() + data.add_field( + "file", + b"abc", + filename="C:\\fakepath\\bad\x01name.png", + content_type="application/octet-stream", + ) + + status, payload = await _post_upload(data) + + assert status == 201 + attachment = payload["attachment"] + assert attachment["name"] == "badname.png" + assert attachment["media_type"] == "image/png" + + +@pytest.mark.asyncio +async def test_chat_attachment_upload_returns_400_when_reader_next_fails() -> None: + async def _multipart() -> object: + return _FailingReader(ValueError("bad boundary")) + + request = cast(web.Request, _MultipartRequest(_multipart)) + + response = await chat.chat_attachment_upload_handler(_ctx(), request) + + assert response.status == 400 + assert response.text is not None + assert "multipart" in response.text.lower() + + +@pytest.mark.asyncio +async def test_chat_attachment_upload_returns_400_when_read_chunk_fails() -> None: + async def _multipart() -> object: + return _SingleFieldReader(_FailingField()) + + request = cast(web.Request, _MultipartRequest(_multipart)) + + response = await chat.chat_attachment_upload_handler(_ctx(), request) + + assert response.status == 400 + assert response.text is not None + assert "multipart" in response.text.lower() + + +class _FakeRegistry: + """最小附件注册表桩,用于 preview/download 的 registry fallback 测试。""" + + def __init__(self, record: Any) -> None: + self._record = record + + async def load(self) -> None: + return None + + async def resolve_async(self, uid: str, scope_key: str | None) -> Any: + # 模拟 AttachmentRegistry 的 scope 校验:仅放行 webui 作用域 + if uid == self._record.uid and scope_key == "webui": + return self._record + return None + + +def _ctx_with_registry(registry: Any) -> RuntimeAPIContext: + ctx = _ctx() + ctx.ai = SimpleNamespace(attachment_registry=registry) + return ctx + + +def test_normalize_chat_media_type() -> None: + # 已是 MIME(含 /)原样返回 + assert chat._normalize_chat_media_type("image/png", "x.png") == "image/png" + # 粗分类 "image" 按文件名扩展名推断为真正 MIME + assert chat._normalize_chat_media_type("image", "help_list.png") == "image/png" + assert chat._normalize_chat_media_type("image", "photo.jpg") == "image/jpeg" + assert chat._normalize_chat_media_type("", "note.txt") == "text/plain" + # 无扩展名 / 未知扩展名 → 兜底 + assert chat._normalize_chat_media_type("", "") == "application/octet-stream" + assert ( + chat._normalize_chat_media_type("file", "data.unknownext") + == "application/octet-stream" + ) + + +@pytest.mark.asyncio +async def test_chat_attachment_preview_and_download_fall_back_to_registry( + tmp_path: Path, +) -> None: + blob = tmp_path / "pic_source.png" + image_bytes = b"\x89PNG\r\n\x1a\n" + blob.write_bytes(image_bytes) + record = SimpleNamespace( + uid="pic_help1234", + local_path=str(blob), + mime_type="image/png", + media_type="image", # 粗分类,非 MIME(模拟 AttachmentRegistry 记录) + display_name="help_list.png", + kind="image", + scope_key="webui", + ) + ctx = _ctx_with_registry(_FakeRegistry(record)) + + app = web.Application() + + async def _download(request: web.Request) -> web.StreamResponse: + return await chat.chat_attachment_download_handler(ctx, request) + + async def _preview(request: web.Request) -> web.StreamResponse: + return await chat.chat_attachment_preview_handler(ctx, request) + + app.router.add_get("/api/v1/chat/attachments/{attachment_id}", _download) + app.router.add_get("/api/v1/chat/attachments/{attachment_id}/preview", _preview) + client = TestClient(TestServer(app)) + await client.start_server() + try: + download_response = await client.get("/api/v1/chat/attachments/pic_help1234") + preview_response = await client.get( + "/api/v1/chat/attachments/pic_help1234/preview" + ) + + assert download_response.status == 200 + assert await download_response.read() == image_bytes + assert preview_response.status == 200 + assert await preview_response.read() == image_bytes + assert preview_response.headers["Content-Type"].startswith("image/png") + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_chat_attachment_preview_404_when_uid_missing_everywhere() -> None: + record = SimpleNamespace( + uid="pic_other999", + local_path="", + mime_type="image/png", + media_type="image", + display_name="x.png", + kind="image", + scope_key="webui", + ) + ctx = _ctx_with_registry(_FakeRegistry(record)) + + app = web.Application() + + async def _preview(request: web.Request) -> web.StreamResponse: + return await chat.chat_attachment_preview_handler(ctx, request) + + app.router.add_get("/api/v1/chat/attachments/{attachment_id}/preview", _preview) + client = TestClient(TestServer(app)) + await client.start_server() + try: + response = await client.get("/api/v1/chat/attachments/pic_missing01/preview") + assert response.status == 404 + finally: + await client.close() diff --git a/tests/test_runtime_api_chat_history.py b/tests/test_runtime_api_chat_history.py index 2219e159..11a13be0 100644 --- a/tests/test_runtime_api_chat_history.py +++ b/tests/test_runtime_api_chat_history.py @@ -1,19 +1,27 @@ from __future__ import annotations +import asyncio import json +from pathlib import Path from types import SimpleNamespace from typing import Any, cast +from unittest.mock import AsyncMock import pytest from aiohttp import web from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.api.routes import chat as runtime_api_chat + + +@pytest.fixture(autouse=True) +def _isolate_webchat_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) class _DummyHistoryManager: - def get_recent_private(self, user_id: int, count: int) -> list[dict[str, Any]]: - _ = user_id, count - return [ + def __init__(self) -> None: + self.records: list[dict[str, Any]] = [ { "display_name": "system", "message": "你好", @@ -26,6 +34,38 @@ def get_recent_private(self, user_id: int, count: int) -> list[dict[str, Any]]: }, ] + def get_recent_private(self, user_id: int, count: int) -> list[dict[str, Any]]: + _ = user_id, count + return self.records[-count:] + + def get_private_page( + self, + user_id: int, + *, + limit: int, + before: int | None = None, + ) -> tuple[list[dict[str, Any]], bool, int | None, int]: + _ = user_id + end = len(self.records) if before is None else before + start = max(0, end - limit) + return ( + self.records[start:end], + start > 0, + start if start > 0 else None, + len(self.records), + ) + + async def clear_private_history(self, user_id: int) -> int: + _ = user_id + count = len(self.records) + self.records = [] + return count + + +class _JsonRequest(SimpleNamespace): + async def json(self) -> dict[str, object]: + return dict(getattr(self, "_json", {})) + @pytest.mark.asyncio async def test_runtime_chat_history_endpoint_returns_role_mapped_items() -> None: @@ -41,7 +81,11 @@ async def test_runtime_chat_history_endpoint_returns_role_mapped_items() -> None superadmin_qq=10001, bot_qq=20002, ), - onebot=SimpleNamespace(connection_status=lambda: {}), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), ai=SimpleNamespace(memory_storage=SimpleNamespace(count=lambda: 0)), command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), queue_manager=SimpleNamespace(snapshot=lambda: {}), @@ -66,5 +110,587 @@ async def test_runtime_chat_history_endpoint_returns_role_mapped_items() -> None assert payload["count"] == 2 assert payload["items"][0]["role"] == "user" assert payload["items"][0]["content"] == "你好" + assert isinstance(payload["items"][0]["message_id"], str) + assert payload["items"][0]["message_id"] assert payload["items"][1]["role"] == "bot" assert payload["items"][1]["content"] == "你好,我在。" + assert isinstance(payload["items"][1]["message_id"], str) + assert payload["items"][1]["message_id"] + assert payload["items"][0]["message_id"] != payload["items"][1]["message_id"] + + +@pytest.mark.asyncio +async def test_runtime_chat_history_endpoint_returns_stable_message_ids() -> None: + history = _DummyHistoryManager() + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace(memory_storage=SimpleNamespace(count=lambda: 0)), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + request = cast(web.Request, cast(Any, SimpleNamespace(query={"limit": "2"}))) + + first_response = await server._chat_history_handler(request) + second_response = await server._chat_history_handler(request) + + first_payload = json.loads(first_response.text or "{}") + second_payload = json.loads(second_response.text or "{}") + assert [item["message_id"] for item in first_payload["items"]] == [ + item["message_id"] for item in second_payload["items"] + ] + + +@pytest.mark.asyncio +async def test_runtime_chat_history_endpoint_returns_attachment_refs() -> None: + history = _DummyHistoryManager() + history.records = [ + { + "display_name": "Bot", + "message": "[图片 uid=pic_abc123 name=image_1.png]", + "timestamp": "2026-02-25 22:00:02", + "attachments": [ + { + "uid": "pic_abc123", + "kind": "image", + "media_type": "image", + "display_name": "image_1.png", + "source_kind": "base64_image", + } + ], + } + ] + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace( + memory_storage=SimpleNamespace(count=lambda: 0), + attachment_registry=None, + ), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_history_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={"limit": "1"}))) + ) + payload = json.loads(response.text or "{}") + + attachment = payload["items"][0]["attachments"][0] + assert attachment["uid"] == "pic_abc123" + # media_type 规范为真正 MIME(原存储为粗分类 "image") + assert attachment["media_type"] == "image/png" + assert attachment["display_name"] == "image_1.png" + # 历史附件补充 preview_url/download_url,供客户端渲染缩略图 + assert attachment["preview_url"] == "/api/v1/chat/attachments/pic_abc123/preview" + assert attachment["download_url"] == "/api/v1/chat/attachments/pic_abc123" + + +@pytest.mark.asyncio +async def test_runtime_chat_history_endpoint_returns_webchat_metadata_only_item() -> ( + None +): + history = _DummyHistoryManager() + history.records = [ + { + "display_name": "Bot", + "message": "", + "timestamp": "2026-02-25 22:00:02", + "webchat": { + "display_only": True, + "job_id": "job_1", + "mode": "chat", + "status": "done", + "calls": [ + { + "webchat_call_id": "call_1", + "name": "search", + "is_agent": False, + "status": "done", + "result_preview": "ok", + "children": [], + } + ], + "timeline": [ + { + "type": "call", + "seq": 2, + "call": { + "webchat_call_id": "call_1", + "name": "search", + "is_agent": False, + "status": "done", + "result_preview": "ok", + "children": [], + }, + } + ], + "events": [ + { + "seq": 2, + "event": "tool_start", + "payload": { + "job_id": "job_1", + "tool_call_id": "call_1", + "name": "search", + "arguments_preview": '{"q":"test"}', + "is_agent": False, + }, + }, + { + "seq": 3, + "event": "tool_end", + "payload": { + "job_id": "job_1", + "tool_call_id": "call_1", + "name": "search", + "ok": True, + "result_preview": "ok", + "is_agent": False, + }, + }, + ], + }, + } + ] + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace(memory_storage=SimpleNamespace(count=lambda: 0)), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_history_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={"limit": "1"}))) + ) + payload = json.loads(response.text or "{}") + + assert payload["count"] == 1 + item = payload["items"][0] + assert item["role"] == "bot" + assert item["content"] == "" + assert item["webchat"]["job_id"] == "job_1" + assert [event["event"] for event in item["webchat"]["events"]] == [ + "tool_start", + "tool_end", + ] + assert item["webchat"]["calls"][0]["webchat_call_id"] == "call_1" + assert item["webchat"]["calls"][0]["result_preview"] == "ok" + assert item["webchat"]["timeline"][0]["type"] == "call" + assert item["webchat"]["timeline"][0]["call"]["webchat_call_id"] == "call_1" + + +@pytest.mark.asyncio +async def test_runtime_chat_history_endpoint_redacts_legacy_webchat_metadata() -> None: + history = _DummyHistoryManager() + history.records = [ + { + "display_name": "Bot", + "message": "", + "timestamp": "2026-02-25 22:00:02", + "webchat": { + "display_only": True, + "job_id": "job_1", + "mode": "chat", + "status": "done", + "calls": [ + { + "webchat_call_id": "call_1", + "name": "search", + "is_agent": False, + "status": "done", + "arguments_preview": ('{"api_key":"sk-legacy","q":"test"}'), + "result_preview": ("Authorization: Bearer legacy-token"), + "children": [], + } + ], + "timeline": [ + { + "type": "call", + "seq": 2, + "call": { + "webchat_call_id": "call_1", + "name": "search", + "is_agent": False, + "status": "done", + "result_preview": "password=legacy-password", + "children": [], + }, + } + ], + "events": [ + { + "seq": 2, + "event": "tool_start", + "payload": { + "job_id": "job_1", + "tool_call_id": "call_1", + "name": "search", + "arguments_preview": ( + '{"cookie":"sid=legacy-cookie","q":"test"}' + ), + "is_agent": False, + }, + }, + ], + }, + } + ] + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace(memory_storage=SimpleNamespace(count=lambda: 0)), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_history_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={"limit": "1"}))) + ) + payload = json.loads(response.text or "{}") + dumped = json.dumps(payload["items"][0]["webchat"], ensure_ascii=False) + + assert "sk-legacy" not in dumped + assert "legacy-token" not in dumped + assert "legacy-password" not in dumped + assert "legacy-cookie" not in dumped + assert "[redacted]" in dumped + + +@pytest.mark.asyncio +async def test_runtime_chat_history_endpoint_supports_before_pagination() -> None: + history = _DummyHistoryManager() + history.records = [ + {"display_name": "system", "message": f"user {idx}", "timestamp": str(idx)} + for idx in range(5) + ] + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace(memory_storage=SimpleNamespace(count=lambda: 0)), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + request = cast( + web.Request, + cast(Any, SimpleNamespace(query={"limit": "2", "before": "3"})), + ) + response = await server._chat_history_handler(request) + payload = json.loads(response.text or "{}") + + assert [item["content"] for item in payload["items"]] == ["user 1", "user 2"] + assert payload["has_more"] is True + assert payload["next_before"] == 1 + assert payload["total"] == 5 + + +@pytest.mark.asyncio +async def test_runtime_chat_history_clear_clears_only_when_no_active_job() -> None: + history = _DummyHistoryManager() + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + request = cast(web.Request, cast(Any, SimpleNamespace(query={}))) + + response = await server._chat_history_clear_handler(request) + payload = json.loads(response.text or "{}") + + assert payload["success"] is True + assert payload["cleared"] == 2 + conversation = await server._chat_job_manager.conversation_store.get_conversation( + str(payload["conversation_id"]) + ) + assert conversation is not None + assert conversation["messages"] == [] + + +@pytest.mark.asyncio +async def test_runtime_chat_history_clear_returns_409_for_running_job( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str: + _ = text, send_output + await asyncio.Event().wait() + return "chat" + + history = _DummyHistoryManager() + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + create_request = cast( + web.Request, + cast(Any, _JsonRequest(query={}, _json={"message": "hello"})), + ) + await server._chat_job_create_handler(create_request) + + response = await server._chat_history_clear_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={}))) + ) + payload = json.loads(response.text or "{}") + + assert response.status == 409 + assert payload["error"] == "Chat job is still running" + + +@pytest.mark.asyncio +async def test_runtime_chat_non_stream_blocks_history_clear_until_done( + monkeypatch: pytest.MonkeyPatch, +) -> None: + started = asyncio.Event() + release = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str: + assert text == "hello" + started.set() + await release.wait() + await send_output(42, "done") + return "chat" + + history = _DummyHistoryManager() + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + chat_request = cast( + web.Request, + cast(Any, _JsonRequest(query={}, _json={"message": "hello"})), + ) + chat_task = asyncio.create_task(server._chat_handler(chat_request)) + await asyncio.wait_for(started.wait(), timeout=1) + + clear_response = await server._chat_history_clear_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={}))) + ) + clear_payload = json.loads(clear_response.text or "{}") + + assert clear_response.status == 409 + assert clear_payload["error"] == "Chat job is still running" + + release.set() + chat_response = await asyncio.wait_for(chat_task, timeout=1) + chat_payload = json.loads(cast(web.Response, chat_response).text or "{}") + + assert chat_response.status == 200 + assert chat_payload["reply"] == "done" + assert chat_payload["messages"] == ["done"] + + +@pytest.mark.asyncio +async def test_runtime_chat_history_clear_returns_409_until_history_finalized() -> None: + history = _DummyHistoryManager() + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + manager = runtime_api_chat.ChatJobManager(context) + job = runtime_api_chat.ChatJob( + job_id="job_finalizing", + text="hello", + created_at=1.0, + updated_at=1.0, + status="done", + history_finalized=False, + ) + manager._jobs[job.job_id] = job + + response = await runtime_api_chat.chat_history_clear_handler( + context, + manager, + cast(web.Request, cast(Any, SimpleNamespace(query={}))), + ) + payload = json.loads(response.text or "{}") + + assert response.status == 409 + assert payload["error"] == "Chat job is still running" + assert history.records + + job.history_finalized = True + job.done.set() + response = await runtime_api_chat.chat_history_clear_handler( + context, + manager, + cast(web.Request, cast(Any, SimpleNamespace(query={}))), + ) + payload = json.loads(response.text or "{}") + + assert response.status == 200 + assert payload["success"] is True + assert payload["cleared"] == 2 + conversation = await manager.conversation_store.get_conversation( + str(payload["conversation_id"]) + ) + assert conversation is not None + assert conversation["messages"] == [] diff --git a/tests/test_runtime_api_chat_jobs.py b/tests/test_runtime_api_chat_jobs.py new file mode 100644 index 00000000..0da2ee17 --- /dev/null +++ b/tests/test_runtime_api_chat_jobs.py @@ -0,0 +1,1528 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock + +import pytest +from aiohttp import web + +from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.api.routes import chat as runtime_api_chat +from Undefined.utils import io as async_io +from Undefined.utils.paths import ensure_dir + + +@pytest.fixture(autouse=True) +def _isolate_webchat_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + + +class _DummyTransport: + def __init__(self, *, closing_after_writes: int | None = None) -> None: + self._closing_after_writes = closing_after_writes + self.write_count = 0 + + def is_closing(self) -> bool: + if self._closing_after_writes is None: + return False + return self.write_count >= self._closing_after_writes + + +class _DummyRequest(SimpleNamespace): + async def json(self) -> dict[str, object]: + return dict(getattr(self, "_json", {})) + + +class _DummyStreamResponse: + def __init__( + self, + *, + status: int, + reason: str, + headers: dict[str, str], + ) -> None: + self.status = status + self.reason = reason + self.headers = dict(headers) + self.writes: list[bytes] = [] + self.eof_written = False + self._request: Any = None + + async def prepare(self, request: web.Request) -> _DummyStreamResponse: + self._request = request + return self + + async def write(self, data: bytes) -> None: + self.writes.append(data) + transport = getattr(self._request, "transport", None) + if isinstance(transport, _DummyTransport): + transport.write_count += 1 + + async def write_eof(self) -> None: + self.eof_written = True + + +def _context() -> RuntimeAPIContext: + return RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace(add_private_message=AsyncMock()), + ) + + +async def _last_webchat_record(server: RuntimeAPIServer) -> dict[str, Any]: + conversation = await server._chat_job_manager.conversation_store.get_conversation( + "legacy-system-42" + ) + assert conversation is not None + messages = conversation.get("messages") + assert isinstance(messages, list) + assert messages + return cast(dict[str, Any], messages[-1]) + + +async def _store_runtime_attachment( + attachment_id: str = "attachment123", + *, + name: str = "note.txt", + content: bytes = b"runtime attachment", +) -> dict[str, Any]: + ensure_dir(runtime_api_chat._CHAT_ATTACHMENT_BLOB_DIR) + ensure_dir(runtime_api_chat._CHAT_ATTACHMENT_META_DIR) + await asyncio.to_thread( + runtime_api_chat._chat_attachment_blob_path(attachment_id).write_bytes, + content, + ) + metadata = runtime_api_chat._chat_attachment_response_metadata( + { + "id": attachment_id, + "name": name, + "size": len(content), + "media_type": "text/plain", + "kind": "file", + "sha256": hashlib.sha256(content).hexdigest(), + "created_at": "2026-06-08T10:00:00", + } + ) + await async_io.write_json( + runtime_api_chat._chat_attachment_meta_path(attachment_id), + metadata, + use_lock=True, + ) + return metadata + + +def _decode_sse(writes: list[bytes]) -> list[dict[str, Any]]: + payload = b"".join(writes).decode("utf-8") + events: list[dict[str, Any]] = [] + for block in payload.split("\n\n"): + if not block.strip() or block.startswith(":"): + continue + event = "message" + seq = 0 + data_lines: list[str] = [] + for line in block.splitlines(): + if line.startswith("id:"): + seq = int(line[3:].strip()) + elif line.startswith("event:"): + event = line[6:].strip() + elif line.startswith("data:"): + data_lines.append(line[5:].strip()) + if data_lines: + events.append( + { + "seq": seq, + "event": event, + "payload": json.loads("\n".join(data_lines)), + } + ) + return events + + +@pytest.mark.asyncio +async def test_run_webui_chat_prompt_describes_webui_markdown_html_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, str] = {} + + class _AI: + attachment_registry: object = object() + memory_storage: Any = SimpleNamespace(count=lambda: 0) + runtime_config: Any = SimpleNamespace() + + async def ask(self, question: str, **_kwargs: Any) -> str: + captured["question"] = question + return "" + + context = _context() + context.ai = _AI() + context.onebot = SimpleNamespace( + get_image=AsyncMock(return_value=None), + get_forward_msg=AsyncMock(return_value=[]), + ) + context.command_dispatcher = SimpleNamespace(parse_command=lambda _text: None) + + async def _fake_register_message_attachments(**_kwargs: Any) -> Any: + return SimpleNamespace(normalized_text="hello", attachments=[]) + + monkeypatch.setattr( + runtime_api_chat, + "register_message_attachments", + _fake_register_message_attachments, + ) + + mode = await runtime_api_chat.run_webui_chat( + context, + text="hello", + send_output=AsyncMock(), + ) + + assert mode == "chat" + prompt = captured["question"] + assert "【WebUI 会话】" in prompt + assert "WebUI 支持完整 Markdown 渲染和简单安全 HTML" in prompt + assert ( + "复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放进 fenced code block" + in prompt + ) + assert "完整 HTML 页面请优先使用 ```html 代码框" in prompt + assert "优先在当前聊天消息中直接给出" in prompt + assert "不要为了普通代码片段调用文件生成或文件发送工具" in prompt + assert "始终标明语言或类型" in prompt + assert "不确定语言时使用 ```text" in prompt + + +@pytest.mark.asyncio +async def test_chat_job_events_after_reconnect_and_disconnect_does_not_cancel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + started = asyncio.Event() + release = asyncio.Event() + cancelled = False + + async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str: + nonlocal cancelled + assert text == "hello" + try: + await send_output(42, "first") + started.set() + await release.wait() + await send_output(42, "second") + return "chat" + except asyncio.CancelledError: + cancelled = True + raise + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + monkeypatch.setattr(web, "StreamResponse", _DummyStreamResponse) + + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + create_request = cast( + web.Request, + cast(Any, _DummyRequest(_json={"message": "hello"}, query={})), + ) + create_response = await server._chat_job_create_handler(create_request) + create_payload = json.loads(create_response.text or "{}") + job_id = str(create_payload["job_id"]) + + await asyncio.wait_for(started.wait(), timeout=1) + + first_request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job_id}, + query={"after": "0"}, + headers={"Accept": "text/event-stream"}, + transport=_DummyTransport(closing_after_writes=1), + ), + ), + ) + first_response = await server._chat_job_events_handler(first_request) + first_events = _decode_sse(cast(_DummyStreamResponse, first_response).writes) + assert first_events[0]["event"] == "meta" + first_last_seq = first_events[-1]["seq"] + assert cancelled is False + + release.set() + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": job_id}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["status"] == "done": + break + await asyncio.sleep(0.01) + assert isinstance(detail_payload["duration_ms"], int) + assert detail_payload["finished_at"] is not None + + second_request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job_id}, + query={"after": str(first_last_seq)}, + headers={"Accept": "text/event-stream"}, + transport=_DummyTransport(), + ), + ), + ) + second_response = await server._chat_job_events_handler(second_request) + second_events = _decode_sse(cast(_DummyStreamResponse, second_response).writes) + + assert cancelled is False + assert "stage" in [event["event"] for event in second_events] + assert [event["event"] for event in second_events if event["event"] != "stage"] == [ + "message", + "done", + ] + message_events = [event for event in second_events if event["event"] == "message"] + assert message_events[0]["payload"]["content"] == "second" + + +@pytest.mark.asyncio +async def test_chat_job_cancel_unknown_returns_404() -> None: + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": "missing"}, query={})), + ) + + response = await server._chat_job_cancel_handler(request) + payload = json.loads(response.text or "{}") + + assert response.status == 404 + assert payload["error"] == "Job not found" + + +@pytest.mark.asyncio +async def test_chat_jobs_are_concurrent_across_conversations_and_single_flight_per_conversation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, **_kwargs: Any) -> str: + await release.wait() + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + first = await server._chat_job_manager.conversation_store.create_conversation( + title="first" + ) + second = await server._chat_job_manager.conversation_store.create_conversation( + title="second" + ) + + first_response = await server._chat_job_create_handler( + cast( + web.Request, + cast( + Any, + _DummyRequest( + query={}, + _json={ + "conversation_id": first["id"], + "message": "first message", + }, + ), + ), + ) + ) + second_response = await server._chat_job_create_handler( + cast( + web.Request, + cast( + Any, + _DummyRequest( + query={}, + _json={ + "conversation_id": second["id"], + "message": "second message", + }, + ), + ), + ) + ) + duplicate_response = await server._chat_job_create_handler( + cast( + web.Request, + cast( + Any, + _DummyRequest( + query={}, + _json={ + "conversation_id": first["id"], + "message": "duplicate message", + }, + ), + ), + ) + ) + + assert first_response.status == 202 + assert second_response.status == 202 + assert duplicate_response.status == 409 + + release.set() + await server.stop() + + +@pytest.mark.asyncio +async def test_chat_job_active_returns_jobs_array_and_compatible_job( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, **_kwargs: Any) -> str: + await release.wait() + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + first = await server._chat_job_manager.conversation_store.create_conversation() + second = await server._chat_job_manager.conversation_store.create_conversation() + first_job = await server._chat_job_manager.create_job("first", str(first["id"])) + second_job = await server._chat_job_manager.create_job("second", str(second["id"])) + + response = await server._chat_job_active_handler( + cast(web.Request, cast(Any, _DummyRequest(query={}))) + ) + payload = json.loads(response.text or "{}") + filtered_response = await server._chat_job_active_handler( + cast( + web.Request, + cast( + Any, + _DummyRequest(query={"conversation_id": str(second["id"])}), + ), + ) + ) + filtered_payload = json.loads(filtered_response.text or "{}") + + assert {item["job_id"] for item in payload["jobs"]} == { + first_job.job_id, + second_job.job_id, + } + assert payload["job"]["job_id"] == second_job.job_id + assert [item["job_id"] for item in filtered_payload["jobs"]] == [second_job.job_id] + assert filtered_payload["job"]["job_id"] == second_job.job_id + release.set() + await server.stop() + + +@pytest.mark.asyncio +async def test_chat_conversations_active_job_compatible_field_uses_latest_job( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, **_kwargs: Any) -> str: + await release.wait() + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + first = await server._chat_job_manager.conversation_store.create_conversation() + second = await server._chat_job_manager.conversation_store.create_conversation() + first_job = await server._chat_job_manager.create_job("first", str(first["id"])) + second_job = await server._chat_job_manager.create_job("second", str(second["id"])) + + response = await server._chat_conversations_handler( + cast(web.Request, cast(Any, _DummyRequest(query={}))) + ) + payload = json.loads(response.text or "{}") + + assert payload["active_job"]["job_id"] == second_job.job_id + assert payload["active_job"]["job_id"] != first_job.job_id + release.set() + await server.stop() + + +@pytest.mark.asyncio +async def test_chat_job_create_accepts_structured_message_and_persists_references( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured_text: list[str] = [] + + async def _fake_run_webui_chat(_ctx: Any, *, text: str, **_kwargs: Any) -> str: + captured_text.append(text) + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + conversation = ( + await server._chat_job_manager.conversation_store.create_conversation( + title="structured" + ) + ) + await server._chat_job_manager.conversation_store.append_message( + str(conversation["id"]), + role="bot", + text_content="可以引用的回复", + display_name="Bot", + user_name="Bot", + ) + history_page = await server._chat_job_manager.conversation_store.get_history_page( + str(conversation["id"]), limit=1, before=None + ) + source_message_id = str(history_page.records[0]["message_id"]) + + response = await server._chat_job_create_handler( + cast( + web.Request, + cast( + Any, + _DummyRequest( + query={}, + _json={ + "conversation_id": str(conversation["id"]), + "message": { + "text": "请解释这段", + "references": [ + { + "kind": "message", + "source_message_id": source_message_id, + "selected_text": "引用片段", + } + ], + }, + }, + ), + ), + ) + ) + payload = json.loads(response.text or "{}") + + assert response.status == 202 + assert payload["waiting_input"] is None + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": payload["job_id"]}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["status"] == "done": + break + await asyncio.sleep(0.01) + assert captured_text == [ + f"> 引用 message:{source_message_id}\n> 引用片段\n\n请解释这段" + ] + request_history = ( + await server._chat_job_manager.conversation_store.get_history_page( + str(conversation["id"]), limit=1, before=None + ) + ) + record = request_history.records[0] + assert record["message"] == captured_text[0] + assert record["references"][0]["source_message_id"] == source_message_id + + +@pytest.mark.asyncio +async def test_chat_job_create_structured_attachment_is_not_duplicated_in_prompt_or_history( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + async def _fake_run_webui_chat( + _ctx: Any, + *, + text: str, + input_attachments: list[dict[str, str]], + record_input_history: bool, + **_kwargs: Any, + ) -> str: + captured["text"] = text + captured["input_attachments"] = input_attachments + captured["record_input_history"] = record_input_history + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + conversation = ( + await server._chat_job_manager.conversation_store.create_conversation( + title="attachment" + ) + ) + attachment = await _store_runtime_attachment() + + response = await server._chat_job_create_handler( + cast( + web.Request, + cast( + Any, + _DummyRequest( + query={}, + _json={ + "conversation_id": str(conversation["id"]), + "message": { + "text": "请分析附件", + "attachment_ids": [attachment["id"]], + }, + }, + ), + ), + ) + ) + payload = json.loads(response.text or "{}") + + assert response.status == 202 + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": payload["job_id"]}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["status"] == "done": + break + await asyncio.sleep(0.01) + + assert captured["text"] == "请分析附件" + assert " None: + captured: dict[str, Any] = {} + + async def _fake_run_webui_chat( + _ctx: Any, + *, + text: str, + record_input_history: bool, + **_kwargs: Any, + ) -> str: + captured["text"] = text + captured["record_input_history"] = record_input_history + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + conversation = ( + await server._chat_job_manager.conversation_store.create_conversation( + title="retry" + ) + ) + await server._chat_job_manager.conversation_store.append_message( + str(conversation["id"]), + role="user", + text_content="搜索今日国内国际新闻热点", + display_name="system", + user_name="system", + ) + await server._chat_job_manager.conversation_store.append_message( + str(conversation["id"]), + role="bot", + text_content="", + display_name="Bot", + user_name="Bot", + webchat={ + "display_only": True, + "events": [ + { + "seq": 1, + "event": "message", + "payload": {"content": ""}, + } + ], + }, + ) + + response = await server._chat_job_create_handler( + cast( + web.Request, + cast( + Any, + _DummyRequest( + query={}, + _json={ + "conversation_id": str(conversation["id"]), + "message": { + "text": "搜索今日国内国际新闻热点", + }, + "reuse_previous_user_message": True, + }, + ), + ), + ) + ) + payload = json.loads(response.text or "{}") + + assert response.status == 202 + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": payload["job_id"]}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["status"] == "done": + break + await asyncio.sleep(0.01) + + assert captured["text"] == "搜索今日国内国际新闻热点" + assert captured["record_input_history"] is False + history_page = await server._chat_job_manager.conversation_store.get_history_page( + str(conversation["id"]), + limit=20, + before=None, + ) + user_records = [ + record + for record in history_page.records + if str(record.get("display_name", "")).lower() != "bot" + and str(record.get("message", "")).strip() + ] + assert [record["message"] for record in user_records] == [ + "搜索今日国内国际新闻热点" + ] + + +@pytest.mark.asyncio +async def test_chat_job_create_reuse_previous_user_message_requires_matching_tail() -> ( + None +): + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + conversation = ( + await server._chat_job_manager.conversation_store.create_conversation( + title="retry mismatch" + ) + ) + await server._chat_job_manager.conversation_store.append_message( + str(conversation["id"]), + role="user", + text_content="上一条", + display_name="system", + user_name="system", + ) + + response = await server._chat_job_create_handler( + cast( + web.Request, + cast( + Any, + _DummyRequest( + query={}, + _json={ + "conversation_id": str(conversation["id"]), + "message": { + "text": "另一条", + }, + "reuse_previous_user_message": True, + }, + ), + ), + ) + ) + + assert response.status == 400 + payload = json.loads(response.text or "{}") + assert ( + payload["error"] + == "reuse_previous_user_message requires a matching last user message" + ) + + +@pytest.mark.asyncio +async def test_requires_action_event_is_preserved_for_runtime_stream_and_history( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_run_webui_chat( + _ctx: Any, + *, + webchat_event_callback: Any, + **_kwargs: Any, + ) -> str: + await webchat_event_callback( + "requires_action", + { + "action_id": "approval-1", + "kind": "confirm", + "detail": "需要确认", + "secret": "should-redact", + }, + ) + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + job = await server._chat_job_manager.create_job("需要人工确认") + + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": job.job_id}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["status"] == "done": + break + await asyncio.sleep(0.01) + + events_request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job.job_id}, + query={"after": "0", "format": "json"}, + headers={"Accept": "application/json"}, + ), + ), + ) + events_response = cast( + web.Response, await server._chat_job_events_handler(events_request) + ) + events_payload = json.loads(events_response.text or "{}") + requires_action = [ + item for item in events_payload["events"] if item["event"] == "requires_action" + ] + + assert requires_action + assert requires_action[0]["payload"]["action_id"] == "approval-1" + assert requires_action[0]["payload"]["secret"] == "[redacted]" + request_history = ( + await server._chat_job_manager.conversation_store.get_history_page( + job.conversation_id, limit=1, before=None + ) + ) + webchat_events = request_history.records[0]["webchat"]["events"] + assert any(item["event"] == "requires_action" for item in webchat_events) + + +@pytest.mark.asyncio +async def test_chat_job_create_rejects_empty_structured_message() -> None: + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + + response = await server._chat_job_create_handler( + cast( + web.Request, + cast( + Any, + _DummyRequest( + query={}, + _json={"message": {"text": " ", "attachment_ids": []}}, + ), + ), + ) + ) + payload = json.loads(response.text or "{}") + + assert response.status == 400 + assert payload["error"] == "message is required" + + +@pytest.mark.asyncio +async def test_chat_job_create_rejects_unknown_structured_attachment() -> None: + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + + response = await server._chat_job_create_handler( + cast( + web.Request, + cast( + Any, + _DummyRequest( + query={}, + _json={ + "message": { + "text": "hello", + "attachment_ids": ["missing-attachment"], + } + }, + ), + ), + ) + ) + payload = json.loads(response.text or "{}") + + assert response.status == 404 + assert payload["error"] == "Attachment not found" + + +@pytest.mark.asyncio +async def test_chat_job_cancelled_error_event_is_appended_once() -> None: + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + manager = server._chat_job_manager + job = runtime_api_chat.ChatJob( + job_id="job-cancel", + text="hello", + created_at=0.0, + updated_at=0.0, + ) + + await asyncio.gather(*(manager._append_cancelled_event_once(job) for _ in range(8))) + + cancelled_events = [ + event + for event in job.events + if event.event == "error" and event.payload.get("error") == "cancelled" + ] + assert len(cancelled_events) == 1 + + +@pytest.mark.asyncio +async def test_runtime_api_stop_cancels_running_webchat_job( + monkeypatch: pytest.MonkeyPatch, +) -> None: + started = asyncio.Event() + cancelled = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, **_kwargs: Any) -> str: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancelled.set() + raise + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + job = await server._chat_job_manager.create_job("hello") + await asyncio.wait_for(started.wait(), timeout=1) + + await server.stop() + + assert cancelled.is_set() + assert job.status == "cancelled" + assert job.done.is_set() + assert job.history_finalized is True + + +@pytest.mark.asyncio +async def test_runtime_api_stop_recancels_shutdown_task_after_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(runtime_api_chat, "SHUTDOWN_TASK_TIMEOUT", 0.01) + started = asyncio.Event() + cancel_count = 0 + + async def _resist_first_cancel() -> None: + nonlocal cancel_count + started.set() + while True: + try: + await asyncio.sleep(3600) + except asyncio.CancelledError: + cancel_count += 1 + if cancel_count >= 2: + raise + + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + job = runtime_api_chat.ChatJob( + job_id="job-resist-cancel", + text="hello", + created_at=0.0, + updated_at=0.0, + status="running", + ) + job.task = asyncio.create_task(_resist_first_cancel()) + async with server._chat_job_manager._lock: + server._chat_job_manager._jobs[job.job_id] = job + await asyncio.wait_for(started.wait(), timeout=1) + + await asyncio.wait_for(server.stop(), timeout=1) + + assert cancel_count == 2 + assert job.status == "cancelled" + assert job.done.is_set() + + +@pytest.mark.asyncio +async def test_chat_job_events_refreshes_stage_without_advancing_seq( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, **_kwargs: Any) -> str: + await release.wait() + return "chat" + + context = _context() + monkeypatch.setattr(web, "StreamResponse", _DummyStreamResponse) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + job = await server._chat_job_manager.create_job("hello") + await asyncio.sleep(0.01) + request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job.job_id}, + query={"after": str(job.next_seq - 1)}, + headers={"Accept": "text/event-stream"}, + transport=_DummyTransport(closing_after_writes=1), + ), + ), + ) + + response = await server._chat_job_events_handler(request) + events = _decode_sse(cast(_DummyStreamResponse, response).writes) + + assert events[0]["event"] == "stage" + assert events[0]["seq"] == job.next_seq - 1 + assert events[0]["payload"]["stage"] == job.current_stage + assert isinstance(events[0]["payload"]["elapsed_ms"], int) + release.set() + await server._chat_job_manager.cancel_job(job.job_id) + + +@pytest.mark.asyncio +async def test_chat_job_events_refreshes_agent_stage_without_advancing_seq( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat( + _ctx: Any, + *, + webchat_event_callback: Any = None, + **_kwargs: Any, + ) -> str: + assert webchat_event_callback is not None + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_agent", + "webchat_call_id": "call_agent", + "name": "web_agent", + "api_name": "web_agent", + "arguments": {"prompt": "search"}, + "is_agent": True, + }, + ) + await webchat_event_callback( + "agent_stage", + { + "webchat_call_id": "call_agent", + "agent_name": "web_agent", + "stage": "waiting_model", + "detail": "iteration=1", + }, + ) + await release.wait() + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_agent", + "webchat_call_id": "call_agent", + "name": "web_agent", + "api_name": "web_agent", + "ok": True, + "result": "ok", + "is_agent": True, + }, + ) + return "chat" + + monkeypatch.setattr(web, "StreamResponse", _DummyStreamResponse) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + job = await server._chat_job_manager.create_job("hello") + + for _ in range(20): + if any(event.event == "agent_stage" for event in job.events): + break + await asyncio.sleep(0.01) + after = job.next_seq - 1 + request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job.job_id}, + query={"after": str(after)}, + headers={"Accept": "text/event-stream"}, + transport=_DummyTransport(closing_after_writes=2), + ), + ), + ) + + response = await server._chat_job_events_handler(request) + events = _decode_sse(cast(_DummyStreamResponse, response).writes) + + agent_stage_events = [event for event in events if event["event"] == "agent_stage"] + assert agent_stage_events + assert agent_stage_events[0]["seq"] == after + assert agent_stage_events[0]["payload"]["webchat_call_id"] == "call_agent" + assert agent_stage_events[0]["payload"]["stage"] == "waiting_model" + assert isinstance(agent_stage_events[0]["payload"]["stage_elapsed_ms"], int) + release.set() + await server._chat_job_manager.cancel_job(job.job_id) + + +@pytest.mark.asyncio +async def test_chat_job_events_json_returns_incremental_events_and_live_stage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat( + _ctx: Any, + *, + webchat_event_callback: Any = None, + **_kwargs: Any, + ) -> str: + assert webchat_event_callback is not None + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_agent", + "webchat_call_id": "call_agent", + "name": "web_agent", + "api_name": "web_agent", + "arguments": {"prompt": "search"}, + "is_agent": True, + }, + ) + await webchat_event_callback( + "agent_stage", + { + "webchat_call_id": "call_agent", + "agent_name": "web_agent", + "stage": "waiting_model", + }, + ) + await release.wait() + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_agent", + "webchat_call_id": "call_agent", + "name": "web_agent", + "api_name": "web_agent", + "ok": True, + "result": "ok", + "is_agent": True, + }, + ) + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + job = await server._chat_job_manager.create_job("hello") + + for _ in range(20): + if any(event.event == "agent_stage" for event in job.events): + break + await asyncio.sleep(0.01) + after = job.next_seq - 1 + request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job.job_id}, + query={"after": str(after), "format": "json"}, + headers={"Accept": "application/json"}, + transport=_DummyTransport(), + ), + ), + ) + + response = cast(web.Response, await server._chat_job_events_handler(request)) + payload = json.loads(response.text or "{}") + + assert payload["after"] == after + assert payload["last_seq"] == after + assert payload["job"]["current_agent_stages"][0]["stage"] == "waiting_model" + assert payload["job"]["current_tool_calls"][0]["webchat_call_id"] == "call_agent" + assert payload["job"]["current_tool_calls"][0]["status"] == "running" + assert payload["job"]["current_tool_calls"][0]["is_agent"] is True + assert isinstance(payload["job"]["current_tool_calls"][0]["duration_ms"], int) + assert isinstance(payload["job"]["current_tool_calls"][0]["started_at"], float) + assert payload["job"]["current_tool_calls"][0]["current_stage"] == "waiting_model" + assert payload["events"][0]["event"] == "stage" + assert payload["events"][1]["event"] == "agent_stage" + assert payload["events"][1]["seq"] == after + assert payload["events"][1]["payload"]["transient"] is True + release.set() + await server._chat_job_manager.cancel_job(job.job_id) + + +@pytest.mark.asyncio +async def test_chat_job_events_reject_wrong_conversation_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + release = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, **_kwargs: Any) -> str: + await release.wait() + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + conversation = ( + await server._chat_job_manager.conversation_store.create_conversation() + ) + conversation_id = str(conversation["id"]) + job = await server._chat_job_manager.create_job("hello", conversation_id) + + request = cast( + web.Request, + cast( + Any, + _DummyRequest( + match_info={"job_id": job.job_id}, + query={"after": "0", "format": "json", "conversation_id": "other"}, + headers={"Accept": "application/json"}, + transport=_DummyTransport(), + ), + ), + ) + + response = cast(web.Response, await server._chat_job_events_handler(request)) + payload = json.loads(response.text or "{}") + + assert response.status == 404 + assert payload["error"] == "Job not found" + release.set() + await server._chat_job_manager.cancel_job(job.job_id) + + +@pytest.mark.asyncio +async def test_chat_job_persists_webchat_lifecycle_history( + monkeypatch: pytest.MonkeyPatch, +) -> None: + history_calls: list[dict[str, Any]] = [] + + class _History: + async def add_private_message(self, **kwargs: Any) -> None: + history_calls.append(dict(kwargs)) + + async def flush_pending_saves(self) -> None: + return None + + async def _fake_run_webui_chat( + _ctx: Any, + *, + text: str, + send_output: Any, + webchat_event_callback: Any = None, + ) -> str: + assert text == "hello" + assert webchat_event_callback is not None + await webchat_event_callback("stage", {"stage": "waiting_model"}) + await webchat_event_callback("token_delta", {"delta": "ignored"}) + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_1", + "webchat_call_id": "agent_1", + "name": "web_agent", + "api_name": "web_agent", + "arguments": {"prompt": "search"}, + "is_agent": True, + }, + ) + await webchat_event_callback( + "agent_stage", + { + "webchat_call_id": "agent_1", + "name": "web_agent", + "agent_name": "web_agent", + "stage": "waiting_model", + "detail": "iteration=1", + "is_agent": True, + }, + ) + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_1_1", + "webchat_call_id": "agent_1/search_1", + "parent_webchat_call_id": "agent_1", + "name": "search", + "api_name": "search", + "arguments": {"q": "test"}, + "is_agent": False, + "depth": 1, + "agent_path": ["web_agent"], + }, + ) + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_1_1", + "webchat_call_id": "agent_1/search_1", + "parent_webchat_call_id": "agent_1", + "name": "search", + "api_name": "search", + "ok": True, + "result": "nested result", + "is_agent": False, + "depth": 1, + "agent_path": ["web_agent"], + }, + ) + await send_output(42, "final") + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_1", + "webchat_call_id": "agent_1", + "name": "web_agent", + "api_name": "web_agent", + "ok": True, + "result": "agent result", + "is_agent": True, + }, + ) + return "chat" + + context = _context() + context.history_manager = _History() + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_job_create_handler( + cast( + web.Request, cast(Any, _DummyRequest(_json={"message": "hello"}, query={})) + ) + ) + job_id = str(json.loads(response.text or "{}")["job_id"]) + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": job_id}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["history_finalized"] is True: + break + await asyncio.sleep(0.01) + + assert history_calls == [] + call = await _last_webchat_record(server) + assert call["user_id"] == "42" + assert call["message"] == "final" + webchat = call["webchat"] + assert webchat["display_only"] is True + assert webchat["job_id"] == job_id + assert isinstance(webchat["duration_ms"], int) + assert webchat["finished_at"] is not None + assert [event["event"] for event in webchat["events"]] == [ + "agent_start", + "agent_stage", + "tool_start", + "tool_end", + "message", + "agent_end", + ] + assert webchat["events"][0]["payload"]["webchat_call_id"] == "agent_1" + assert webchat["events"][1]["payload"]["stage"] == "waiting_model" + assert webchat["events"][1]["payload"]["job_id"] == job_id + assert isinstance(webchat["events"][1]["payload"]["stage_elapsed_ms"], int) + assert webchat["events"][2]["payload"]["parent_webchat_call_id"] == "agent_1" + assert webchat["events"][3]["payload"]["result_preview"] == "nested result" + assert "duration_ms" in webchat["events"][3]["payload"] + assert webchat["events"][4]["payload"]["content"] == "final" + assert webchat["events"][4]["payload"]["parent_webchat_call_id"] == "agent_1" + assert webchat["events"][5]["payload"]["result_preview"] == "agent result" + assert len(webchat["calls"]) == 1 + assert webchat["calls"][0]["webchat_call_id"] == "agent_1" + assert webchat["calls"][0]["is_agent"] is True + assert webchat["calls"][0]["current_stage"] == "waiting_model" + assert webchat["calls"][0]["children"][0]["webchat_call_id"] == "agent_1/search_1" + assert webchat["calls"][0]["children"][0]["result_preview"] == "nested result" + assert [item["type"] for item in webchat["timeline"]] == ["call"] + assert webchat["timeline"][0]["call"]["webchat_call_id"] == "agent_1" + assert webchat["timeline"][0]["call"]["children"][0]["name"] == "search" + assert [item["type"] for item in webchat["calls"][0]["timeline"]] == [ + "stage", + "call", + "message", + ] + assert webchat["calls"][0]["timeline"][0]["stage"] == "waiting_model" + assert webchat["calls"][0]["timeline"][1]["call"]["name"] == "search" + assert webchat["calls"][0]["timeline"][2]["content"] == "final" + + +@pytest.mark.asyncio +async def test_chat_job_finalizes_unclosed_webchat_calls_as_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + history_calls: list[dict[str, Any]] = [] + + class _History: + async def add_private_message(self, **kwargs: Any) -> None: + history_calls.append(dict(kwargs)) + + async def _fake_run_webui_chat( + _ctx: Any, + *, + text: str, + send_output: Any, + webchat_event_callback: Any = None, + ) -> str: + _ = send_output + assert text == "hello" + assert webchat_event_callback is not None + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_1", + "webchat_call_id": "call_1", + "name": "search", + "api_name": "search", + "arguments": {"q": "test"}, + "is_agent": False, + }, + ) + raise RuntimeError("boom") + + context = _context() + context.history_manager = _History() + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_job_create_handler( + cast( + web.Request, cast(Any, _DummyRequest(_json={"message": "hello"}, query={})) + ) + ) + job_id = str(json.loads(response.text or "{}")["job_id"]) + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": job_id}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["history_finalized"] is True: + break + await asyncio.sleep(0.01) + + assert history_calls == [] + webchat = (await _last_webchat_record(server))["webchat"] + assert [event["event"] for event in webchat["events"]] == [ + "tool_start", + "tool_end", + ] + assert webchat["events"][1]["payload"]["ok"] is False + assert webchat["events"][1]["payload"]["status"] == "error" + assert webchat["calls"][0]["status"] == "error" + assert webchat["timeline"][0]["call"]["status"] == "error" + + +@pytest.mark.asyncio +async def test_chat_job_history_persists_redacted_webchat_previews( + monkeypatch: pytest.MonkeyPatch, +) -> None: + history_calls: list[dict[str, Any]] = [] + + class _History: + async def add_private_message(self, **kwargs: Any) -> None: + history_calls.append(dict(kwargs)) + + async def _fake_run_webui_chat( + _ctx: Any, + *, + text: str, + send_output: Any, + webchat_event_callback: Any = None, + ) -> str: + _ = text, send_output + assert webchat_event_callback is not None + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_secret", + "webchat_call_id": "call_secret", + "name": "external.search", + "arguments": { + "q": "test", + "api_key": "sk-history-secret", + "headers": {"Authorization": "Bearer auth-history-secret"}, + }, + }, + ) + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_secret", + "webchat_call_id": "call_secret", + "name": "external.search", + "ok": True, + "result": {"password": "history-password", "summary": "ok"}, + }, + ) + return "chat" + + context = _context() + context.history_manager = _History() + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._chat_job_create_handler( + cast( + web.Request, cast(Any, _DummyRequest(_json={"message": "hello"}, query={})) + ) + ) + job_id = str(json.loads(response.text or "{}")["job_id"]) + detail_request = cast( + web.Request, + cast(Any, _DummyRequest(match_info={"job_id": job_id}, query={})), + ) + for _ in range(20): + detail_response = await server._chat_job_detail_handler(detail_request) + detail_payload = json.loads(detail_response.text or "{}") + if detail_payload["history_finalized"] is True: + break + await asyncio.sleep(0.01) + + assert history_calls == [] + webchat = (await _last_webchat_record(server))["webchat"] + dumped = json.dumps(webchat, ensure_ascii=False) + assert "sk-history-secret" not in dumped + assert "auth-history-secret" not in dumped + assert "history-password" not in dumped + assert "[redacted]" in dumped + assert webchat["calls"][0]["result_preview"] == ( + '{"password":"[redacted]","summary":"ok"}' + ) diff --git a/tests/test_runtime_api_chat_stream.py b/tests/test_runtime_api_chat_stream.py index 09738cbf..9837fd59 100644 --- a/tests/test_runtime_api_chat_stream.py +++ b/tests/test_runtime_api_chat_stream.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +from pathlib import Path from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock @@ -11,6 +13,11 @@ from Undefined.api.routes import chat as runtime_api_chat +@pytest.fixture(autouse=True) +def _isolate_webchat_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + + class _DummyTransport: def is_closing(self) -> bool: return False @@ -46,27 +53,157 @@ async def write_eof(self) -> None: self.eof_written = True +def test_sanitize_webchat_event_payload_compacts_webchat_private_send_tool() -> None: + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_start", + { + "tool_call_id": "call_1", + "name": "messages.send_message", + "api_name": "messages-_-send_message", + "arguments": { + "target_type": "private", + "target_id": 42, + "message": "这段正文会作为 message 事件展示", + }, + }, + ) + + assert payload["ui_hint"] == "webchat_private_send" + assert payload["arguments_preview"] == "" + + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_end", + { + "tool_call_id": "call_1", + "name": "messages.send_message", + "api_name": "messages-_-send_message", + "ok": True, + "result": "消息已发送(message_id=123)", + }, + ) + + assert payload["result_preview"] == "消息已发送(message_id=123)" + + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_end", + { + "tool_call_id": "call_2", + "name": "messages.send_private_message", + "api_name": "messages-_-send_private_message", + "ok": True, + "result": "私聊消息已发送给用户 42(message_id=456)", + }, + ) + + assert payload["ui_hint"] == "webchat_private_send" + assert payload["result_preview"] == "私聊消息已发送给用户 42(message_id=456)" + + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_start", + { + "tool_call_id": "call_2", + "name": "messages.send_private_message", + "api_name": "messages-_-send_private_message", + "arguments": { + "target_id": 42, + "message": "私聊正文", + }, + }, + ) + + assert payload["ui_hint"] == "webchat_private_send" + assert payload["arguments_preview"] == "" + + +def test_sanitize_webchat_event_payload_keeps_group_send_message_details() -> None: + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_start", + { + "tool_call_id": "call_1", + "name": "messages.send_message", + "api_name": "messages-_-send_message", + "arguments": { + "target_type": "group", + "target_id": 10001, + "message": "群聊消息", + }, + }, + ) + + assert "ui_hint" not in payload + assert "群聊消息" in payload["arguments_preview"] + assert json.loads(payload["arguments_preview"]) == { + "target_type": "group", + "target_id": 10001, + "message": "群聊消息", + } + + +def test_sanitize_webchat_event_payload_compacts_successful_end_tool() -> None: + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_end", + { + "tool_call_id": "call_end", + "name": "end", + "api_name": "end", + "ok": True, + "result": "对话已结束", + }, + ) + + assert payload["ui_hint"] == "webchat_end" + assert payload["result_preview"] == "对话已结束" + + +def test_sanitize_webchat_event_payload_redacts_secret_previews() -> None: + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_start", + { + "tool_call_id": "call_secret", + "name": "external.search", + "arguments": { + "q": "weather", + "api_key": "sk-live-secret", + "headers": { + "Authorization": "Bearer token-secret", + "Cookie": "sid=session-secret", + }, + }, + }, + ) + + preview = payload["arguments_preview"] + assert "weather" in preview + assert "sk-live-secret" not in preview + assert "token-secret" not in preview + assert "session-secret" not in preview + assert "[redacted]" in preview + + payload = runtime_api_chat._sanitize_webchat_event_payload( + "tool_end", + { + "tool_call_id": "call_secret", + "name": "external.search", + "ok": True, + "result": "Authorization: Bearer result-secret password=plain-secret", + }, + ) + + result_preview = payload["result_preview"] + assert "result-secret" not in result_preview + assert "plain-secret" not in result_preview + assert "[redacted]" in result_preview + + +@pytest.mark.skip( + reason="render_message_with_pic_placeholders function no longer exists" +) @pytest.mark.asyncio async def test_runtime_chat_stream_renders_each_message_once( monkeypatch: pytest.MonkeyPatch, ) -> None: render_calls: list[str] = [] - async def _fake_render_message_with_pic_placeholders( - message: str, - *, - registry: Any, - scope_key: str, - strict: bool, - ) -> Any: - _ = registry, scope_key, strict - render_calls.append(message) - return SimpleNamespace( - delivery_text="rendered stream reply", - history_text="rendered history reply", - attachments=[], - ) - context = RuntimeAPIContext( config_getter=lambda: SimpleNamespace( api=SimpleNamespace( @@ -79,14 +216,21 @@ async def _fake_render_message_with_pic_placeholders( superadmin_qq=10001, bot_qq=20002, ), - onebot=SimpleNamespace(connection_status=lambda: {}), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), ai=SimpleNamespace( attachment_registry=object(), memory_storage=SimpleNamespace(count=lambda: 0), ), command_dispatcher=SimpleNamespace(), queue_manager=SimpleNamespace(snapshot=lambda: {}), - history_manager=SimpleNamespace(add_private_message=AsyncMock()), + history_manager=SimpleNamespace( + add_private_message=AsyncMock(), + flush_pending_saves=AsyncMock(), + ), ) server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) @@ -95,11 +239,6 @@ async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str await send_output(42, "bot reply with ") return "chat" - monkeypatch.setattr( - runtime_api_chat, - "render_message_with_pic_placeholders", - _fake_render_message_with_pic_placeholders, - ) monkeypatch.setattr(web, "StreamResponse", _DummyStreamResponse) monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) @@ -122,6 +261,114 @@ async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str assert "rendered stream reply" in payload assert "event: done" in payload assert response.eof_written is True + context.history_manager.add_private_message.assert_not_awaited() + conversation = await server._chat_job_manager.conversation_store.get_conversation( + "legacy-system-42" + ) + assert conversation is not None + messages = conversation.get("messages") + assert isinstance(messages, list) + assert [item["message"] for item in messages] == ["rendered history reply"] + + +@pytest.mark.asyncio +async def test_runtime_chat_stream_uses_webchat_lifecycle_events_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=lambda uid: None, + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace( + add_private_message=AsyncMock(), + flush_pending_saves=AsyncMock(), + ), + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + async def _fake_run_webui_chat( + _ctx: Any, + *, + text: str, + send_output: Any, + webchat_event_callback: Any = None, + ) -> str: + assert text == "hello" + assert webchat_event_callback is not None + await webchat_event_callback("token_delta", {"delta": "ignored"}) + await webchat_event_callback( + "tool_delta", + {"id": "call_1", "arguments_delta": '{"q"'}, + ) + await webchat_event_callback( + "tool_start", + { + "tool_call_id": "call_1", + "name": "search", + "api_name": "search", + "arguments": {"q": "weather"}, + "is_agent": False, + }, + ) + await webchat_event_callback( + "tool_end", + { + "tool_call_id": "call_1", + "name": "search", + "api_name": "search", + "ok": True, + "result": "sunny", + "is_agent": False, + }, + ) + await send_output(42, "final") + return "chat" + + monkeypatch.setattr(web, "StreamResponse", _DummyStreamResponse) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + + request = cast( + web.Request, + cast( + Any, + _DummyRequest( + transport=_DummyTransport(), + ), + ), + ) + + response = await server._chat_handler(request) + + assert isinstance(response, _DummyStreamResponse) + payload = b"".join(response.writes).decode("utf-8") + assert "event: token_delta" not in payload + assert "event: tool_delta" not in payload + assert "event: stage" in payload + assert '"stage": "received"' in payload + assert '"elapsed_ms":' in payload + assert '"duration_ms":' in payload + assert "event: tool_start" in payload + assert "event: tool_end" in payload + assert "event: message" in payload @pytest.mark.asyncio diff --git a/tests/test_runtime_api_commands.py b/tests/test_runtime_api_commands.py new file mode 100644 index 00000000..631b1d65 --- /dev/null +++ b/tests/test_runtime_api_commands.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from aiohttp import web + +from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.services.commands.registry import CommandRegistry + + +def _write_command( + commands_dir: Path, + directory: str, + config: dict[str, Any], +) -> None: + command_dir = commands_dir / directory + command_dir.mkdir(parents=True) + (command_dir / "config.json").write_text( + json.dumps(config, ensure_ascii=False), + encoding="utf-8", + ) + (command_dir / "handler.py").write_text( + "from Undefined.services.commands.context import CommandContext\n\n" + "async def execute(args: list[str], context: CommandContext) -> None:\n" + " _ = args, context\n", + encoding="utf-8", + ) + + +def _config() -> Any: + config = cast( + Any, + SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + admin_qqs=[10001], + ), + ) + config.is_superadmin = lambda user_id: int(user_id) == 10001 + config.is_admin = lambda user_id: int(user_id) in {10001} + return config + + +def _context(registry: CommandRegistry) -> RuntimeAPIContext: + dispatcher = SimpleNamespace( + command_registry=registry, + sender=SimpleNamespace(), + ai=SimpleNamespace(), + faq_storage=SimpleNamespace(), + onebot=SimpleNamespace(), + security=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + rate_limiter=None, + ) + return RuntimeAPIContext( + config_getter=_config, + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace(memory_storage=SimpleNamespace(count=lambda: 0)), + command_dispatcher=dispatcher, + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace(), + ) + + +@pytest.mark.asyncio +async def test_commands_api_exposes_changelog_alias_subcommands() -> None: + commands_dir = Path("src/Undefined/skills/commands") + registry = CommandRegistry(commands_dir) + registry.load_commands() + server = RuntimeAPIServer(_context(registry), host="127.0.0.1", port=8788) + + response = await server._command_detail_handler( + cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={"scope": "webui"}, + match_info={"command_name": "cl"}, + ), + ), + ) + ) + payload = json.loads(response.text or "{}") + command = payload["command"] + + assert command["name"] == "changelog" + assert command["aliases"] == ["cl"] + subcommands = {item["name"]: item for item in command["subcommands"]} + assert set(subcommands) == {"latest", "list", "show"} + assert subcommands["list"]["usage"] == "/changelog list [数量]" + assert subcommands["show"]["usage"] == "/changelog show <版本号>" + assert subcommands["latest"]["usage"] == "/changelog latest" + + +@pytest.mark.asyncio +async def test_commands_api_lists_webui_available_commands_and_subcommands( + tmp_path: Path, +) -> None: + commands_dir = tmp_path / "commands" + commands_dir.mkdir() + _write_command( + commands_dir, + "public_cmd", + { + "name": "faq", + "description": "FAQ 管理", + "usage": "/faq [ls|view|del]", + "example": "/faq ls", + "permission": "public", + "allow_in_private": True, + "aliases": ["f"], + "order": 10, + "subcommands": { + "ls": {"description": "列出 FAQ"}, + "del": { + "description": "删除 FAQ", + "permission": "admin", + "args": "", + }, + }, + }, + ) + _write_command( + commands_dir, + "group_only", + { + "name": "bugfix", + "description": "群聊修复报告", + "usage": "/bugfix <开始> <结束>", + "permission": "admin", + "allow_in_private": False, + "order": 20, + }, + ) + registry = CommandRegistry(commands_dir) + registry.load_commands() + server = RuntimeAPIServer(_context(registry), host="127.0.0.1", port=8788) + + response = await server._commands_list_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={"scope": "webui"}))) + ) + payload = json.loads(response.text or "{}") + + assert payload["scope"] == "webui" + assert payload["execution_scope"] == "private" + assert payload["sender_id"] == 10001 + assert [item["name"] for item in payload["commands"]] == ["faq"] + command = payload["commands"][0] + assert command["trigger"] == "/faq" + assert command["aliases"] == ["f"] + assert command["alias_triggers"] == ["/f"] + assert command["available"] is True + assert [item["name"] for item in command["subcommands"]] == ["del", "ls"] + delete_subcommand = command["subcommands"][0] + assert delete_subcommand["trigger"] == "/faq del" + assert delete_subcommand["usage"] == "/faq del " + assert delete_subcommand["available"] is True + + +@pytest.mark.asyncio +async def test_command_detail_accepts_alias_and_can_include_unavailable( + tmp_path: Path, +) -> None: + commands_dir = tmp_path / "commands" + commands_dir.mkdir() + _write_command( + commands_dir, + "faq", + { + "name": "faq", + "description": "FAQ 管理", + "usage": "/faq [ls]", + "permission": "public", + "allow_in_private": False, + "aliases": ["f"], + "show_in_help": True, + }, + ) + registry = CommandRegistry(commands_dir) + registry.load_commands() + server = RuntimeAPIServer(_context(registry), host="127.0.0.1", port=8788) + + response = await server._command_detail_handler( + cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={"scope": "webui", "include_unavailable": "true"}, + match_info={"command_name": "f"}, + ), + ), + ) + ) + payload = json.loads(response.text or "{}") + + assert payload["requested_name"] == "f" + assert payload["command"]["name"] == "faq" + assert payload["command"]["available"] is False + assert payload["command"]["unavailable_reason"] == "private_not_allowed" diff --git a/tests/test_runtime_api_probes.py b/tests/test_runtime_api_probes.py index 673a1741..cd9f768e 100644 --- a/tests/test_runtime_api_probes.py +++ b/tests/test_runtime_api_probes.py @@ -147,6 +147,59 @@ async def test_runtime_internal_probe_includes_group_superadmin_queue_snapshot() assert payload["queues"]["totals"]["group_superadmin"] == 3 +@pytest.mark.asyncio +async def test_runtime_internal_probe_includes_scheduler_summary() -> None: + scheduler = SimpleNamespace( + scheduler=SimpleNamespace(running=True), + list_tasks=lambda: { + "task_daily": {"cron": "0 9 * * *"}, + "task_weekly": {"cron": "0 8 * * 1"}, + }, + ) + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + chat_model=SimpleNamespace( + model_name="gpt-5.4", + api_url="https://api.example.com/v1", + api_mode="responses", + thinking_enabled=False, + thinking_tool_call_compat=True, + responses_tool_choice_compat=False, + responses_force_stateless_replay=False, + reasoning_enabled=True, + reasoning_effort="high", + ), + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace(memory_storage=None), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace(), + scheduler=scheduler, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + response = await server._internal_probe_handler( + cast(web.Request, cast(Any, SimpleNamespace())) + ) + response_text = response.text + assert response_text is not None + payload = json.loads(response_text) + + assert payload["scheduler"] == { + "available": True, + "count": 2, + "running": True, + } + + @pytest.mark.asyncio async def test_runtime_internal_probe_includes_all_skill_directory_summaries() -> None: class FakeCommandRegistry: diff --git a/tests/test_runtime_api_schedules.py b/tests/test_runtime_api_schedules.py new file mode 100644 index 00000000..e5995a4a --- /dev/null +++ b/tests/test_runtime_api_schedules.py @@ -0,0 +1,425 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from aiohttp import web + +from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.api.routes.schedules import build_schedules_summary +from Undefined.utils.scheduler import SELF_CALL_TOOL_NAME + + +class _JsonRequest(SimpleNamespace): + async def json(self) -> dict[str, Any]: + return dict(getattr(self, "_json", {})) + + +class _FakeJob: + def __init__(self) -> None: + self.next_run_time = datetime(2026, 6, 7, 9, 0, tzinfo=timezone.utc) + + +class _FakeApscheduler: + def __init__(self) -> None: + self.running = True + + def get_job(self, _task_id: str) -> _FakeJob: + return _FakeJob() + + +class _FakeScheduler: + def __init__(self) -> None: + self.scheduler = _FakeApscheduler() + self.tasks: dict[str, dict[str, Any]] = {} + self.add_calls: list[dict[str, Any]] = [] + self.update_calls: list[dict[str, Any]] = [] + self.remove_calls: list[str] = [] + + def list_tasks(self) -> dict[str, dict[str, Any]]: + return self.tasks + + async def add_task(self, **kwargs: Any) -> bool: + self.add_calls.append(dict(kwargs)) + task_id = str(kwargs["task_id"]) + self.tasks[task_id] = { + "task_id": task_id, + "task_name": kwargs.get("task_name") or "", + "tool_name": kwargs["tool_name"], + "tool_args": kwargs["tool_args"], + "cron": kwargs["cron_expression"], + "target_id": kwargs.get("target_id"), + "target_type": kwargs.get("target_type"), + "max_executions": kwargs.get("max_executions"), + "tools": kwargs.get("tools"), + "execution_mode": kwargs.get("execution_mode"), + "self_instruction": kwargs.get("self_instruction"), + } + return True + + async def update_task(self, **kwargs: Any) -> bool: + self.update_calls.append(dict(kwargs)) + task_id = str(kwargs["task_id"]) + task = self.tasks[task_id] + if kwargs.get("cron_expression") is not None: + task["cron"] = kwargs["cron_expression"] + if kwargs.get("target_id_provided"): + task["target_id"] = kwargs.get("target_id") + if kwargs.get("target_type") is not None: + task["target_type"] = kwargs.get("target_type") + if kwargs.get("max_executions_provided"): + task["max_executions"] = kwargs.get("max_executions") + if kwargs.get("tool_name") is not None: + task["tool_name"] = kwargs.get("tool_name") + task["tool_args"] = kwargs.get("tool_args", {}) + elif kwargs.get("tool_args") is not None: + task["tool_args"] = kwargs.get("tool_args") + return True + + async def remove_task(self, task_id: str) -> bool: + self.remove_calls.append(task_id) + self.tasks.pop(task_id, None) + return True + + +def _context(scheduler: Any | None) -> RuntimeAPIContext: + return RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ) + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace(memory_storage=None), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=SimpleNamespace(), + scheduler=scheduler, + ) + + +def _payload(response: web.Response) -> dict[str, Any]: + assert response.text is not None + return cast(dict[str, Any], json.loads(response.text)) + + +def test_build_schedules_summary_includes_running_when_unavailable() -> None: + assert build_schedules_summary(_context(None)) == { + "available": False, + "count": 0, + "running": False, + } + + +def test_build_schedules_summary_includes_running_when_list_tasks_missing() -> None: + context = _context(SimpleNamespace(scheduler=SimpleNamespace(running=True))) + + assert build_schedules_summary(context) == { + "available": False, + "count": 0, + "running": False, + } + + +@pytest.mark.asyncio +async def test_runtime_schedule_list_returns_items_with_next_run_time() -> None: + scheduler = _FakeScheduler() + scheduler.tasks["task_daily"] = { + "task_id": "task_daily", + "task_name": "daily", + "tool_name": "get_current_time", + "tool_args": {}, + "cron": "0 9 * * *", + "target_id": 10001, + "target_type": "group", + } + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + + response = await server._schedules_list_handler( + cast(web.Request, cast(Any, SimpleNamespace())) + ) + payload = _payload(response) + + assert payload["count"] == 1 + item = cast(list[dict[str, Any]], payload["items"])[0] + assert item["task_id"] == "task_daily" + assert item["mode"] == "single" + assert item["next_run_time"] == "2026-06-07T09:00:00+00:00" + + +@pytest.mark.asyncio +async def test_runtime_schedule_list_preserves_single_item_multi_mode() -> None: + scheduler = _FakeScheduler() + scheduler.tasks["task_multi_one"] = { + "task_id": "task_multi_one", + "task_name": "single item multi", + "tool_name": "get_current_time", + "tool_args": {}, + "tools": [{"tool_name": "get_current_time", "tool_args": {}}], + "execution_mode": "serial", + "cron": "0 9 * * *", + "target_id": None, + "target_type": "group", + } + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + + response = await server._schedules_list_handler( + cast(web.Request, cast(Any, SimpleNamespace())) + ) + payload = _payload(response) + + item = cast(list[dict[str, Any]], payload["items"])[0] + assert item["mode"] == "multi" + assert item["tools"] == [{"tool_name": "get_current_time", "tool_args": {}}] + + +@pytest.mark.asyncio +async def test_runtime_schedule_list_marks_single_self_tool_as_self_instruction() -> ( + None +): + scheduler = _FakeScheduler() + scheduler.tasks["task_self_tool"] = { + "task_id": "task_self_tool", + "task_name": "single self tool", + "tool_name": SELF_CALL_TOOL_NAME, + "tool_args": {"prompt": "请检查提醒事项。"}, + "tools": [ + { + "tool_name": SELF_CALL_TOOL_NAME, + "tool_args": {"prompt": "请检查提醒事项。"}, + } + ], + "execution_mode": "serial", + "cron": "0 9 * * *", + "target_id": None, + "target_type": "group", + } + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + + response = await server._schedules_list_handler( + cast(web.Request, cast(Any, SimpleNamespace())) + ) + payload = _payload(response) + + item = cast(list[dict[str, Any]], payload["items"])[0] + assert item["mode"] == "self_instruction" + assert item["self_instruction"] == "请检查提醒事项。" + + +@pytest.mark.asyncio +async def test_runtime_schedule_create_supports_self_instruction() -> None: + scheduler = _FakeScheduler() + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + request = _JsonRequest( + _json={ + "task_id": "task_self", + "task_name": "future self", + "cron_expression": "0 9 * * *", + "mode": "self_instruction", + "self_instruction": "请总结昨天的待办。", + "target_type": "private", + "target_id": 12345, + "max_executions": 1, + } + ) + + response = await server._schedules_create_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _payload(response) + + assert response.status == 201 + assert payload["ok"] is True + assert payload["task"]["mode"] == "self_instruction" + assert payload["task"]["tool_name"] == SELF_CALL_TOOL_NAME + assert payload["task"]["self_instruction"] == "请总结昨天的待办。" + add_call = scheduler.add_calls[0] + assert add_call["tool_args"] == {"prompt": "请总结昨天的待办。"} + assert add_call["execution_mode"] == "serial" + + +@pytest.mark.asyncio +async def test_runtime_schedule_create_rejects_invalid_cron() -> None: + scheduler = _FakeScheduler() + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + request = _JsonRequest( + _json={ + "cron_expression": "not a cron", + "mode": "single", + "tool_name": "get_current_time", + } + ) + + response = await server._schedules_create_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _payload(response) + + assert response.status == 400 + assert payload["error"] == "cron_expression is invalid" + assert scheduler.add_calls == [] + + +@pytest.mark.asyncio +async def test_runtime_schedule_create_rejects_conflicting_mode_fields() -> None: + scheduler = _FakeScheduler() + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + request = _JsonRequest( + _json={ + "cron_expression": "0 9 * * *", + "mode": "single", + "tool_name": "get_current_time", + "self_instruction": "冲突字段", + } + ) + + response = await server._schedules_create_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _payload(response) + + assert response.status == 400 + assert "mode conflicts" in str(payload["error"]) + assert scheduler.add_calls == [] + + +@pytest.mark.asyncio +async def test_runtime_schedule_create_rejects_explicit_empty_task_id() -> None: + scheduler = _FakeScheduler() + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + request = _JsonRequest( + _json={ + "task_id": "", + "cron_expression": "0 9 * * *", + "mode": "single", + "tool_name": "get_current_time", + } + ) + + response = await server._schedules_create_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _payload(response) + + assert response.status == 400 + assert payload["error"] == "task_id is required" + assert scheduler.add_calls == [] + + +@pytest.mark.asyncio +async def test_runtime_schedule_update_can_clear_target_and_max_runs() -> None: + scheduler = _FakeScheduler() + scheduler.tasks["task_daily"] = { + "task_id": "task_daily", + "tool_name": "get_current_time", + "tool_args": {}, + "cron": "0 9 * * *", + "target_id": 10001, + "target_type": "group", + "max_executions": 3, + } + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + request = _JsonRequest( + _json={"target_id": None, "max_executions": None, "target_type": "private"}, + match_info={"task_id": "task_daily"}, + ) + + response = await server._schedule_update_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _payload(response) + + assert payload["ok"] is True + update_call = scheduler.update_calls[0] + assert update_call["target_id"] is None + assert update_call["target_id_provided"] is True + assert update_call["max_executions"] is None + assert update_call["max_executions_provided"] is True + assert payload["task"]["target_id"] is None + assert payload["task"]["max_executions"] is None + assert payload["task"]["target_type"] == "private" + + +@pytest.mark.asyncio +async def test_runtime_schedule_update_can_patch_single_tool_args_alone() -> None: + scheduler = _FakeScheduler() + scheduler.tasks["task_daily"] = { + "task_id": "task_daily", + "tool_name": "messages.send_message", + "tool_args": {"message": "旧内容"}, + "cron": "0 9 * * *", + "target_id": 10001, + "target_type": "group", + } + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + request = _JsonRequest( + _json={"tool_args": {"message": "新内容"}}, + match_info={"task_id": "task_daily"}, + ) + + response = await server._schedule_update_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _payload(response) + + assert response.status == 200 + assert payload["ok"] is True + update_call = scheduler.update_calls[0] + assert update_call["tool_name"] is None + assert update_call["tool_args"] == {"message": "新内容"} + assert payload["task"]["tool_name"] == "messages.send_message" + assert payload["task"]["tool_args"] == {"message": "新内容"} + + +@pytest.mark.asyncio +async def test_runtime_schedule_update_accepts_existing_legacy_unicode_task_id() -> ( + None +): + scheduler = _FakeScheduler() + task_id = "task_每天早上8点发一张表情包_8d18" + scheduler.tasks[task_id] = { + "task_id": task_id, + "task_name": "旧任务", + "tool_name": "get_current_time", + "tool_args": {}, + "cron": "0 8 * * *", + "target_id": 1067860266, + "target_type": "group", + } + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + request = _JsonRequest( + _json={"task_name": "每天早上8点发一张表情包"}, + match_info={"task_id": task_id}, + ) + + response = await server._schedule_update_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _payload(response) + + assert response.status == 200 + assert payload["ok"] is True + assert scheduler.update_calls[0]["task_id"] == task_id + + +@pytest.mark.asyncio +async def test_runtime_schedule_delete_missing_returns_404() -> None: + scheduler = _FakeScheduler() + server = RuntimeAPIServer(_context(scheduler), host="127.0.0.1", port=8788) + request = SimpleNamespace(match_info={"task_id": "missing_task"}) + + response = await server._schedule_delete_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _payload(response) + + assert response.status == 404 + assert payload["error"] == "Schedule task not found" + assert scheduler.remove_calls == [] diff --git a/tests/test_runtime_api_webchat_output.py b/tests/test_runtime_api_webchat_output.py new file mode 100644 index 00000000..5c2b102d --- /dev/null +++ b/tests/test_runtime_api_webchat_output.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import base64 +from pathlib import Path + +import pytest + +from Undefined.api.routes.chat import _normalize_webchat_output +from Undefined.attachments import AttachmentRegistry + +# 最小合法 PNG +_PNG_BYTES = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00" + b"\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc``\x00\x00\x00\x02\x00\x01" + b"\x0b\xe7\x02\x9d" + b"\x00\x00\x00\x00IEND\xaeB`\x82" +) + +_SCOPE = "webui:private:42" + + +def _registry(tmp_path: Path) -> AttachmentRegistry: + return AttachmentRegistry( + registry_path=tmp_path / "registry.json", + cache_dir=tmp_path / "attachments", + ) + + +@pytest.mark.asyncio +async def test_normalize_webchat_output_registers_base64_image_as_uid( + tmp_path: Path, +) -> None: + """命令输出里的 base64 CQ 图片应注册为附件并替换为 , + + 避免把整段 base64 写入历史 / 喂给后续 LLM(token 爆炸根因)。 + """ + registry = _registry(tmp_path) + encoded = base64.b64encode(_PNG_BYTES).decode("ascii") + content = f"📊 最近 7 天的 Token 使用统计:\n\n[CQ:image,file=base64://{encoded}]" + + text, attachments = await _normalize_webchat_output( + content, + registry=registry, + scope_key=_SCOPE, + resolve_image_url=None, + get_forward_messages=None, + ) + + # 文本不再包含 base64,改为 UID 占位 + assert "base64://" not in text + assert encoded not in text + assert "' in text + assert registry.resolve(uid, _SCOPE) is not None + + +@pytest.mark.asyncio +async def test_normalize_webchat_output_plain_text_unchanged( + tmp_path: Path, +) -> None: + """纯文本输出原样返回,不产生附件。""" + registry = _registry(tmp_path) + text, attachments = await _normalize_webchat_output( + "Undefined v3.5.1 发布说明", + registry=registry, + scope_key=_SCOPE, + resolve_image_url=None, + get_forward_messages=None, + ) + assert text == "Undefined v3.5.1 发布说明" + assert attachments == [] + + +@pytest.mark.asyncio +async def test_normalize_webchat_output_no_registry_passthrough() -> None: + """无注册表 / 作用域时原样返回,不抛错。""" + text, attachments = await _normalize_webchat_output( + "纯文本", + registry=None, + scope_key=None, + resolve_image_url=None, + get_forward_messages=None, + ) + assert text == "纯文本" + assert attachments == [] diff --git a/tests/test_scheduler_self_instruction.py b/tests/test_scheduler_self_instruction.py index 11a22054..9fcb1b64 100644 --- a/tests/test_scheduler_self_instruction.py +++ b/tests/test_scheduler_self_instruction.py @@ -165,3 +165,60 @@ async def _send_message(message: str) -> None: assert ask_call.kwargs["extra_context"]["scheduled_task_id"] == "task_self_abc" assert ask_call.kwargs["extra_context"]["scheduled_task_name"] == "future-review" assert sent_messages == ["未来指令已执行"] + + +@pytest.mark.asyncio +async def test_task_scheduler_update_task_refreshes_job_args() -> None: + ai = SimpleNamespace( + ask=AsyncMock(), + memory_storage=SimpleNamespace(), + runtime_config=SimpleNamespace(), + ) + sender = SimpleNamespace( + send_group_message=AsyncMock(), + send_private_message=AsyncMock(), + ) + onebot = SimpleNamespace( + send_like=AsyncMock(), + get_image=AsyncMock(return_value=None), + get_forward_msg=AsyncMock(return_value=[]), + ) + scheduler = TaskScheduler( + ai, + sender, + onebot, + SimpleNamespace(), + task_storage=cast(Any, _DummyTaskStorage()), + ) + + try: + created = await scheduler.add_task( + task_id="task_edit_args", + tool_name="get_current_time", + tool_args={"format": "iso"}, + cron_expression="0 9 * * *", + target_id=10001, + target_type="group", + ) + updated = await scheduler.update_task( + task_id="task_edit_args", + tool_name="messages.send_message", + tool_args={"message": "updated"}, + target_id=None, + target_id_provided=True, + target_type="private", + ) + job = scheduler.scheduler.get_job("task_edit_args") + finally: + scheduler.scheduler.shutdown(wait=False) + + assert created is True + assert updated is True + assert job is not None + assert list(job.args) == [ + "task_edit_args", + "messages.send_message", + {"message": "updated"}, + None, + "private", + ] diff --git a/tests/test_send_message_tool.py b/tests/test_send_message_tool.py index 74de2253..01bf901f 100644 --- a/tests/test_send_message_tool.py +++ b/tests/test_send_message_tool.py @@ -8,7 +8,10 @@ import pytest from Undefined.attachments import AttachmentRecord, AttachmentRegistry +from Undefined.context import RequestContext from Undefined.skills.toolsets.messages.send_message.handler import execute +from Undefined.utils.coerce import was_message_sent +from Undefined.utils.message_turn import mark_message_sent_this_turn def _build_runtime_config() -> Any: @@ -18,6 +21,10 @@ def _build_runtime_config() -> Any: ) +def _tool_context(**values: Any) -> dict[str, Any]: + return {"mark_message_sent_this_turn": mark_message_sent_this_turn, **values} + + @pytest.mark.asyncio async def test_send_message_private_passes_context_group_as_preferred_temp_group() -> ( None @@ -26,15 +33,15 @@ async def test_send_message_private_passes_context_group_as_preferred_temp_group send_group_message=AsyncMock(), send_private_message=AsyncMock(), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "user_id": 20002, - "sender_id": 20002, - "request_id": "req-1", - "runtime_config": _build_runtime_config(), - "sender": sender, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + user_id=20002, + sender_id=20002, + request_id="req-1", + runtime_config=_build_runtime_config(), + sender=sender, + ) result = await execute( { @@ -60,14 +67,14 @@ async def test_send_message_private_passes_context_group_as_preferred_temp_group @pytest.mark.asyncio async def test_send_message_group_callback_passes_reply_to() -> None: send_message_callback = AsyncMock() - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "request_id": "req-2", - "runtime_config": _build_runtime_config(), - "send_message_callback": send_message_callback, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-2", + runtime_config=_build_runtime_config(), + send_message_callback=send_message_callback, + ) result = await execute( { @@ -85,14 +92,14 @@ async def test_send_message_group_callback_passes_reply_to() -> None: @pytest.mark.asyncio async def test_send_message_private_callback_passes_reply_to() -> None: send_private_message_callback = AsyncMock() - context: dict[str, Any] = { - "request_type": "private", - "user_id": 30003, - "sender_id": 30003, - "request_id": "req-3", - "runtime_config": _build_runtime_config(), - "send_private_message_callback": send_private_message_callback, - } + context: dict[str, Any] = _tool_context( + request_type="private", + user_id=30003, + sender_id=30003, + request_id="req-3", + runtime_config=_build_runtime_config(), + send_private_message_callback=send_private_message_callback, + ) result = await execute( { @@ -109,21 +116,46 @@ async def test_send_message_private_callback_passes_reply_to() -> None: assert context["message_sent_this_turn"] is True +@pytest.mark.asyncio +async def test_send_message_marks_request_context_when_context_is_copied() -> None: + send_message_callback = AsyncMock() + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-request-context", + runtime_config=_build_runtime_config(), + send_message_callback=send_message_callback, + ) + + async with RequestContext( + request_type="group", + group_id=10001, + sender_id=20002, + ) as req_ctx: + result = await execute({"message": "hello"}, dict(context)) + + assert result == "消息已发送" + assert was_message_sent(req_ctx) is True + + assert "message_sent_this_turn" not in context + + @pytest.mark.asyncio async def test_send_message_does_not_implicitly_use_trigger_message_id() -> None: sender = SimpleNamespace( send_group_message=AsyncMock(), send_private_message=AsyncMock(), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "trigger_message_id": 99999, - "request_id": "req-4", - "runtime_config": _build_runtime_config(), - "sender": sender, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + trigger_message_id=99999, + request_id="req-4", + runtime_config=_build_runtime_config(), + sender=sender, + ) result = await execute( { @@ -147,14 +179,14 @@ async def test_send_message_returns_sent_message_id_when_available() -> None: send_group_message=AsyncMock(return_value=77777), send_private_message=AsyncMock(), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "request_id": "req-5", - "runtime_config": _build_runtime_config(), - "sender": sender, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-5", + runtime_config=_build_runtime_config(), + sender=sender, + ) result = await execute( { @@ -183,15 +215,15 @@ async def test_send_message_renders_pic_uid_before_sending(tmp_path: Path) -> No display_name="demo.png", source_kind="test", ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "request_id": "req-6", - "runtime_config": _build_runtime_config(), - "sender": sender, - "attachment_registry": registry, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-6", + runtime_config=_build_runtime_config(), + sender=sender, + attachment_registry=registry, + ) result = await execute( { @@ -236,16 +268,16 @@ async def test_send_message_renders_webui_scoped_pic_uid_before_sending( display_name="webui.png", source_kind="test", ) - context: dict[str, Any] = { - "request_type": "private", - "user_id": 42, - "sender_id": 10001, - "request_id": "req-webui-1", - "runtime_config": _build_runtime_config(), - "sender": sender, - "attachment_registry": registry, - "webui_session": True, - } + context: dict[str, Any] = _tool_context( + request_type="private", + user_id=42, + sender_id=10001, + request_id="req-webui-1", + runtime_config=_build_runtime_config(), + sender=sender, + attachment_registry=registry, + webui_session=True, + ) result = await execute( { @@ -307,15 +339,15 @@ async def test_send_message_passes_meme_attachments_for_global_meme_uid( else None ) ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "request_id": "req-meme-1", - "runtime_config": _build_runtime_config(), - "sender": sender, - "attachment_registry": registry, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-meme-1", + runtime_config=_build_runtime_config(), + sender=sender, + attachment_registry=registry, + ) result = await execute( { diff --git a/tests/test_send_poke_tool.py b/tests/test_send_poke_tool.py index 7d4af4c2..65e5d79b 100644 --- a/tests/test_send_poke_tool.py +++ b/tests/test_send_poke_tool.py @@ -8,6 +8,7 @@ from Undefined.context import RequestContext from Undefined.skills.toolsets.messages.send_poke.handler import execute +from Undefined.utils.message_turn import mark_message_sent_this_turn def _build_runtime_config() -> Any: @@ -18,6 +19,10 @@ def _build_runtime_config() -> Any: ) +def _tool_context(**values: Any) -> dict[str, Any]: + return {"mark_message_sent_this_turn": mark_message_sent_this_turn, **values} + + @pytest.mark.asyncio async def test_send_poke_group_default_target_writes_group_history() -> None: history_manager = SimpleNamespace( @@ -28,16 +33,16 @@ async def test_send_poke_group_default_target_writes_group_history() -> None: send_group_poke=AsyncMock(), send_private_poke=AsyncMock(), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "user_id": 20002, - "sender_id": 20002, - "request_id": "req-1", - "runtime_config": _build_runtime_config(), - "history_manager": history_manager, - "sender": sender, - } + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + user_id=20002, + sender_id=20002, + request_id="req-1", + runtime_config=_build_runtime_config(), + history_manager=history_manager, + sender=sender, + ) result = await execute({}, context) @@ -61,15 +66,15 @@ async def test_send_poke_private_default_target_writes_private_history() -> None add_private_message=AsyncMock(), ) onebot_client = SimpleNamespace(send_private_poke=AsyncMock()) - context: dict[str, Any] = { - "request_type": "private", - "user_id": 30003, - "sender_id": 30003, - "request_id": "req-2", - "runtime_config": _build_runtime_config(), - "history_manager": history_manager, - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="private", + user_id=30003, + sender_id=30003, + request_id="req-2", + runtime_config=_build_runtime_config(), + history_manager=history_manager, + onebot_client=onebot_client, + ) result = await execute({}, context) @@ -92,15 +97,15 @@ async def test_send_poke_explicit_group_and_target_user() -> None: add_private_message=AsyncMock(), ) onebot_client = SimpleNamespace(send_group_poke=AsyncMock()) - context: dict[str, Any] = { - "request_type": "private", - "user_id": 40004, - "sender_id": 40004, - "request_id": "req-3", - "runtime_config": _build_runtime_config(), - "history_manager": history_manager, - "onebot_client": onebot_client, - } + context: dict[str, Any] = _tool_context( + request_type="private", + user_id=40004, + sender_id=40004, + request_id="req-3", + runtime_config=_build_runtime_config(), + history_manager=history_manager, + onebot_client=onebot_client, + ) result = await execute( {"target_type": "group", "target_id": 88888, "target_user_id": 99999}, @@ -123,10 +128,10 @@ async def test_send_poke_infers_from_request_context_when_context_missing() -> N send_group_poke=AsyncMock(), send_private_poke=AsyncMock(), ) - context: dict[str, Any] = { - "sender": sender, - "runtime_config": _build_runtime_config(), - } + context: dict[str, Any] = _tool_context( + sender=sender, + runtime_config=_build_runtime_config(), + ) async with RequestContext( request_type="group", @@ -147,19 +152,19 @@ async def test_send_poke_group_blacklist_message() -> None: send_group_poke=AsyncMock(), send_private_poke=AsyncMock(), ) - context: dict[str, Any] = { - "request_type": "group", - "group_id": 10001, - "sender_id": 20002, - "request_id": "req-blacklist-1", - "runtime_config": SimpleNamespace( + context: dict[str, Any] = _tool_context( + request_type="group", + group_id=10001, + sender_id=20002, + request_id="req-blacklist-1", + runtime_config=SimpleNamespace( bot_qq=123456, is_group_allowed=lambda _gid: False, is_private_allowed=lambda _uid: True, group_access_denied_reason=lambda _gid: "blacklist", ), - "sender": sender, - } + sender=sender, + ) result = await execute({}, context) diff --git a/tests/test_send_private_message_tool.py b/tests/test_send_private_message_tool.py index 0cd79d00..7e67bb44 100644 --- a/tests/test_send_private_message_tool.py +++ b/tests/test_send_private_message_tool.py @@ -6,7 +6,10 @@ import pytest +from Undefined.context import RequestContext from Undefined.skills.toolsets.messages.send_private_message.handler import execute +from Undefined.utils.coerce import was_message_sent +from Undefined.utils.message_turn import mark_message_sent_this_turn def _build_runtime_config() -> Any: @@ -15,15 +18,19 @@ def _build_runtime_config() -> Any: ) +def _tool_context(**values: Any) -> dict[str, Any]: + return {"mark_message_sent_this_turn": mark_message_sent_this_turn, **values} + + @pytest.mark.asyncio async def test_send_private_message_callback_passes_reply_to() -> None: send_private_message_callback = AsyncMock() - context: dict[str, Any] = { - "user_id": 12345, - "request_id": "req-private-1", - "runtime_config": _build_runtime_config(), - "send_private_message_callback": send_private_message_callback, - } + context: dict[str, Any] = _tool_context( + user_id=12345, + request_id="req-private-1", + runtime_config=_build_runtime_config(), + send_private_message_callback=send_private_message_callback, + ) result = await execute( { @@ -40,17 +47,38 @@ async def test_send_private_message_callback_passes_reply_to() -> None: assert context["message_sent_this_turn"] is True +@pytest.mark.asyncio +async def test_send_private_message_marks_request_context_when_context_is_copied() -> ( + None +): + send_private_message_callback = AsyncMock() + context: dict[str, Any] = _tool_context( + user_id=12345, + request_id="req-private-context", + runtime_config=_build_runtime_config(), + send_private_message_callback=send_private_message_callback, + ) + + async with RequestContext(request_type="private", user_id=12345) as req_ctx: + result = await execute({"message": "hello direct private"}, dict(context)) + + assert result == "私聊消息已发送给用户 12345" + assert was_message_sent(req_ctx) is True + + assert "message_sent_this_turn" not in context + + @pytest.mark.asyncio async def test_send_private_message_returns_sent_message_id_when_available() -> None: sender = SimpleNamespace( send_private_message=AsyncMock(return_value=99999), ) - context: dict[str, Any] = { - "user_id": 12345, - "request_id": "req-private-2", - "runtime_config": _build_runtime_config(), - "sender": sender, - } + context: dict[str, Any] = _tool_context( + user_id=12345, + request_id="req-private-2", + runtime_config=_build_runtime_config(), + sender=sender, + ) result = await execute( { diff --git a/tests/test_summary_agent.py b/tests/test_summary_agent.py index 1b4f72cf..b935607e 100644 --- a/tests/test_summary_agent.py +++ b/tests/test_summary_agent.py @@ -7,6 +7,7 @@ import pytest from Undefined.config.models import AgentModelConfig +from Undefined.skills.agents.runner import DEFAULT_AGENT_MAX_ITERATIONS from Undefined.skills.agents.summary_agent.handler import ( _build_user_content, execute as summary_agent_execute, @@ -47,7 +48,7 @@ async def test_summary_agent_normal_execution() -> None: assert "消息总结助手" in call_kwargs["default_prompt"] assert call_kwargs["context"] is context assert isinstance(call_kwargs["agent_dir"], Path) - assert call_kwargs["max_iterations"] == 10 + assert call_kwargs["max_iterations"] == DEFAULT_AGENT_MAX_ITERATIONS assert call_kwargs["tool_error_prefix"] == "错误" diff --git a/tests/test_system_prompt_constraints.py b/tests/test_system_prompt_constraints.py index f81b9393..727b492c 100644 --- a/tests/test_system_prompt_constraints.py +++ b/tests/test_system_prompt_constraints.py @@ -32,11 +32,96 @@ def test_system_prompts_include_info_gate_and_style_constraints(path: Path) -> N def test_naga_prompt_requires_scope_before_naga_analysis() -> None: text = Path("res/prompts/undefined_nagaagent.xml").read_text(encoding="utf-8") + assert '' in text + assert "强制路由规则" in text + assert "必须调用的工具/Agent 名称就是 `naga_code_analysis_agent`" in text + assert ( + "不得凭自身记忆、历史印象、常识、旧上下文或用户提供的片段直接回答 NagaAgent 技术问题" + in text + ) + assert ( + "不要改用 web_agent、file_analysis_agent、undefined_self_code_agent、普通搜索、直接读文件工具或你自己的推测替代" + in text + ) + assert "不要用 undefined_self_code_agent 查 `code/NagaAgent/`" in text + assert "`code/NagaAgent/` 是 NagaAgent 子模块" in text + assert ( + "如果问题同时比较 Undefined 与 NagaAgent:Undefined 侧调用 `undefined_self_code_agent`,NagaAgent 侧调用 `naga_code_analysis_agent`" + in text + ) assert "直接把宽泛问题丢给 naga_code_analysis_agent" in text assert ( "先追问具体模块 / 报错 / 现象;只有范围收窄后再调用 naga_code_analysis_agent" in text ) + assert "待范围收窄后再调用 `naga_code_analysis_agent`" in text + + +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_route_undefined_self_code_questions(path: Path) -> None: + text = path.read_text(encoding="utf-8") + + assert "undefined_self_code_agent" in text + assert ( + "需要查阅 Undefined 自身源码、测试、文档、资源、脚本、配置示例或 App 实现" + in text + ) + assert "undefined_self_code_agent 仅可只读查阅 Undefined 自身代码" in text + assert "不能写代码、执行命令或读取 `code/NagaAgent/`" in text + + +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_define_code_project_routing_matrix(path: Path) -> None: + text = path.read_text(encoding="utf-8") + + required_snippets = [ + "代码/项目问题路由矩阵", + "查 Undefined 当前仓库源码、测试、文档、资源、脚本、配置示例或 App 实现 → undefined_self_code_agent", + "写代码、改代码、执行验证、打包交付 → code_delivery_agent", + "用户上传文件、截图、外部文件或外部代码片段解析 → file_analysis_agent", + "undefined_self_code_agent 只查 Undefined 自身允许范围", + "不包含 `code/NagaAgent/`", + "也不能写代码、改代码或运行命令", + ] + for snippet in required_snippets: + assert snippet in text + + +def test_default_prompt_does_not_force_naga_agent_route() -> None: + text = Path("res/prompts/undefined.xml").read_text(encoding="utf-8") + + assert "必须先调用 naga_code_analysis_agent" not in text + assert ( + "查 NagaAgent 项目或 `code/NagaAgent/` → naga_code_analysis_agent" not in text + ) + + +def test_naga_prompt_routes_naga_code_separately_from_undefined_self_code() -> None: + text = Path("res/prompts/undefined_nagaagent.xml").read_text(encoding="utf-8") + + assert "查 NagaAgent 项目或 `code/NagaAgent/` → naga_code_analysis_agent" in text + assert "naga_code_analysis_agent 只负责 NagaAgent 项目" in text + assert "undefined_self_code_agent 仅可只读查阅 Undefined 自身代码" in text + + +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_describe_webui_markdown_and_html_output(path: Path) -> None: + text = path.read_text(encoding="utf-8") + + required_snippets = [ + "WebUI Markdown 与 HTML 输出", + 'location="WebUI私聊"', + "WebUI 私聊的身份视角固定为系统虚拟用户 system#42", + "权限视角固定为 superadmin", + "WebUI 支持完整 Markdown 渲染", + "简单安全 HTML", + "在 WebUI 会话中,凡是需要输出代码,优先直接在聊天回复里给出", + "复杂 HTML、包含 JS/CSS 的页面、可运行示例或较长代码必须放入 fenced code block", + "所有代码块都必须标明语言或类型", + "完整 HTML 页面优先使用 ```html 代码框输出", + ] + for snippet in required_snippets: + assert snippet in text @pytest.mark.parametrize("path", PROMPT_PATHS) @@ -60,6 +145,16 @@ def test_system_prompts_define_persona_nicknames_and_ownership_bounds( assert "资深开发者" not in text +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_pin_undefined_literal_spelling(path: Path) -> None: + text = path.read_text(encoding="utf-8") + + assert "必须逐字拼写为 Undefined" in text + assert "必须使用字面量 Undefined" in text + assert "公开回复、工具参数、memo、observations" in text + assert "禁止在 observations 中写成 Unfined、Undefind、undefind" in text + + def test_naga_prompt_keeps_relationship_contextual_and_non_claiming() -> None: text = Path("res/prompts/undefined_nagaagent.xml").read_text(encoding="utf-8") @@ -122,13 +217,34 @@ def test_system_prompts_tell_end_to_record_whole_current_input_batch( assert "memo / observations 必须覆盖整个【当前输入批次】" in text assert "不要只根据最后一条消息记录" in text - assert "end.observations 必须覆盖整批消息中值得留存的信息" in text + assert "end.observations 必须覆盖整批消息中有价值的信息" in text + assert "不要求与 bot 相关,也不要求长期稳定" in text + assert "当前批次中有价值即可记录" in text + assert "不能作为 observations 的新事实来源" in text assert "系统会围绕当前输入批次自动检索相关内容" in text assert "何时应该填写 memo" in text assert "何时应该填写 summary" not in text assert "summary 应该是对未来有帮助的信息" not in text +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_encourage_active_memory_lookup(path: Path) -> None: + text = path.read_text(encoding="utf-8") + + required_snippets = [ + "记忆查阅要主动", + "不要凭印象回答;先查看已注入的记忆,必要时主动调用 cognitive.*", + "涉及用户偏好、身份、习惯、长期计划、承诺待办、群规、群氛围", + "优先调用 cognitive.search_events 或 cognitive.get_profile 查证", + "检索词要围绕当前输入批次、目标用户 QQ 号、群号和关键对象组织", + "如果当前问题需要修改、删除或核对 memory.* 置顶备忘,先调用 memory.list", + "不要凭印象编造 UUID 或假设备忘不存在", + "不要机械地每轮都查", + ] + for snippet in required_snippets: + assert snippet in text + + def test_end_tool_schema_mentions_current_input_batch() -> None: schema = json.loads( Path("src/Undefined/skills/tools/end/config.json").read_text(encoding="utf-8") @@ -138,8 +254,14 @@ def test_end_tool_schema_mentions_current_input_batch() -> None: observations = properties["observations"] assert "当前输入批次" in function["description"] + assert "不要求与 bot 相关" in function["description"] + assert "不要求长期稳定" in function["description"] + assert "项目名/主名必须逐字写作 Undefined" in function["description"] assert "必须覆盖整批消息内容" in observations["description"] assert "不能只记录最后一条" in observations["description"] + assert "当前批次中有价值即可记录" in observations["description"] + assert "禁止从其中摘取新事实写入 observations" in observations["description"] + assert "禁止写成 Unfined、Undefind、undefind" in observations["description"] assert "summary" not in properties assert "action_summary" not in properties assert "new_info" not in properties @@ -149,9 +271,23 @@ def test_historian_prompts_reference_current_input_batch_source() -> None: rewrite = Path("res/prompts/historian_rewrite.md").read_text(encoding="utf-8") merge = Path("res/prompts/historian_profile_merge.md").read_text(encoding="utf-8") - assert "当前输入批次提取到的一条新记忆" in rewrite + assert "当前输入批次提取到的一条有价值新观察" in rewrite + assert "最近消息参考只能消歧,禁止作为新事实来源" in rewrite assert "当前输入批次原文(触发本轮;连续消息会按时间顺序列出多条)" in rewrite assert "当前输入批次原文" in merge + assert "禁止作为本轮新事实来源" in merge + + +@pytest.mark.parametrize("path", PROMPT_PATHS) +def test_system_prompts_do_not_treat_you_ai_bot_as_automatic_mention( + path: Path, +) -> None: + text = path.read_text(encoding="utf-8") + + assert "不要先入为主把「你」「AI」「bot」「机器人」当作在叫 Undefined" in text + assert "泛称不是触发词" in text + assert "无法确认指向 Undefined 时默认不回复" in text + assert "「你」「AI」「bot」「机器人」不是自动触发" in text @pytest.mark.parametrize("path", PROMPT_PATHS) diff --git a/tests/test_undefined_self_code_agent.py b/tests/test_undefined_self_code_agent.py new file mode 100644 index 00000000..c96e67df --- /dev/null +++ b/tests/test_undefined_self_code_agent.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from Undefined.skills.agents import AgentRegistry +from Undefined.skills.agents.undefined_self_code_agent.tools.glob import ( + handler as glob_handler, +) +from Undefined.skills.agents.undefined_self_code_agent.tools.list_directory import ( + handler as list_handler, +) +from Undefined.skills.agents.undefined_self_code_agent.tools.read_file import ( + handler as read_handler, +) +from Undefined.skills.agents.undefined_self_code_agent.tools.search_file_content import ( + handler as search_handler, +) +from Undefined.utils import io as async_io + + +AGENT_DIR = ( + Path(__file__).resolve().parent.parent + / "src" + / "Undefined" + / "skills" + / "agents" + / "undefined_self_code_agent" +) + + +async def _make_repo(tmp_path: Path) -> Path: + root = tmp_path / "repo" + (root / "src" / "Undefined").mkdir(parents=True) + (root / "scripts").mkdir() + (root / "tests").mkdir() + (root / "res").mkdir() + (root / "docs").mkdir() + (root / "apps" / "undefined-chat" / "src").mkdir(parents=True) + (root / "data").mkdir() + (root / "logs").mkdir() + (root / "code" / "NagaAgent").mkdir(parents=True) + + await async_io.write_text(root / "pyproject.toml", "[project]\nname='x'\n") + await async_io.write_text(root / "README.md", "# Undefined\n") + await async_io.write_text(root / "CHANGELOG.md", "## Unreleased\n") + await async_io.write_text(root / "ARCHITECTURE.md", "AgentRegistry\n") + await async_io.write_text( + root / "config.toml.example", + "[models.agent]\nmodel_name = 'x'\n", + ) + await async_io.write_text( + root / "src" / "Undefined" / "main.py", + "def run() -> None:\n print('Undefined')\n", + ) + await async_io.write_text( + root / "src" / "Undefined" / ".hidden.py", + "hidden = True\n", + ) + await async_io.write_text(root / "scripts" / "tool.py", "print('tool')\n") + await async_io.write_text( + root / "tests" / "test_main.py", + "def test_main() -> None:\n assert True\n", + ) + await async_io.write_text(root / "res" / "prompt.txt", "prompt\n") + await async_io.write_text(root / "docs" / "usage.md", "usage docs\n") + await async_io.write_text( + root / "apps" / "undefined-chat" / "src" / "App.tsx", + "export const App = () => 'chat';\n", + ) + await async_io.write_text(root / "data" / "secret.txt", "secret\n") + await async_io.write_text(root / "logs" / "run.log", "log\n") + await async_io.write_text(root / "code" / "NagaAgent" / "main.py", "naga\n") + await async_io.write_text(root / ".env", "TOKEN=secret\n") + return root + + +def _context(root: Path) -> dict[str, Any]: + return {"repo_root": root} + + +def test_config_json_schema() -> None: + cfg: dict[str, Any] = json.loads((AGENT_DIR / "config.json").read_text("utf-8")) + function = cfg["function"] + + assert cfg["type"] == "function" + assert function["name"] == "undefined_self_code_agent" + assert function["parameters"]["required"] == ["prompt"] + assert "prompt" in function["parameters"]["properties"] + + +def test_agent_registry_loads_description_from_intro() -> None: + registry = AgentRegistry(AGENT_DIR.parent) + schema = { + item["function"]["name"]: item["function"]["description"] + for item in registry.get_agents_schema() + } + + assert "undefined_self_code_agent" in schema + assert "Undefined 自身代码查阅助手" in schema["undefined_self_code_agent"] + assert "只读查阅" in schema["undefined_self_code_agent"] + assert ( + "`code/NagaAgent/` 是 NagaAgent 子模块" in schema["undefined_self_code_agent"] + ) + + +def test_prompt_and_intro_exclude_naga_submodule() -> None: + prompt = (AGENT_DIR / "prompt.md").read_text("utf-8") + intro = (AGENT_DIR / "intro.md").read_text("utf-8") + + assert "`code/NagaAgent/` 是 NagaAgent 子模块" in prompt + assert "永远不属于 Undefined 自身代码查阅范围" in prompt + assert "`code/NagaAgent/` 是 NagaAgent 子模块" in intro + assert "不属于 Undefined 自身代码查阅范围" in intro + + +@pytest.mark.asyncio +async def test_read_file_allows_allowed_paths(tmp_path: Path) -> None: + root = await _make_repo(tmp_path) + + result = await read_handler.execute( + {"file_path": "src/Undefined/main.py"}, + _context(root), + ) + + assert "=== src/Undefined/main.py" in result + assert "def run() -> None" in result + + +@pytest.mark.asyncio +async def test_read_file_allows_config_example_root_file(tmp_path: Path) -> None: + root = await _make_repo(tmp_path) + + result = await read_handler.execute( + {"file_path": "config.toml.example"}, + _context(root), + ) + + assert "[models.agent]" in result + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path", + [ + "pyproject.toml", + ".env", + "data/secret.txt", + "logs/run.log", + "code/NagaAgent/main.py", + "src/Undefined/.hidden.py", + "../outside.txt", + ], +) +async def test_read_file_rejects_disallowed_paths(tmp_path: Path, path: str) -> None: + root = await _make_repo(tmp_path) + + result = await read_handler.execute({"file_path": path}, _context(root)) + + assert "权限不足" in result + assert "允许目录" in result + + +@pytest.mark.asyncio +async def test_list_directory_root_only_lists_allowed_scope(tmp_path: Path) -> None: + root = await _make_repo(tmp_path) + + result = await list_handler.execute({}, _context(root)) + + assert "📁 src/" in result + assert "📄 README.md" in result + assert "data/" not in result + assert "pyproject.toml" not in result + + +@pytest.mark.asyncio +async def test_glob_only_returns_allowed_files(tmp_path: Path) -> None: + root = await _make_repo(tmp_path) + + result = await glob_handler.execute({"pattern": "**/*.py"}, _context(root)) + + assert "src/Undefined/main.py" in result + assert "scripts/tool.py" in result + assert "tests/test_main.py" in result + assert "code/NagaAgent/main.py" not in result + + +@pytest.mark.asyncio +async def test_glob_handles_allowed_root_files(tmp_path: Path) -> None: + root = await _make_repo(tmp_path) + + result = await glob_handler.execute({"pattern": "*.md"}, _context(root)) + + assert "README.md" in result + assert "CHANGELOG.md" in result + + +@pytest.mark.asyncio +async def test_glob_handles_recursive_pattern_for_allowed_root_files( + tmp_path: Path, +) -> None: + root = await _make_repo(tmp_path) + + result = await glob_handler.execute({"pattern": "**/*.md"}, _context(root)) + + assert "README.md" in result + assert "docs/usage.md" in result + + +@pytest.mark.asyncio +@pytest.mark.parametrize("pattern", ["../*.py", "/tmp/*.py", "src/../*.py"]) +async def test_glob_rejects_traversal_patterns( + tmp_path: Path, + pattern: str, +) -> None: + root = await _make_repo(tmp_path) + + result = await glob_handler.execute({"pattern": pattern}, _context(root)) + + assert "glob 模式无效" in result + + +@pytest.mark.asyncio +async def test_search_only_returns_allowed_files(tmp_path: Path) -> None: + root = await _make_repo(tmp_path) + + result = await search_handler.execute( + {"pattern": "secret", "case_sensitive": False}, + _context(root), + ) + + assert "data/secret.txt" not in result + assert ".env" not in result + assert "未找到匹配" in result + + +@pytest.mark.asyncio +async def test_search_can_find_allowed_content(tmp_path: Path) -> None: + root = await _make_repo(tmp_path) + + result = await search_handler.execute( + {"pattern": "Undefined", "path": "src", "include": "*.py"}, + _context(root), + ) + + assert "src/Undefined/main.py:2:" in result + + +@pytest.mark.asyncio +async def test_binary_file_is_rejected(tmp_path: Path) -> None: + root = await _make_repo(tmp_path) + binary = root / "src" / "Undefined" / "asset.bin" + await async_io.write_bytes(binary, b"\x00\x01\x02") + + result = await read_handler.execute( + {"file_path": "src/Undefined/asset.bin"}, + _context(root), + ) + + assert "不是可读取的文本文件" in result + + +@pytest.mark.asyncio +async def test_read_file_empty_line_window_has_valid_header(tmp_path: Path) -> None: + root = await _make_repo(tmp_path) + empty_path = root / "src" / "Undefined" / "empty.py" + await async_io.write_text(empty_path, "") + + result = await read_handler.execute( + {"file_path": "src/Undefined/empty.py", "offset": 1, "limit": 10}, + _context(root), + ) + + assert "行 0-0/0(空文件)" in result + assert "行 1-0/0" not in result diff --git a/tests/test_webchat_conversations.py b/tests/test_webchat_conversations.py new file mode 100644 index 00000000..5d1d96fc --- /dev/null +++ b/tests/test_webchat_conversations.py @@ -0,0 +1,495 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock + +import pytest +from aiohttp import web + +from Undefined.api import RuntimeAPIContext, RuntimeAPIServer +from Undefined.api.routes import chat as runtime_api_chat +from Undefined.api.webchat_store import ( + DEFAULT_WEBCHAT_CONVERSATION_ID, + WEBCHAT_VIRTUAL_USER_ID, + generate_webchat_title, +) + + +class _JsonRequest(SimpleNamespace): + async def json(self) -> dict[str, object]: + return dict(getattr(self, "_json", {})) + + +class _History: + def __init__(self) -> None: + self.records: list[dict[str, Any]] = [ + {"display_name": "system", "message": "旧问题是什么", "timestamp": "1"}, + {"display_name": "Bot", "message": "旧答案是这个", "timestamp": "2"}, + ] + + def get_recent_private(self, user_id: int, count: int) -> list[dict[str, Any]]: + _ = user_id + return self.records[-count:] + + +def _context(history: Any | None = None) -> RuntimeAPIContext: + return RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace( + connection_status=lambda: {}, + get_image=AsyncMock(return_value=None), + get_forward_msg=AsyncMock(return_value=[]), + ), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(parse_command=lambda _text: None), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history or _History(), + ) + + +@pytest.mark.asyncio +async def test_webchat_runtime_has_detailed_flow_logs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.chdir(tmp_path) + + async def _fake_ask( + *_args: Any, + send_message_callback: Any, + **_kwargs: Any, + ) -> str: + await send_message_callback("AI 已处理") + return "" + + async def _fake_generate_title(_ai: Any, question: str, answer: str) -> str: + _ = question, answer + return "生成标题" + + ai = SimpleNamespace( + ask=_fake_ask, + attachment_registry=None, + memory_storage=SimpleNamespace(count=lambda: 0), + runtime_config=SimpleNamespace(), + ) + context = _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])) + context.ai = ai + monkeypatch.setattr( + runtime_api_chat, "generate_webchat_title", _fake_generate_title + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + + with caplog.at_level(logging.INFO): + create_response = await server._chat_job_create_handler( + cast( + web.Request, + cast(Any, _JsonRequest(query={}, _json={"message": "请回答"})), + ) + ) + create_payload = json.loads(create_response.text or "{}") + job_id = str(create_payload["job_id"]) + job = await server._chat_job_manager.get_job(job_id) + assert job is not None + await job.done.wait() + + events_response = await server._chat_job_events_handler( + cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={}, + headers={}, + match_info={"job_id": job_id}, + ), + ), + ) + ) + assert events_response.status == 200 + title_task = server._chat_job_manager.conversation_store._title_tasks[ + job.conversation_id + ] + await title_task + + log_text = caplog.text + assert "[RuntimeAPI][WebChat] 创建 job" in log_text + assert "[RuntimeAPI][WebChat] job 开始" in log_text + assert "[RuntimeAPI][WebChat] 输入附件注册完成" in log_text + assert "[RuntimeAPI][WebChat] 调用 AI" in log_text + assert "[RuntimeAPI][WebChat] job 历史落盘" in log_text + assert "[RuntimeAPI][WebChat] 调度标题生成" in log_text + assert "[RuntimeAPI][WebChat] 标题生成完成" in log_text + assert "[WebChat] 会话存储加载完成" in log_text + assert "[WebChat] 追加消息" in log_text + + +@pytest.mark.asyncio +async def test_webchat_legacy_history_migrates_once_and_delete_does_not_remigrate( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + request = cast(web.Request, cast(Any, SimpleNamespace(query={}))) + + first = await server._chat_conversations_handler(request) + payload = json.loads(first.text or "{}") + assert [item["id"] for item in payload["conversations"]] == [ + DEFAULT_WEBCHAT_CONVERSATION_ID + ] + assert payload["conversations"][0]["title"].startswith("旧问题") + + delete = await server._chat_conversation_delete_handler( + cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={}, + match_info={"conversation_id": DEFAULT_WEBCHAT_CONVERSATION_ID}, + ), + ), + ) + ) + assert delete.status == 200 + + second = await server._chat_conversations_handler(request) + payload = json.loads(second.text or "{}") + assert payload["conversations"] == [] + assert (tmp_path / "data" / "webchat" / "legacy_private_42_migrated.json").exists() + + +@pytest.mark.asyncio +async def test_webchat_title_generation_uses_first_question_and_answer( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + context = _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])) + captured: dict[str, str] = {} + + async def _fake_generate_title(_ai: Any, question: str, answer: str) -> str: + captured["question"] = question + captured["answer"] = answer + return "首问首答标题" + + monkeypatch.setattr( + runtime_api_chat, "generate_webchat_title", _fake_generate_title + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + create_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + conversation = json.loads(create_response.text or "{}")["conversation"] + conversation_id = str(conversation["id"]) + + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="user", + text_content="请解释缓存命中", + display_name="system", + user_name="system", + ) + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="bot", + text_content="缓存命中依赖稳定前缀。", + display_name="Bot", + user_name="Bot", + ) + await server._chat_job_manager.maybe_schedule_title_generation(conversation_id) + task = server._chat_job_manager.conversation_store._title_tasks[conversation_id] + await task + + updated = await server._chat_job_manager.conversation_store.get_conversation( + conversation_id + ) + assert captured == { + "question": "请解释缓存命中", + "answer": "缓存命中依赖稳定前缀。", + } + assert updated is not None + assert updated["title"] == "首问首答标题" + assert updated["title_status"] == "generated" + + +@pytest.mark.asyncio +async def test_webchat_title_generation_concurrent_schedule_starts_once( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + context = _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])) + started = asyncio.Event() + release = asyncio.Event() + calls = 0 + + async def _fake_generate_title(_ai: Any, question: str, answer: str) -> str: + nonlocal calls + assert question == "并发问题" + assert answer == "并发回答" + calls += 1 + started.set() + await release.wait() + return "并发标题" + + monkeypatch.setattr( + runtime_api_chat, "generate_webchat_title", _fake_generate_title + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + create_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + conversation_id = str( + json.loads(create_response.text or "{}")["conversation"]["id"] + ) + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="user", + text_content="并发问题", + display_name="system", + user_name="system", + ) + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="bot", + text_content="并发回答", + display_name="Bot", + user_name="Bot", + ) + + await asyncio.gather( + server._chat_job_manager.maybe_schedule_title_generation(conversation_id), + server._chat_job_manager.maybe_schedule_title_generation(conversation_id), + ) + await asyncio.wait_for(started.wait(), timeout=1) + assert calls == 1 + assert len(server._chat_job_manager.conversation_store._title_tasks) == 1 + + release.set() + task = server._chat_job_manager.conversation_store._title_tasks[conversation_id] + await task + updated = await server._chat_job_manager.conversation_store.get_conversation( + conversation_id + ) + + assert updated is not None + assert updated["title"] == "并发标题" + assert updated["title_status"] == "generated" + assert calls == 1 + + +@pytest.mark.asyncio +async def test_webchat_title_generation_uses_chat_model_not_summary_model() -> None: + chat_config = SimpleNamespace(model_name="chat-model") + selected_config = SimpleNamespace(model_name="selected-chat-model") + captured: dict[str, Any] = {} + + def _select_chat_config( + primary: Any, + *, + group_id: int, + user_id: int, + global_enabled: bool, + ) -> Any: + captured["primary"] = primary + captured["group_id"] = group_id + captured["user_id"] = user_id + captured["global_enabled"] = global_enabled + return selected_config + + async def _submit_background_llm_call(**kwargs: Any) -> dict[str, Any]: + captured["submit_kwargs"] = kwargs + return {"choices": [{"message": {"content": " 聊天模型标题 "}}]} + + def _summary_resolver() -> Any: + raise AssertionError("summary model resolver should not be used") + + ai = SimpleNamespace( + chat_config=chat_config, + runtime_config=SimpleNamespace(model_pool_enabled=True), + model_selector=SimpleNamespace(select_chat_config=_select_chat_config), + submit_background_llm_call=_submit_background_llm_call, + _resolve_summary_model_for_requests=_summary_resolver, + ) + + title = await generate_webchat_title(ai, "首问", "首答") + + assert title == "聊天模型标题" + assert captured["primary"] is chat_config + assert captured["group_id"] == 0 + assert captured["user_id"] == WEBCHAT_VIRTUAL_USER_ID + assert captured["global_enabled"] is True + submit_kwargs = captured["submit_kwargs"] + assert submit_kwargs["model_config"] is selected_config + assert submit_kwargs["call_type"] == "webchat_title" + assert "max_tokens" not in submit_kwargs + + +@pytest.mark.asyncio +async def test_webchat_manual_title_blocks_generated_overwrite( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + server = RuntimeAPIServer( + _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])), + host="127.0.0.1", + port=8788, + ) + create_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + conversation_id = str( + json.loads(create_response.text or "{}")["conversation"]["id"] + ) + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="user", + text_content="第一个问题", + display_name="system", + user_name="system", + ) + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="bot", + text_content="第一个回答", + display_name="Bot", + user_name="Bot", + ) + await server._chat_job_manager.conversation_store.rename_conversation( + conversation_id, + "手动标题", + ) + + await server._chat_job_manager.maybe_schedule_title_generation(conversation_id) + updated = await server._chat_job_manager.conversation_store.get_conversation( + conversation_id + ) + + assert updated is not None + assert updated["title"] == "手动标题" + assert updated["title_status"] == "manual" + + +@pytest.mark.asyncio +async def test_webchat_history_isolated_by_conversation_id( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + server = RuntimeAPIServer( + _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])), + host="127.0.0.1", + port=8788, + ) + first_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + second_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + first_id = str(json.loads(first_response.text or "{}")["conversation"]["id"]) + second_id = str(json.loads(second_response.text or "{}")["conversation"]["id"]) + + await server._chat_job_manager.conversation_store.append_message( + first_id, + role="user", + text_content="第一会话消息", + display_name="system", + user_name="system", + ) + await server._chat_job_manager.conversation_store.append_message( + second_id, + role="user", + text_content="第二会话消息", + display_name="system", + user_name="system", + ) + + first_history = await server._chat_history_handler( + cast( + web.Request, + cast(Any, SimpleNamespace(query={"conversation_id": first_id})), + ) + ) + first_payload = json.loads(first_history.text or "{}") + second_history = await server._chat_history_handler( + cast( + web.Request, + cast(Any, SimpleNamespace(query={"conversation_id": second_id})), + ) + ) + second_payload = json.loads(second_history.text or "{}") + + assert first_payload["conversation_id"] == first_id + assert [item["content"] for item in first_payload["items"]] == ["第一会话消息"] + assert second_payload["conversation_id"] == second_id + assert [item["content"] for item in second_payload["items"]] == ["第二会话消息"] + + +@pytest.mark.asyncio +async def test_webchat_delete_and_clear_reject_while_job_running( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + server = RuntimeAPIServer( + _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])), + host="127.0.0.1", + port=8788, + ) + create_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + conversation_id = str( + json.loads(create_response.text or "{}")["conversation"]["id"] + ) + job = await server._chat_job_manager.create_job("hello", conversation_id) + job.task = AsyncMock() + + delete_response = await server._chat_conversation_delete_handler( + cast( + web.Request, + cast( + Any, + SimpleNamespace( + query={}, match_info={"conversation_id": conversation_id} + ), + ), + ) + ) + clear_response = await server._chat_history_clear_handler( + cast( + web.Request, + cast(Any, SimpleNamespace(query={"conversation_id": conversation_id})), + ) + ) + + assert delete_response.status == 409 + assert clear_response.status == 409 + assert ( + await server._chat_job_manager.conversation_store.get_conversation( + conversation_id + ) + is not None + ) diff --git a/tests/test_webui_autostart.py b/tests/test_webui_autostart.py new file mode 100644 index 00000000..2706ec47 --- /dev/null +++ b/tests/test_webui_autostart.py @@ -0,0 +1,172 @@ +"""Tests for WebUI bot autostart functionality. + +This module tests the on_startup hook to ensure proper autostart behavior +based on the autostart_bot configuration and pending_bot_autostart marker. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiohttp import web + +from Undefined.config.webui_settings import WebUISettings +from Undefined.webui.app import on_startup +from Undefined.webui.routes._shared import BOT_APP_KEY, SETTINGS_APP_KEY + + +def _make_app(bot: MagicMock, settings: WebUISettings) -> web.Application: + """构造仅含 bot 与 settings 的伪 app(on_startup 只做 app[KEY] 访问)。""" + return cast( + web.Application, + {BOT_APP_KEY: bot, SETTINGS_APP_KEY: settings}, + ) + + +def _make_bot() -> MagicMock: + """创建 mock BotProcessController。""" + bot = MagicMock() + bot.start = AsyncMock() + bot.status = MagicMock(return_value={"running": False}) + return bot + + +def _make_settings(*, autostart_bot: bool) -> WebUISettings: + """创建指定 autostart_bot 的配置。""" + return WebUISettings( + url="127.0.0.1", + port=8787, + password="test", + autostart_bot=autostart_bot, + using_default_password=False, + config_exists=True, + ) + + +@pytest.fixture(autouse=True) +def _patch_config_manager(monkeypatch: pytest.MonkeyPatch) -> None: + """统一 mock 配置管理器,避免真实热重载副作用。""" + manager = MagicMock() + manager.start_hot_reload = MagicMock() + monkeypatch.setattr("Undefined.webui.app.get_config_manager", lambda: manager) + + +@pytest.mark.asyncio +async def test_on_startup_with_autostart_enabled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """测试 autostart_bot=true 时调用 bot.start()。""" + bot = _make_bot() + app = _make_app(bot, _make_settings(autostart_bot=True)) + + # 确保 pending_bot_autostart marker 不存在 + marker_path = tmp_path / "pending_bot_autostart" + monkeypatch.setattr("Undefined.webui.app.Path", lambda _: marker_path) + + await on_startup(app) + + bot.start.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_on_startup_with_autostart_disabled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """测试 autostart_bot=false 时不调用 bot.start()。""" + bot = _make_bot() + app = _make_app(bot, _make_settings(autostart_bot=False)) + + # 确保 pending_bot_autostart marker 不存在 + marker_path = tmp_path / "pending_bot_autostart" + monkeypatch.setattr("Undefined.webui.app.Path", lambda _: marker_path) + + await on_startup(app) + + bot.start.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_startup_recovery_marker_takes_priority( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """测试 pending_bot_autostart marker 优先于 autostart_bot 配置。""" + bot = _make_bot() + # 即使 autostart_bot=False,存在 marker 也应启动 bot + app = _make_app(bot, _make_settings(autostart_bot=False)) + + # 创建 pending_bot_autostart marker 文件 + marker_path = tmp_path / "data" / "cache" / "pending_bot_autostart" + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.touch() + + original_path = Path + + def mock_path_factory(path_str: str) -> Path: + if path_str == "data/cache/pending_bot_autostart": + return marker_path + return original_path(path_str) + + monkeypatch.setattr("Undefined.webui.app.Path", mock_path_factory) + + await on_startup(app) + + # 1. bot.start() 被调用(通过自动恢复标记) + bot.start.assert_awaited_once() + # 2. marker 文件被删除 + assert not marker_path.exists() + + +@pytest.mark.asyncio +async def test_on_startup_marker_does_not_double_start_with_autostart( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """测试 marker 命中后即 return,不会因 autostart 再次启动(仅启动一次)。""" + bot = _make_bot() + # marker 与 autostart 同时存在,应只启动一次 + app = _make_app(bot, _make_settings(autostart_bot=True)) + + marker_path = tmp_path / "data" / "cache" / "pending_bot_autostart" + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.touch() + + original_path = Path + + def mock_path_factory(path_str: str) -> Path: + if path_str == "data/cache/pending_bot_autostart": + return marker_path + return original_path(path_str) + + monkeypatch.setattr("Undefined.webui.app.Path", mock_path_factory) + + await on_startup(app) + + # 只启动一次(marker 分支命中后 return) + bot.start.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_on_startup_autostart_failure_does_not_block_webui( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """测试自动启动失败不会阻塞 WebUI 启动。""" + bot = _make_bot() + bot.start.side_effect = RuntimeError("Bot start failed") + app = _make_app(bot, _make_settings(autostart_bot=True)) + + # 确保 pending_bot_autostart marker 不存在 + marker_path = tmp_path / "pending_bot_autostart" + monkeypatch.setattr("Undefined.webui.app.Path", lambda _: marker_path) + + # 执行启动钩子(不应抛出异常) + await on_startup(app) + + # 验证 bot.start() 被调用(虽然失败了) + bot.start.assert_awaited_once() diff --git a/tests/test_webui_management_api.py b/tests/test_webui_management_api.py index 2b82f168..d4be70e6 100644 --- a/tests/test_webui_management_api.py +++ b/tests/test_webui_management_api.py @@ -1,23 +1,35 @@ from __future__ import annotations import json +from pathlib import Path from types import SimpleNamespace from typing import Any, cast from aiohttp import web +from aiohttp.test_utils import make_mocked_request from Undefined.api import _helpers as runtime_api_helpers from Undefined.changelog import ChangelogEntry from Undefined.webui import app as webui_app from Undefined.webui.app import create_app from Undefined.webui.core import SessionStore -from Undefined.webui.routes import _auth, _config, _index, _memes, _shared, _system +from Undefined.webui.routes import ( + _auth, + _config, + _index, + _logs, + _memes, + _runtime, + _shared, + _system, +) from Undefined.webui.routes._shared import ( REDIRECT_TO_CONFIG_ONCE_APP_KEY, SESSION_COOKIE, SESSION_STORE_APP_KEY, SETTINGS_APP_KEY, ) +from Undefined.utils.paths import WEBUI_FILE_CACHE_DIR class DummyRequest(SimpleNamespace): @@ -25,6 +37,33 @@ async def json(self) -> dict[str, object]: return dict(getattr(self, "_json", {})) +class DummyMultipartField: + def __init__(self, chunks: list[bytes], *, filename: str = "file.bin") -> None: + self.name = "file" + self.filename = filename + self._chunks = list(chunks) + + async def read_chunk(self) -> bytes: + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +class DummyMultipartRequest(DummyRequest): + def __init__(self, field: DummyMultipartField | None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._field = field + + async def multipart(self) -> object: + field = self._field + + class _Reader: + async def next(self) -> DummyMultipartField | None: + return field + + return _Reader() + + def _request( *, json_body: dict[str, object] | None = None, @@ -344,8 +383,52 @@ def test_create_app_registers_management_routes() -> None: assert ("GET", "/api/v1/management/probes/bootstrap") in routes assert ("GET", "/api/v1/management/changelog") in routes assert ("GET", "/api/v1/management/runtime/meta") in routes + assert ("GET", "/api/v1/management/runtime/schedules") in routes + assert ("POST", "/api/v1/management/runtime/schedules") in routes + assert ("GET", "/api/v1/management/runtime/schedules/{task_id}") in routes + assert ("PATCH", "/api/v1/management/runtime/schedules/{task_id}") in routes + assert ("DELETE", "/api/v1/management/runtime/schedules/{task_id}") in routes assert ("POST", "/api/v1/management/config/validate") in routes assert ("POST", "/api/v1/management/bot/start") in routes + assert ("POST", "/api/v1/management/runtime/chat/jobs") in routes + assert ("GET", "/api/v1/management/runtime/chat/jobs/active") in routes + assert ("GET", "/api/v1/management/runtime/chat/jobs/{job_id}") in routes + assert ("GET", "/api/v1/management/runtime/chat/jobs/{job_id}/events") in routes + assert ("POST", "/api/v1/management/runtime/chat/jobs/{job_id}/cancel") in routes + assert ("DELETE", "/api/v1/management/runtime/chat/history") in routes + assert ( + "GET", + "/api/v1/management/runtime/chat/attachments/capabilities", + ) in routes + assert ("POST", "/api/v1/management/runtime/chat/attachments") in routes + assert ( + "GET", + "/api/v1/management/runtime/chat/attachments/{attachment_id}", + ) in routes + assert ( + "GET", + "/api/v1/management/runtime/chat/attachments/{attachment_id}/preview", + ) in routes + assert ("POST", "/api/v1/management/runtime/chat/files") in routes + + +def test_management_logs_line_limit_clamps_to_larger_cap() -> None: + assert ( + _logs._parse_log_lines(cast(web.Request, cast(Any, _request()))) + == _logs.DEFAULT_LOG_TAIL_LINES + ) + assert ( + _logs._parse_log_lines( + cast(web.Request, cast(Any, _request(query={"lines": "50000"}))) + ) + == _logs.MAX_LOG_TAIL_LINES + ) + assert ( + _logs._parse_log_lines( + cast(web.Request, cast(Any, _request(query={"lines": "bad"}))) + ) + == _logs.DEFAULT_LOG_TAIL_LINES + ) async def test_index_handler_applies_launcher_mode_and_initial_view() -> None: @@ -387,6 +470,76 @@ async def test_index_handler_renders_mobile_shell_and_action_toggles() -> None: assert 'id="logsMobileActionsToggle"' in payload_text +async def test_runtime_chat_file_upload_handler_caches_authenticated_file( + monkeypatch: Any, tmp_path: Path +) -> None: + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + monkeypatch.chdir(tmp_path) + field = DummyMultipartField([b"hello", b" world"], filename="../note.txt") + request = DummyMultipartRequest( + field, + headers={}, + cookies={}, + query={}, + app={}, + remote="127.0.0.1", + scheme="http", + host="127.0.0.1:8787", + transport=None, + ) + + response = await _runtime.runtime_chat_file_upload_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _json_payload(response) + + assert cast(web.Response, response).status == 200 + assert isinstance(payload["id"], str) + assert str(payload["id"]).isalnum() + assert payload["name"] == "note.txt" + assert payload["size"] == 11 + cached_dir = tmp_path / WEBUI_FILE_CACHE_DIR / str(payload["id"]) + cached_files = list(cached_dir.iterdir()) + assert len(cached_files) == 1 + cached_file = cached_files[0] + assert cached_file.name != "note.txt" + assert cached_file.name.startswith("file_") + assert cached_file.read_bytes() == b"hello world" + + +async def test_runtime_chat_file_upload_handler_requires_auth(monkeypatch: Any) -> None: + monkeypatch.setattr(_runtime, "check_auth", lambda _request: False) + request = DummyMultipartRequest( + None, + headers={}, + cookies={}, + query={}, + app={}, + remote="127.0.0.1", + scheme="http", + host="127.0.0.1:8787", + transport=None, + ) + + response = await _runtime.runtime_chat_file_upload_handler( + cast(web.Request, cast(Any, request)) + ) + + assert cast(web.Response, response).status == 401 + + +async def test_index_handler_renders_schedules_tab() -> None: + request = _request(query={"view": "app", "tab": "schedules"}) + + response = await _index.index_handler(cast(web.Request, cast(Any, request))) + payload_text = cast(web.Response, response).text + + assert payload_text is not None + assert 'id="tab-schedules"' in payload_text + assert 'data-tab="schedules"' in payload_text + assert '' in payload_text + + def test_webui_cors_only_allows_trusted_origins(monkeypatch: Any) -> None: monkeypatch.setattr( webui_app, @@ -530,3 +683,519 @@ async def _fake_proxy_binary(request: web.Request, path: str) -> web.Response: assert payload["ok"] is True assert captured["path"] == "/api/v1/memes/pic%20a%2Fb%3F/blob" + + +async def test_management_schedule_create_requires_auth( + monkeypatch: Any, +) -> None: + called = False + + async def _fake_proxy_runtime(**_kwargs: Any) -> web.Response: + nonlocal called + called = True + return web.json_response({"ok": True}) + + monkeypatch.setattr(_runtime, "check_auth", lambda _request: False) + monkeypatch.setattr(_runtime, "_proxy_runtime", _fake_proxy_runtime) + + response = await _runtime.runtime_schedules_create_handler( + cast(web.Request, cast(Any, _request(json_body={"task_id": "task_demo"}))) + ) + payload = _json_payload(response) + + assert cast(web.Response, response).status == 401 + assert payload["error"] == "Unauthorized" + assert called is False + + +async def test_management_schedule_update_returns_400_on_invalid_json( + monkeypatch: Any, +) -> None: + class _BadJsonRequest(SimpleNamespace): + async def json(self) -> dict[str, object]: + raise json.JSONDecodeError("bad", "x", 0) + + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + request = cast( + web.Request, + cast( + Any, + _BadJsonRequest( + headers={}, + cookies={}, + query={}, + match_info={"task_id": "task_demo"}, + app=_request().app, + ), + ), + ) + + response = await _runtime.runtime_schedule_update_handler(request) + payload = _json_payload(response) + + assert cast(web.Response, response).status == 400 + assert payload["error"] == "Invalid JSON payload" + + +async def test_management_schedule_detail_url_encodes_task_id( + monkeypatch: Any, +) -> None: + captured: dict[str, str] = {} + + async def _fake_proxy_runtime(**kwargs: Any) -> web.Response: + captured["method"] = str(kwargs["method"]) + captured["path"] = str(kwargs["path"]) + return web.json_response({"ok": True}) + + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + monkeypatch.setattr(_runtime, "_proxy_runtime", _fake_proxy_runtime) + + request = cast( + web.Request, + cast( + Any, + SimpleNamespace( + headers={}, + cookies={}, + query={}, + match_info={"task_id": "task a/b?"}, + app=_request().app, + ), + ), + ) + + response = await _runtime.runtime_schedule_detail_handler(request) + payload = _json_payload(response) + + assert payload["ok"] is True + assert captured == { + "method": "GET", + "path": "/api/v1/schedules/task%20a%2Fb%3F", + } + + +async def test_management_schedule_create_proxies_json_payload( + monkeypatch: Any, +) -> None: + captured: dict[str, Any] = {} + + async def _fake_proxy_runtime(**kwargs: Any) -> web.Response: + captured.update(kwargs) + return web.json_response({"ok": True}, status=201) + + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + monkeypatch.setattr(_runtime, "_proxy_runtime", _fake_proxy_runtime) + + response = await _runtime.runtime_schedules_create_handler( + cast( + web.Request, + cast( + Any, + _request( + json_body={ + "task_id": "task_demo", + "cron_expression": "0 9 * * *", + } + ), + ), + ) + ) + payload = _json_payload(response) + + assert cast(web.Response, response).status == 201 + assert payload["ok"] is True + assert captured["method"] == "POST" + assert captured["path"] == "/api/v1/schedules" + assert captured["payload"] == { + "task_id": "task_demo", + "cron_expression": "0 9 * * *", + } + + +async def test_runtime_chat_job_proxy_routes_require_management_auth() -> None: + request = cast( + web.Request, + cast( + Any, + SimpleNamespace( + headers={}, + cookies={}, + query={}, + match_info={"job_id": "job_1"}, + app=_request().app, + ), + ), + ) + + handlers = [ + _runtime.runtime_chat_conversations_handler, + _runtime.runtime_chat_conversation_create_handler, + _runtime.runtime_chat_conversation_update_handler, + _runtime.runtime_chat_conversation_delete_handler, + _runtime.runtime_chat_history_clear_handler, + _runtime.runtime_chat_job_create_handler, + _runtime.runtime_chat_job_active_handler, + _runtime.runtime_chat_job_detail_handler, + _runtime.runtime_chat_job_events_handler, + _runtime.runtime_chat_job_cancel_handler, + ] + for handler in handlers: + response = await handler(request) + assert cast(web.Response, response).status == 401 + + +async def test_runtime_chat_job_proxy_json_injects_runtime_api_key( + monkeypatch: Any, +) -> None: + captured: dict[str, Any] = {} + + async def _fake_proxy_runtime(**kwargs: Any) -> web.Response: + captured.update(kwargs) + return web.json_response({"ok": True}) + + monkeypatch.setattr(_runtime, "_proxy_runtime", _fake_proxy_runtime) + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + request = cast( + web.Request, + cast( + Any, + SimpleNamespace( + headers={"Accept": "application/json"}, + cookies={}, + query={"after": "7", "format": "json"}, + match_info={"job_id": "job /secret"}, + app=_request().app, + ), + ), + ) + + response = await _runtime.runtime_chat_job_events_handler(request) + payload = _json_payload(response) + + assert payload["ok"] is True + assert captured["method"] == "GET" + assert captured["path"] == "/api/v1/chat/jobs/job%20%2Fsecret/events" + assert captured["params"]["after"] == "7" + assert captured["timeout_seconds"] == 20.0 + + +async def test_runtime_chat_job_proxy_preserves_structured_message( + monkeypatch: Any, +) -> None: + captured: dict[str, Any] = {} + + async def _fake_proxy_runtime(**kwargs: Any) -> web.Response: + captured.update(kwargs) + return web.json_response({"job_id": "job-1"}) + + monkeypatch.setattr(_runtime, "_proxy_runtime", _fake_proxy_runtime) + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + request = _request( + json_body={ + "conversation_id": "conv-1", + "message": { + "text": "分析附件", + "attachment_ids": ["att-1"], + "references": [{"message_id": "msg-1", "quote": "引用"}], + }, + } + ) + + response = await _runtime.runtime_chat_job_create_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _json_payload(response) + + assert payload["job_id"] == "job-1" + assert captured["method"] == "POST" + assert captured["path"] == "/api/v1/chat/jobs" + assert captured["payload"] == { + "conversation_id": "conv-1", + "message": { + "text": "分析附件", + "attachment_ids": ["att-1"], + "references": [{"message_id": "msg-1", "quote": "引用"}], + }, + } + + +async def test_runtime_chat_job_proxy_forwards_retry_reuse_flag( + monkeypatch: Any, +) -> None: + captured: dict[str, Any] = {} + + async def _fake_proxy_runtime(**kwargs: Any) -> web.Response: + captured.update(kwargs) + return web.json_response({"job_id": "job-retry"}) + + monkeypatch.setattr(_runtime, "_proxy_runtime", _fake_proxy_runtime) + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + request = _request( + json_body={ + "conversation_id": "conv-1", + "message": "重新生成", + "reuse_previous_user_message": True, + } + ) + + response = await _runtime.runtime_chat_job_create_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _json_payload(response) + + assert payload["job_id"] == "job-retry" + assert captured["payload"] == { + "conversation_id": "conv-1", + "message": "重新生成", + "reuse_previous_user_message": True, + } + + +async def test_runtime_chat_attachment_capabilities_proxy( + monkeypatch: Any, +) -> None: + captured: dict[str, Any] = {} + + async def _fake_proxy_runtime(**kwargs: Any) -> web.Response: + captured.update(kwargs) + return web.json_response({"multipart_field": "file"}) + + monkeypatch.setattr(_runtime, "_proxy_runtime", _fake_proxy_runtime) + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + request = _request() + + response = await _runtime.runtime_chat_attachment_capabilities_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _json_payload(response) + + assert payload["multipart_field"] == "file" + assert captured["method"] == "GET" + assert captured["path"] == "/api/v1/chat/attachments/capabilities" + + +async def test_runtime_chat_attachment_upload_proxy( + monkeypatch: Any, +) -> None: + captured: dict[str, Any] = {} + + async def _fake_proxy_runtime_multipart_file( + request: web.Request, + **kwargs: Any, + ) -> web.Response: + captured["request"] = request + captured.update(kwargs) + return web.json_response({"attachment": {"id": "att-1"}}, status=201) + + monkeypatch.setattr( + _runtime, + "_proxy_runtime_multipart_file", + _fake_proxy_runtime_multipart_file, + ) + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + request = DummyMultipartRequest(DummyMultipartField([b"data"])) + + response = await _runtime.runtime_chat_attachment_upload_handler( + cast(web.Request, cast(Any, request)) + ) + payload = _json_payload(response) + + assert cast(web.Response, response).status == 201 + assert cast(dict[str, object], payload["attachment"])["id"] == "att-1" + assert captured["path"] == "/api/v1/chat/attachments" + assert captured["request"] is request + + +async def test_runtime_chat_attachment_download_and_preview_proxy( + monkeypatch: Any, +) -> None: + captured: list[dict[str, Any]] = [] + + async def _fake_proxy_runtime_binary(**kwargs: Any) -> web.Response: + captured.append(dict(kwargs)) + return web.Response(body=b"PNG", content_type="image/png") + + monkeypatch.setattr(_runtime, "_proxy_runtime_binary", _fake_proxy_runtime_binary) + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + download_request = cast( + web.Request, + cast( + Any, + SimpleNamespace( + headers={}, + cookies={}, + query={}, + match_info={"attachment_id": "att /1"}, + app=_request().app, + ), + ), + ) + preview_request = cast( + web.Request, + cast( + Any, + SimpleNamespace( + headers={}, + cookies={}, + query={}, + match_info={"attachment_id": "att /1"}, + app=_request().app, + ), + ), + ) + + download_response = await _runtime.runtime_chat_attachment_download_handler( + download_request + ) + preview_response = await _runtime.runtime_chat_attachment_preview_handler( + preview_request + ) + + assert cast(web.Response, download_response).body == b"PNG" + assert cast(web.Response, preview_response).body == b"PNG" + assert captured == [ + { + "method": "GET", + "path": "/api/v1/chat/attachments/att%20%2F1", + "timeout_seconds": 60.0, + }, + { + "method": "GET", + "path": "/api/v1/chat/attachments/att%20%2F1/preview", + "timeout_seconds": 60.0, + }, + ] + + +def test_management_api_docs_describe_native_chat_contract() -> None: + docs_path = Path(__file__).resolve().parents[1] / "docs" / "management-api.md" + text = docs_path.read_text(encoding="utf-8") + + assert "全局 job 互斥" not in text + assert "CQ:file" not in text + assert "attachment_ids" in text + assert "同一会话" in text + + +async def test_runtime_chat_job_proxy_sse_uses_stream_proxy( + monkeypatch: Any, +) -> None: + captured: dict[str, Any] = {} + + async def _fake_proxy_runtime_stream( + request: web.Request, + **kwargs: Any, + ) -> web.Response: + captured["accept"] = request.headers.get("Accept") + captured.update(kwargs) + return web.json_response({"stream": True}) + + monkeypatch.setattr(_runtime, "_proxy_runtime_stream", _fake_proxy_runtime_stream) + monkeypatch.setattr(_runtime, "check_auth", lambda _request: True) + monkeypatch.setattr(_runtime, "_chat_proxy_timeout_seconds", lambda: 123.0) + request = cast( + web.Request, + cast( + Any, + SimpleNamespace( + headers={"Accept": "text/event-stream"}, + cookies={}, + query={"after": "0"}, + match_info={"job_id": "job_1"}, + app=_request().app, + ), + ), + ) + + response = await _runtime.runtime_chat_job_events_handler(request) + payload = _json_payload(response) + + assert payload["stream"] is True + assert captured["method"] == "GET" + assert captured["path"] == "/api/v1/chat/jobs/job_1/events" + assert captured["params"]["after"] == "0" + assert captured["timeout_seconds"] == 123.0 + assert captured["accept"] == "text/event-stream" + + +async def test_proxy_runtime_injects_runtime_api_key(monkeypatch: Any) -> None: + captured: dict[str, Any] = {} + + class _FakeResponse: + status = 200 + headers = {"Content-Type": "application/json"} + content_type = "application/json" + charset = "utf-8" + + async def __aenter__(self) -> _FakeResponse: + return self + + async def __aexit__(self, *_args: Any) -> None: + return None + + async def text(self) -> str: + return '{"ok": true}' + + class _FakeSession: + def __init__(self, *args: Any, **kwargs: Any) -> None: + _ = args, kwargs + + async def __aenter__(self) -> _FakeSession: + return self + + async def __aexit__(self, *_args: Any) -> None: + return None + + def request(self, **kwargs: Any) -> _FakeResponse: + captured.update(kwargs) + return _FakeResponse() + + monkeypatch.setattr( + _runtime, + "get_config", + lambda strict=False: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + loopback_url="http://127.0.0.1:8788", + auth_key="runtime-secret", + ) + ), + ) + monkeypatch.setattr(_runtime, "ClientSession", _FakeSession) + + response = await _runtime._proxy_runtime(method="GET", path="/api/v1/chat/jobs") + payload = _json_payload(response) + + assert payload["ok"] is True + assert captured["headers"] == {"X-Undefined-API-Key": "runtime-secret"} + + +async def test_static_assets_get_no_cache_revalidation_header() -> None: + async def _handler(_request: web.Request) -> web.StreamResponse: + return web.Response(text="asset") + + request = make_mocked_request("GET", "/static/js/runtime.js") + response = await webui_app.security_headers_middleware(request, _handler) + + # 静态资源强制按 ETag 重新校验,避免前端更新后被强缓存挡住 + assert response.headers["Cache-Control"] == "no-cache" + + +async def test_security_headers_csp_allows_blob_image_previews() -> None: + async def _handler(_request: web.Request) -> web.StreamResponse: + return web.Response(text="page") + + request = make_mocked_request("GET", "/") + response = await webui_app.security_headers_middleware(request, _handler) + + assert "img-src 'self' data: blob:;" in response.headers["Content-Security-Policy"] + + +async def test_non_static_responses_have_no_explicit_cache_control() -> None: + async def _handler(_request: web.Request) -> web.StreamResponse: + return web.Response(text="page") + + request = make_mocked_request("GET", "/api/v1/management/health") + response = await webui_app.security_headers_middleware(request, _handler) + + assert "Cache-Control" not in response.headers diff --git a/tests/test_webui_runtime_chat_frontend.py b/tests/test_webui_runtime_chat_frontend.py new file mode 100644 index 00000000..2250e42e --- /dev/null +++ b/tests/test_webui_runtime_chat_frontend.py @@ -0,0 +1,1547 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Final + +from Undefined.utils import io as async_io + + +RUNTIME_JS: Final[Path] = Path("src/Undefined/webui/static/js/runtime.js") +RUNTIME_CSS: Final[Path] = Path("src/Undefined/webui/static/css/components.css") +WEBUI_TEMPLATE: Final[Path] = Path("src/Undefined/webui/templates/index.html") +MAIN_JS: Final[Path] = Path("src/Undefined/webui/static/js/main.js") +API_JS: Final[Path] = Path("src/Undefined/webui/static/js/api.js") +LOG_VIEW_JS: Final[Path] = Path("src/Undefined/webui/static/js/log-view.js") +APP_CSS: Final[Path] = Path("src/Undefined/webui/static/css/app.css") +RESPONSIVE_CSS: Final[Path] = Path("src/Undefined/webui/static/css/responsive.css") +I18N_JS: Final[Path] = Path("src/Undefined/webui/static/js/i18n.js") +WEBUI_APP_PY: Final[Path] = Path("src/Undefined/webui/app.py") +TAURI_CONF: Final[Path] = Path("apps/undefined-console/src-tauri/tauri.conf.json") + + +def _read_source(path: Path) -> str: + text = asyncio.run(async_io.read_text(path)) + assert text is not None + return text + + +def test_webchat_frontend_reuses_job_message_for_final_message() -> None: + source = _read_source(RUNTIME_JS) + + assert "activeChatMessageId" in source + assert 'if (event === "message")' in source + message_branch = source.split('if (event === "message")', 1)[1].split( + 'if (event === "done")', 1 + )[0] + + assert "ensureStreamingMessage(eventJobId)" in message_branch + assert 'appendChatMessage("bot", content)' not in message_branch + + +def test_webchat_html_preview_csp_allows_inline_scripts_without_eval() -> None: + webui_app = _read_source(WEBUI_APP_PY) + tauri_conf = _read_source(TAURI_CONF) + + assert "\"script-src 'self' 'nonce-{nonce}'; \"" in webui_app + assert "script-src 'self';" in tauri_conf + assert "script-src 'self' 'unsafe-inline'" not in webui_app + assert "script-src 'self' 'unsafe-inline'" not in tauri_conf + assert "__CSP_NONCE__" in _read_source(WEBUI_TEMPLATE) + assert "htmlRunnerCspMeta" in _read_source(RUNTIME_JS) + assert "unsafe-eval" not in webui_app + assert "unsafe-eval" not in tauri_conf + + +def test_webchat_frontend_handles_tool_lifecycle_and_webchat_hints() -> None: + source = _read_source(RUNTIME_JS) + + assert 'event === "token_delta"' not in source + assert 'event === "tool_delta"' not in source + assert "pendingToolDeltas" not in source + assert "appendTokenDelta" not in source + assert "consumeSse" not in source + assert "attachChatJobSse" not in source + assert "text/event-stream" not in source + assert 'event === "tool_start"' in source + assert 'event === "tool_end"' in source + assert 'event === "agent_start"' in source + assert 'event === "agent_end"' in source + assert 'block.uiHint === "webchat_private_send"' in source + assert 'block.uiHint === "webchat_end"' in source + assert "payload && payload.result_preview" in source + assert 'nextUiHint === "webchat_end"' not in source + + +def test_webchat_frontend_renders_live_stage_after_ai_label() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + i18n = _read_source(I18N_JS) + + assert 'runtime-chat-role-label">AI' in source + assert "runtime-chat-stage" in source + assert 'if (event === "stage")' in source + assert "setChatStage(item, payload || {})" in source + assert "setChatStage(item, null)" in source + assert "function updateChatStageDisplay" in source + assert "function refreshActiveChatTimers" in source + assert "updateToolDurationDisplay(block)" in source + assert "formatDurationMs" in source + assert "payload && payload.elapsed_ms" in source + assert "Date.now() - runtimeState.activeStageStartedAt" not in source + assert "runtime.chat_stage_waiting_model" in i18n + assert "runtime.chat_stage_searching_cognitive_memory" in i18n + assert ".runtime-chat-stage" in css + assert "runtime-chat-stage-pulse" not in css + + +def test_webchat_frontend_has_conversation_sidebar() -> None: + source = _read_source(RUNTIME_JS) + template = _read_source(WEBUI_TEMPLATE) + app_css = _read_source(APP_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + i18n = _read_source(I18N_JS) + + assert "runtimeChatConversations" in template + assert "btnRuntimeChatNew" in template + assert "btnRuntimeChatClear" not in template + assert "runtimeChatCurrentTitle" in template + assert 'id="runtimeChatConversationDrawerToggle"' in template + assert "runtime-chat-sidebar-tab" in template + assert "runtime-chat-sidebar-panel" in template + assert "loadChatConversations" in source + assert "switchChatConversation" in source + assert "renameChatConversation" in source + assert "deleteChatConversation" in source + assert "/api/runtime/chat/conversations" in source + assert ".runtime-chat-sidebar" in app_css + sidebar_block = app_css.split(".runtime-chat-sidebar {", 1)[1].split( + ".runtime-chat-sidebar:hover", 1 + )[0] + assert "position: absolute;" in sidebar_block + assert "right: 0;" in sidebar_block + assert "transform: translateX(calc(100% - 36px));" in sidebar_block + assert "transition:" in sidebar_block + assert ".runtime-chat-sidebar:hover" in app_css + assert ".runtime-chat-sidebar:focus-within" in app_css + assert "transform: translateX(0);" in app_css + assert ".runtime-chat-sidebar-tab" in app_css + assert "runtime-chat-conversation-created" in app_css + assert ".runtime-chat-conversation.is-new" in app_css + assert "recentlyCreatedConversationId" in source + assert 'showToast(t("runtime.chat_conversation_created")' in source + assert '"runtime.chat_conversation_created"' in i18n + assert 'get("btnRuntimeChatClear")' not in source + assert "chatConversationDrawerOpen: false" in source + assert "function setChatConversationDrawerOpen" in source + assert "function canToggleChatConversationDrawer" in source + assert "window.innerWidth <= 768" in source + assert "runtimeChatConversationDrawerToggle" in source + assert 'toggle.setAttribute(\n "aria-expanded",' in source + mobile_sidebar_block = responsive_css.split(".runtime-chat-sidebar {", 1)[1].split( + ".runtime-chat-sidebar-panel", 1 + )[0] + assert "position: static;" in mobile_sidebar_block + assert "transform: none;" in mobile_sidebar_block + mobile_panel_block = responsive_css.split(".runtime-chat-sidebar-panel {", 1)[ + 1 + ].split(".runtime-chat-sidebar.is-open .runtime-chat-sidebar-panel", 1)[0] + assert "display: none;" in mobile_panel_block + assert ".runtime-chat-sidebar.is-open .runtime-chat-sidebar-panel" in responsive_css + assert "display: block;" in responsive_css + mobile_tab_block = responsive_css.split(".runtime-chat-sidebar-tab {", 1)[1].split( + ".runtime-chat-sidebar-tab::after", 1 + )[0] + assert "display: flex;" in mobile_tab_block + assert "width: 100%;" in mobile_tab_block + assert "runtime.chat_new_conversation" in i18n + + +def test_webchat_frontend_has_slash_command_palette() -> None: + source = _read_source(RUNTIME_JS) + template = _read_source(WEBUI_TEMPLATE) + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + i18n = _read_source(I18N_JS) + + assert 'id="runtimeChatCommandPalette"' in template + input_row = template.split('class="runtime-chat-input-row"', 1)[1].split( + 'id="runtimeChatReferences"', + 1, + )[0] + assert input_row.index('id="runtimeChatCommandPalette"') < input_row.index( + 'id="runtimeChatInput"' + ) + + assert "chatCommandsLoaded" in source + assert "CHAT_COMMAND_CACHE_MS" in source + assert "CHAT_COMMAND_MAX_MATCHES" in source + assert '"/api/runtime/commands?scope=webui"' in source + assert "function buildChatCommandContext" in source + assert 'if (!beforeCursor.startsWith("/")) return null' in source + assert "if (tokenCount > 2) return null" in source + assert 'mode: hasCommandBoundary ? "subcommand" : "command"' in source + assert "function currentChatCommandMatches" in source + assert "findChatCommandByNameOrAlias(context.commandQuery)" in source + assert "commandMatchesForQuery(context.commandQuery)" in source + assert "function chatCommandDisplayName" in source + assert "typedCommandName: chatCommandDisplayName(" in source + assert "const commandName = match.typedCommandName || match.command.name" in source + assert ( + "if (!command) {\n return commandMatchesForQuery(context.commandQuery);" + in source + ) + assert "function renderChatCommandNoSubcommandsHelp" in source + assert "function chatCommandPaletteEmptyHtml" in source + assert "commandTextWithTypedTrigger" in source + assert "commandAliasText" in source + assert "!runtimeState.chatCommandsLoaded" in source + assert "runtime.chat_command_loading" in source + assert "runtime.chat_command_unknown_command" in source + assert "runtime.chat_command_subcommand_empty" in source + assert "runtime.command_no_subcommands_note" in source + assert "runtime.command_usage" in source + assert "runtime.command_example" in source + assert "runtime.command_aliases" in source + assert "runtime-chat-command-help" in source + assert "function replaceChatCommandInput" in source + assert "chooseActiveChatCommandMatch()" in source + assert 'event.key === "ArrowDown"' in source + assert 'event.key === "ArrowUp"' in source + assert 'event.key === "Tab"' in source + assert 'event.key === "Escape"' in source + assert "data-command-match-index" in source + assert "closeChatCommandPalette()" in source + + assert ".runtime-chat-command-palette" in css + palette_block = css.split(".runtime-chat-command-palette {", 1)[1].split( + ".runtime-chat-command-palette.is-open", + 1, + )[0] + assert "position: absolute;" in palette_block + assert "bottom: calc(100% + 10px);" in palette_block + assert "max-height: min(360px, 46vh);" in palette_block + assert ".runtime-chat-command-item" in css + assert ".runtime-chat-command-side code" in css + assert ".runtime-chat-command-palette" in responsive_css + assert "grid-template-columns: minmax(0, 1fr);" in responsive_css + assert "runtime.chat_command_hint" in i18n + assert "runtime.chat_command_hint_subcommand" in i18n + assert "runtime.chat_command_loading" in i18n + assert "runtime.chat_command_empty" in i18n + assert "runtime.chat_command_unknown_command" in i18n + assert "runtime.chat_command_subcommand_empty" in i18n + assert "runtime.chat_command_subcommands" in i18n + assert "runtime.command_help" in i18n + assert "runtime.command_usage" in i18n + assert "runtime.command_example" in i18n + assert "runtime.command_aliases" in i18n + assert "runtime.command_no_subcommands_note" in i18n + + +def test_webchat_frontend_sends_conversation_id_with_history_and_jobs() -> None: + source = _read_source(RUNTIME_JS) + + assert "currentChatConversationId" in source + assert 'chatUrl("/api/runtime/chat/history"' in source + assert 'chatUrl("/api/runtime/chat/jobs/active"' in source + assert "runtimeChatJobEventsUrls" in source + assert "conversation_id: currentChatConversationId()" in source + assert "activeJobConversationId" in source + assert ( + "runtimeState.activeJobConversationId || currentChatConversationId()" in source + ) + assert "eventConversationId === currentChatConversationId()" in source + assert "jobConversationId !== currentChatConversationId()" in source + + +def test_webchat_frontend_resumes_backend_job_after_refresh_or_reconnect() -> None: + source = _read_source(RUNTIME_JS) + history_helper = source.split("async function loadChatHistory", 1)[1].split( + "async function loadOlderChatHistory", + 1, + )[0] + conversation_helper = source.split( + "async function loadChatConversations", + 1, + )[1].split("async function createChatConversation", 1)[0] + resume_helper = source.split("async function resumeActiveChatJob", 1)[1].split( + "async function clearChatHistory", + 1, + )[0] + + assert "{ resumeActiveJob = true }" in history_helper + assert "if (runtimeState.chatHistoryLoaded && !force)" in history_helper + assert "await resumeActiveChatJob();" in history_helper + assert "const localJobId = runtimeState.activeJobId" in resume_helper + assert 'chatUrl("/api/runtime/chat/jobs/active")' in resume_helper + assert "attachChatJob(jobId, runtimeState.lastEventSeq)" in resume_helper + assert "runtimeState.activeJobId) return" not in resume_helper + assert "await loadChatHistory(true, { resumeActiveJob: false })" in resume_helper + assert "runtimeState.chatBusy = false" in resume_helper + assert "const previousJobId = runtimeState.activeJobId" in conversation_helper + assert 'const nextJobId = String(activeJob.job_id || "")' in conversation_helper + assert "previousJobId !== nextJobId" in conversation_helper + assert "localJobId !== jobId" in resume_helper + assert "runtimeState.lastEventSeq = 0" in conversation_helper + assert "clearToolCollapseTimers()" in conversation_helper + assert 'window.addEventListener(\n "online"' in source + + +def test_webchat_frontend_lazy_load_preserves_scroll_offset() -> None: + source = _read_source(RUNTIME_JS) + older_helper = source.split("async function loadOlderChatHistory", 1)[1].split( + "function applyChatEvent", + 1, + )[0] + + assert "const previousHeight = log.scrollHeight" in older_helper + assert "const previousTop = log.scrollTop" in older_helper + assert "appendHistoryChatItem(items[idx], {" in older_helper + assert "prepend: true" in older_helper + assert ( + "log.scrollTop = previousTop + (log.scrollHeight - previousHeight)" + in older_helper + ) + + +def test_webui_logs_fetch_more_tail_lines_by_default() -> None: + source = _read_source(LOG_VIEW_JS) + + assert "const LOG_TAIL_LINES = 1000;" in source + assert 'lines: "200"' not in source + assert "lines: String(LOG_TAIL_LINES)" in source + + +def test_webchat_frontend_keeps_final_duration_after_done() -> None: + source = _read_source(RUNTIME_JS) + + done_branch = source.split('if (event === "done")', 1)[1].split( + 'if (event === "error")', 1 + )[0] + finalize_helper = source.split("function finalizeActiveChatMessage", 1)[1].split( + "function chatStageLabel", 1 + )[0] + history_helper = source.split("function appendHistoryChatItem", 1)[1].split( + "function clearChatMessages", 1 + )[0] + + assert "finalizeActiveChatMessage(payload || {})" in done_branch + assert "payload && payload.duration_ms" in finalize_helper + assert 'stage: "done"' in finalize_helper + assert "final: true" in finalize_helper + assert "webchat.duration_ms" in history_helper + assert "setChatStage(message, {" in history_helper + + +def test_webchat_frontend_restores_history_tool_blocks_without_stream_state() -> None: + source = _read_source(RUNTIME_JS) + + assert "function appendHistoryChatItem" in source + assert "function renderHistoryTimeline" in source + assert "function reduceToolBlock" in source + assert "function normalizeToolCallNode" in source + assert "function normalizeHistoryTimelineNode" in source + assert 'entry.event === "message"' in source + assert "item.webchat.calls" in source + assert "item.webchat.timeline" in source + assert 'message.classList.add("tool-only")' in source + assert "appendHistoryChatItem(item, { scroll: false })" in source + assert "appendHistoryChatItem(items[idx], {" in source + + history_helper = source.split("function appendHistoryChatItem", 1)[1].split( + "function clearChatMessages", 1 + )[0] + assert "applyChatEvent(" not in history_helper + assert "upsertToolBlock(" not in history_helper + assert "ensureStreamingMessage(" not in history_helper + assert "data-job-id" not in history_helper + + +def test_webchat_frontend_renders_chat_as_event_timeline() -> None: + source = _read_source(RUNTIME_JS) + message_branch = source.split('if (event === "message")', 1)[1].split( + 'if (event === "done")', 1 + )[0] + timeline_helper = source.split("function upsertTimelineToolBlock", 1)[1].split( + "function upsertToolBlock", 1 + )[0] + + assert 'appendTimelineMessage(item, content, "bot", {' in message_branch + assert "attachments: payload && payload.attachments" in message_branch + assert "appendNestedTimelineMessage(" in message_branch + assert 'updateChatMessage(item, content, "bot")' not in message_branch + assert "timeline.appendChild(node)" in timeline_helper + assert "parent_webchat_call_id" in timeline_helper + assert "parent.children" in timeline_helper + assert ( + 'appendToolTimelineEntry(parent, { type: "call", call: block })' + in timeline_helper + ) + assert "topLevelToolKey(blocks, parentKey)" in timeline_helper + assert "runtime-tool-children" in _read_source(RUNTIME_CSS) + assert "function renderToolNodeIfChanged" in source + assert "node.dataset.renderSignature === nextSignature" in source + assert "updateToolMetaDisplay(block)" in source + assert "data-tool-status-for" in source + + +def test_webchat_frontend_prefers_backend_history_timeline() -> None: + source = _read_source(RUNTIME_JS) + history_timeline_branch = source.split("if (timelineItems.length)", 1)[1].split( + "if (calls.length)", 1 + )[0] + + assert 'entry.type === "message"' in history_timeline_branch + assert 'entry.type !== "call"' in history_timeline_branch + assert "renderToolBlock(entry.call)" in history_timeline_branch + assert "reduceToolBlock(" not in history_timeline_branch + + +def test_webchat_frontend_renders_nested_tool_timeline() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + + assert "function renderToolTimelineItem" in source + assert "function appendNestedTimelineMessage" in source + assert "function appendToolTimelineEntry" in source + assert "block.timeline" in source + assert "renderToolTimelineItem" in source + assert "runtime-tool-message" in source + nested_message_helper = source.split("function appendNestedTimelineMessage", 1)[ + 1 + ].split("function upsertToolBlock", 1)[0] + assert "payload.parent_webchat_call_id" in nested_message_helper + assert 'type: "message"' in nested_message_helper + assert "redrawToolTimelineNode(item, blocks, parentKey)" in nested_message_helper + assert "runtime-tool-reveal" in css + assert ".runtime-tool-block::before" in css + assert ".runtime-tool-block summary::before" in css + + +def test_webchat_tool_snapshots_do_not_rerender_unchanged_blocks() -> None: + source = _read_source(RUNTIME_JS) + live_update_helper = source.split("function upsertTimelineToolBlock", 1)[1].split( + "function appendNestedTimelineMessage", 1 + )[0] + agent_stage_helper = source.split("function upsertAgentStageBlock", 1)[1].split( + "function historyWebchatEvents", + 1, + )[0] + history_helper = source.split("function renderHistoryTimeline", 1)[1].split( + "function appendHistoryChatItem", + 1, + )[0] + + assert "previousParentSignature === nextParentSignature" in live_update_helper + assert "previousRootSignature === nextRootSignature" in live_update_helper + assert 'status === "tool_snapshot"' in live_update_helper + assert "renderToolNodeIfChanged(parentNode, parent)" in live_update_helper + assert "renderToolNodeIfChanged(rootNode, root)" in live_update_helper + assert "renderToolNodeIfChanged(node, block)" in live_update_helper + assert "previousParentSignature === nextParentSignature" in agent_stage_helper + assert "renderToolNodeIfChanged(node, block)" in agent_stage_helper + assert "node.innerHTML = renderToolBlock" not in live_update_helper + assert "node.innerHTML = renderToolBlock" not in agent_stage_helper + assert "node.innerHTML = renderToolBlock" in history_helper + + +def test_webchat_frontend_updates_agent_stage_summary_without_timeline_noise() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + + assert 'event === "agent_stage"' in source + assert "function upsertAgentStageBlock" in source + assert "function reduceAgentStageBlock" in source + assert "currentStage" in source + assert "current_stage_elapsed_ms" in source + render_helper = source.split("function renderToolTimelineItem", 1)[1].split( + "function toolBlockKey", 1 + )[0] + reduce_helper = source.split("function reduceAgentStageBlock", 1)[1].split( + "function agentStageRenderSignature", 1 + )[0] + + assert 'entry.type === "stage"' in render_helper + assert 'return "";' in render_helper + assert 'type: "stage"' not in reduce_helper + assert "function agentStageRenderSignature" in source + assert "previousSignature === agentStageRenderSignature(block)" in source + assert "currentStage: stage || previous.currentStage" in source + assert "runtime-tool-stage" not in source + assert ".runtime-tool-stage" not in css + + +def test_webchat_frontend_polls_job_events_incrementally() -> None: + source = _read_source(RUNTIME_JS) + + assert "function pollChatJob" in source + assert "CHAT_POLL_INTERVAL_MS = 500" in source + assert "CHAT_CLOCK_INTERVAL_MS = 500" in source + assert 'format: "json"' in source + assert "after: String(runtimeState.lastEventSeq)" in source + assert "function applyChatEventsPayload" in source + assert "function applyChatJobSnapshot" in source + assert "job.current_tool_calls" in source + assert "upsertToolSnapshot" in source + assert "runtimeState.chatPollTimer" in source + assert "runtimeState.chatPollBackoffMs" in source + assert "pollChatJob(jobId).catch" in source + assert 'Accept: "text/event-stream"' not in source + + +def test_webchat_frontend_retries_active_job_resume_after_refresh_failure() -> None: + source = _read_source(RUNTIME_JS) + resume_helper = source.split("async function resumeActiveChatJob", 1)[1].split( + "async function clearChatHistory", 1 + )[0] + + assert "activeJobResumeTimer" in source + assert "ACTIVE_JOB_RESUME_MAX_ATTEMPTS = 20" in source + assert "runtimeState.activeJobResumeAttempts += 1" in resume_helper + assert "setTimeout(() => {" in resume_helper + assert "resumeActiveChatJob().catch" in resume_helper + assert 'window.addEventListener(\n "online"' in source + + +def test_webchat_tool_summary_uses_compact_single_line_order() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + render_helper = source.split("function renderToolBlock", 1)[1].split( + "function renderToolTimelineItem", 1 + )[0] + summary_css = css.split(".runtime-tool-block summary {", 1)[1].split( + ".runtime-tool-block summary::-webkit-details-marker", 1 + )[0] + + assert "runtime-tool-name" in render_helper + assert "runtime-tool-duration" in render_helper + assert "runtime-tool-status" in render_helper + assert "runtime-tool-kind" in render_helper + assert ( + render_helper.index("runtime-tool-name") + < render_helper.index("runtime-tool-duration") + < render_helper.index("runtime-tool-status") + < render_helper.index("runtime-tool-kind") + ) + assert "grid-template-columns: auto minmax(0, 1fr) auto auto;" in summary_css + assert "min-height: 32px;" in summary_css + assert "padding: 3px 10px 3px 13px;" in summary_css + assert "line-height: 1.2;" in summary_css + name_css = css.split(".runtime-tool-block summary .runtime-tool-name", 1)[1].split( + ".runtime-tool-block summary .runtime-tool-duration", 1 + )[0] + duration_css = css.split(".runtime-tool-block summary .runtime-tool-duration", 1)[ + 1 + ].split(".runtime-tool-block summary .runtime-tool-status", 1)[0] + assert "font-weight: 650;" in name_css + assert "font-family: var(--font-mono);" in duration_css + assert "white-space: nowrap;" in duration_css + + +def test_webchat_tool_blocks_auto_collapse_after_minimum_visible_time() -> None: + source = _read_source(RUNTIME_JS) + assert "TOOL_AUTO_COLLAPSE_MIN_VISIBLE_MS = 2000" in source + assert "runtimeState.toolCollapseTimers" in source + assert "function scheduleToolAutoCollapse" in source + assert 'block.autoOpen ? " open" : ""' in source + assert "autoOpen: isStart || isSnapshot ? true : !!previous.autoOpen" in source + assert "localStartedAtMs: isStart" in source + assert "finishedAtMs: isEnd" in source + signature_helper = source.split("function toolRenderSignature", 1)[1].split( + "function updateToolMetaDisplay", + 1, + )[0] + assert "block.autoOpen" in signature_helper + assert "const childSignature" in signature_helper + assert "block.children.map(toolRenderSignature)" in signature_helper + assert "const timelineSignature" in signature_helper + assert "`call:${toolRenderSignature(entry.call)}`" in signature_helper + collapse_helper = source.split("function scheduleToolAutoCollapse", 1)[1].split( + "function upsertTimelineToolBlock", 1 + )[0] + assert "latest.autoOpen = false" in collapse_helper + assert "redrawToolTimelineNode(item, blocks, timerKey)" in collapse_helper + assert "setTimeout(collapse, TOOL_AUTO_COLLAPSE_MIN_VISIBLE_MS)" in collapse_helper + assert "TOOL_AUTO_COLLAPSE_MIN_VISIBLE_MS -" not in collapse_helper + clear_helper = source.split("function clearToolCollapseTimers", 1)[1].split( + "function finishStreamingMessage", 1 + )[0] + assert "clearTimeout(timer)" in clear_helper + + +def test_webchat_auto_scroll_toggle_controls_stream_scroll() -> None: + source = _read_source(RUNTIME_JS) + template = _read_source(WEBUI_TEMPLATE) + css = _read_source(RUNTIME_CSS) + + assert "runtimeChatAutoScroll" in template + assert "runtime.chat_auto_scroll" in template + assert "CHAT_AUTO_SCROLL_STORAGE_KEY" in source + assert "readChatAutoScrollPreference()" in source + assert "setChatAutoScroll(autoScrollToggle.checked)" in source + assert "if (!runtimeState.chatAutoScroll) return;" in source + assert "forceScrollChatToBottom()" in source + assert "prefersReducedMotion()" in source + assert "chatScrollBehavior()" in source + assert "behavior: chatScrollBehavior()" in source + assert ".toggle-input:focus-visible + .toggle-track" in css + assert ".toggle-input { display: none;" not in css + + +def test_webchat_tab_activation_forces_bottom_scroll_after_history_load() -> None: + source = _read_source(RUNTIME_JS) + load_helper = source.split("async function loadChatHistory", 1)[1].split( + "async function loadOlderChatHistory", 1 + )[0] + tab_helper = source.split("function onTabActivated", 1)[1].split( + "window.RuntimeController", 1 + )[0] + chat_branch = tab_helper.split('if (tab === "chat")', 1)[1].split( + "return;", + 1, + )[0] + + assert "forceScrollChatToBottomSoon()" in load_helper + assert "forceScrollChatToBottom();" not in load_helper + assert "loadChatConversations()" in chat_branch + assert ".then(() => loadChatHistory())" in chat_branch + assert "forceScrollChatToBottomSoon()" in chat_branch + assert "CHAT_TOP_LOAD_SUPPRESS_MS = 900" in source + assert "suppressChatTopHistoryLoad()" in source + assert "isChatTopHistoryLoadSuppressed()" in source + assert "chatTopLoadSuppressedUntil" in source + + +def test_webchat_frontend_renders_tool_duration() -> None: + source = _read_source(RUNTIME_JS) + + assert "block.durationMs" in source + assert "payload.duration_ms" in source + assert "runtime-tool-duration" in source + assert "formatDurationMs(runningDurationMs(block))" in source + assert "function runningDurationMs" in source + assert "function backendDurationClock" in source + assert "function updateToolDurationDisplay" in source + assert "function toolRenderSignature" in source + assert "durationBaseMs" in source + assert "durationReceivedAtMs" in source + assert "statusLabel} · ${durationLabel}" not in source + + +def test_webchat_tool_previews_render_structured_input_output() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + i18n = _read_source(I18N_JS) + + assert "function formatToolPreview" in source + assert "JSON.parse(text)" in source + assert "function renderStructuredToolValue" in source + assert "function renderToolPreviewSection" in source + assert '"runtime.tool_input"' in source + assert '"runtime.tool_output"' in source + assert "runtime-tool-structured-row" in source + assert "runtime-tool-key" in source + assert "runtime-tool-value" in source + assert "renderChatContent(preview.text, !!options.markdown)" in source + + assert ".runtime-tool-preview" in css + assert ".runtime-tool-preview-label" in css + assert ".runtime-tool-preview-body.is-structured" in css + assert ".runtime-tool-key" in css + assert ".runtime-tool-value.string" in css + assert ".runtime-tool-value.number" in css + assert ".runtime-tool-value.boolean" in css + assert "runtime.tool_input" in i18n + assert "runtime.tool_output" in i18n + + +def test_webchat_frontend_sanitizes_markdown_html_and_unsafe_links() -> None: + source = _read_source(RUNTIME_JS) + render_helper = source.split("function createSafeMarkedRenderer", 1)[1].split( + "function renderChatContent", 1 + )[0] + sanitizer_helper = source.split("function sanitizeHtmlSnippet", 1)[0].split( + "function isSafeRenderedImageUrl", 1 + )[1] + + assert "renderer.html" in render_helper + assert 'sanitizeHtmlSnippet(text || "")' in render_helper + assert "SAFE_HTML_TAGS" in sanitizer_helper + assert "DROP_HTML_TAGS" in sanitizer_helper + assert 'name.startsWith("on")' in sanitizer_helper + assert 'name === "style"' in sanitizer_helper + assert "isSafeRenderedUrl(attr.value)" in sanitizer_helper + assert "isSafeRenderedImageUrl(attr.value)" in sanitizer_helper + assert 'element.setAttribute("rel", "noreferrer")' in sanitizer_helper + assert 'element.setAttribute("loading", "lazy")' in sanitizer_helper + assert "isSafeRenderedUrl(href)" in render_helper + assert 'rel="noreferrer"' in render_helper + assert "renderer.image" in render_helper + assert "chatImageMarkup(href, label)" in render_helper + assert "renderer: createSafeMarkedRenderer()" in source + + +def test_webchat_frontend_has_clickable_image_viewer() -> None: + source = _read_source(RUNTIME_JS) + template = _read_source(WEBUI_TEMPLATE) + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + i18n = _read_source(I18N_JS) + + assert 'id="runtimeChatImageViewer"' in template + assert 'id="runtimeChatImageViewerImage"' in template + assert "data-chat-image-viewer-close" in template + assert "function chatImageMarkup" in source + assert 'data-chat-image-preview="1"' in source + assert "function openChatImageViewer" in source + assert "function closeChatImageViewer" in source + assert "runtimeState.imageViewerPreviousFocus" in source + assert '".runtime-chat-image[data-chat-image-preview]"' in source + assert 'event.key === "Escape"' in source + assert 'target.closest(".runtime-chat-image-viewer-figure")' in source + assert "runtime.open_image_preview" in i18n + assert "runtime.image_preview" in i18n + assert ".runtime-chat-image-viewer" in css + assert ".runtime-chat-image-viewer.is-open" in css + assert ".runtime-chat-image-viewer-close" in css + assert "cursor: zoom-in;" in css + assert "@keyframes runtime-chat-image-viewer-in" in css + assert ".runtime-chat-image-viewer" in responsive_css + + +def test_webchat_markdown_images_render_as_clickable_preview_images() -> None: + source = _read_source(RUNTIME_JS) + renderer_helper = source.split("function createSafeMarkedRenderer", 1)[1].split( + "return renderer;", + 1, + )[0] + + assert "renderer.image" in renderer_helper + assert "isSafeRenderedImageUrl(href)" in renderer_helper + assert "chatImageMarkup(href, label)" in renderer_helper + assert "escapeHtml(label)" in renderer_helper + assert 'renderer.image = ({ text }) => escapeHtml(text || "")' not in source + + +def test_webchat_markdown_quotes_render_as_collapsible_scroll_blocks() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + renderer_helper = source.split("function createSafeMarkedRenderer", 1)[1].split( + "renderer.link", + 1, + )[0] + append_helper = source.split("function appendChatMessage", 1)[1].split( + "function formatDurationMs", + 1, + )[0] + update_helper = source.split("function updateChatMessage", 1)[1].split( + "function currentChatJobId", + 1, + )[0] + quote_css = css.split(".runtime-quote-block", 1)[1].split( + ".runtime-chat-content.markdown table", + 1, + )[0] + + assert "function hasMarkdownBlockquote" in source + assert "function shouldRenderChatMarkdown" in source + assert 'role !== "user" || hasMarkdownBlockquote(content)' in source + assert "renderer.blockquote = ({ tokens }) =>" in renderer_helper + assert '
' in renderer_helper + assert "shouldRenderChatMarkdown(role, content)" in append_helper + assert "renderChatContent(" in append_helper + assert "chatRenderOptions(options.attachments)" in append_helper + assert 'contentEl.classList.toggle("markdown", useMarkdown)' in update_helper + # CSS 样式已简化,不再有 max-height 和 overflow 限制 + assert ".runtime-quote-block" in quote_css + + +def test_webchat_frontend_renders_standalone_html_without_markdown_code_blocks() -> ( + None +): + source = _read_source(RUNTIME_JS) + render_helper = source.split("function renderChatContent", 1)[1].split( + "function readFileAsDataUrl", 1 + )[0] + sanitizer_section = source.split("const SAFE_HTML_TAGS", 1)[1].split( + "const CODE_LANGUAGE_ALIASES", 1 + )[0] + + assert "function looksLikeStandaloneHtml" in source + assert "STANDALONE_HTML_ROOT_TAGS" in source + assert "looksLikeStandaloneHtml(processed)" in render_helper + assert "html = sanitizeHtmlSnippet(processed)" in render_helper + assert '"head"' in sanitizer_section + assert '"title"' in sanitizer_section + assert '"style"' in sanitizer_section + + +def test_webchat_frontend_highlights_markdown_code_blocks() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + template = _read_source(WEBUI_TEMPLATE) + + assert "function highlightCodeBlock" in source + assert 'typeof hljs === "undefined"' in source + assert "hljs.getLanguage(lang)" in source + assert "hljs.highlight(code, {" in source + assert "hljs.highlightAuto(code).value" in source + assert "renderer.code" in source + assert "runtime-code-block" in source + assert "runtime-code-toolbar" in source + assert "runtime-code-action" in source + assert "CODE_COLLAPSE_LINE_THRESHOLD = 8" in source + assert "function shouldCollapseCodeBlock" in source + assert "function toggleCodeBlock" in source + assert "data-code-toggle" in source + assert "runtime.expand_code" in source + assert "runtime.collapse_code" in source + assert "data-code-copy" in source + assert "data-code-run-html" in source + assert "function isRunnableHtmlCode" in source + assert "function copyCodeBlock" in source + assert "function runHtmlCodeBlock" in source + assert "navigator.clipboard.writeText" in source + assert 'document.execCommand("copy")' in source + assert 'chatLog.addEventListener("click"' in source + assert 'target.closest("[data-code-toggle]")' in source + assert "highlightCodeBlock(codeText, normalizedLanguage)" in source + assert "language-${escapeHtml(normalizedLanguage)}" in source + assert 'runtime.copy_code": "复制"' in _read_source(I18N_JS) + assert 'runtime.run_html": "运行"' in _read_source(I18N_JS) + assert 'runtime.expand_code": "展开"' in _read_source(I18N_JS) + assert 'runtime.collapse_code": "折叠"' in _read_source(I18N_JS) + assert "/static/js/vendor/highlight.min.js" in template + assert "/static/css/highlight-github.min.css" in template + assert Path("src/Undefined/webui/static/js/vendor/highlight.min.js").is_file() + assert Path("src/Undefined/webui/static/js/vendor/highlightjs.LICENSE").is_file() + assert Path("src/Undefined/webui/static/css/highlight-github.min.css").is_file() + + assert ".runtime-code-toolbar" in css + assert ( + "position: sticky;" + in css.split(".runtime-code-toolbar", 1)[1].split( + ".runtime-code-language", + 1, + )[0] + ) + assert ".runtime-code-language" in css + assert ".runtime-code-action" in css + assert ".runtime-code-action.primary" in css + assert ".runtime-code-block.is-collapsed .runtime-code-body" in css + assert ".runtime-code-block.is-collapsed .runtime-code-body::after" in css + content_css = css.split(".runtime-chat-content {", 1)[1].split( + ".runtime-chat-timeline", + 1, + )[0] + table_css = css.split(".runtime-chat-content.markdown table", 1)[1].split( + ".runtime-chat-content.markdown th", + 1, + )[0] + pre_css = css.split(".runtime-chat-content.markdown pre {", 1)[1].split( + ".runtime-chat-content.markdown pre code", + 1, + )[0] + code_css = css.split(".runtime-chat-content.markdown pre code {", 1)[1].split( + ".runtime-chat-content.markdown pre code.hljs", + 1, + )[0] + assert "max-width: 100%;" in content_css + assert "overflow-wrap: anywhere;" in content_css + assert "table-layout: fixed;" in table_css + assert "overflow-x: hidden;" in pre_css + assert "white-space: pre-wrap;" in pre_css + assert "white-space: pre-wrap;" in code_css + assert "overflow-wrap: anywhere;" in code_css + collapsed_css = css.split( + ".runtime-code-block.is-collapsed .runtime-code-body", + 1, + )[1].split( + ".runtime-code-block.is-collapsed .runtime-code-body::after", + 1, + )[0] + collapsed_after_css = css.split( + ".runtime-code-block.is-collapsed .runtime-code-body::after", + 1, + )[1].split(".runtime-chat-content.markdown pre", 1)[0] + assert "height: 9.2em;" in collapsed_css + assert "overflow: auto;" in collapsed_css + assert "scrollbar-gutter: stable;" in collapsed_css + assert "display: none;" in collapsed_after_css + assert ".runtime-chat-content.markdown pre code.hljs" in css + assert ".runtime-code-block .hljs-keyword" in css + assert ".runtime-code-block .hljs-string" in css + assert ".runtime-code-block .hljs-comment" in css + assert ".runtime-code-block .hljs-number" in css + assert ".runtime-code-block .hljs-title.function_" in css + assert ".runtime-code-block .hljs-property" in css + assert '[data-theme="dark"] .runtime-code-block' in css + + +def test_webchat_html_runner_runs_code_in_sandboxed_preview() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + template = _read_source(WEBUI_TEMPLATE) + i18n = _read_source(I18N_JS) + + assert 'id="runtimeHtmlRunner"' in template + assert 'id="runtimeHtmlRunnerFrame"' in template + assert 'sandbox="allow-scripts"' in template + assert "allow-forms" not in template + assert "allow-modals" not in template + assert "allow-same-origin" not in template + assert 'id="btnRuntimeHtmlPick"' in template + assert 'id="btnRuntimeHtmlClose"' in template + assert 'id="runtimeHtmlRunnerResize"' in template + assert "runtime.html_runner" in template + + assert "htmlRunnerSource" in source + assert "htmlRunnerPickMode" in source + assert "htmlRunnerResize" in source + assert "htmlRunnerDrag" in source + assert "HTML_RUNNER_MIN_WIDTH = 360" in source + assert "HTML_RUNNER_MIN_HEIGHT = 280" in source + assert "const minWidth = Math.min(HTML_RUNNER_MIN_WIDTH, viewportWidth)" in source + assert ( + "const minHeight = Math.min(HTML_RUNNER_MIN_HEIGHT, viewportHeight)" in source + ) + assert "function buildHtmlRunnerDocument" in source + assert "function htmlRunnerPickerScript" in source + assert "function injectHtmlRunnerSecurity" in source + assert "function syncHtmlRunnerPickModeToFrame" in source + assert "function setHtmlRunnerPickMode" in source + assert "function clampHtmlRunnerPosition" in source + assert "function setHtmlRunnerRect" in source + assert "function setHtmlRunnerSize" in source + assert "function clearHtmlRunnerInteraction" in source + assert "function ensureHtmlRunnerInitialRect" in source + assert "function startHtmlRunnerResize" in source + assert "function moveHtmlRunnerResize" in source + assert "function stopHtmlRunnerResize" in source + assert "function startHtmlRunnerDrag" in source + assert "function moveHtmlRunnerDrag" in source + assert "function stopHtmlRunnerDrag" in source + assert "function clampVisibleHtmlRunner" in source + assert "function openHtmlRunner" in source + assert "function closeHtmlRunner" in source + assert "function handleHtmlRunnerPicked" in source + assert ( + 'const confirmHint = JSON.stringify(t("runtime.html_pick_confirm_hint"))' + in source + ) + assert "let locked = null;" in source + assert "if (locked) return;" in source + assert "if (!locked) {" in source + assert ( + "locked = selected || candidateFromPoint(event.clientX, event.clientY)" + in source + ) + assert "return;\n }\n const target = locked;" in source + assert "clearHtmlRunnerInteraction()" in source + assert "ensureHtmlRunnerInitialRect(runner)" in source + assert "frame.srcdoc = injectHtmlRunnerSecurity(html)" in source + assert ( + "sanitizeHtmlSnippet" + not in source.split( + "function buildHtmlRunnerDocument", + 1, + )[1].split("function htmlRunnerPickerScript", 1)[0] + ) + assert 'parent.postMessage({ type: "webui-html-picked", html }, "*")' in source + assert "data-webui-html-picker-overlay" in source + assert "data-webui-html-picker-label" in source + assert "data-webui-html-picking" in source + assert "document.elementsFromPoint" in source + assert "candidateFromPoint(event.clientX, event.clientY)" in source + assert 'document.addEventListener("pointerdown"' in source + assert 'parent.postMessage({ type: "webui-html-picker-ready" }, "*")' in source + assert "requestAnimationFrame(() =>" in source + assert "elementLabel(element)" in source + assert "event.source !== frame.contentWindow" in source + assert 'data.type === "webui-html-picker-ready"' in source + assert 'data.type !== "webui-html-picked"' in source + assert "btnRuntimeHtmlClose" in source + assert "btnRuntimeHtmlPick" in source + assert "runtimeHtmlRunnerResize" in source + assert ".runtime-html-runner-toolbar" in source + assert "setHtmlRunnerPickMode(!runtimeState.htmlRunnerPickMode)" in source + assert "syncHtmlRunnerPickModeToFrame()" in source + assert "startHtmlRunnerResize" in source + assert "moveHtmlRunnerResize" in source + assert "stopHtmlRunnerResize" in source + assert "startHtmlRunnerDrag" in source + assert "moveHtmlRunnerDrag" in source + assert "stopHtmlRunnerDrag" in source + assert '"lostpointercapture"' in source + assert 'window.addEventListener("pointerup"' in source + assert 'window.addEventListener("pointercancel"' in source + assert 'window.addEventListener("blur"' in source + assert "setHtmlRunnerRect(rect.left, rect.top, rect.width, rect.height)" in source + assert 'window.addEventListener("resize", clampVisibleHtmlRunner)' in source + assert "setPointerCapture(pointerId)" in source + assert "releasePointerCapture(state.pointerId)" in source + assert 'button.setAttribute("aria-pressed", active ? "true" : "false")' in source + + assert ".runtime-html-runner" in css + assert ".runtime-html-runner-panel" in css + assert ".runtime-html-runner-toolbar" in css + assert ".runtime-html-runner-frame" in css + runner_css = css.split(".runtime-html-runner {", 1)[1].split( + ".runtime-html-runner[hidden]", + 1, + )[0] + runner_panel_css = css.split(".runtime-html-runner-panel {", 1)[1].split( + ".runtime-html-runner-toolbar", + 1, + )[0] + assert "resize: both;" not in runner_css + assert "right:" not in runner_css + assert "bottom:" not in runner_css + assert "overflow: visible;" in runner_css + assert "pointer-events: auto;" in runner_css + assert "height: 360px;" in runner_css + assert "grid-template-rows: auto minmax(0, 1fr);" in runner_panel_css + assert "width: 100%;" in runner_panel_css + assert "height: 100%;" in runner_panel_css + assert ".runtime-html-runner-resize" in css + assert ".runtime-html-runner.is-resizing" in css + assert ".runtime-html-runner.is-dragging" in css + assert ( + "pointer-events: none;" + in css.split( + ".runtime-html-runner.is-resizing .runtime-html-runner-frame", + 1, + )[1].split(".runtime-html-runner-toolbar", 1)[0] + ) + assert ( + "pointer-events: none;" + in css.split( + ".runtime-html-runner.is-dragging .runtime-html-runner-frame", + 1, + )[1].split(".runtime-html-runner-toolbar", 1)[0] + ) + toolbar_css = css.split(".runtime-html-runner-toolbar {", 1)[1].split( + ".runtime-html-runner-actions", + 1, + )[0] + assert "cursor: move;" in toolbar_css + assert "touch-action: none;" in toolbar_css + assert ".runtime-html-runner-actions,\n.runtime-html-runner-actions *" in css + assert ".runtime-html-runner-actions button" in css + assert ".runtime-html-runner-btn.is-active" in css + assert ".runtime-html-runner.is-picking .runtime-html-runner-panel" in css + assert "@keyframes runtime-html-runner-in" in css + assert ".runtime-html-runner" in responsive_css + responsive_runner_css = responsive_css.split(".runtime-html-runner {", 1)[1].split( + ".runtime-html-runner-panel", 1 + )[0] + assert "right:" not in responsive_runner_css + assert "bottom:" not in responsive_runner_css + assert ( + "max-height: calc(100dvh - 24px - env(safe-area-inset-bottom));" + in responsive_css + ) + responsive_toolbar_css = responsive_css.split( + ".runtime-html-runner-toolbar", + 1, + )[1].split(".runtime-html-runner-title", 1)[0] + responsive_title_css = responsive_css.split(".runtime-html-runner-title", 1)[ + 1 + ].split(".runtime-html-runner-meta", 1)[0] + responsive_meta_css = responsive_css.split(".runtime-html-runner-meta", 1)[1].split( + ".runtime-html-runner-actions", 1 + )[0] + assert "flex-wrap: wrap;" in responsive_toolbar_css + assert "flex: 1 1 min(160px, 100%);" in responsive_title_css + assert "max-width: min(62vw, 260px);" in responsive_meta_css + assert "runtime.html_ready" in i18n + assert "runtime.pick_html" in i18n + assert "runtime.html_pick_confirm_hint" in i18n + + +def test_webchat_references_are_prepended_as_markdown_quotes() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + template = _read_source(WEBUI_TEMPLATE) + i18n = _read_source(I18N_JS) + + assert "chatReferences: []" in source + assert "chatReferenceSeq" in source + assert "function addChatReference" in source + assert "function renderPendingChatReferences" in source + assert "function formatChatReferencesAsMarkdown" in source + assert "function buildChatMessageWithReferences" in source + assert "function chatMessageTextForQuote" in source + assert "[`> ${label}:`, ...lines.map((line) => `> ${line}`)]" in source + assert ( + "buildChatMessageWithReferences(\n message,\n references" + in source + ) + assert "clearChatReferences()" in source + assert 'addChatReference({ type: "html", text: picked })' in source + assert 'addChatReference({ type: "message", text })' in source + assert 'addChatReference({ type: "selection", text })' in source + assert "runtimeState.chatReferences =" in source + assert "runtimeState.chatReferences.filter" in source + assert 'api("/api/runtime/chat/files"' in source + + send_helper = source.split("async function sendChatMessage", 1)[1].split( + "function handleChatFilesPicked", + 1, + )[0] + assert "const references = [...runtimeState.chatReferences]" in send_helper + assert "const outboundAttachments = retryMessage ? [] : attachments" in send_helper + assert "const outboundReferences = retryMessage ? [] : references" in send_helper + assert ( + "!message &&\n !outboundAttachments.length &&\n !outboundReferences.length" + in send_helper + ) + assert "clearChatReferences()" in send_helper + + assert 'id="runtimeChatReferences"' in template + input_row = template.split('class="runtime-chat-input-row"', 1)[1].split( + 'class="runtime-chat-actions"', + 1, + )[0] + assert input_row.index('id="runtimeChatInput"') < input_row.index( + 'id="runtimeChatReferences"' + ) + + assert ".runtime-chat-references" in css + assert ".runtime-chat-reference" in css + assert ".runtime-chat-reference-remove" in css + assert ".runtime-chat-quote-btn" in css + assert ".runtime-chat-selection-quote" in css + assert "@keyframes runtime-chat-selection-quote-in" in css + assert ".runtime-chat-references" in responsive_css + assert "runtime.reference_added" in i18n + assert "runtime.reference_html" in i18n + assert "runtime.quote_selection" in i18n + + +def test_webchat_tool_status_colors_drive_left_bar_and_status_text() -> None: + css = _read_source(RUNTIME_CSS) + running_block = css.split(".runtime-tool-block.running {", 1)[1].split( + ".runtime-tool-block.done", 1 + )[0] + done_block = css.split(".runtime-tool-block.done {", 1)[1].split( + ".runtime-tool-block.error", 1 + )[0] + error_accent_block = css.split(".runtime-tool-block.error {", 1)[1].split( + ".runtime-tool-block.cancelled", 1 + )[0] + pseudo_block = css.split(".runtime-tool-block::before", 1)[1].split( + ".runtime-tool-block.is-agent", 1 + )[0] + status_block = css.split( + ".runtime-tool-block.error summary .runtime-tool-status", 1 + )[1].split(".runtime-tool-preview", 1)[0] + + assert "--tool-accent: color-mix(in srgb, var(--warning)" in running_block + assert "--tool-accent: var(--success);" in done_block + assert "--tool-accent: var(--error);" in error_accent_block + assert "background: var(--tool-accent);" in pseudo_block + assert ".runtime-tool-block.running summary .runtime-tool-status" in css + assert ".runtime-tool-block.done summary .runtime-tool-status" in css + assert "color: var(--error);" in status_block + assert ".runtime-tool-block.cancelled summary .runtime-tool-status" in status_block + assert "var(--danger)" not in status_block + + +def test_webchat_send_scrolls_to_bottom_after_layout_updates() -> None: + source = _read_source(RUNTIME_JS) + force_helper = source.split("function forceScrollChatToBottomSoon", 1)[1].split( + "function scrollChatToBottomSoon", 1 + )[0] + helper = source.split("function scrollChatToBottomSoon", 1)[1].split( + "function updateChatMessage", 1 + )[0] + send_helper = source.split("async function sendChatMessage", 1)[1].split( + "function handleChatFilesPicked", 1 + )[0] + + assert "requestAnimationFrame(() =>" in force_helper + assert "requestAnimationFrame(forceScrollChatToBottom)" in force_helper + assert "setTimeout(forceScrollChatToBottom, 80)" in force_helper + assert "requestAnimationFrame(scrollChatToBottom)" in helper + assert "setTimeout(scrollChatToBottom, 0)" in helper + assert "buildChatMessageWithAttachments(" in send_helper + assert ( + 'if (!retryMessage) {\n appendChatMessage("user", outboundMessage);\n }' + in send_helper + ) + assert 'input.value = ""' in send_helper + assert "clearChatAttachments()" in send_helper + assert "forceScrollChatToBottomSoon()" in send_helper + assert "ensureStreamingMessage()" in send_helper + + +def test_webchat_frontend_cancel_and_retry_controls() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + i18n = _read_source(I18N_JS) + + assert "chatCancelBusy: false" in source + assert "runtime-chat-cancel-btn" in source + assert "runtime-chat-retry-btn" in source + assert "data-cancel-job" in source + assert "data-retry-message" in source + assert "function cancelActiveChatJob" in source + assert ( + "/api/runtime/chat/jobs/${encodeURIComponent(resolvedJobId)}/cancel" in source + ) + assert ( + 'method: "POST"' + in source.split("function cancelActiveChatJob", 1)[1].split( + "async function sendChatMessage", + 1, + )[0] + ) + assert "function retryChatMessage" in source + assert "sendChatMessage({ retryMessage: message })" in source + assert "function syncChatMessageActions" in source + assert "function syncActiveCancelButtons" in source + assert "function syncChatRetryButtons" in source + assert "function hasBotReplyAfter" in source + assert "function removeEmptyChatMessage" in source + assert "removeEmptyChatMessage(item)" in source + assert 'message === "cancelled"' in source + assert 'showToast(t("runtime.chat_cancelled")' in source + assert "button.dataset.cancelJob = activeJobId" in source + assert "item === lastItem" in source + assert "!hasBotReplyAfter(item)" in source + assert "item.dataset.retryContent" in source + assert "const outboundAttachments = retryMessage ? [] : attachments" in source + assert "const outboundReferences = retryMessage ? [] : references" in source + assert "reuse_previous_user_message: !!retryMessage" in source + + chat_click_block = source.split('chatLog.addEventListener("click"', 1)[1].split( + 'chatLog.addEventListener("mouseup"', + 1, + )[0] + assert "[data-cancel-job]" in chat_click_block + assert "cancelActiveChatJob(jobId)" in chat_click_block + assert "[data-retry-message]" in chat_click_block + assert "retryChatMessage(item)" in chat_click_block + + assert ".runtime-chat-cancel-btn" in css + assert ".runtime-chat-retry-btn" in css + assert ".runtime-chat-quote-btn[hidden]" in css + assert ".runtime-chat-quote-btn.is-visible" in css + assert "runtime.cancel" in i18n + assert "runtime.retry" in i18n + assert "runtime.chat_cancelled" in i18n + + +def test_webchat_frontend_pastes_files_as_pending_attachments() -> None: + source = _read_source(RUNTIME_JS) + css = _read_source(RUNTIME_CSS) + template = _read_source(WEBUI_TEMPLATE) + i18n = _read_source(I18N_JS) + api_source = _read_source(API_JS) + + assert "chatAttachments: []" in source + assert "function addChatFiles" in source + assert "function renderPendingChatAttachments" in source + assert "async function uploadChatFile" in source + assert "async function buildChatMessageWithAttachments" in source + assert "CHAT_INLINE_IMAGE_MAX_BYTES" in source + assert "URL.createObjectURL(file)" in source + assert "URL.revokeObjectURL" in source + assert "runtime-chat-attachment-thumb" in source + assert "is-missing-thumb" in source + assert 'item.kind === "image" ? "IMG" : "FILE"' in source + assert "CHAT_ATTACHMENT_RAIL_BASE_WIDTH" in source + assert "CHAT_ATTACHMENT_RAIL_STEP_WIDTH" in source + assert "CHAT_ATTACHMENT_RAIL_MAX_WIDTH" in source + assert "CHAT_ATTACHMENT_CARD_MAX_WIDTH" in source + assert "CHAT_ATTACHMENT_CARD_MIN_WIDTH" in source + assert "CHAT_ATTACHMENT_COMPRESSED_COUNT" in source + assert "Math.min(\n CHAT_ATTACHMENT_RAIL_MAX_WIDTH" in source + assert '"--chat-attachment-rail-width"' in source + assert '"--chat-attachment-card-width"' in source + assert '"is-attachment-rail-full"' in source + assert '"is-attachment-compressed"' in source + assert "Math.floor(\n (width - Math.max" in source + assert 'api("/api/runtime/chat/files"' in source + assert "event.clipboardData && event.clipboardData.files" in source + assert 'addChatFiles(files, { source: "paste" })' in source + assert ( + "sendChatMessage()" + not in source.split('chatInput.addEventListener("paste"', 1)[1].split("});", 1)[ + 0 + ] + ) + assert 'id="runtimeChatAttachments"' in template + input_row = template.split('class="runtime-chat-input-row"', 1)[1].split( + 'class="runtime-chat-actions"', + 1, + )[0] + assert input_row.index('id="runtimeChatInput"') < input_row.index( + 'id="runtimeChatAttachments"' + ) + assert 'id="runtimeChatFileInput" type="file" multiple hidden' in template + assert 'data-i18n="runtime.attach_file"' in template + assert ".runtime-chat-attachments" in css + input_row_block = css.split(".runtime-chat-input-row {", 1)[1].split( + ".runtime-chat-input-row > .runtime-chat-input", + 1, + )[0] + input_block = css.split( + ".runtime-chat-input-row > .runtime-chat-input", + 1, + )[1].split(".runtime-chat-attachments", 1)[0] + attachments_block = css.split(".runtime-chat-attachments {", 1)[1].split( + ".runtime-chat-attachments[hidden]", + 1, + )[0] + hidden_block = css.split(".runtime-chat-attachments[hidden]", 1)[1].split( + ".runtime-chat-attachment {", + 1, + )[0] + compressed_block = css.split( + ".runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment", + 1, + )[1].split(".runtime-chat-attachment-preview", 1)[0] + compressed_preview_block = css.split( + ".runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-preview", + 1, + )[1].split( + ".runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-main", + 1, + )[0] + compressed_remove_block = css.split( + ".runtime-chat-input-row.is-attachment-compressed .runtime-chat-attachment-remove", + 1, + )[1].split("@keyframes runtime-chat-attachment-in", 1)[0] + responsive_attachments = ( + _read_source(RESPONSIVE_CSS) + .split( + ".runtime-chat-attachments", + 1, + )[1] + .split(".runtime-chat-attachment", 1)[0] + ) + mobile_input_row_block = ( + _read_source(RESPONSIVE_CSS) + .split( + ".runtime-chat-input-row", + 1, + )[1] + .split(".runtime-chat-references", 1)[0] + ) + assert "--chat-attachment-rail-width: 0px;" in input_row_block + assert "--chat-attachment-card-width: 132px;" in input_row_block + assert "--chat-attachment-gap: 8px;" in input_row_block + assert "display: flex;" in input_row_block + assert "flex: 1 1 auto;" in input_block + assert "min-width: min(100%, 260px);" in input_block + assert "height: 54px;" in attachments_block + assert "flex: 0 0 var(--chat-attachment-rail-width);" in attachments_block + assert "width: var(--chat-attachment-rail-width);" in attachments_block + assert "max-width: var(--chat-attachment-rail-width);" in attachments_block + assert "overflow-x: auto;" in attachments_block + assert "overflow-y: hidden;" in attachments_block + assert "scrollbar-width: none;" in attachments_block + assert "display: grid;" in mobile_input_row_block + assert ( + 'grid-template-areas:\n "references references"\n "attachments attachments"\n "input actions";' + in mobile_input_row_block + ) + assert "column-gap: 7px;" in mobile_input_row_block + assert "row-gap: 0;" in mobile_input_row_block + assert "flex-basis: 0;" in hidden_block + assert "width: 0;" in hidden_block + assert "max-width: 0;" in hidden_block + attachment_block = css.split(".runtime-chat-attachment {", 1)[1].split( + ".runtime-chat-attachment:hover", + 1, + )[0] + assert "flex: 0 0 var(--chat-attachment-card-width);" in attachment_block + assert "max-width: var(--chat-attachment-card-width);" in attachment_block + assert "grid-template-columns: minmax(24px, 1fr);" in compressed_block + assert "width: 100%;" in compressed_preview_block + assert "height: 38px;" in compressed_preview_block + assert "width: 22px;" in compressed_remove_block + assert "height: 22px;" in compressed_remove_block + assert "font-weight: 700;" in compressed_remove_block + assert ".runtime-chat-attachment-preview.is-missing-thumb::before" in css + assert "grid-area: attachments;" in responsive_attachments + assert "width: 100%;" in responsive_attachments + assert "max-width: 100%;" in responsive_attachments + assert ".runtime-chat-attachment-thumb" in css + assert ".runtime-chat-attachment-preview" in css + assert ".runtime-chat-attachment-remove" in css + assert "runtime.attach_file" in i18n + assert "runtime.attachment_added" in i18n + assert "body instanceof FormData" in api_source + assert "!isNativeBody" in api_source + + +def test_webchat_layout_keeps_input_at_bottom_and_log_scrollable() -> None: + app_css = _read_source(APP_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + main_js = _read_source(MAIN_JS) + template = _read_source(WEBUI_TEMPLATE) + + assert ".main-content.chat-layout {" in app_css + assert "display: flex;" in app_css + assert "height: 100dvh;" in app_css + assert "overflow: hidden;" in app_css + assert "#appContent" in app_css + assert "grid-template-rows: auto minmax(0, 1fr);" in app_css + assert "#tab-chat.active" in app_css + assert "grid-template-rows: auto minmax(0, 1fr);" in app_css + + chat_card_block = app_css.split( + ".main-content.chat-layout #tab-chat .chat-runtime-card", 1 + )[1].split(".main-content.chat-layout #tab-chat .runtime-chat-log", 1)[0] + assert "grid-template-rows: auto auto minmax(0, 1fr) auto;" in chat_card_block + assert "min-height: 0;" in chat_card_block + + log_block = app_css.split( + ".main-content.chat-layout #tab-chat .runtime-chat-log", 1 + )[1].split(".main-content.chat-layout #tab-chat .runtime-chat-input", 1)[0] + assert "overflow-y: auto;" in log_block + assert "overscroll-behavior: contain;" in log_block + + input_row_block = app_css.split( + ".main-content.chat-layout #tab-chat .runtime-chat-input-row", 1 + )[1].split(".main-content.chat-layout #tab-chat .runtime-chat-content", 1)[0] + assert "position: relative;" in input_row_block + assert "position: sticky;" not in input_row_block + assert "position: fixed;" not in input_row_block + assert "var(--bg-main)" not in input_row_block + + chat_header_block = app_css.split(".runtime-chat-header {", 1)[1].split( + ".runtime-chat-title", 1 + )[0] + title_meta_block = app_css.split(".runtime-chat-title-meta", 1)[1].split( + ".main-content.chat-layout #tab-chat .chat-runtime-card", 1 + )[0] + mobile_header_block = responsive_css.split(".runtime-chat-header-actions", 1)[ + 1 + ].split(".runtime-chat-auto-scroll-toggle", 1)[0] + + assert "align-items: center;" in chat_header_block + assert "white-space: nowrap;" in title_meta_block + assert "justify-content: space-between;" in mobile_header_block + assert ".main-content.chat-layout" in responsive_css + assert "height: 100dvh;" in responsive_css + assert "function syncMainContentLayout()" in main_js + assert ( + 'appContent.style.display = state.tab === "chat" ? "grid" : "block";' in main_js + ) + assert 'role="log"' in template + assert 'aria-live="polite"' in template + assert 'data-i18n-aria-label="runtime.chat_log_label"' in template + assert 'class="header runtime-chat-header"' in template + assert "runtime-chat-title-meta" in template + assert "该会话由 WebUI 发起" in template + + +def test_webchat_mobile_tool_rows_have_overflow_guards() -> None: + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + + status_css = css.split(".runtime-tool-block summary .runtime-tool-status", 1)[ + 1 + ].split(".runtime-tool-block summary .runtime-tool-kind", 1)[0] + kind_css = css.split(".runtime-tool-block summary .runtime-tool-kind", 1)[1].split( + ".runtime-tool-block.webchat-private-send", 1 + )[0] + structured_css = css.split(".runtime-tool-structured-row", 1)[1].split( + ".runtime-tool-key", 1 + )[0] + + assert "min-width: 0;" in status_css + assert "text-overflow: ellipsis;" in status_css + assert "overflow: hidden;" in kind_css + assert "grid-template-columns: minmax(64px, min(34%, 180px))" in structured_css + assert ".runtime-tool-block summary .runtime-tool-duration" in responsive_css + + +def test_webchat_content_wraps_long_code_and_markdown_without_horizontal_scroll() -> ( + None +): + css = _read_source(RUNTIME_CSS) + responsive_css = _read_source(RESPONSIVE_CSS) + + log_css = css.split(".runtime-chat-log {", 1)[1].split( + ".runtime-chat-load-more", + 1, + )[0] + item_css = css.split(".runtime-chat-item {", 1)[1].split( + ".runtime-chat-item.user", + 1, + )[0] + code_block_css = css.split(".runtime-code-block {", 1)[1].split( + ".runtime-code-toolbar", + 1, + )[0] + inline_code_css = css.split(".runtime-chat-content code {", 1)[1].split( + ".runtime-chat-image", + 1, + )[0] + mobile_table_css = responsive_css.split( + ".runtime-chat-content.markdown table", + 1, + )[1].split(".runtime-chat-input-row", 1)[0] + + assert "min-width: 0;" in log_css + assert "overflow-x: hidden;" in log_css + assert "min-width: 0;" in item_css + assert "max-width: 100%;" in item_css + assert "min-width: 0;" in code_block_css + assert "max-width: 100%;" in code_block_css + assert "white-space: normal;" in inline_code_css + assert "overflow-wrap: anywhere;" in inline_code_css + assert "display: table;" in mobile_table_css + assert "overflow-x: visible;" in mobile_table_css + assert "white-space: normal;" in mobile_table_css + mobile_code_toolbar_css = responsive_css.split(".runtime-code-toolbar", 1)[1].split( + ".runtime-code-actions", 1 + )[0] + mobile_code_action_css = responsive_css.split(".runtime-code-action", 1)[1].split( + ".runtime-chat-input-row", + 1, + )[0] + assert "min-height: 32px;" in mobile_code_toolbar_css + assert "padding: 4px 6px 4px 9px;" in mobile_code_toolbar_css + assert "min-height: 24px;" in mobile_code_action_css + assert "font-size: 11px;" in mobile_code_action_css + assert ".runtime-tool-block summary .runtime-tool-kind" in responsive_css + assert "display: none;" in responsive_css + assert "max-width: 30vw;" in responsive_css + + +def test_webchat_inlines_attachment_images_and_dedupes_card() -> None: + source = _read_source(RUNTIME_JS) + + assert "function attachmentPreviewUrl(" in source + assert "/api/runtime/chat/attachments/" in source + assert "/api/v1/runtime/chat/attachments/" not in source + assert "attachmentPreviewUrl(trimmedUid, attachment)" in source + + # appendHistoryChatItem 把附件元数据传入统一内容渲染入口,避免标签残留 + history_item_fn = source.split("function appendHistoryChatItem(", 1)[1].split( + "\n function ", 1 + )[0] + assert "attachments: item && item.attachments" in history_item_fn + + # 实时消息事件也传入 payload.attachments,避免必须刷新历史后才显示图片 + message_event_fn = source.split('if (event === "message")', 1)[1].split( + 'if (event === "done")', + 1, + )[0] + assert "attachments: payload && payload.attachments" in message_event_fn + + # 附件区按非图片过滤,避免与正文内联图重复 + assert "function attachmentIsImage(" in source + build_markup_fn = source.split("function buildAttachmentMarkup(", 1)[1].split( + "\n function ", 1 + )[0] + assert "attachmentIsImage" in build_markup_fn diff --git a/tests/test_webui_settings.py b/tests/test_webui_settings.py new file mode 100644 index 00000000..1f94d10b --- /dev/null +++ b/tests/test_webui_settings.py @@ -0,0 +1,167 @@ +"""Tests for WebUI settings loading. + +This module tests the load_webui_settings function to ensure proper +parsing of the autostart_bot configuration field from config.toml. +""" + +from __future__ import annotations + +from pathlib import Path + +from Undefined.config.webui_settings import load_webui_settings + + +def test_load_webui_settings_with_autostart_bot_true(tmp_path: Path) -> None: + """测试 autostart_bot = true 时正确解析。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "0.0.0.0" +port = 8080 +password = "test123" +autostart_bot = true +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.url == "0.0.0.0" + assert settings.port == 8080 + assert settings.password == "test123" + assert settings.autostart_bot is True + assert settings.using_default_password is False + assert settings.config_exists is True + + +def test_load_webui_settings_with_autostart_bot_false(tmp_path: Path) -> None: + """测试 autostart_bot = false 时正确解析。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "mypassword" +autostart_bot = false +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.url == "127.0.0.1" + assert settings.port == 8787 + assert settings.password == "mypassword" + assert settings.autostart_bot is False + assert settings.using_default_password is False + assert settings.config_exists is True + + +def test_load_webui_settings_autostart_bot_missing(tmp_path: Path) -> None: + """测试 autostart_bot 缺失时默认为 false。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "test" +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.autostart_bot is False # 默认值 + assert settings.using_default_password is False + assert settings.config_exists is True + + +def test_load_webui_settings_autostart_bot_with_default_password( + tmp_path: Path, +) -> None: + """测试密码为空时 autostart_bot 仍能正确解析。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "" +autostart_bot = true +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.autostart_bot is True + assert settings.using_default_password is True + assert settings.password == "changeme" + + +def test_load_webui_settings_autostart_bot_string_true(tmp_path: Path) -> None: + """测试 autostart_bot 接受字符串 'true'。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "test" +autostart_bot = "true" +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.autostart_bot is True + + +def test_load_webui_settings_autostart_bot_numeric(tmp_path: Path) -> None: + """测试 autostart_bot 接受数值 1/0。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "test" +autostart_bot = 1 +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.autostart_bot is True + + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "test" +autostart_bot = 0 +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.autostart_bot is False + + +def test_load_webui_settings_no_config_file() -> None: + """测试配置文件不存在时的默认行为。""" + settings = load_webui_settings(Path("/nonexistent/config.toml")) + + assert settings.url == "127.0.0.1" + assert settings.port == 8787 + assert settings.password == "changeme" + assert settings.autostart_bot is False # 默认值 + assert settings.using_default_password is True + assert settings.config_exists is False diff --git a/tests/test_xml_utils.py b/tests/test_xml_utils.py index fbb89a0d..5b7aad87 100644 --- a/tests/test_xml_utils.py +++ b/tests/test_xml_utils.py @@ -2,7 +2,15 @@ from __future__ import annotations -from Undefined.utils.xml import escape_xml_attr, escape_xml_text +from collections.abc import Mapping, Sequence +from typing import cast + +from Undefined.utils.xml import ( + escape_xml_attr, + escape_xml_text, + escape_xml_text_preserving_attachment_tags, + format_message_xml, +) class TestEscapeXmlText: @@ -98,3 +106,59 @@ def test_unicode(self) -> None: def test_zero(self) -> None: assert escape_xml_attr(0) == "0" + + +class TestAttachmentTagPreservation: + def test_preserves_known_attachment_tag(self) -> None: + result = escape_xml_text_preserving_attachment_tags( + '看图 & 继续', + [{"uid": "pic_abc123"}], + ) + + assert '' in result + assert "&" in result + + def test_escapes_unknown_attachment_tag(self) -> None: + result = escape_xml_text_preserving_attachment_tags( + '伪造 ', + [{"uid": "pic_real"}], + ) + + assert " None: + attachments = cast( + Sequence[Mapping[str, str]], + [{"uid": "pic_abc123"}, "not-a-mapping"], + ) + result = escape_xml_text_preserving_attachment_tags( + '看图 ', + attachments, + ) + + assert '' in result + + def test_format_message_xml_preserves_known_inline_attachment(self) -> None: + result = format_message_xml( + { + "type": "group", + "display_name": "用户", + "user_id": "10001", + "chat_id": "20001", + "chat_name": "测试群", + "timestamp": "2026-06-20 12:00:00", + "message": '看 ', + "attachments": [ + { + "uid": "pic_demo", + "kind": "image", + "media_type": "image", + "display_name": "demo.png", + } + ], + } + ) + + assert '' in result + assert '